senren-ui 0.1.6 → 0.3.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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +215 -0
  3. data/CONTRIBUTING.md +41 -3
  4. data/README.md +138 -33
  5. data/Rakefile +14 -1
  6. data/docs/components.md +10 -10
  7. data/docs/hot_reload.md +103 -0
  8. data/docs/performance_testing.md +7 -3
  9. data/docs/visual_style.md +80 -0
  10. data/lib/generators/senren/component/templates/controller.js.tt +7 -4
  11. data/lib/generators/senren/install/install_generator.rb +77 -0
  12. data/lib/generators/senren/install/templates/base_component.rb.tt +142 -4
  13. data/lib/generators/senren/install/templates/conventions.md.tt +57 -1
  14. data/lib/generators/senren/install/templates/senren_themes.css.tt +339 -0
  15. data/lib/senren/rails/agent_rules_writer.rb +65 -19
  16. data/lib/senren/rails/asset_path_guard.rb +128 -0
  17. data/lib/senren/rails/base_component_patch.rb +64 -0
  18. data/lib/senren/rails/component_copier.rb +101 -50
  19. data/lib/senren/rails/component_installer.rb +26 -0
  20. data/lib/senren/rails/doctor.rb +7 -4
  21. data/lib/senren/rails/engine.rb +23 -0
  22. data/lib/senren/rails/host_paths.rb +11 -2
  23. data/lib/senren/rails/marker_block.rb +81 -0
  24. data/lib/senren/rails/registry.rb +10 -4
  25. data/lib/senren/rails/safe_write.rb +169 -0
  26. data/lib/senren/rails/skill_writer.rb +47 -11
  27. data/lib/senren/rails/version.rb +1 -1
  28. data/lib/senren/rails.rb +1 -1
  29. data/lib/senren-ui.rb +15 -0
  30. data/lib/tasks/senren.rake +43 -17
  31. data/registry/components.yml +45 -0
  32. data/registry/recipes.yml +12 -0
  33. data/templates/components/accordion/accordion_component.html.erb +3 -3
  34. data/templates/components/alert_dialog/alert_dialog_component.html.erb +6 -3
  35. data/templates/components/alert_dialog/alert_dialog_component.rb +5 -1
  36. data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
  37. data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
  38. data/templates/components/avatar/avatar_component.rb +8 -1
  39. data/templates/components/breadcrumb/breadcrumb_component.html.erb +8 -6
  40. data/templates/components/button/button_component.html.erb +1 -1
  41. data/templates/components/button/button_component.rb +27 -1
  42. data/templates/components/calendar/calendar_component.html.erb +2 -2
  43. data/templates/components/card/card_component.html.erb +4 -6
  44. data/templates/components/carousel/carousel_component.html.erb +1 -1
  45. data/templates/components/cart/cart_component.html.erb +67 -0
  46. data/templates/components/cart/cart_component.rb +71 -0
  47. data/templates/components/checkbox/checkbox_component.rb +1 -1
  48. data/templates/components/clipboard/clipboard_component.html.erb +3 -3
  49. data/templates/components/collapsible/collapsible_component.html.erb +3 -3
  50. data/templates/components/combobox/combobox_component.html.erb +3 -3
  51. data/templates/components/command/command_component.html.erb +1 -1
  52. data/templates/components/command/command_component.rb +1 -1
  53. data/templates/components/context_menu/context_menu_component.html.erb +6 -3
  54. data/templates/components/date_picker/date_picker_component.html.erb +3 -3
  55. data/templates/components/dialog/dialog_component.html.erb +7 -3
  56. data/templates/components/dialog/dialog_component.rb +12 -1
  57. data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +6 -3
  58. data/templates/components/dropdown_menu/dropdown_menu_component.rb +57 -5
  59. data/templates/components/form/form_component.rb +9 -1
  60. data/templates/components/hover_card/hover_card_component.html.erb +6 -3
  61. data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +3 -2
  62. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
  63. data/templates/components/link/link_component.html.erb +1 -1
  64. data/templates/components/native_select/native_select_component.html.erb +2 -2
  65. data/templates/components/pagination/pagination_component.html.erb +2 -2
  66. data/templates/components/popover/popover_component.html.erb +6 -3
  67. data/templates/components/product_card/product_card_component.html.erb +38 -0
  68. data/templates/components/product_card/product_card_component.rb +49 -0
  69. data/templates/components/progress/progress_component.html.erb +2 -2
  70. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
  71. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
  72. data/templates/components/search_input/search_input_component.html.erb +2 -2
  73. data/templates/components/separator/separator_component.rb +7 -0
  74. data/templates/components/sheet/sheet_component.html.erb +13 -4
  75. data/templates/components/sheet/sheet_component.rb +12 -1
  76. data/templates/components/shortcut_key/shortcut_key_component.html.erb +2 -2
  77. data/templates/components/sidebar/sidebar_component.html.erb +2 -2
  78. data/templates/components/switch/switch_component.html.erb +1 -1
  79. data/templates/components/table/table_component.html.erb +2 -2
  80. data/templates/components/tabs/tabs_component.html.erb +3 -3
  81. data/templates/components/tooltip/tooltip_component.html.erb +5 -3
  82. data/templates/components/tooltip/tooltip_component.rb +2 -2
  83. data/templates/components/top_nav/top_nav_component.html.erb +2 -2
  84. data/templates/components/typography/typography_component.rb +7 -0
  85. data/templates/controllers/accordion_controller.js +1 -1
  86. data/templates/controllers/alert_dialog_controller.js +31 -7
  87. data/templates/controllers/cart_controller.js +85 -0
  88. data/templates/controllers/clipboard_controller.js +12 -1
  89. data/templates/controllers/command_controller.js +3 -4
  90. data/templates/controllers/context_menu_controller.js +38 -11
  91. data/templates/controllers/data_table_controller.js +8 -3
  92. data/templates/controllers/dialog_controller.js +47 -21
  93. data/templates/controllers/dropdown_menu_controller.js +40 -27
  94. data/templates/controllers/hover_card_controller.js +8 -0
  95. data/templates/controllers/invite_member_dialog_controller.js +6 -0
  96. data/templates/controllers/masked_input_controller.js +8 -1
  97. data/templates/controllers/popover_controller.js +25 -10
  98. data/templates/controllers/rich_text_editor_lite_controller.js +165 -28
  99. data/templates/controllers/sheet_controller.js +41 -11
  100. metadata +14 -17
  101. data/lib/senren/rails/installer.rb +0 -85
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
+ require 'pathname'
4
5
  require 'yaml'
