rubik 0.1.6 → 0.2.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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "logger"
4
+ require_relative "requires_dag"
4
5
 
5
6
  module Rubik
6
7
  module Plugin
@@ -109,84 +110,161 @@ module Rubik
109
110
  end
110
111
  end
111
112
 
112
- # Detects a cycle in the plugin requires: DAG seen so far.
113
- # `requires_map` is a hash of plugin_name -> array of required coeffect names.
114
- # Only edges where both endpoints are known plugins are considered (coeffects
115
- # that are external services, not plugin names, are ignored for cycle detection).
116
- def cyclic_requires?(requires_map)
117
- # Build adjacency: plugin -> [plugin deps only]
118
- known = requires_map.keys.to_set
119
- adjacency = requires_map.transform_values { |reqs| reqs.select { |r| known.include?(r) } }
113
+ def load_plugin_file(plugin_meta, runtime_manager:)
114
+ name = plugin_meta[:name]
115
+ component_name = "plugin_#{name}"
120
116
 
121
- visited = Set.new
122
- stack = Set.new
117
+ unload_plugin(name, runtime_manager: runtime_manager) if runtime_manager.fibers.key?(component_name)
123
118
 
124
- dfs = lambda do |node|
125
- return true if stack.include?(node)
126
- return false if visited.include?(node)
119
+ definition = prepare_plugin_definition(plugin_meta)
120
+ return false unless definition
127
121
 
128
- stack.add(node)
129
- (adjacency[node] || []).each do |neighbor|
130
- return true if dfs.call(neighbor)
131
- end
132
- stack.delete(node)
133
- visited.add(node)
122
+ fiber = register_plugin_component(name, definition, plugin_meta, runtime_manager: runtime_manager)
123
+ return false unless fiber
124
+
125
+ # Honest load result (issue #144): a failed activation (factory raised)
126
+ # is reported as failure, not as a successful load.
127
+ fiber.state != :failed
128
+ end
129
+
130
+ def unload_plugin(name, runtime_manager:)
131
+ component_name = "plugin_#{name}"
132
+ if runtime_manager.fibers.key?(component_name)
133
+ runtime_manager.unregister_component(component_name)
134
+ @registered_plugins.delete(name)
135
+ Rubik::Plugin::Registry.definitions.delete(name.to_s)
136
+ true
137
+ else
134
138
  false
135
139
  end
140
+ end
136
141
 
137
- adjacency.each_key do |node|
138
- return true if dfs.call(node)
142
+ # Reload is transactional (issue #144): discovery, whole-package
143
+ # integrity verification, and definition preparation all happen BEFORE
144
+ # the running component is touched, so a missing, tampered, legacy, or
145
+ # unparseable replacement leaves the old version running. An activation
146
+ # failure then rolls the swap back — ComponentFiber#activate! has
147
+ # already run the failed instance's partial inverses — and re-registers
148
+ # the previous definition so the plugin stays usable.
149
+ #
150
+ # In-flight work: this runtime serializes component lifecycle on the
151
+ # REPL turn boundary (single-threaded), so a reload never interleaves
152
+ # with a tool call; calls after a successful swap hit the new version
153
+ # only, calls after a failed reload hit the restored previous version
154
+ # only — no silent old/new mix.
155
+ def reload_plugin(name, storage:, runtime_manager:)
156
+ target = storage.discover_plugins.find { |p| p[:name] == name.to_s }
157
+ unless target
158
+ @logger.error("Reload of drop-in plugin '#{name}' failed: plugin not found in storage; previous version kept")
159
+ return false
139
160
  end
161
+
162
+ integrity = storage.verify_integrity(target[:path], scope: target[:scope])
163
+ if integrity == :rejected
164
+ @logger.error("Reload of drop-in plugin '#{name}' refused: integrity check failed; previous version kept")
165
+ return false
166
+ end
167
+
168
+ previous = Rubik::Plugin::Registry.get(name)
169
+ definition = prepare_plugin_definition(target)
170
+ unless definition
171
+ @logger.error("Reload of drop-in plugin '#{name}' failed during preparation; previous version kept")
172
+ return false
173
+ end
174
+
175
+ unload_plugin(name, runtime_manager: runtime_manager)
176
+ Rubik::Plugin::Registry.register(definition)
177
+ fiber = register_plugin_component(name, definition, target, runtime_manager: runtime_manager)
178
+ return true if fiber && fiber.state != :failed
179
+
180
+ @logger.error("Reload of drop-in plugin '#{name}' failed during activation; rolling back")
181
+ runtime_manager.unregister_component("plugin_#{name}")
182
+ restore_previous_plugin(name, previous, target, runtime_manager: runtime_manager)
140
183
  false
