andromeda_cms 0.7.1 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5bde1931ffa5be3d639889a72d5313207373205be6e8d29303bd3138241dfa79
4
- data.tar.gz: ea3d032420f6e0b5ca7316c896581b5a038ccd73e9e924b00a223b5be07e13c1
3
+ metadata.gz: f5fcf2b6c7dc4e2ae34e6e729b2ce4c7b811c30b5ac1a330a371aa6bcf112114
4
+ data.tar.gz: b32192863c7a781f648e9ea4441f91841e21fbc792a33d7df2500df453a124f9
5
5
  SHA512:
6
- metadata.gz: 312ddc99a1d8a3abd5b47c63ddbc8493ca18e51300bef3ae9743061bd44ca3ad4b952d174f6a0749cd2403ba33435c58f24adcd1b6ca7283966c240883ff3b47
7
- data.tar.gz: 2fe047ea4e0dcf8fab1956f32546a014da39951956f277ebc72a73060787c8af4a5927b97f43fd9e64f2993c7f71d2ef5b655bf76b6b6b90930a25741ce8f155
6
+ metadata.gz: 376cc0430b6244ac65b3c6e112d4ed6e73a12c407af27a1cf3081f1bd352b17a3a7b2e861f6ec0b37f560bc25da5681120e931d20d47a679268eb643b9cea4ca
7
+ data.tar.gz: 6d0d3a7fe600e46a22a6165d317ff5186ba7028f5ed3f1fc0de7e13e0d1513580959582898b85b63aa992f86358abf9ff45108c9922562e1cbfd991cc89f4920
data/CHANGELOG.md CHANGED
@@ -5,6 +5,55 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.8.0] - 2026-11-14
9
+
10
+ ### Changed
11
+
12
+ - An MDX `import` now resolves only to a partial inside the components
13
+ directory (`Andromeda.config.components_path`). An import that points
14
+ anywhere else under `app/views`, such as a layout partial, is ignored and
15
+ the component falls back to its conventional partial, as an import that
16
+ does not resolve already did. Imported content could otherwise render
17
+ any partial in the application.
18
+ - `rails g andromeda:import_astro` skips a collection whose name cannot
19
+ become a valid Ruby class, such as a `src/content/blog-posts` directory or
20
+ `const BEGIN = defineCollection(...)`, and lists it in the report. These
21
+ used to produce an entry class that failed to load.
22
+
23
+ ### Fixed
24
+
25
+ - Security: `rails g andromeda:import_astro` no longer writes text from the
26
+ imported project into the generated entry classes as Ruby code. A
27
+ malicious Astro project could put code in a `.default(...)` value, a glob
28
+ `pattern`, a `reference(...)` name, a quoted field name or a schema
29
+ comment, and it ran every time the application loaded the class. Defaults
30
+ are now parsed into Ruby values, every imported value is written with
31
+ `inspect`, and text placed in comments is kept on one line. A default that
32
+ cannot be carried over is now noted in the class instead of silently
33
+ dropped.
34
+ - Security: `rails g andromeda:import_astro` copies only files that are
35
+ inside the Astro project. A `glob({ base: ... })` pointing elsewhere (such
36
+ as `~/.ssh`) or a symlink to a file outside the project used to copy that
37
+ file into the application. A glob `pattern` that leaves the collection
38
+ directory is replaced with the default pattern.
39
+ - `rails g andromeda:import_astro` generates a partial stub for every
40
+ component an MDX file uses, not only the first one, and none for
41
+ lowercase HTML tags.
42
+ - `rails g andromeda:import_astro` no longer stops partway through on an
43
+ invalid component name, a content file that is not valid UTF-8, or a
44
+ malformed generated JSON Schema. A `content.config.ts` that is not valid
45
+ UTF-8 stops the import before anything is written.
46
+ - `rails g andromeda:import_astro` no longer gives a collection the
47
+ schema of another collection whose name starts with the same text (`blog`
48
+ and `blogPosts`), and parses a config with many collections in linear
49
+ time.
50
+ - Renaming frontmatter keys (`andromeda:fix` and the Astro import) no longer
51
+ corrupts a file whose frontmatter contains backslash sequences such as
52
+ `\0` or `\&`.
53
+ - `rails g andromeda:component` turns a prop that is not a valid Ruby local
54
+ name (such as `class`, `a:b` or `content`) into a TODO comment instead of
55
+ writing a partial that fails to compile.
56
+
8
57
  ## [0.7.1] - 2026-11-08
9
58
 
10
59
  ### Changed