5
6
  require 'time'
7
+ require 'senren/rails/base_component_patch'
8
+ require 'senren/rails/safe_write'
6
9
 
7
10
  module Senren
8
11
  module Rails
@@ -15,40 +18,7 @@ module Senren
15
18
  '../../generators/senren/install/templates', __dir__
16
19
  ).freeze
17
20
  BASE_COMPONENT_TEMPLATE = File.join(INSTALL_GENERATOR_TEMPLATES, 'base_component.rb.tt').freeze
18
- BASE_URL_HELPER_PATCH = <<~RUBY
19
-
20
- # Added by senren:add for compatibility with URL-aware component templates.
21
- require 'uri'
22
-
23
- module Senren
24
- class BaseComponent
25
- SAFE_URL_PROTOCOLS = %w[http https mailto tel].freeze unless const_defined?(:SAFE_URL_PROTOCOLS)
26
- SAFE_MEDIA_URL_PROTOCOLS = %w[http https].freeze unless const_defined?(:SAFE_MEDIA_URL_PROTOCOLS)
27
-
28
- private
29
-
30
- def safe_url(value, fallback: '#', protocols: SAFE_URL_PROTOCOLS)
31
- url = value.to_s.strip
32
- return fallback if url.empty?
33
- return url if url.start_with?('#')
34
- return url if url.start_with?('/') && !url.start_with?('//')
35
-
36
- uri = URI.parse(url)
37
- return url if uri.scheme && Array(protocols).map(&:to_s).include?(uri.scheme.downcase)
38
- return fallback if uri.host
39
- return url unless uri.scheme
40
-
41
- fallback
42
- rescue URI::InvalidURIError
43
- fallback
44
- end
45
-
46
- def safe_media_url(value, fallback: nil)
47
- safe_url(value, fallback: fallback, protocols: SAFE_MEDIA_URL_PROTOCOLS)
48
- end
49
- end
50
- end
51
- RUBY
21
+ BASE_URL_HELPER_PATCH = BaseComponentPatch::URL_HELPERS
52
22
 
53
23
  attr_reader :registry, :paths, :stdout
54
24
 
@@ -62,30 +32,64 @@ module Senren
62
32
  # Returns the ordered list of component names actually installed.
63
33
  def install(component_names, client_override: nil, force: false)
64
34
  wanted = registry.dependencies(*component_names)
35
+ validate_client_override!(component_names, client_override)
65
36
  paths.ensure_dirs!