141
184
  end
142
185
 
143
- def load_plugin_file(plugin_meta, runtime_manager:)
186
+ private
187
+
188
+ # Prepare phase (issue #144): load the plugin file and validate the
189
+ # definition WITHOUT touching any running component, so a rejected
190
+ # replacement can never cost the old version. Returns the new
191
+ # Definition on success; nil on failure, with the previous registry
192
+ # entry restored and the load-path claim released.
193
+ def prepare_plugin_definition(plugin_meta)
144
194
  name = plugin_meta[:name]
145
195
  path = plugin_meta[:path]
146
196
  load_path = plugin_meta[:load_path]
147
197
 
148
- component_name = "plugin_#{name}"
149
-
150
- unload_plugin(name, runtime_manager: runtime_manager) if runtime_manager.fibers.key?(component_name)
198
+ previous = Rubik::Plugin::Registry.get(name)
199
+ claim_load_path(load_path)
151
200
 
152
- # Load the file to register the DSL
153
201
  begin
154
- $LOAD_PATH.unshift(load_path) unless $LOAD_PATH.include?(load_path)
155
- @load_path_refs[load_path] += 1
156
202
  load path
157
203
  rescue StandardError, ScriptError => e
158
204
  @logger.error("Failed to parse drop-in plugin '#{name}': #{e.message}")
205
+ rollback_prepared_definition(name, previous)
159
206
  release_load_path(load_path)
160
- return false
207
+ return nil
161
208
  end
162
209
 
163
210
  definition = Rubik::Plugin::Registry.get(name)
164
- unless definition
211
+ # Identity check: a re-load that did not run the DSL leaves the
212
+ # PREVIOUS definition parked in the registry — that is "no new
213
+ # definition", not a successful prepare.
214
+ if definition.nil? || definition.equal?(previous)
165
215
  @logger.warn("Drop-in plugin '#{name}' did not register via Rubik.plugin DSL.")
216
+ rollback_prepared_definition(name, previous)
166
217
  release_load_path(load_path)
167
- return false
218
+ return nil
168
219
  end
169
220
 
170
221
  # Coeffect DAG validation (Plan 86 D-3): requires: cycles among known
171
222
  # plugins are rejected; external coeffect requirements resolve at runtime.
172
- if definition.requires && !definition.requires.empty?
173
- # Build the current requires map from all registered definitions
174
- requires_map = {}
175
- Rubik::Plugin::Registry.definitions.each do |plugin_name, plugin_def|
176
- requires_map[plugin_name] = plugin_def.requires if plugin_def.requires && !plugin_def.requires.empty?
177
- end
178
- # Include the new plugin's requirements
179
- requires_map[name] = definition.requires
223
+ if cyclic_definition?(name, definition)
224
+ @logger.error("Drop-in plugin '#{name}' has a cyclic requires: dependency: #{definition.requires.join(' -> ')}")
225
+ rollback_prepared_definition(name, previous)
226
+ release_load_path(load_path)
227
+ return nil
228
+ end
180
229
 
