Module: Cataract::ImportResolver
- Defined in:
- lib/cataract/import_resolver.rb
Overview
Resolves @import statements in CSS Handles fetching imported files and inlining them with proper security controls
Constant Summary collapse
- SAFE_DEFAULTS =
Default options for safe import resolution
{ max_depth: 5, # Prevent infinite recursion allowed_schemes: ['https'], # Only HTTPS by default extensions: ['css'], # Only .css files timeout: 10, # 10 second timeout for fetches follow_redirects: true, # Follow redirects base_path: nil # Base path for resolving relative imports }.freeze
Class Method Summary collapse
-
.extract_imports(css) ⇒ Object
Extract @import statements from CSS Returns array of hashes: { url: “…”, media: “…”, full_match: “…” } Delegates to C implementation for performance.
-
.fetch_http(uri, options) ⇒ Object
Fetch content via HTTP/HTTPS.
-
.fetch_url(url, options) ⇒ Object
Fetch content from URL.
-
.normalize_options(options) ⇒ Object
Normalize options with safe defaults.
-
.normalize_url(url, base_path = nil) ⇒ Object
Normalize URL - handle relative paths and missing schemes Returns a URI object.
-
.resolve(css, options = {}, depth: 0, imported_urls: Set.new) ⇒ String
Resolve @import statements in CSS.
-
.validate_url(url, options) ⇒ Object
Validate URL against security options.
Class Method Details
.extract_imports(css) ⇒ Object
Extract @import statements from CSS Returns array of hashes: { url: “…”, media: “…”, full_match: “…” } Delegates to C implementation for performance
101 102 103 |
# File 'lib/cataract/import_resolver.rb', line 101 def self.extract_imports(css) Cataract.extract_imports(css) end |
.fetch_http(uri, options) ⇒ Object
Fetch content via HTTP/HTTPS
199 200 201 202 203 204 205 206 207 208 |
# File 'lib/cataract/import_resolver.rb', line 199 def self.fetch_http(uri, ) # Use open-uri with timeout = { read_timeout: [:timeout], redirect: [:follow_redirects] } # Use uri.open instead of URI.open to avoid shell command injection uri.open(, &:read) end |
.fetch_url(url, options) ⇒ Object
Fetch content from URL
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 |
# File 'lib/cataract/import_resolver.rb', line 175 def self.fetch_url(url, ) uri = normalize_url(url, [:base_path]) case uri.scheme when 'file' # Read from local filesystem File.read(uri.path) when 'http', 'https' # Fetch from network fetch_http(uri, ) else raise ImportError, "Unsupported scheme: #{uri.scheme}" end rescue Errno::ENOENT raise ImportError, "Import file not found: #{url}" rescue OpenURI::HTTPError => e raise ImportError, "HTTP error fetching import: #{url} (#{e.})" rescue SocketError => e raise ImportError, "Network error fetching import: #{url} (#{e.})" rescue StandardError => e raise ImportError, "Error fetching import: #{url} (#{e.class}: #{e.})" end |
.normalize_options(options) ⇒ Object
Normalize options with safe defaults
86 87 88 89 90 91 92 93 94 95 96 |
# File 'lib/cataract/import_resolver.rb', line 86 def self.() if == true # imports: true -> use safe defaults SAFE_DEFAULTS.dup elsif .is_a?(Hash) # imports: { ... } -> merge with safe defaults SAFE_DEFAULTS.merge() else raise ArgumentError, 'imports option must be true or a Hash' end end |
.normalize_url(url, base_path = nil) ⇒ Object
Normalize URL - handle relative paths and missing schemes Returns a URI object
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 |
# File 'lib/cataract/import_resolver.rb', line 107 def self.normalize_url(url, base_path = nil) # Try to parse as-is first uri = URI.parse(url) # If no scheme, treat as relative file path if uri.scheme.nil? # Convert to file:// URL # Relative paths stay relative, absolute paths stay absolute if url.start_with?('/') uri = URI.parse("file://#{url}") else # Relative path - make it absolute relative to base_path or current directory absolute_path = if base_path File.(url, base_path) else File.(url) end uri = URI.parse("file://#{absolute_path}") end end uri rescue URI::InvalidURIError => e raise ImportError, "Invalid import URL: #{url} (#{e.})" end |
.resolve(css, options = {}, depth: 0, imported_urls: Set.new) ⇒ String
Resolve @import statements in CSS
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/cataract/import_resolver.rb', line 31 def self.resolve(css, = {}, depth: 0, imported_urls: Set.new) # Normalize options opts = () # Check recursion depth # depth starts at 0, max_depth is count of imports allowed # depth 0: parsing main file (counts as import 1) # depth 1: parsing first @import (counts as import 2) # depth 2: parsing nested @import (counts as import 3) if depth > opts[:max_depth] raise ImportError, "Import nesting too deep: exceeded maximum depth of #{opts[:max_depth]}" end # Find all @import statements at the top of the file # Per CSS spec, @import must come before all rules except @charset imports = extract_imports(css) return css if imports.empty? # Process each import resolved_css = +'' # Mutable string remaining_css = css imports.each do |import_data| url = import_data[:url] media = import_data[:media] # Validate URL validate_url(url, opts) # Check for circular references raise ImportError, "Circular import detected: #{url}" if imported_urls.include?(url) # Fetch imported CSS imported_css = fetch_url(url, opts) # Recursively resolve imports in the imported CSS imported_urls_copy = imported_urls.dup imported_urls_copy.add(url) imported_css = resolve(imported_css, opts, depth: depth + 1, imported_urls: imported_urls_copy) # Wrap in @media if import had media query imported_css = "@media #{media} {\n#{imported_css}\n}" if media resolved_css << imported_css << "\n" # Remove this import from remaining CSS remaining_css = remaining_css.sub(import_data[:full_match], '') end # Return resolved imports + remaining CSS resolved_css + remaining_css end |
.validate_url(url, options) ⇒ Object
Validate URL against security options
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/cataract/import_resolver.rb', line 134 def self.validate_url(url, ) uri = normalize_url(url, [:base_path]) # Check scheme unless [:allowed_schemes].include?(uri.scheme) raise ImportError, "Import scheme '#{uri.scheme}' not allowed. Allowed schemes: #{[:allowed_schemes].join(', ')}" end # Check extension path = uri.path || '' ext = File.extname(path).delete_prefix('.') unless ext.empty? || [:extensions].include?(ext) raise ImportError, "Import extension '.#{ext}' not allowed. Allowed extensions: #{[:extensions].join(', ')}" end # Additional security checks for file:// scheme if uri.scheme == 'file' # Resolve to absolute path to prevent directory traversal file_path = uri.path # Check file exists and is readable unless File.exist?(file_path) && File.readable?(file_path) raise ImportError, "Import file not found or not readable: #{file_path}" end # Prevent reading sensitive files (basic check) dangerous_paths = ['/etc/', '/proc/', '/sys/', '/dev/'] if dangerous_paths.any? { |prefix| file_path.start_with?(prefix) } raise ImportError, "Import of sensitive system files not allowed: #{file_path}" end end true rescue URI::InvalidURIError => e raise ImportError, "Invalid import URL: #{url} (#{e.})" end |