66
37
  ensure_base_component_url_helpers!
67
38
 
39
+ requested = Array(component_names).map(&:to_s)
68
40
  wanted.each do |name|
69
41
  comp = registry.fetch(name)
70
- install_component(comp, client_override: client_override, force: force)
42
+ install_component(comp, client_override: override_for(name, requested, client_override), force: force)
71
43
  end
72
44
 
73
- update_installed_ledger(wanted, client_override: client_override)
45
+ update_installed_ledger(wanted, requested: requested, client_override: client_override)
74
46
  wanted
75
47
  end
76
48
 
77
49
  private
78
50
 
51
+ # --client / --no-client describes what the user asked for, not what its
52
+ # dependencies are. Applying it to the whole closure meant
53
+ # `senren:add context_menu --no-client` also suppressed the controller for
54
+ # dropdown_menu, whose markup emits data-controller unconditionally — so
55
+ # the installed menu silently never opened, and the ledger then recorded
56
+ # client: false for a component this command was never asked about.
57
+ #
58
+ # validate_client_override! already exempts dependencies in the other
59
+ # direction, for the same reason.
60
+ def override_for(name, requested, client_override)
61
+ requested.include?(name) ? client_override : nil
62
+ end
63
+
79
64
  def ensure_base_component_url_helpers!
80
- if paths.base_component_path.exist?
65
+ dest = paths.base_component_path
66
+
67
+ # Checked before #exist?, which follows the link, so a destination
68
+ # resolving outside the app root is refused before it is read.
69
+ return if refuse_symlink?(dest, 'base_component.rb')
70
+
71
+ if dest.exist?
81
72
  return if base_component_has_url_helpers?
82
73
 
83
- File.open(paths.base_component_path, 'a') { |file| file.write(BASE_URL_HELPER_PATCH) }
84
- stdout.puts " update #{paths.base_component_path} (url helpers)"
74
+ # Read-modify-write through SafeWrite rather than File.open(dest, 'a').
75
+ # The append was the one non-atomic write left in the gem: a process
76
+ # killed partway through left base_component.rb holding half a method,
77
+ # and an app that will not boot. This is the migration path onto the
78
+ # hardened URL helpers, so it runs on apps that already have code
79
+ # worth not corrupting.
80
+ SafeWrite.write!(dest, dest.read + BASE_URL_HELPER_PATCH, paths.root, 'base_component.rb')
81
+ stdout.puts " update #{dest} (url helpers)"
85
82
  return
86
83
  end
87
84
 
88
- copy_file(BASE_COMPONENT_TEMPLATE, paths.base_component_path, force: false, label: 'base_component.rb')
85
+ copy_file(BASE_COMPONENT_TEMPLATE, dest, force: false, label: 'base_component.rb')
86
+ end
87
+
88
+ # Delegates to SafeWrite so an intermediate symlinked directory is caught,
89
+ # not just a symlinked leaf. `app/components/senren -> /elsewhere` used to
90
+ # pass every check here.
91
+ def refuse_symlink?(dest, label)
92
+ SafeWrite.resolve(dest, paths.root, label, io: stdout).nil?
89
93
  end
90
94
 
91
95
  def base_component_has_url_helpers?
@@ -106,11 +110,28 @@ module Senren
106
110
  end
107
111
  end
108
112
 
113
+ # Only the explicitly requested components are checked: pulling in a
114
+ # dependency that has no controller must not fail the whole install.
115
+ def validate_client_override!(requested, override)
116
+ return unless override
117
+
118
+ offenders = Array(requested).map { |name| registry.fetch(name) }.reject { |comp| controller_file_for(comp) }
119
+ return if offenders.empty?
120
+
121
+ raise ArgumentError,
122
+ "--client was requested for #{offenders.map(&:name).join(', ')}, but the registry lists no Stimulus " \
123
+ 'controller for them. Drop --client, or add a controller file to the registry entry.'
124
+ end
125
+
126
+ def controller_file_for(comp)
127
+ comp.files.find { |relative| controller_source_path?(comp, relative) }
128
+ end
129
+
109
130
  def effective_client_for(comp, override)
110
- return comp.client? if override.nil?
111
- return false unless comp.can_have_client
131
+ desired = override.nil? ? comp.client? : (comp.can_have_client && override)
112
132
 
113
- override
133
+ # Never record client behavior in the ledger that was not installed.
134
+ desired && !controller_file_for(comp).nil?
114
135
  end
115
136
 