181
- if cyclic_requires?(requires_map)
182
- @logger.error("Drop-in plugin '#{name}' has a cyclic requires: dependency: #{definition.requires.join(' -> ')}")
183
- Rubik::Plugin::Registry.definitions.delete(name.to_s) # sound rollback: the load leaves no trace
184
- release_load_path(load_path)
185
- return false
186
- end
230
+ release_load_path(load_path)
231
+ definition
232
+ end
233
+
234
+ # Sound rollback of a failed prepare: the previous definition (when one
235
+ # existed) goes back into the registry; otherwise the failed load leaves
236
+ # no trace.
237
+ def rollback_prepared_definition(name, previous)
238
+ if previous
239
+ Rubik::Plugin::Registry.register(previous)
240
+ else
241
+ Rubik::Plugin::Registry.definitions.delete(name.to_s)
242
+ end
243
+ end
244
+
245
+ def cyclic_definition?(name, definition)
246
+ return false if definition.requires.nil? || definition.requires.empty?
247
+
248
+ # Build the current requires map from all registered definitions
249
+ requires_map = {}
250
+ Rubik::Plugin::Registry.definitions.each do |plugin_name, plugin_def|
251
+ requires_map[plugin_name] = plugin_def.requires if plugin_def.requires && !plugin_def.requires.empty?
187
252
  end
253
+ # Include the new plugin's requirements
254
+ requires_map[name] = definition.requires
255
+ RequiresDag.cyclic?(requires_map)
256
+ end
188
257
 
189
- runtime_manager.register_component(component_name, inject: definition.inject, requires: definition.requires) do |ctx|
258
+ # Activation phase: register the definition as a runtime component. The
259
+ # Each activation owns its own load-path claim and paired inverse.
260
+ # Pending fibers own none, and reactivation acquires a fresh claim.
261
+ def register_plugin_component(name, definition, plugin_meta, runtime_manager:)
262
+ load_path = plugin_meta[:load_path]
263
+ runtime_manager.register_component(
264
+ "plugin_#{name}",
265
+ inject: definition.inject, requires: definition.requires
266
+ ) do |ctx|
267
+ claim_load_path(load_path)
190
268
  ctx.on_dispose do
191
269
  release_load_path(load_path)
192
270
  @registered_plugins.delete(name)
@@ -198,37 +276,36 @@ module Rubik
198
276
  @registered_plugins << name unless @registered_plugins.include?(name)
199
277
  true
200
278
  end
201
-
202
- true
279
+ rescue StandardError => e
280
+ @logger.error("Failed to register drop-in plugin '#{name}': #{e.class}: #{e.message}")
281
+ nil
203
282
  end
204
283
 
205
- def unload_plugin(name, runtime_manager:)
206
- component_name = "plugin_#{name}"
207
- if runtime_manager.fibers.key?(component_name)
208
- runtime_manager.unregister_component(component_name)
209
- @registered_plugins.delete(name)
210
- Rubik::Plugin::Registry.definitions.delete(name.to_s)
211
- true
212
- else
213
- false
284
+ # Roll back a failed activation swap: drop the failed replacement's
285
+ # definition, then re-register the previous definition (when one
286
+ # existed) so the plugin stays usable. The caller has already disposed
287
+ # the failed fiber, so the name is free to re-register.
288
+ def restore_previous_plugin(name, previous, plugin_meta, runtime_manager:)
289
+ Rubik::Plugin::Registry.definitions.delete(name.to_s)
290
+ return false unless previous
291
+
292
+ Rubik::Plugin::Registry.register(previous)
293
+ fiber = register_plugin_component(name, previous, plugin_meta, runtime_manager: runtime_manager)
294
+ unless fiber && fiber.state != :failed
295
+ @logger.error("Reload of drop-in plugin '#{name}': previous version could not be restored")
296
+ return false
214
297
  end
215
- end
216
298
 
217
- def reload_plugin(name, storage:, runtime_manager:)
218
- unload_plugin(name, runtime_manager: runtime_manager)
219
- plugins = storage.discover_plugins
220
- target = plugins.find { |p| p[:name] == name.to_s }
221
- if target
222
- integrity = storage.verify_integrity(target[:path], scope: target[:scope])
223
- if integrity != :rejected
224
- load_plugin_file(target, runtime_manager: runtime_manager)
225
- return true
226
- end
227
- end
228
- false
299
+ @logger.warn("Reload of drop-in plugin '#{name}': previous version restored after failed activation")
300
+ true
229
301
  end
230
302
 
231
- private
303
+ def claim_load_path(path)
304
+ return unless path
305
+
306
+ $LOAD_PATH.unshift(path) unless $LOAD_PATH.include?(path)
307
+ @load_path_refs[path] += 1
308
+ end
232
309
 