@@ -241,6 +290,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
241
290
  - Generators: `andromeda:install`, `andromeda:collection`,
242
291
  `andromeda:component` and `andromeda:import_astro`.
243
292
 
293
+ [0.8.0]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.8.0
244
294
  [0.7.1]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.7.1
245
295
  [0.7.0]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.7.0
246
296
  [0.6.3]: https://github.com/AndromedaCMS/andromeda_cms/releases/tag/v0.6.3
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
4
+
3
5
  require_relative "components/errors"
4
6
  require_relative "components/import_scanner"
5
7
  require_relative "components/static_expression"
@@ -163,11 +165,21 @@ module Andromeda
163
165
  end
164
166
 
165
167
  import_path = @imports[name]
166
- return import_path if import_path && partial_exists?(import_path)
168
+ return import_path if import_path && within_components_path?(import_path) && partial_exists?(import_path)
167
169
 
168
170
  convention_partial(name)
169
171
  end
170
172
 
173
+ # An `import` is author-controlled text; without this it could make the
174
+ # renderer render any partial under app/views (a layout partial, an
175
+ # admin one). Imports resolve only inside the components directory, and
176
+ # anything else falls back to convention like an unresolvable import.
177
+ def within_components_path?(partial)
178
+ base = Andromeda.config.components_path.sub(%r{\Aapp/views/}, "")
179
+ normalized = Pathname.new(partial).cleanpath.to_s
180
+ normalized.start_with?("#{base}/")
181
+ end
182
+
171
183
  def convention_partial(name)
172
184
  base = Andromeda.config.components_path.sub(%r{\Aapp/views/}, "")
173
185
  "#{base}/#{self.class.underscore(name)}"
data/lib/andromeda/fix.rb CHANGED
@@ -51,7 +51,9 @@ module Andromeda
51
51
  line.sub(KEY_LINE) { "#{match[1]}#{match[2]}#{snake}#{match[2]}#{match[4]}" }
52
52
  end.join
53
53
 
54
- [source.sub(frontmatter, fixed_frontmatter), renames]
54
+ # Block form: a replacement string would interpret `\0`, `\&` and `\\`
55
+ # sequences in the frontmatter's own values and corrupt the file.
56
+ [source.sub(frontmatter) { fixed_frontmatter }, renames]
55
57
  end
56
58
 
57
59
  def snake_case(key)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Andromeda
4
- VERSION = "0.7.1"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -27,6 +27,17 @@ module Andromeda
27
27
  "#{Andromeda.config.components_path}/_#{name.underscore}.html.erb"
28
28
  end
29
29
 
30
+ # A prop becomes a Ruby local (`<%= title %>`, `locals: (title: nil)`),
31
+ # so it must be a plain identifier that is not a keyword. Props come
32
+ # from MDX attributes (`a:b`, `$x`, `class`), which are legal JSX but
33
+ # would produce a template that fails to compile.
34
+ LOCAL_NAME_RE = /\A[a-z_][a-zA-Z0-9_]*\z/
35
+ RESERVED_WORDS = %w[
36
+ __ENCODING__ __LINE__ __FILE__ BEGIN END alias and begin break case class def defined? do else elsif
37
+ end ensure false for if in module next nil not or redo rescue retry return self super then true undef
38
+ unless until when while yield
39
+ ].freeze
40
+
30
41
  def create_partial
31
42
  create_file "#{Andromeda.config.components_path}/_#{underscored_name}.html.erb", partial_content
32
43
  end
@@ -41,10 +52,32 @@ module Andromeda
41
52
  name.camelize
42
53
  end
43
54
 
55
+ # `content` is always declared for the tag's body (see
56
+ # `strict_locals`), so a prop of that name would be declared twice.
57
+ def valid_local?(prop)
58
+ local = prop.underscore
59
+ local.match?(LOCAL_NAME_RE) && !RESERVED_WORDS.include?(local) && local != "content"
60
+ end
61
+
62
+ def valid_props
63
+ props.select { |prop| valid_local?(prop) }
64
+ end
65
+
66
+ # Rejected props are not emitted as code; a comment tells the author
67
+ # to rename them by hand. The prop text is attacker-influenced (it
68
+ # comes from MDX), so it must not be able to close the ERB tag or
69
+ # start a new line.
70
+ def rejected_prop_lines
71
+ props.reject { |prop| valid_local?(prop) }.map do |prop|
72
+ safe = prop.gsub(/\s+/, " ").gsub("%>", "")
73
+ "<%# TODO: prop `#{safe}` is not a valid local name -- rename it by hand. %>"
74
+ end
75
+ end
76
+
44
77
  def partial_content