116
137
  def source_for(comp, relative)
@@ -142,18 +163,46 @@ module Senren
142
163
  def copy_file(src, dest, force:, label:)
143
164
  raise MissingTemplate, "Missing component template: #{src} (#{label})" unless File.exist?(src)
144
165
 
166
+ dest = assert_inside_host_root!(dest, label)
167
+ return if refuse_symlink?(dest, label)
168
+
145
169
  if File.exist?(dest) && !force
146
170
  stdout.puts " skip #{dest} (already exists)"
147
171
  return
148
172
  end
149
- FileUtils.mkdir_p(File.dirname(dest))
150
- FileUtils.cp(src, dest)
173
+ SafeWrite.mkdir_p!(File.dirname(dest), paths.root, label)
174
+ SafeWrite.copy!(src, dest, paths.root, label)
151
175
  stdout.puts " copy #{dest}"
152
176
  end
153
177
 
154
- def update_installed_ledger(names, client_override:)
178
+ # Defense in depth: destinations are registry-derived, but a copier that
179
+ # can write anywhere is one bad registry entry away from a traversal.
180
+ #
181
+ # This used to compare expand_path, which normalises lexically and does
182
+ # not resolve symlinks, so a symlinked ancestor escaped it entirely.
183
+ def assert_inside_host_root!(dest, label)
184
+ SafeWrite.assert_inside!(dest, paths.root, label)
185
+ rescue SafeWrite::Escape => e
186
+ raise ArgumentError, e.message
187
+ end
188
+
189
+ # A ledger holding anything but a mapping used to reach `ledger['installed']
190
+ # ||= []` and raise `IndexError: string not matched` from String#[]=, which
191
+ # tells the user nothing about which file is wrong or why.
192
+ def load_ledger(path)
193
+ return {} unless path.exist?
194
+
195
+ content = YAML.safe_load_file(path) || {}
196
+ return content if content.is_a?(Hash)
197
+
198
+ raise ArgumentError,
199
+ "#{path} is not a Senren ledger: expected a YAML mapping, got #{content.class}. " \
200
+ 'Fix or delete the file and run the command again.'
201
+ end
202
+
203
+ def update_installed_ledger(names, requested:, client_override:)
155
204
  ledger_path = paths.installed_components
156
- ledger = ledger_path.exist? ? (YAML.safe_load_file(ledger_path) || {}) : {}
205
+ ledger = load_ledger(ledger_path)
157
206
  installed = ledger['installed'] ||= []
158
207
 
159
208
  names.each do |name|
@@ -162,7 +211,9 @@ module Senren
162
211
  'name' => name,
163
212
  'version' => Senren::Rails::VERSION,
164
213
  'installed_at' => Time.now.utc.iso8601,
165
- 'client' => effective_client_for(registry.fetch(name), client_override)
214
+ 'client' => effective_client_for(
215
+ registry.fetch(name), override_for(name, requested, client_override)
216
+ )
166
217
  }
167
218
  if existing
168
219
  existing.merge!(attrs.except('installed_at'))
@@ -172,8 +223,8 @@ module Senren
172
223
  end
173
224
 
174
225
  installed.sort_by! { |e| e['name'] }
175
- ledger_path.parent.mkpath
176
- File.write(ledger_path, YAML.dump(ledger))
226
+ SafeWrite.mkdir_p!(ledger_path.parent, paths.root, 'ledger')
227
+ SafeWrite.write!(ledger_path, YAML.dump(ledger), paths.root, 'ledger')
177
228
  end
178
229
  end
179
230
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
3
4
  require 'senren/rails/agent_rules_writer'
4
5
  require 'senren/rails/component_copier'
5
6
  require 'senren/rails/host_paths'
@@ -33,15 +34,40 @@ module Senren
33
34
  normalized_names = self.class.normalize_names(names)
34
35
  raise ArgumentError, USAGE if normalized_names.empty?
35
36
 
37
+ # Preflight. Everything below writes to disk, and a failure partway
38
+ # through leaves components copied, the ledger updated and the guidance
39
+ # files stale -- which is exactly what the first version of the adapter
40
+ # collision check did.
41
+ AgentRulesWriter.new(registry: registry, paths: paths).assert_distinct_adapters!
42
+
36
43
  installed = ComponentCopier.new(registry: registry, paths: paths, stdout: stdout)
37
44
  .install(normalized_names, client_override: client_override, force: force)
38
45
 
46
+ refresh_registry_mirror
39
47
  SkillWriter.new(registry: registry, paths: paths).sync!