233
310
  # Decrement load_path reference count; only remove from $LOAD_PATH
234
311
  # when no plugin still holds a reference to prevent pulling the rug
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Rubik
6
+ module Plugin
7
+ # Containment + inventory policy for directory plugin packages
8
+ # (issue #144). A package is the set of regular files under its root,
9
+ # each identified by a package-relative POSIX path and a SHA-256 of its
10
+ # content; that inventory is the consent snapshot recorded at `!plugin
11
+ # add` time and the thing verification compares against. Symlinks can
12
+ # escape the package and special files are not content, so both are
13
+ # violations — add and verify fail closed on them.
14
+ module PackageInventory
15
+ class Violation < StandardError; end
16
+
17
+ module_function
18
+
19
+ def inventory(dir)
20
+ files = {}
21
+ walk(dir) do |rel, full|
22
+ files[rel] = file_hash(full)
23
+ end
24
+ files
25
+ end
26
+
27
+ # nil when the tree is clean; a violation message otherwise (used to
28
+ # refuse an add before anything is copied).
29
+ def violation(dir)
30
+ walk(dir)
31
+ nil
32
+ rescue Violation => e
33
+ e.message
34
+ end
35
+
36
+ # Audits additions, deletions, and modifications: any drift means the
37
+ # package no longer matches the consented inventory.
38
+ def drift(expected, current)
39
+ added = current.keys - expected.keys
40
+ removed = expected.keys - current.keys
41
+ modified = (current.keys & expected.keys).reject { |f| current[f] == expected[f] }
42
+
43
+ parts = []
44
+ parts << "added: #{added.sort.join(', ')}" unless added.empty?
45
+ parts << "removed: #{removed.sort.join(', ')}" unless removed.empty?
46
+ parts << "modified: #{modified.sort.join(', ')}" unless modified.empty?
47
+ parts.join("; ")
48
+ end
49
+
50
+ def content_hash(content)
51
+ Digest::SHA256.hexdigest(content.to_s.delete("\r"))
52
+ end
53
+
54
+ def file_hash(path)
55
+ content_hash(File.binread(path))
56
+ end
57
+
58
+ # Recursive walk with the containment policy: only regular files and
59
+ # directories under `dir`; symlinks and special entries raise
60
+ # Violation so add and verify both fail closed on them.
61
+ def walk(dir)
62
+ base = File.expand_path(dir)
63
+ raise Violation, "symlink package root is not allowed: #{base}" if File.symlink?(base)
64
+
65
+ stack = [base]
66
+ until stack.empty?
67
+ current = stack.pop
68
+ Dir.children(current).sort.each do |child|
69
+ full = "#{current}#{File::SEPARATOR}#{child}"
70
+ rel = full.delete_prefix("#{base}#{File::SEPARATOR}").tr("\\", "/")
71
+ raise Violation, "symlink entry is not allowed: #{rel}" if File.symlink?(full)
72
+ raise Violation, "unsupported entry type: #{rel}" unless File.file?(full) || File.directory?(full)
73
+
74
+ if File.directory?(full)
75
+ stack << full
76
+ elsif block_given?
77
+ yield(rel, full)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubik
4
+ module Plugin
5
+ # Cycle detection for the `requires:` DAG (Plan 86 D-3), extracted from
6
+ # Manager so the coordinator stays a coordinator (issue #144). Only
7
+ # edges where both endpoints are known plugins are considered —
8
+ # coeffect requirements that name external services are ignored.
9
+ module RequiresDag
10
+ module_function
11
+
12
+ # `requires_map` is plugin_name -> array of required names.
13
+ def cyclic?(requires_map)
14
+ known = requires_map.keys.to_set
15
+ adjacency = requires_map.transform_values { |reqs| reqs.select { |r| known.include?(r) } }
16
+
17
+ visited = Set.new
18
+ stack = Set.new
19
+
20
+ dfs = lambda do |node|
21
+ return true if stack.include?(node)
22
+ return false if visited.include?(node)
23
+
24
+ stack.add(node)
25
+ (adjacency[node] || []).each do |neighbor|
26
+ return true if dfs.call(neighbor)
27
+ end
28
+ stack.delete(node)
29
+ visited.add(node)
30
+ false
31
+ end
32
+
33
+ adjacency.each_key do |node|
34
+ return true if dfs.call(node)
35
+ end
36
+ false
37
+ end
38
+ end
39
+ end
40
+ end
@@ -2,11 +2,11 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "json"
5
- require "digest"
6
5
  require "time"
