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.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +88 -0
- data/README.md +30 -17
- data/lib/rubik/ast/extraction_result.rb +37 -0
- data/lib/rubik/ast/extractor.rb +35 -9
- data/lib/rubik/ast/graph.rb +27 -1
- data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
- data/lib/rubik/ast/treesitter_extractor.rb +45 -17
- data/lib/rubik/buffer/apply_contract.rb +78 -0
- data/lib/rubik/buffer/manager.rb +61 -5
- data/lib/rubik/concurrency/event_loop.rb +56 -44
- data/lib/rubik/concurrency/scheduler.rb +7 -24
- data/lib/rubik/context/git_lister.rb +6 -1
- data/lib/rubik/context/scanner.rb +15 -3
- data/lib/rubik/mcp/transport/stdio.rb +14 -11
- data/lib/rubik/platform/process.rb +74 -2
- data/lib/rubik/plugin/manager.rb +148 -71
- data/lib/rubik/plugin/package_inventory.rb +84 -0
- data/lib/rubik/plugin/requires_dag.rb +40 -0
- data/lib/rubik/plugin/storage.rb +100 -14
- data/lib/rubik/repl/commands/buffer.rb +28 -18
- data/lib/rubik/runtime/repair_probe.rb +98 -0
- data/lib/rubik/security/blocked_guidance.rb +19 -0
- data/lib/rubik/security/guard.rb +115 -6
- data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
- data/lib/rubik/version.rb +1 -1
- metadata +6 -1
data/lib/rubik/plugin/manager.rb
CHANGED
|
@@ -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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
122
|
-
stack = Set.new
|
|
117
|
+
unload_plugin(name, runtime_manager: runtime_manager) if runtime_manager.fibers.key?(component_name)
|
|
123
118
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return false if visited.include?(node)
|
|
119
|
+
definition = prepare_plugin_definition(plugin_meta)
|
|
120
|
+
return false unless definition
|
|
127
121
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
207
|
+
return nil
|
|
161
208
|
end
|
|
162
209
|
|
|
163
210
|
definition = Rubik::Plugin::Registry.get(name)
|
|
164
|
-
|
|
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
|
|
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
|
|
173
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
data/lib/rubik/plugin/storage.rb
CHANGED
|
@@ -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" =>
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
ctx.
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|