40
48
  AgentRulesWriter.new(registry: registry, paths: paths).sync!
41
49
 
42
50
  stdout.puts "Installed: #{installed.join(', ')}"
43
51
  installed
44
52
  end
53
+
54
+ private
55
+
56
+ # The mirror was written once at install time and never again, so it drifted
57
+ # from the gem on every upgrade while the generated agent rules kept
58
+ # advertising it as the authoritative "component registry mirror". Agents
59
+ # read stale component metadata and had no way to tell.
60
+ # The FileUtils.cp here was lifted from the Installer#mirror_registry that
61
+ # this same review deleted, and it carried that method's defect with it:
62
+ # containment was applied to the directory, so a symlinked
63
+ # .senren/registry.yml was followed and a file outside the app root was
64
+ # overwritten. Moving a bug is not fixing it. SafeWrite.copy! checks the
65
+ # destination file itself.
66
+ def refresh_registry_mirror
67
+ dest = paths.registry_mirror
68
+ SafeWrite.mkdir_p!(dest.dirname, paths.root, 'registry mirror')
69
+ SafeWrite.copy!(Senren::Rails.registry_path, dest, paths.root, 'registry mirror')
70
+ end
45
71
  end
46
72
  end
47
73
  end
@@ -28,14 +28,17 @@ module Senren
28
28
  private
29
29
 
30
30
  def check(label)
31
- ok = false
32
31
  detail = nil
33
- begin
34
- ok = !yield.nil?
32
+ result = begin
33
+ yield
35
34
  rescue StandardError => e
36
35
  detail = e.message
36
+ nil
37
37
  end
38
- Result.new(label, ok, detail)
38
+
39
+ # Checks return either a boolean (File#exist?) or nil/truthy (defined?).
40
+ # Only a truthy result passes: `false` must fail, not slip through.
41
+ Result.new(label, result ? true : false, detail)
39
42
  end
40
43
 
41
44
  def gem_loadable?(name)
@@ -11,6 +11,29 @@ module Senren
11
11
  rake_path = File.expand_path('../../tasks/senren.rake', __dir__)
12
12
  app.paths['lib/tasks'] << rake_path if File.exist?(rake_path)
13
13
  end
14
+
15
+ # The gem's only production-boot behaviour, and it earns the exception:
16
+ # with app/components on the asset load path, Propshaft publishes
17
+ # component source.
18
+ #
19
+ # after_initialize rather than an `after:` anchor. Hosts append asset
20
+ # paths from config/initializers/assets.rb, so the check has to see a
21
+ # fully configured app. after_initialize is the only placement that
22
+ # guarantees that without reasoning about railtie order.
23
+ #
24
+ # This was first written as `after: :append_assets_path`, copied from
25
+ # sprockets-rails. Propshaft names its equivalent
26
+ # "propshaft.append_assets_path", and Rails resolves an unknown ordering
27
+ # anchor to an empty dependency set rather than raising — so the anchor
28
+ # was inert. It happened not to matter, because an application's own
29
+ # initializers run ahead of every engine's, but an inert anchor that reads
30
+ # like a guarantee is worse than no anchor.
31
+ initializer 'senren.rails.guard_asset_paths' do |app|
32
+ app.config.after_initialize do
33
+ require 'senren/rails/asset_path_guard'
34
+ Senren::Rails::AssetPathGuard.check!(app)
35
+ end
36
+ end
14
37
  end
15
38
  end
16
39
  end
@@ -1,4 +1,5 @@
1
1
  require 'pathname'
2
+ require 'senren/rails/safe_write'
2
3
 
3
4
  module Senren
4
5
  module Rails
@@ -32,13 +33,21 @@ module Senren
32
33
  def claude_md = root.join('CLAUDE.md')
33
34
  def codex_agents_md = root.join('AGENTS.md')
34
35
 
36
+ # Pathname#mkpath stops at File.directory?, which follows symlinks, so a
37
+ # checkout shipping app/components/senren as a link to somewhere outside
38
+ # the project had that link preserved and every later write redirected
39
+ # through it. SafeWrite refuses instead of silently accepting it.
35
40
  def ensure_dirs!
36
41
  [senren_dir, components_dir, stimulus_dir,
37
- stylesheet_path.dirname, github_dir, cursor_rules_dir].each(&:mkpath)
42
+ stylesheet_path.dirname, github_dir, cursor_rules_dir].each do |dir|
43
+ SafeWrite.mkdir_p!(dir, root, 'ensure_dirs!')
44
+ end
38
45
  end