7
6
  # save_manifest atomik yazımı Platform.atomic_write kullanır; dosyanın tek başına
8
7
  # require edilmesi durumunda da çözümlenebilsin diye açık yüklenir.
9
8
  require_relative "../platform"
9
+ require_relative "package_inventory"
10
10
 
11
11
  module Rubik
12
12
  module Plugin
@@ -54,19 +54,13 @@ module Rubik
54
54
  Platform.atomic_write(manifest_path(dir), JSON.pretty_generate(manifest))
55
55
  end
56
56
 
57
- def normalize_content(raw)
58
- raw.delete("\r")
59
- end
60
-
61
57
  def record_hash(path, scope: :project)
62
58
  dir = scope.to_sym == :global ? global_plugins_dir : project_plugins_dir
63
59
  filename = File.basename(path)
64
- content = File.binread(path)
65
- hash = Digest::SHA256.hexdigest(normalize_content(content))
66
60
 
67
61
  manifest = load_manifest(dir)
68
62
  manifest[filename] = {
69
- "sha256" => hash,
63
+ "sha256" => PackageInventory.file_hash(path),
70
64
  "scope" => scope.to_s,
71
65
  "created_at" => Time.now.iso8601
72
66
  }
@@ -77,11 +71,15 @@ module Rubik
77
71
  return :trusted if ENV["RUBIK_TRUST_OVERLAYS"] == "1"
78
72
 
79
73
  dir = scope.to_sym == :global ? global_plugins_dir : project_plugins_dir
74
+
75
+ package_dir = package_root_for(path, dir)
76
+ return verify_package_integrity(package_dir, scope: scope) if package_dir
77
+
80
78
  basename = File.basename(path)
81
79
  manifest = load_manifest(dir)
82
80
 
83
81
  current_content = File.exist?(path) ? File.binread(path) : ""
84
- current_hash = Digest::SHA256.hexdigest(normalize_content(current_content))
82
+ current_hash = PackageInventory.content_hash(current_content)
85
83
 
86
84
  if manifest.key?(basename)
87
85
  expected_hash = manifest[basename]["sha256"]
@@ -97,6 +95,21 @@ module Rubik
97
95
  end
98
96
  end
99
97
 
98
+ # Records the operator's consent for a whole package: the inventory is
99
+ # the consent snapshot that verify_package_integrity later compares
100
+ # against. Keyed by plugin name (the package root's basename).
101
+ def record_package(plugin_dir, name, scope: :project)
102
+ target_dir = scope.to_sym == :global ? global_plugins_dir : project_plugins_dir
103
+ manifest = load_manifest(target_dir)
104
+ manifest[name.to_s] = {
105
+ "type" => "directory",
106
+ "scope" => scope.to_s,
107
+ "created_at" => Time.now.iso8601,
108
+ "files" => PackageInventory.inventory(plugin_dir)
109
+ }
110
+ save_manifest(target_dir, manifest)
111
+ end
112
+
100
113
  def discover_plugins(scope: nil)
101
114
  plugins = []
102
115
 
@@ -139,12 +152,35 @@ module Rubik
139
152
  if File.directory?(source_path)
140
153
  plugin_name = File.basename(source_path)
141
154
  dest_path = File.join(target_dir, plugin_name)