45
78
  <<~ERB
46
79
  #{usage_comment}
47
- #{strict_locals}
80
+ #{([strict_locals] + rejected_prop_lines).join("\n")}
48
81
  <div class="#{underscored_name.dasherize}">
49
82
  #{prop_lines.join("\n")}#{"\n" unless prop_lines.empty?} <%= content %>
50
83
  </div>
@@ -54,12 +87,12 @@ module Andromeda
54
87
  # Every prop is optional, and `content` is declared too: it is passed
55
88
  # even to a self-closing tag (`<Callout />`), where it is empty.
56
89
  def strict_locals
57
- locals = props.map { |prop| "#{prop.underscore}: nil" } << "content: nil"
90
+ locals = valid_props.map { |prop| "#{prop.underscore}: nil" } << "content: nil"
58
91
  "<%# locals: (#{locals.join(', ')}) %>"
59
92
  end
60
93
 
61
94
  def prop_lines
62
- props.map do |prop|
95
+ valid_props.map do |prop|
63
96
  local = prop.underscore
64
97
  " <% if local_assigns[:#{local}] %><p class=\"#{underscored_name.dasherize}__#{local.dasherize}\">" \
65
98
  "<%= #{local} %></p><% end %>"
@@ -67,7 +100,7 @@ module Andromeda
67
100
  end
68
101
 
69
102
  def usage_comment
70
- attrs = props.map { |prop| "#{prop.underscore.camelize(:lower)}=\"...\"" }.join(" ")
103
+ attrs = valid_props.map { |prop| "#{prop.underscore.camelize(:lower)}=\"...\"" }.join(" ")
71
104
  opening = attrs.empty? ? "<#{tag_name}>" : "<#{tag_name} #{attrs}>"
72
105
 
73
106
  <<~ERB.chomp
@@ -66,7 +66,10 @@ module Andromeda
66
66
  def numeric_precision(schema_json, field_name)
67
67
  return nil unless schema_json.is_a?(Hash)
68
68
 
69
- property = (schema_json["properties"] || {})[field_name]
69
+ properties = schema_json["properties"]
70
+ return nil unless properties.is_a?(Hash)
71
+
72
+ property = properties[field_name]
70
73
  return nil unless property.is_a?(Hash)
71
74
 
72
75
  case property["type"]
@@ -28,6 +28,8 @@ module Andromeda
28
28
 
29
29
  DEFAULT_PATTERN = "**/*.{md,mdx}"
30
30
 
31
+ COLLECTION_RE = /const\s+(\w+)\s*=\s*defineCollection\s*\(/.freeze
32
+
31
33
  # Bash-style extglob openers (`!(drafts)`, `@(a|b)`, ...), which
32
34
  # Astro's glob loader accepts but Ruby's `Dir.glob` does not.
33
35
  EXTGLOB_RE = /[!@+*?]\(/.freeze
@@ -37,11 +39,16 @@ module Andromeda
37
39
  # expression text) is always kept so the generator can turn it into
38
40
  # a TODO comment naming exactly what needs a human (per the
39
41
  # generator's "never a silent omission" requirement).
42
+ #
43
+ # `default` is an already-parsed Ruby value (never source text), and
44
+ # `has_default` says whether there is one at all -- `default: false`
45
+ # and "no default" must not collapse into the same nil.
40
46
  Attribute = Struct.new(
41
- :name, :ruby_type, :required, :default, :of, :values, :collection, :source, :note,
47
+ :name, :ruby_type, :required, :default, :has_default, :of, :values, :collection, :source, :note,
42
48
  keyword_init: true
43
49
  ) do
44
50
  def unsupported? = ruby_type.nil?
51
+ def default? = has_default ? true : false
45
52
  end
46
53
 
47
54
  # Astro's own base Zod types. Order matters: more specific
@@ -55,7 +62,25 @@ module Andromeda
55
62
  [/\Az\.date\(\)\z/, :date]
56
63
  ].freeze
57
64
 
58
- DEFAULT_LITERAL_RE = /\A(?:true|false|-?\d+(?:\.\d+)?|'[^']*'|"[^"]*"|\[[^\]]*\])\z/.freeze
65
+ # Returned by `parse_literal` for anything it does not recognize, so
66
+ # that a literal `nil`/`false` default stays distinguishable.
67
+ UNPARSEABLE = Object.new.freeze
68
+
69
+ # The JS string escapes `parse_js_string` decodes. Any other
70
+ # backslash sequence makes the whole literal unparseable rather
71
+ # than risk decoding it differently from JS.
72
+ JS_STRING_ESCAPES = {
73
+ "\\" => "\\", "'" => "'", '"' => '"', "/" => "/", "$" => "$",
74
+ "n" => "\n", "t" => "\t", "r" => "\r", "b" => "\b", "f" => "\f", "v" => "\v"
75
+ }.freeze
76
+
77
+ # `\xHH`, `\uHHHH` and `\u{H...}`; `\0` only when no digit follows
78
+ # (`\01` is a legacy octal escape, which is not decoded).
79
+ JS_CODE_ESCAPE_RE = /\G(?:x(\h{2})|u(\h{4})|u\{(\h{1,6})\}|0(?!\d))/.freeze
80
+
81
+ # The field names the generator can declare: after
82
+ # `Andromeda::Fix.snake_case` these become plain Ruby symbols.
83
+ FIELD_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_-]*\z/.freeze
59
84
 