39
46
 
40
47
  def ensure_agent_dirs!
41
- [senren_dir, github_dir, cursor_rules_dir].each(&:mkpath)
48
+ [senren_dir, github_dir, cursor_rules_dir].each do |dir|
49
+ SafeWrite.mkdir_p!(dir, root, 'ensure_agent_dirs!')
50
+ end
42
51
  end
43
52
  end
44
53
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ module Rails
5
+ # Replaces the content between a START/END marker pair, preserving
6
+ # everything outside it.
7
+ #
8
+ # These markers delimit generated instructions inside files that humans and
9
+ # AI agents also edit (CLAUDE.md, AGENTS.md, .senren/skill.md). A tampered
10
+ # or duplicated marker pair is treated as an error rather than something to
11
+ # work around: silently "repairing" it produces a block that regeneration
12
+ # can never clean up again, which is indistinguishable from a persistent
13
+ # prompt injection.
14
+ module MarkerBlock
15
+ class MalformedMarkers < StandardError; end
16
+
17
+ module_function
18
+
19
+ # Returns `existing` with the marker block replaced by `generated`.
20
+ # Appends a fresh block when no markers are present yet.
21
+ def inject(existing, generated, start_marker:, end_marker:, label: nil)
22
+ existing = existing.to_s
23
+ body = generated.to_s.strip
24
+
25
+ assert_body_is_marker_free!(body, start_marker, end_marker, label)
26
+ assert_well_formed!(existing, start_marker, end_marker, label)
27
+
28
+ block = "#{start_marker}\n\n#{body}\n\n#{end_marker}"
29
+ return append(existing, block) unless existing.include?(start_marker)
30
+
31
+ pattern = /#{Regexp.escape(start_marker)}.*?#{Regexp.escape(end_marker)}/m
32
+ # Block form is required. Given a replacement *string*, String#sub
33
+ # expands \0, \1 and \& as backreferences: a body containing one would
34
+ # splice the old block — markers and all — back into the new one,
35
+ # resurrecting stale content and duplicating the delimiters. The block
36
+ # form takes the return value literally.
37
+ existing.sub(pattern) { block }
38
+ end
39
+
40
+ # A generated block that contains its own delimiters produces a file this
41
+ # module then refuses to parse: the writer corrupts its own output and the
42
+ # error blames the user for hand-editing. Fail at the source instead.
43
+ def assert_body_is_marker_free!(body, start_marker, end_marker, label)
44
+ found = [start_marker, end_marker].select { |marker| body.include?(marker) }
45
+ return if found.empty?
46
+
47
+ where = label ? " for #{label}" : ''
48
+ raise MalformedMarkers,
49
+ "Generated content#{where} contains #{found.join(' and ')}. " \
50
+ 'The data this block is rendered from must not contain the markers themselves.'
51
+ end
52
+
53
+ def append(existing, block)
54
+ head = existing.rstrip
55
+ head.empty? ? "#{block}\n" : "#{head}\n\n#{block}\n"
56
+ end
57
+
58
+ # A file is well formed when it has either no markers at all, or exactly
59
+ # one START followed by exactly one END.
60
+ def assert_well_formed!(existing, start_marker, end_marker, label)
61
+ starts = existing.scan(start_marker).size
62
+ ends = existing.scan(end_marker).size
63
+
64
+ return if starts.zero? && ends.zero?
65
+
66
+ where = label ? " in #{label}" : ''
67
+ if starts != 1 || ends != 1
68
+ raise MalformedMarkers,
69
+ "Expected exactly one #{start_marker} and one #{end_marker}#{where}, " \
70
+ "found #{starts} and #{ends}. Fix the markers by hand, then re-run the sync."
71
+ end
72
+
73
+ return if existing.index(start_marker) < existing.index(end_marker)
74
+
75
+ raise MalformedMarkers,
76
+ "#{end_marker} appears before #{start_marker}#{where}. " \
77
+ 'Fix the marker order by hand, then re-run the sync.'
78
+ end
79
+ end
80
+ end
81
+ end
@@ -10,15 +10,12 @@ module Senren
10
10
  OPTIONAL_KEYS = %w[controller stub].freeze
11
11
  ALLOWED_KEYS = (REQUIRED_KEYS + OPTIONAL_KEYS).freeze
12
12
  VALID_CATEGORIES = %w[actions forms overlays navigation layout data saas rich].freeze
13
+ NAME_PATTERN = /\A[a-z][a-z0-9_]*\z/
13
14
 