155
+
156
+ if (violation = PackageInventory.violation(source_path))
157
+ return { ok: false, error: "Plugin package rejected: #{violation}" }
158
+ end
159
+
160
+ source_entry = File.join(source_path, "#{plugin_name}.rb")
161
+ source_entry = File.join(source_path, "lib", "#{plugin_name}.rb") unless File.file?(source_entry)
162
+ return { ok: false, error: "Plugin package '#{plugin_name}' has no entry file (#{plugin_name}.rb)." } unless File.file?(source_entry)
163
+
164
+ # Re-consenting the installed package must not delete its source.
165
+ if File.identical?(source_path, dest_path)
166
+ record_package(dest_path, plugin_name, scope: scope)
167
+ return { ok: true, name: plugin_name, path: dest_path, type: :directory }
168
+ end
169
+
170
+ # Deterministic re-add (also the legacy-manifest re-consent path):
171
+ # replace any previous copy so stale helpers cannot survive into
172
+ # the newly consented package.
173
+ FileUtils.rm_rf(dest_path)
142
174
  FileUtils.cp_r(source_path, dest_path)
143
175
 
144
176
  entry_file = File.join(dest_path, "#{plugin_name}.rb")
145
177
  entry_file = File.join(dest_path, "lib", "#{plugin_name}.rb") unless File.exist?(entry_file)
178
+ unless File.exist?(entry_file)
179
+ FileUtils.rm_rf(dest_path)
180
+ return { ok: false, error: "Plugin package '#{plugin_name}' has no entry file (#{plugin_name}.rb)." }
181
+ end
146
182
 
147
- record_hash(entry_file, scope: scope) if File.exist?(entry_file)
183
+ record_package(dest_path, plugin_name, scope: scope)
148
184
 
149
185
  { ok: true, name: plugin_name, path: dest_path, type: :directory }
150
186
  else
@@ -173,11 +209,9 @@ module Rubik
173
209
  save_manifest(target_dir, manifest)
174
210
  removed = true
175
211
  elsif Dir.exist?(dir_path)
176
- entry_file_one = File.join(dir_path, "#{name}.rb")
177
- entry_file_two = File.join(dir_path, "lib", "#{name}.rb")
178
-
179
212
  manifest = load_manifest(target_dir)
180
- manifest.delete("#{name}.rb") if File.exist?(entry_file_one) || File.exist?(entry_file_two)
213
+ manifest.delete(name.to_s) # package inventory (current format)
214
+ manifest.delete("#{name}.rb") # legacy entry-file-only key
181
215
  save_manifest(target_dir, manifest)
182
216
 
183
217
  FileUtils.rm_rf(dir_path)
@@ -190,6 +224,58 @@ module Rubik
190
224
  { ok: false, error: "Plugin '#{name}' not found in #{scope} scope." }
191
225
  end
192
226
  end
227
+
228
+ private
229
+
230
+ # The package root for a path inside the plugins dir: plugins/<first
231
+ # segment> when that segment is a DIRECTORY (directory plugin); nil for
232
+ # a single-file plugin. A path outside the plugins dir is not a package.
233
+ def package_root_for(path, plugins_dir)
234
+ base = File.expand_path(plugins_dir)
235
+ prefix = "#{base}#{File::SEPARATOR}"
236
+ expanded = File.expand_path(path)
237
+ return nil unless expanded.start_with?(prefix)
238
+
239
+ first = expanded.delete_prefix(prefix).split(File::SEPARATOR, 2).first
240
+ return nil if first.nil? || first.empty? || first == ".."
241
+
242
+ candidate = "#{base}#{File::SEPARATOR}#{first}"
243
+ File.directory?(candidate) ? candidate : nil
244
+ rescue ArgumentError
245
+ nil
246
+ end
247
+
248
+ # Whole-package verification. A legacy (entry-file-only) manifest cannot
249
+ # vouch for helpers that were never consented to, so it is rejected with
250
+ # an explicit re-consent path: re-add the plugin, or opt into trust with
251
+ # RUBIK_TRUST_OVERLAYS=1.
252
+ def verify_package_integrity(package_dir, scope: :project)
253
+ dir = scope.to_sym == :global ? global_plugins_dir : project_plugins_dir
254
+ name = File.basename(package_dir)
255
+ entry = load_manifest(dir)[name]
256
+
257
+ unless entry.is_a?(Hash) && entry["type"] == "directory" && entry["files"].is_a?(Hash)
258
+ warn "[SECURITY] Directory plugin '#{name}' has no package inventory in the manifest " \
259
+ "(legacy manifest). Skipping; re-add it with '!plugin add' or set RUBIK_TRUST_OVERLAYS=1 to trust it."
260
+ return :rejected
261
+ end
262
+
263
+ current = nil
264
+ begin
265
+ current = PackageInventory.inventory(package_dir)
266
+ rescue PackageInventory::Violation => e
267
+ warn "[SECURITY] Integrity check failed for plugin package '#{name}': #{e.message}. " \
268
+ "Re-add it with '!plugin add' or set RUBIK_TRUST_OVERLAYS=1 to trust it."
269
+ return :rejected
270
+ end
271
+
272
+ drift = PackageInventory.drift(entry["files"], current)
273
+ return :verified if drift.empty?
274
+
275
+ warn "[SECURITY] Integrity check failed for plugin package '#{name}': #{drift}. " \
276
+ "Re-add it with '!plugin add' or set RUBIK_TRUST_OVERLAYS=1 to trust it."
277
+ :rejected
278
+ end
193
279
  end
