kataba 1.0.3 → 1.1.1

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 (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/kataba/fetcher.rb +109 -0
  3. data/lib/kataba.rb +16 -15
  4. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97107593bef37bc5a20d934c683ca45c5c1142d301bc705067a20b3e94a07d58
4
- data.tar.gz: fee7e5c92557a7f85170a395a97de0055b447d978d822575aadf8a6fe301b391
3
+ metadata.gz: 701caff7f874cbe1f9a02c4d30e58576e3c9970aba61cef220ac96460b729f9c
4
+ data.tar.gz: 803f46524665ca8f9d0e1ee82dfa154dee798363a2326195feeb54ef412e26b5
5
5
  SHA512:
6
- metadata.gz: 28c18ab1818719194b5f9ae1a512bfad8716caf430fac3ce4509dad455e4796cf9634bf33fec4b0e51501cea3dc70b1908945dfd4c80d71316d61bac2fa68293
7
- data.tar.gz: d7d05f52acc72e4f6ed031d255304b7d37984f07e89d24bf76c74e9e6be40bf06e1dab08a07f9eb8e7b2d8633c39b6d93ea10df503752189672ea0689e397dc0
6
+ metadata.gz: 7b323b6a3742abfba45ebf1dd98345c73f5e7b703229b9a7f852069a616e2b7cac26db91e9d23f71b5495878108bad8bb71d0e765ea5a4c7f8ce7b8415349ecb
7
+ data.tar.gz: e653e7a40a363a9d4b174d84ebc5e3907edc0a05c8c9a13231688d86ed033e6c0c38ff26f84ac8d58c6b97fb4eb22fa70897a2b29aaf774164e20a1a31ac4741
@@ -0,0 +1,109 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+
4
+ module Kataba
5
+ # Fetches a schema body, recovering from LoC-shaped delivery quirks
6
+ # that a verbatim URI.open would surface as cache-poisoning errors:
7
+ #
8
+ # - 5xx (Cloudflare bot-management 503/529, origin overload):
9
+ # retry once on the alternate scheme.
10
+ # - same-origin HTTPS->HTTP 3xx: follow. open-uri refuses all
11
+ # scheme downgrades; we relax to same-origin because the
12
+ # consumer already trusted this host by putting it in their
13
+ # schemaLocation. Cross-origin downgrades stay refused — that's
14
+ # the actual DNS-redirect attack vector.
15
+ # - /mods/xml.xsd: rewrite to /standards/mods/xml.xsd before the
16
+ # first request. The /mods/xml.xsd path is what every mods-3-N.xsd
17
+ # embeds in its xs:import, but LoC only serves the file from
18
+ # /standards/mods/xml.xsd today — the embedded path bounces
19
+ # HTTPS->HTTP and 503s. Applied here so transitive xs:import
20
+ # resolution benefits, not just top-level fetch_schema calls.
21
+ #
22
+ # mirror_list remains the consumer's backstop for URI-identity
23
+ # changes (path renames, host moves) that no delivery heuristic
24
+ # can rescue.
25
+ class Fetcher
26
+ MAX_REDIRECTS = 5
27
+
28
+ # Map from a path that's embedded in published schemas but no longer
29
+ # serves the file, to the path that does. xml.xsd: every mods-3-N.xsd
30
+ # imports /mods/xml.xsd, but only /standards/mods/xml.xsd serves it.
31
+ PATH_REWRITES = {
32
+ '/mods/xml.xsd' => '/standards/mods/xml.xsd',
33
+ }.freeze
34
+
35
+ class FetchError < StandardError; end
36
+
37
+ def initialize(uri)
38
+ @original_uri = uri
39
+ end
40
+
41
+ def fetch
42
+ attempt(normalize(@original_uri), alt_scheme_retry: true)
43
+ end
44
+
45
+ private
46
+
47
+ def normalize(uri)
48
+ PATH_REWRITES.each do |from, to|
49
+ rewritten = uri.sub(%r{(\Ahttps?://[^/]+)#{Regexp.escape(from)}\z}i, "\\1#{to}")
50
+ return rewritten unless rewritten == uri
51
+ end
52
+ uri
53
+ end
54
+
55
+ def attempt(uri, alt_scheme_retry:)
56
+ response = request_with_redirects(uri, redirect_depth: 0)
57
+
58
+ case response
59
+ when Net::HTTPSuccess
60
+ response.body
61
+ when Net::HTTPServerError
62
+ if alt_scheme_retry && (alt = swap_scheme(uri))
63
+ attempt(alt, alt_scheme_retry: false)
64
+ else
65
+ raise FetchError, "#{response.code} #{response.message} fetching #{uri}"
66
+ end
67
+ else
68
+ raise FetchError, "#{response.code} #{response.message} fetching #{uri}"
69
+ end
70
+ end
71
+
72
+ def request_with_redirects(uri, redirect_depth:)
73
+ raise FetchError, "too many redirects fetching #{@original_uri}" if redirect_depth > MAX_REDIRECTS
74
+
75
+ parsed = URI.parse(uri)
76
+ http = Net::HTTP.new(parsed.host, parsed.port)
77
+ http.use_ssl = (parsed.scheme == 'https')
78
+ response = http.get(parsed.request_uri)
79
+
80
+ if response.is_a?(Net::HTTPRedirection)
81
+ target = resolve_redirect(parsed, response['location'])
82
+ request_with_redirects(target, redirect_depth: redirect_depth + 1)
83
+ else
84
+ response
85
+ end
86
+ end
87
+
88
+ def resolve_redirect(from, location)
89
+ raise FetchError, "redirect with no Location header from #{from}" if location.nil? || location.empty?
90
+
91
+ target = URI.parse(location)
92
+ target = from + target if target.relative?
93
+
94
+ if from.scheme == 'https' && target.scheme == 'http' && from.host != target.host
95
+ raise FetchError, "cross-origin HTTPS->HTTP redirect refused: #{from} -> #{target}"
96
+ end
97
+
98
+ target.to_s
99
+ end
100
+
101
+ def swap_scheme(uri)
102
+ if uri.start_with?('https://')
103
+ uri.sub(/\Ahttps:/, 'http:')
104
+ elsif uri.start_with?('http://')
105
+ uri.sub(/\Ahttp:/, 'https:')
106
+ end
107
+ end
108
+ end
109
+ end
data/lib/kataba.rb CHANGED
@@ -1,9 +1,9 @@
1
1
  require 'nokogiri'
2
2
  require 'tmpdir'
3
3
  require 'digest/md5'
4
- require 'open-uri'
5
4
  require 'fileutils'
6
5
  require 'yaml'
6
+ require 'kataba/fetcher'
7
7
 
8
8
  module Kataba
9
9
 
@@ -110,25 +110,26 @@ module Kataba
110
110
  file_path = "#{dir_name}/#{uri_md5}.xsd"
111
111
  tmp_path = "#{file_path}.part"
112
112
 
113
+ # Resolve mirror first, if configured, then fetch. Fetching BEFORE
114
+ # opening tmp_path means a failed network fetch can't leave an
115
+ # orphaned 0-byte .part on disk — the file simply isn't created.
116
+ fetch_uri = xsd_uri
117
+ if !self.configuration.mirror_list.to_s.empty?
118
+ # YAML.load_file returns nil for a comments-only or empty file.
119
+ # Treat that as "no mirror configured" rather than NoMethodError.
120
+ mirror_list = YAML.load_file(self.configuration.mirror_list) || {}
121
+ mirror = mirror_list[xsd_uri]
122
+ fetch_uri = mirror unless mirror.to_s.empty?
123
+ end
124
+
125
+ body = Kataba::Fetcher.new(fetch_uri).fetch
126
+
113
127
  # Write to a .part file first; only rename to the final cache path
114
128
  # after we've confirmed the bytes parse as XML. Without this, a
115
129
  # malformed response (HTML error page, truncated TCP stream, captive
116
130
  # portal stub) would land at the canonical cache path and poison
117
131
  # every subsequent fetch.
118
- File.open(tmp_path, "wb+") do |file|
119
- if !self.configuration.mirror_list.to_s.empty?
120
- mirror_list = YAML.load_file(self.configuration.mirror_list)
121
- mirror = mirror_list[xsd_uri]
122
- if mirror.to_s.empty?
123
- # No mirror for that uri
124
- file.write(URI.open(xsd_uri).read)
125
- else
126
- file.write(URI.open(mirror).read)
127
- end
128
- else
129
- file.write(URI.open(xsd_uri).read)
130
- end
131
- end
132
+ File.open(tmp_path, "wb+") { |file| file.write(body) }
132
133
 
133
134
  begin
134
135
  Nokogiri::XML(File.read(tmp_path)) { |c| c.strict }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kataba
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.3
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Cliff
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-15 00:00:00.000000000 Z
11
+ date: 2026-05-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
@@ -74,6 +74,7 @@ extensions: []
74
74
  extra_rdoc_files: []
75
75
  files:
76
76
  - lib/kataba.rb
77
+ - lib/kataba/fetcher.rb
77
78
  homepage: http://rubygems.org/gems/kataba
78
79
  licenses:
79
80
  - MIT