14
15
  Component = Struct.new(:name, :category, :client, :can_have_client, :controller, :stub, :files, :depends_on,
15
16
  :pairs_with, :variants, :accessibility, :use_for, :avoid, keyword_init: true) do
16
17
  def stub? = stub == true
17
18
  def client? = client == true
18
-
19
- def to_h_full
20
- to_h.merge(stub: stub?, client: client?)
21
- end
22
19
  end
23
20
 
24
21
  attr_reader :components, :groups, :recipes
@@ -107,6 +104,7 @@ module Senren
107
104
  end
108
105
 
109
106
  def validate_component(name, comp, errors)
107
+ validate_component_name(name, errors)
110
108
  validate_component_keys(name, errors)
111
109
  validate_component_category(name, comp, errors)
112
110
  validate_component_dependencies(name, comp, errors)
@@ -114,6 +112,14 @@ module Senren
114
112
  validate_component_file_paths(name, comp, errors)
115
113
  end
116
114
 
115
+ # The file-path allowlist is derived from the component name, so an
116
+ # unconstrained name would validate a traversal path against itself.
117
+ def validate_component_name(name, errors)
118
+ return if NAME_PATTERN.match?(name)
119
+
120
+ errors << "#{name.inspect}: invalid component name (expected #{NAME_PATTERN.source})"
121
+ end
122
+
117
123
  def validate_component_keys(name, errors)
118
124
  extra_keys = @raw_components.fetch(name).keys - ALLOWED_KEYS