194
280
  end
195
281
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
+ require_relative "../../buffer/apply_contract"
4
5
 
5
6
  module Rubik
6
7
  module REPL
@@ -51,27 +52,36 @@ module Rubik
51
52
 
52
53
  shell.register_command("apply", description: "Apply dirty buffer changes to disk") do |args, ctx|
53
54
  target_path = args.first
54
- if target_path && !target_path.empty?
55
- applied = ctx.buffer_manager.apply!(target_path)
56
- conflicts = ctx.buffer_manager.last_conflicts || []
57
- if applied.empty? && conflicts.empty?
58
- ctx.out.puts "No unapplied buffer changes for '#{target_path}'."
55
+ begin
56
+ if target_path && !target_path.empty?
57
+ applied = ctx.buffer_manager.apply!(target_path)
58
+ conflicts = ctx.buffer_manager.last_conflicts || []
59
+ if applied.empty? && conflicts.empty?
60
+ ctx.out.puts "No unapplied buffer changes for '#{target_path}'."
61
+ else
62
+ ctx.sync_context_and_ast(applied) unless applied.empty?
63
+ ctx.out.puts "Applied changes to file: #{applied.first}" unless applied.empty?
64
+ end
59
65
  else
60
- ctx.sync_context_and_ast(applied) unless applied.empty?
61
- ctx.out.puts "Applied changes to file: #{applied.first}" unless applied.empty?
66
+ applied = ctx.buffer_manager.apply!
67
+ conflicts = ctx.buffer_manager.last_conflicts || []
68
+ if applied.empty? && conflicts.empty?
69
+ ctx.out.puts "No unapplied buffer changes."
70
+ else
71
+ ctx.sync_context_and_ast(applied) unless applied.empty?
72
+ ctx.out.puts "Applied changes to #{applied.size} file(s): #{applied.join(', ')}" unless applied.empty?
73
+ end
62
74
  end
63
- else
64
- applied = ctx.buffer_manager.apply!
65
- conflicts = ctx.buffer_manager.last_conflicts || []
66
- if applied.empty? && conflicts.empty?
67
- ctx.out.puts "No unapplied buffer changes."
68
- else
69
- ctx.sync_context_and_ast(applied) unless applied.empty?
70
- ctx.out.puts "Applied changes to #{applied.size} file(s): #{applied.join(', ')}" unless applied.empty?
75
+ conflicts.each do |c|
76
+ ctx.out.puts "[CONFLICT] #{c}: disk content changed since last read — skipped to prevent data loss. Re-read the file to resolve."
71
77
  end
72
- end
73
- conflicts.each do |c|
74
- ctx.out.puts "[CONFLICT] #{c}: disk content changed since last read — skipped to prevent data loss. Re-read the file to resolve."
78
+ rescue Rubik::Buffer::ApplyFailedError => e
79
+ # Partial apply (issue #146): the report names what DID reach
80
+ # disk before the failure — a mixed outcome must not be
81
+ # presented as a plain success or a plain failure.
82
+ lines = Rubik::Buffer::ApplyContract.failure_lines(e, sync: ->(paths) { ctx.sync_context_and_ast(paths) })
83
+ ctx.out.puts "❌ #{lines.first}"
84
+ lines[1..].each { |line| ctx.out.puts line }
75
85
  end
76
86
  end
77
87