60
85
  module_function
61
86
 
@@ -64,13 +89,17 @@ module Andromeda
64
89
  # to resolve a `loader: glob({ base: ... })` path.
65
90
  # @return [Array<Collection>]
66
91
  def parse(source, astro_root:)
67
- source.scan(/const\s+(\w+)\s*=\s*defineCollection\s*\(/).flatten.map do |const_name|
68
- open = source.index("defineCollection", source.index("const #{const_name}"))
69
- paren = source.index("(", open)
92
+ # Each match's own position is used: looking the name up again
93
+ # with `index("const #{name}")` would find `const blog` inside
94
+ # `const blogPosts`, and rescanning from the top for every
95
+ # collection made a large file quadratic.
96
+ source.to_enum(:scan, COLLECTION_RE).filter_map do
97
+ match = Regexp.last_match
98
+ paren = match.end(0) - 1
70
99
  close = BalancedScanner.matching_close(source, paren)
71
- body = source[(paren + 1)...close]
100
+ next unless close
72
101
 
73
- build_collection(const_name, body, astro_root: astro_root)
102
+ build_collection(match[1], source[(paren + 1)...close], astro_root: astro_root)
74
103
  end
75
104
  end
76
105
 
@@ -79,6 +108,15 @@ module Andromeda
79
108
  base = extract_string(body, /base\s*:/) || "./src/content/#{name}"
80
109
  pattern, notes = extract_pattern(body)
81
110
 
111
+ # The pattern ends up in the generated class and is globbed from
112
+ # `app/content/<name>/` at runtime, so one that climbs out of it
113
+ # (`../../config/*`, `/etc/*`, `~/...`) is not carried over.
114
+ if escapes_base?(pattern)
115
+ notes << "pattern `#{pattern}` reaches outside the collection directory -- " \
116
+ "replaced with `#{DEFAULT_PATTERN}`"
117
+ pattern = DEFAULT_PATTERN
118
+ end
119
+
82
120
  Collection.new(
83
121
  name: name,
84
122
  base: File.expand_path(base, astro_root),
@@ -131,6 +169,10 @@ module Andromeda
131
169
  [pattern, notes]
132
170
  end
133
171
 
172
+ def escapes_base?(pattern)
173
+ pattern.start_with?("/", "~") || pattern.split(%r{[/\\]}).include?("..")
174
+ end
175
+
134
176
  def backticked(elements)
135
177
  elements.map { |element| "`#{element}`" }.join(", ")
136
178
  end
@@ -168,13 +210,30 @@ module Andromeda
168
210
  end
169
211
 
170
212
  def build_attribute(key, expr)
171
- expr, required, default = strip_modifiers(expr)
213
+ # A quoted key can hold anything (`"a; system('…')": ...`); only a
214
+ # name that is a plain identifier is declared, anything else goes
215
+ # to the TODO list.
216
+ unless key.match?(FIELD_NAME_RE)
217
+ return Attribute.new(
218
+ name: key, ruby_type: nil, required: false, source: expr,
219
+ note: "field name `#{key}` is not a valid attribute name"
220
+ )
221
+ end
222
+
223
+ expr, required, default, has_default, dropped_default = strip_modifiers(expr)
172
224
  type, extra = classify(expr)
173
225
 
226
+ notes = [extra[:note]]
227
+ if dropped_default
228
+ notes << "default `#{dropped_default}` is not a plain literal and was not carried over -- " \
229
+ "set `default:` by hand"
230
+ end
231
+
174
232
  Attribute.new(
175
- name: key, ruby_type: type, required: type.nil? ? false : required, default: default,
233
+ name: key, ruby_type: type, required: type.nil? ? false : required,
234
+ default: default, has_default: has_default,
176
235
  of: extra[:of], values: extra[:values], collection: extra[:collection],
177
- source: expr, note: extra[:note]
236
+ source: expr, note: notes.compact.join("; ").then { |note| note.empty? ? nil : note }
178
237
  )
179
238
  end
180
239
 
@@ -186,6 +245,8 @@ module Andromeda
186
245
  def strip_modifiers(expr)
187
246
  required = true
188
247
  default = nil
248
+ has_default = false
249
+ dropped_default = nil
189
250
 
190
251
  loop do
191
252
  if expr.end_with?(".optional()")
@@ -195,7 +256,8 @@ module Andromeda
195
256
  expr = inner.strip
196
257
  required = false
197
258
  elsif (inner = suffix_call_argument(expr, ".default"))
198
- default = literal_default(inner)
259
+ has_default, default = literal_default(inner)
260
+ dropped_default = has_default ? nil : inner.strip
199
261
  expr = expr[0...-(".default(#{inner})".length)].strip
200
262
  required = false
201
263
  else
@@ -203,7 +265,7 @@ module Andromeda
203
265
  end
204
266
  end
205
267
 
206
- [expr, required, default]
268
+ [expr, required, default, has_default, dropped_default]
207
269
  end
208
270
 
209
271
  # @return [String, nil] the argument text of a trailing `.method(...)`
@@ -231,13 +293,77 @@ module Andromeda
231
293
  expr[(prefix.length + 1)...close]
232
294
  end
233
295
 
234
- # Only a recognizable Ruby-literal-compatible default is carried
235
- # over verbatim (JS and Ruby agree on `true`/`false`/numbers/quoted
236
- # strings/arrays-of-those); anything else is dropped and the field
237
- # falls back to being flagged unsupported by `classify` seeing an
238
- # expression it was not built to trust the shape of.
296
+ # Parses a `.default(...)` argument into a Ruby value: `true`/`false`,
297
+ # an integer, a float, a quoted string, or an array of those.
298
+ # Anything else is dropped. The text itself is never carried over --
299
+ # the generator writes the value back out with `inspect`, so a JS
300
+ # string like `"#{system('…')}"` can never become Ruby code.
301
+ #
302
+ # @return [Array(Boolean, Object)] whether a default was recognized,
303
+ # and its value.
239
304
  def literal_default(text)
240
- text.strip.match?(DEFAULT_LITERAL_RE) ? text.strip : nil
305
+ value = parse_literal(text.strip, allow_array: true)
306
+ value.equal?(UNPARSEABLE) ? [false, nil] : [true, value]
307
+ end
308
+
309
+ def parse_literal(text, allow_array:)
310
+ case text
311
+ when "true" then true
312
+ when "false" then false
313
+ # A leading zero is left out: JS reads `0123` as legacy octal (83).
314
+ when /\A-?(?:0|[1-9]\d*)\z/ then Integer(text, 10)
315
+ when /\A-?(?:0|[1-9]\d*)\.\d+\z/
316
+ # A literal too large for a Float becomes `Infinity`, which
317
+ # `inspect` writes as an undefined constant.
318
+ value = Float(text)
319
+ value.finite? ? value : UNPARSEABLE
320
+ when /\A(['"]).*\1\z/m then parse_js_string(text)
321
+ when /\A\[.*\]\z/m
322
+ return UNPARSEABLE unless allow_array
323
+
324
+ values = BalancedScanner.split_top_level(text[1...-1]).map do |element|
325
+ parse_literal(element, allow_array: false)
326
+ end
327
+ values.any? { |value| value.equal?(UNPARSEABLE) } ? UNPARSEABLE : values
328
+ else
329
+ UNPARSEABLE
330
+ end
331
+ end
332
+
333
+ # Decodes a single- or double-quoted JS string literal (including
334
+ # its quotes). Returns UNPARSEABLE for an unescaped closing quote or
335
+ # newline inside it, or an escape outside JS_STRING_ESCAPES.
336
+ def parse_js_string(text)
337
+ quote = text[0]
338
+ body = text[1...-1]
339
+ result = +""
340
+ i = 0
341
+
342
+ while i < body.length
343
+ char = body[i]
344
+ if char == "\\"
345
+ if (decoded = JS_STRING_ESCAPES[body[i + 1]])
346
+ result << decoded
347
+ i += 2
348
+ elsif (code = JS_CODE_ESCAPE_RE.match(body, i + 1))
349
+ codepoint = (code[1] || code[2] || code[3] || "0").to_i(16)
350
+ # A lone surrogate half (`\uD83D`) has no UTF-8 encoding.
351
+ return UNPARSEABLE if codepoint > 0x10FFFF || (0xD800..0xDFFF).cover?(codepoint)
352
+
353
+ result << codepoint.chr(Encoding::UTF_8)
354
+ i = code.end(0)
355
+ else
356
+ return UNPARSEABLE
357
+ end
358
+ elsif char == quote || char == "\n"
359
+ return UNPARSEABLE
360
+ else
361
+ result << char
362
+ i += 1
363
+ end
364
+ end
365
+
366
+ result
241
367
  end
242
368
 
243
369
  # @return [Array(Symbol, Hash), Array(nil, Hash)] the Ruby attribute
@@ -37,6 +37,12 @@ module Andromeda
37
37
  class_option :dry_run, type: :boolean, default: false,
38
38
  desc: "Print the migration report without writing any files"
39
39
 
40
+ # A collection name becomes `collection :name` and, singularized and
41
+ # camelized, the class name in the generated Ruby.
42
+ COLLECTION_NAME_RE = /\A[A-Za-z][A-Za-z0-9_]*\z/.freeze
43
+ CLASS_NAME_RE = /\A[A-Z][A-Za-z0-9_]*\z/.freeze
44
+ RESERVED_CLASS_NAMES = %w[BEGIN END].freeze
45
+
40
46
  def import
41
47
  # Thor's own options Hash is frozen by the time a command runs, so
42
48
  # `--dry-run` is translated into Thor's built-in `--pretend`
@@ -71,15 +77,30 @@ module Andromeda
71
77
 
72
78
  # @return [Array<ImportAstro::ContentConfigConverter::Collection>]
73
79
  def load_collections
74
- @collections =
80
+ collections =
75
81
  if File.file?(config_path)
76
- ImportAstro::ContentConfigConverter.parse(File.read(config_path), astro_root: astro_root)
82
+ source = File.read(config_path)
83
+ # Checked before anything is written, so a bad file stops the
84
+ # import cleanly instead of halfway through.
85
+ raise Thor::Error, "src/content.config.ts is not valid UTF-8" unless source.valid_encoding?
86
+
87
+ ImportAstro::ContentConfigConverter.parse(source, astro_root: astro_root)
77
88
  else
78
89
  @report[:notes] << "no src/content.config.ts found -- falling back to every directory under " \
79
90
  "src/content/ with no schema"
80
91
  fallback_collections
81
92
  end
82
93
 
94
+ # Whether it came from `const <name> = defineCollection(...)` or a
95
+ # directory under src/content/, a name that cannot become valid
96
+ # Ruby (`blog-posts`, `BEGIN`, `_`, or `x; system('…') #` in a
97
+ # malicious theme) is skipped rather than written into code.
98
+ @collections, invalid = collections.partition { |collection| valid_collection_name?(collection.name) }
99
+ invalid.each do |collection|
100
+ @report[:notes] << "#{one_line(collection.name)}: skipped -- a collection name must be letters, digits " \
101
+ "and underscores, starting with a letter, and must make a valid class name"
102
+ end
103
+
83
104
  @collections.each do |collection|
84
105
  refine_with_json_schema!(collection)
85
106
  collection.notes.each { |note| @report[:notes] << "#{collection.name}: #{note}" }
@@ -118,13 +139,46 @@ module Andromeda
118
139
  end
119
140
  end
120
141
 
142
+ def valid_collection_name?(name)
143
+ return false unless name.match?(COLLECTION_NAME_RE)
144
+
145
+ class_name = name.to_s.singularize.camelize
146
+ class_name.match?(CLASS_NAME_RE) && !RESERVED_CLASS_NAMES.include?(class_name)
147
+ end
148
+
149
+ # Only files whose real path is inside the Astro project are copied.
150
+ # `glob({ base: '~/.ssh' })`, `base: '../..'`, or a symlink such as
151
+ # `src/assets/key -> ~/.ssh/id_rsa` in a malicious theme would
152
+ # otherwise copy files from elsewhere on the machine into the app.
153
+ def inside_astro_root?(path)
154
+ @real_astro_root ||= File.realpath(astro_root)
155
+ real = File.realpath(path)
156
+ real == @real_astro_root || real.start_with?("#{@real_astro_root}#{File::SEPARATOR}")
157
+ rescue SystemCallError
158
+ false
159
+ end
160
+
161
+ def report_outside(path)
162
+ @report[:notes] << "#{one_line(path)}: not copied -- it resolves to a location outside the Astro project"
163
+ end
164
+
121
165
  def copy_collections
122
166
  @collections.each do |collection|
123
167
  next unless Dir.exist?(collection.base)
124
168
 
169
+ unless inside_astro_root?(collection.base)
170
+ report_outside(collection.base)
171
+ next
172
+ end
173
+
125
174
  Dir.glob(File.join(collection.base, "**/*")).sort.each do |src|
126
175
  next unless File.file?(src)
127
176
 
177
+ unless inside_astro_root?(src)
178
+ report_outside(src.delete_prefix("#{astro_root}/"))
179
+ next
180
+ end
181
+
128
182
  relative = src.delete_prefix("#{collection.base}/")
129
183
  dest = "app/content/#{collection.name}/#{relative}"
130
184
  copy_content_file(src, dest)
@@ -144,6 +198,13 @@ module Andromeda
144
198
  end
145
199
 
146
200
  source = File.read(src)
201
+ unless source.valid_encoding?
202
+ create_file dest, File.binread(src)
203
+ @report[:copied] << dest
204
+ @report[:notes] << "#{one_line(dest)}: not valid UTF-8 -- copied unchanged, without frontmatter or import rewrites"
205
+ return
206
+ end
207
+
147
208
  fixed, renames = Andromeda::Fix.fix_source(source)
148
209
  renames.each { |from, to| @report[:frontmatter_renames] << "#{dest}: #{from} -> #{to}" }
149
210
 
@@ -172,9 +233,19 @@ module Andromeda
172
233
  assets_root = File.join(astro_root, "src/assets")
173
234
  return unless Dir.exist?(assets_root)
174
235
 
236
+ unless inside_astro_root?(assets_root)
237
+ report_outside("src/assets")
238
+ return
239
+ end
240
+
175
241
  Dir.glob(File.join(assets_root, "**/*")).sort.each do |src|
176
242
  next unless File.file?(src)
177
243
 
244
+ unless inside_astro_root?(src)
245
+ report_outside(src.delete_prefix("#{astro_root}/"))
246
+ next
247
+ end
248
+
178
249
  relative = src.delete_prefix("#{assets_root}/")
179
250
  dest = "app/assets/#{relative}"
180
251
  create_file dest, File.binread(src)
@@ -198,17 +269,26 @@ module Andromeda
198
269
  return unless @component_usages
199
270
 
200
271
  @component_usages.each_value do |usage|
272
+ # A lowercase tag is plain HTML to the renderer, not a component.
273
+ next unless usage.name.match?(/\A[A-Z]/)
274
+
275
+ unless usage.name.match?(Andromeda::Generators::ComponentGenerator::VALID_NAME_RE)
276
+ @report[:notes] << "<#{one_line(usage.name)}>: no partial stub generated -- the name is not a valid " \
277
+ "component name; add the partial by hand"
278
+ next
279
+ end
201
280
  next if partial_exists?(usage.name)
202
281
 
203
- # Reuses the real `andromeda:component` generator (rather than
204
- # duplicating its stub-content logic here) via Thor's own
205
- # `invoke`, which shares this generator's `destination_root`
206
- # automatically. `pretend:` is passed through explicitly --
207
- # `invoke` only inherits a fresh invocation's *original* options
208
- # (captured at construction time), not this instance's later
209
- # `self.options = options.merge(pretend: true)` translation of
210
- # `--dry-run` above.
211
- invoke Andromeda::Generators::ComponentGenerator, [usage.name, *usage.props], pretend: options[:dry_run]
282
+ # Reuses the real `andromeda:component` generator rather than
283
+ # duplicating its stub-content logic here. It is instantiated
284
+ # directly for every component: Thor's `invoke` runs a given
285
+ # generator class only once per run, so every stub after the
286
+ # first was silently skipped. `pretend:` is passed explicitly so
287
+ # `--dry-run` reaches it too.
288
+ Andromeda::Generators::ComponentGenerator.new(
289
+ [usage.name, *usage.props], { pretend: options[:dry_run] },
290
+ destination_root: destination_root, shell: shell
291
+ ).invoke_all
212
292
  @report[:stubs] << component_partial_path(usage.name)
213
293
  end
214
294
  end
@@ -235,10 +315,10 @@ module Andromeda
235
315
  namespace_modules.each_with_index { |mod, i| lines << "#{" " * i}module #{mod}" }
236
316
  lines << "#{indent}class #{collection.name.to_s.singularize.camelize} < Andromeda::Entry"
237
317
  collection.notes.each do |note|
238
- lines << "#{indent} # TODO: #{note} (content.config.ts) -- fix `pattern` by hand."
318
+ lines << "#{indent} # TODO: #{one_line(note)} (content.config.ts) -- fix `pattern` by hand."
239
319
  end
240
320
  lines << "#{indent} collection :#{collection.name}, base: \"app/content/#{collection.name}\", " \
241
- "pattern: \"#{collection.pattern}\""
321
+ "pattern: #{collection.pattern.inspect}"
242
322
 
243
323
  unless collection.attributes.empty?
244
324
  lines << ""
@@ -258,17 +338,25 @@ module Andromeda
258
338
  return unsupported_attribute_lines(attr) if attr.unsupported?
259
339
 
260
340
  lines = []
261
- lines << "# NOTE: #{attr.note}" if attr.note
262
- lines << "attribute :#{snake_name(attr)}, :#{attr.ruby_type}#{attribute_modifiers(attr)}"
341
+ lines << "# NOTE: #{one_line(attr.note)}" if attr.note
342
+ lines << "attribute #{snake_name(attr).to_sym.inspect}, :#{attr.ruby_type}#{attribute_modifiers(attr)}"
263
343
  lines
264
344
  end
265
345
 
266
346
  def unsupported_attribute_lines(attr)
267
- [
268
- "# TODO: unsupported schema for `#{attr.name}` in content.config.ts (#{attr.source}) -- " \
269
- "add the equivalent `attribute` by hand.",
270
- "# attribute :#{snake_name(attr)}, :string"
271
- ]
347
+ lines = []
348
+ lines << "# NOTE: #{one_line(attr.note)}" if attr.note
349
+ lines << "# TODO: unsupported schema for `#{one_line(attr.name)}` in content.config.ts " \
350
+ "(#{one_line(attr.source)}) -- add the equivalent `attribute` by hand."
351
+ lines << "# attribute :#{one_line(snake_name(attr))}, :string"
352
+ lines
353
+ end
354
+
355
+ # Everything taken from the imported project that ends up in a
356
+ # generated `#` comment goes through here: a newline would end the
357
+ # comment and turn the rest of the text into Ruby code.
358
+ def one_line(text)
359
+ text.to_s.gsub(/\s+/, " ")
272
360
  end
273
361
 
274
362
  # The generated class's `attribute` calls must use the same
@@ -280,13 +368,16 @@ module Andromeda
280
368
  Andromeda::Fix.snake_case(attr.name)
281
369
  end
282
370
 
371
+ # Every value taken from the imported project is written with
372
+ # `inspect`, never interpolated as source text, so nothing in
373
+ # content.config.ts can turn into Ruby code in the generated class.
283
374
  def attribute_modifiers(attr)
284
375
  parts = []
285
376
  parts << "of: :#{attr.of}" if attr.ruby_type == :array && attr.of
286
377
  parts << "values: #{attr.values.inspect}" if attr.ruby_type == :enum
287
- parts << "collection: :#{attr.collection}" if attr.ruby_type == :reference
288
- parts << "required: true" if attr.required && attr.default.nil?
289
- parts << "default: #{attr.default}" if attr.default
378
+ parts << "collection: #{attr.collection.inspect}" if attr.ruby_type == :reference
379
+ parts << "required: true" if attr.required && !attr.default?
380
+ parts << "default: #{attr.default.inspect}" if attr.default?
290
381
  parts.empty? ? "" : ", #{parts.join(", ")}"
291
382
  end
292
383
 
@@ -307,11 +398,18 @@ module Andromeda
307
398
  report_section " Notes", @report[:notes], indent: " "
308
399
  end
309
400
 
401
+ def printable(text)
402
+ text.to_s.gsub(/[[:cntrl:]]/) { |char| char.dump[1...-1] }
403
+ end
404
+
310
405
  def report_section(title, items, indent: " ")
311
406
  return if items.empty?
312
407
 
313
408
  say "#{title}:"
314
- items.each { |item| say "#{indent}#{item}" }
409
+ # Items carry file names and text from the imported project; a
410
+ # control character (an ANSI escape, say) is shown escaped rather
411
+ # than sent to the terminal.
412
+ items.each { |item| say "#{indent}#{printable(item)}" }
315
413
  say ""
316
414
  end
317
415
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: andromeda_cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - LIMHAUS Inc.