119
125
  errors << "#{name}: unknown keys #{extra_keys.sort.join(', ')}" if extra_keys.any?
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pathname'
5
+ require 'securerandom'
6
+
7
+ module Senren
8
+ module Rails
9
+ # Containment for everything this gem writes into a host app.
10
+ #
11
+ # The threat model is a repository you clone and run `senren:install` in.
12
+ # The *content* written is gem-controlled, so this is a file-creation and
13
+ # overwrite primitive rather than an arbitrary-content write — but a
14
+ # checkout that ships `app/components/senren` as a symlink pointing outside
15
+ # the project could previously redirect every copied file, and
16
+ # `write_adapter_file` reads its destination before rewriting it, so
17
+ # pre-existing content outside the checkout was modified too.
18
+ #
19
+ # Two independent defects made that possible, and both are fixed here:
20
+ #
21
+ # 1. Containment used Pathname#expand_path, which normalises `..`
22
+ # lexically and does NOT resolve symlinks. An escaping path therefore
23
+ # passed the start_with? check. Containment must compare realpath.
24
+ # 2. Symlinks were refused on the leaf only, never on an intermediate
25
+ # directory, and Pathname#mkpath stops at File.directory? — which
26
+ # follows a link — so a symlinked parent was preserved rather than
27
+ # replaced.
28
+ module SafeWrite
29
+ class Escape < StandardError; end
30
+
31
+ module_function
32
+
33
+ MAX_LINK_DEPTH = 32
34
+
35
+ # Where a write to `path` would actually land, with symlinks followed --
36
+ # including a DANGLING one, by reading its declared target rather than
37
+ # asking the filesystem to resolve it.
38
+ #
39
+ # Following the declared target is what makes the dangling case decidable.
40
+ # `exist?` is false on a broken link, so a check that resolved only
41
+ # existing ancestors would clear the parent directory and let the write
42
+ # land wherever the link points.
43
+ def real_target(path, depth = 0)
44
+ current = Pathname.new(path).expand_path
45
+ return current if depth > MAX_LINK_DEPTH
46
+
47
+ if current.symlink?
48
+ link = Pathname.new(File.readlink(current))
49
+ link = current.dirname + link unless link.absolute?
50
+ return real_target(link, depth + 1)
51
+ end
52
+ return current.realpath if current.exist?
53
+
54
+ resolve_existing_prefix(current)
55
+ rescue SystemCallError
56
+ Pathname.new(path).expand_path
57
+ end
58
+
59
+ # For a destination that does not exist yet: resolve the deepest ancestor
60
+ # that does, then re-append the part that is still missing.
61
+ #
62
+ # `symlink?` is part of the loop condition, not just `exist?`. A dangling
63
+ # link is invisible to `exist?`, so a walk that only tested existence
64
+ # stepped straight over an intermediate one and rebuilt the tail
65
+ # lexically -- reporting a path inside the root for a write that would
66
+ # land wherever the link pointed. Not reachable through today's callers,
67
+ # because each one runs mkdir_p! on the parent first and that catches it,
68
+ # but that is an implicit contract between caller and helper, and this
69
+ # cycle has already broken two of those.
70
+ def resolve_existing_prefix(path)
71
+ tail = []
72
+ current = path
73
+ until current.exist? || current.symlink? || current.root?
74
+ tail.unshift(current.basename)
75
+ current = current.parent
76
+ end
77
+ current = real_target(current) if current.symlink?
78
+ current = current.realpath if current.exist?
79
+ tail.reduce(current) { |acc, part| acc.join(part) }
80
+ end
81
+
82
+ # True when a write to `path` would land inside `root`.
83
+ #
84
+ # The property is containment, NOT the absence of symlinks. An earlier
85
+ # version refused every link on the path regardless of where it pointed,
86
+ # which broke `ln -s AGENTS.md CLAUDE.md` -- an ordinary way to keep one
87
+ # set of agent instructions -- and took `senren:add` down with it.
88
+ def inside?(path, root)
89
+ real_root = Pathname.new(root).expand_path
90
+ real_root = real_root.realpath if real_root.exist?
91
+ target = real_target(path)
92
+
93
+ target == real_root || target.to_s.start_with?("#{real_root}#{File::SEPARATOR}")
94
+ end
95
+
96
+ def assert_inside!(path, root, label)
97
+ return real_target(path) if inside?(path, root)
98
+
99
+ raise Escape,
100
+ "Refusing to write outside the app root: #{path} resolves to " \
101
+ "#{real_target(path)} (#{label})"
102
+ end
103
+
104
+ # Creates `dir`, refusing only when it would land outside the root.
105
+ def mkdir_p!(dir, root, label)
106
+ target = assert_inside!(dir, root, label)
107
+ FileUtils.mkdir_p(target)
108
+ target
109
+ end
110
+
111
+ # Resolves a destination for writing, or returns nil when it must be
112
+ # skipped. Raises when the destination escapes the app root.
113
+ def resolve(dest, root, label, io: $stdout)
114
+ assert_inside!(dest, root, label)
115
+ rescue Escape => e
116
+ io.puts " skip #{dest} (#{e.message}) [#{label}]"
117
+ nil
118
+ end
119
+
120
+ # An in-repo symlink is left in place: the write goes to what it points
121
+ # at, so `ln -s AGENTS.md CLAUDE.md` survives the write instead of being
122
+ # replaced by a regular file.
123
+ def assert_writable!(path, root, label)
124
+ assert_inside!(path, root, label)
125
+ end
126
+
127
+ # Every write this gem performs goes through here or #copy!.
128
+ #
129
+ # Hand-rolled File.write / FileUtils.cp is what let component source
130
+ # escape twice: once through a symlinked directory, and once -- after
131
+ # that was fixed -- through a symlinked destination *file*, because
132
+ # containment had been applied to the parent only. Writing via a
133
+ # temporary and renaming also means a killed process cannot truncate the
134
+ # file: rename is atomic and does not follow a symlink at the target.
135
+ def write!(path, content, root, label)
136
+ target = assert_writable!(path, root, label)
137
+ atomically(target) { |io| io.write(content) }
138
+ end
139
+
140
+ def copy!(source, path, root, label)
141
+ target = assert_writable!(path, root, label)
142
+ atomically(target) { |io| IO.copy_stream(source.to_s, io) }
143
+ end
144
+
145
+ # The temporary was the last way out of the app root, and containment
146
+ # never looked at it.
147
+ #
148
+ # `<dest>.<pid>.tmp` is guessable, and git stores symlinks, so a
149
+ # repository could ship `.senren/registry.yml.<pid>.tmp` as a link to
150
+ # something outside the checkout. `File.write` follows a symlink, so the
151
+ # write landed there with every containment check having passed —
152
+ # verified before this was changed.
153
+ #
154
+ # Two independent fixes, because either alone is thin. A random suffix
155
+ # removes the guess, and O_CREAT|O_EXCL refuses to open an existing path
156
+ # at all and does not follow a final symlink, so a lucky guess still
157
+ # fails. 0o644 rather than 0o600 keeps the permissions a plain write
158
+ # would have produced.
159
+ def atomically(target, &)
160
+ tmp = Pathname.new("#{target}.#{SecureRandom.hex(12)}.tmp")
161
+ File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o644, &)
162
+ File.rename(tmp, target)
163
+ target
164
+ ensure
165
+ FileUtils.rm_f(tmp) if tmp&.exist?
166
+ end
167
+ end
168
+ end
169
+ end