woods 2.0.1 → 2.1.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/CHANGELOG.md +94 -7
- data/CONTRIBUTING.md +134 -19
- data/README.md +1 -1
- data/docs/AGENT_GUIDE.md +19 -0
- data/docs/AGENT_SETUP.md +22 -2
- data/docs/BACKEND_MATRIX.md +7 -0
- data/docs/CLIENT_HOOKS.md +6 -0
- data/docs/CONFIGURATION_REFERENCE.md +133 -25
- data/docs/CONSOLE_MCP_SETUP.md +82 -30
- data/docs/EMBEDDING_MODELS.md +16 -19
- data/docs/EXTRACTOR_REFERENCE.md +219 -21
- data/docs/FAQ.md +11 -25
- data/docs/GETTING_STARTED.md +7 -1
- data/docs/INCREMENTAL_EXTRACTION.md +261 -19
- data/docs/INDEX_LAYOUT.md +5 -0
- data/docs/INTERNALS.md +9 -0
- data/docs/MCP_HTTP_TRANSPORT.md +20 -15
- data/docs/MCP_SERVERS.md +87 -8
- data/docs/MCP_TOOL_COOKBOOK.md +13 -55
- data/docs/NOTION_INTEGRATION.md +7 -1
- data/docs/PUBLISHED_INDEX.md +6 -0
- data/docs/README.md +6 -1
- data/docs/RETRIEVAL_GUIDE.md +17 -0
- data/docs/SOURCE_FRESHNESS.md +157 -5
- data/docs/TOKEN_BENCHMARK.md +10 -18
- data/docs/TROUBLESHOOTING.md +70 -14
- data/docs/UNBLOCKED_INTEGRATION.md +60 -8
- data/docs/UPGRADING_TO_2.md +153 -38
- data/docs/WATCH_DAEMON.md +97 -14
- data/exe/woods-console-mcp +2 -2
- data/lib/generators/woods/templates/woods.rb.tt +2 -1
- data/lib/tasks/woods.rake +23 -7
- data/lib/tasks/woods_checks.rake +2 -2
- data/lib/woods/agent_configuration/cli.rb +1 -1
- data/lib/woods/agent_configuration/layout.rb +16 -2
- data/lib/woods/agent_configuration/plan.rb +13 -3
- data/lib/woods/agent_configuration/planner_validation.rb +4 -2
- data/lib/woods/agent_configuration/preflight.rb +5 -3
- data/lib/woods/builder.rb +17 -57
- data/lib/woods/cache/cache_middleware.rb +56 -30
- data/lib/woods/chunking/contributor_chunks.rb +119 -0
- data/lib/woods/chunking/semantic_chunker.rb +44 -21
- data/lib/woods/console/connection_manager.rb +56 -3
- data/lib/woods/console/embedded_executor.rb +30 -5
- data/lib/woods/console/rack_middleware.rb +29 -1
- data/lib/woods/dependency_graph.rb +34 -10
- data/lib/woods/embedding/fake.rb +12 -0
- data/lib/woods/embedding/indexer.rb +195 -98
- data/lib/woods/embedding/input_budget.rb +67 -0
- data/lib/woods/embedding/openai.rb +70 -20
- data/lib/woods/embedding/provider.rb +37 -25
- data/lib/woods/embedding/text_preparer.rb +76 -32
- data/lib/woods/embedding/token_counter.rb +18 -81
- data/lib/woods/embedding/vector_configuration.rb +48 -0
- data/lib/woods/extraction_identities.rb +175 -0
- data/lib/woods/extractor.rb +304 -107
- data/lib/woods/extractors/action_cable_extractor.rb +8 -3
- data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
- data/lib/woods/extractors/class_declarations.rb +121 -0
- data/lib/woods/extractors/configuration_extractor.rb +11 -3
- data/lib/woods/extractors/declaration_ancestry.rb +92 -0
- data/lib/woods/extractors/event_extractor.rb +8 -0
- data/lib/woods/extractors/graphql_extractor.rb +134 -77
- data/lib/woods/extractors/job_extractor.rb +5 -1
- data/lib/woods/extractors/lib_extractor.rb +132 -15
- data/lib/woods/extractors/mailer_extractor.rb +3 -5
- data/lib/woods/extractors/manager_extractor.rb +7 -21
- data/lib/woods/extractors/migration_declaration.rb +87 -0
- data/lib/woods/extractors/migration_extractor.rb +5 -39
- data/lib/woods/extractors/phlex_extractor.rb +6 -2
- data/lib/woods/extractors/policy_extractor.rb +9 -5
- data/lib/woods/extractors/poro_extractor.rb +112 -53
- data/lib/woods/extractors/pundit_extractor.rb +11 -6
- data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
- data/lib/woods/extractors/serializer_extractor.rb +34 -22
- data/lib/woods/extractors/shared_utility_methods.rb +18 -1
- data/lib/woods/extractors/source_nesting.rb +142 -106
- data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
- data/lib/woods/extractors/state_machine_extractor.rb +46 -40
- data/lib/woods/extractors/view_component_extractor.rb +9 -7
- data/lib/woods/flow_assembler.rb +4 -1
- data/lib/woods/generation.rb +25 -0
- data/lib/woods/hooks/context_hint.rb +7 -2
- data/lib/woods/mcp/bootstrapper.rb +33 -7
- data/lib/woods/mcp/config_resolver.rb +26 -7
- data/lib/woods/mcp/index_reader.rb +125 -24
- data/lib/woods/mcp/index_reader_pinning.rb +16 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
- data/lib/woods/mcp/search_results.rb +7 -1
- data/lib/woods/mcp/server.rb +24 -4
- data/lib/woods/module_reconciliation.rb +151 -0
- data/lib/woods/path_dispatcher.rb +7 -2
- data/lib/woods/rake_helpers.rb +43 -11
- data/lib/woods/release.rb +1 -1
- data/lib/woods/resilience/index_validator.rb +8 -3
- data/lib/woods/resilience/retryable_provider.rb +18 -1
- data/lib/woods/resolved_config.rb +68 -8
- data/lib/woods/retrieval/context_assembler.rb +3 -3
- data/lib/woods/retrieval/lexical_assembler.rb +3 -2
- data/lib/woods/retrieval/scope.rb +18 -2
- data/lib/woods/retrieval/source_evidence.rb +14 -2
- data/lib/woods/source_contributor_validation.rb +78 -0
- data/lib/woods/source_contributors.rb +116 -0
- data/lib/woods/source_inputs/handoff.rb +37 -0
- data/lib/woods/source_inputs/launcher.rb +53 -13
- data/lib/woods/source_inputs/manifest.rb +84 -3
- data/lib/woods/source_inputs/private_key.rb +44 -12
- data/lib/woods/source_inputs/scanner.rb +98 -27
- data/lib/woods/source_inputs/scopes.rb +1 -1
- data/lib/woods/source_inputs/session.rb +147 -15
- data/lib/woods/source_inputs/stable_reader.rb +127 -0
- data/lib/woods/source_inputs/status.rb +40 -8
- data/lib/woods/source_inputs/verifier.rb +28 -5
- data/lib/woods/source_path_encoding.rb +33 -0
- data/lib/woods/source_references/cache.rb +284 -0
- data/lib/woods/source_references/collector.rb +120 -0
- data/lib/woods/source_references/extraction.rb +185 -0
- data/lib/woods/source_references/inputs.rb +134 -0
- data/lib/woods/source_references/parser_adapter.rb +134 -0
- data/lib/woods/source_references/pass.rb +152 -0
- data/lib/woods/source_references/prism_adapter.rb +116 -0
- data/lib/woods/source_references/registry.rb +178 -0
- data/lib/woods/source_references/runtime_lookup.rb +127 -0
- data/lib/woods/source_references/value_class.rb +82 -0
- data/lib/woods/storage/metadata_store.rb +4 -1
- data/lib/woods/storage/qdrant.rb +2 -2
- data/lib/woods/unblocked/client.rb +12 -7
- data/lib/woods/unblocked/document_builder.rb +4 -1
- data/lib/woods/unblocked/exporter.rb +127 -37
- data/lib/woods/unblocked/sync_manifest.rb +137 -21
- data/lib/woods/unblocked/uri_migration.rb +105 -0
- data/lib/woods/util/host_guard.rb +3 -2
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/catch_up.rb +138 -0
- data/lib/woods/watch/claim_lease.rb +150 -0
- data/lib/woods/watch/cli.rb +26 -2
- data/lib/woods/watch/daemon.rb +80 -59
- data/lib/woods/watch/installation/options.rb +1 -1
- data/lib/woods/watch/installation/receipt.rb +6 -1
- data/lib/woods/watch/managed_child.rb +1 -1
- data/lib/woods/watch/supervisor.rb +1 -1
- data/lib/woods/watch/tree_scan.rb +14 -2
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.rb +3 -2
- data/plugin/hooks/woods-input-rules.sh +4 -0
- data/plugin/hooks/woods-refresh.sh +15 -7
- data/plugin/hooks/woods-session-start.sh +60 -3
- data/plugin/skills/woods-diagnose/SKILL.md +334 -11
- data/plugin/skills/woods-investigate/SKILL.md +11 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +79 -8
- data/plugin/skills/woods-setup/SKILL.md +53 -6
- metadata +32 -5
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'find'
|
|
4
3
|
require 'openssl'
|
|
5
4
|
require 'woods/source_inputs/private_key'
|
|
6
5
|
require 'woods/source_inputs/scopes'
|
|
@@ -17,8 +16,8 @@ module Woods
|
|
|
17
16
|
attr_reader :root, :scopes
|
|
18
17
|
|
|
19
18
|
def initialize(root:, output_dir:, key:, scopes: Scopes.new, **limits)
|
|
20
|
-
@root =
|
|
21
|
-
@output_dir =
|
|
19
|
+
@root = SourcePathEncoding.expand(root)
|
|
20
|
+
@output_dir = SourcePathEncoding.expand(output_dir)
|
|
22
21
|
@key = key
|
|
23
22
|
@scopes = scopes
|
|
24
23
|
@limits = DEFAULT_LIMITS.merge(limits)
|
|
@@ -28,13 +27,16 @@ module Woods
|
|
|
28
27
|
end
|
|
29
28
|
|
|
30
29
|
def call
|
|
30
|
+
@captured_at = Time.now.to_f
|
|
31
31
|
@started = monotonic
|
|
32
32
|
@files = {}
|
|
33
33
|
@scope_paths = {}
|
|
34
34
|
@errors = []
|
|
35
35
|
@bytes = 0
|
|
36
36
|
@visited = 0
|
|
37
|
+
@directory_links = {}
|
|
37
38
|
walk
|
|
39
|
+
verify_directory_links
|
|
38
40
|
result
|
|
39
41
|
rescue BudgetExceeded => e
|
|
40
42
|
error(e.message)
|
|
@@ -42,6 +44,9 @@ module Woods
|
|
|
42
44
|
rescue SystemCallError, IOError
|
|
43
45
|
error('source_tree_unavailable')
|
|
44
46
|
result
|
|
47
|
+
rescue EncodingError, SourcePathEncoding::Invalid
|
|
48
|
+
error('undecodable_source_path')
|
|
49
|
+
result
|
|
45
50
|
end
|
|
46
51
|
|
|
47
52
|
private
|
|
@@ -49,25 +54,91 @@ module Woods
|
|
|
49
54
|
def walk
|
|
50
55
|
raise Errno::ENOENT, @root unless File.directory?(@root)
|
|
51
56
|
|
|
52
|
-
@resolved_root = File.realpath(@root)
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
@resolved_root = SourcePathEncoding.utf8!(File.realpath(@root))
|
|
58
|
+
@resolved_output = File.directory?(@output_dir) ? resolved_path(@output_dir) : nil
|
|
59
|
+
pending = children(@root).map { |path| [path, [@resolved_root]] }
|
|
60
|
+
until pending.empty?
|
|
61
|
+
path, ancestors = pending.pop
|
|
62
|
+
scan_entry(path, pending, ancestors)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def scan_entry(path, pending, ancestors)
|
|
67
|
+
check_budget!
|
|
68
|
+
path = decoded_entry(path)
|
|
69
|
+
return unless path
|
|
55
70
|
|
|
71
|
+
relative = path.delete_prefix("#{@root}/")
|
|
72
|
+
return if ignored_directory?(path, relative)
|
|
73
|
+
|
|
74
|
+
stat = File.lstat(path)
|
|
75
|
+
if directory_entry?(path, stat)
|
|
76
|
+
@visited += 1 if stat.symlink?
|
|
56
77
|
check_budget!
|
|
57
|
-
relative
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
next
|
|
61
|
-
end
|
|
62
|
-
stat = File.lstat(path)
|
|
63
|
-
next if stat.directory?
|
|
78
|
+
descend(path, relative, pending, ancestors, symlink: stat.symlink?)
|
|
79
|
+
return
|
|
80
|
+
end
|
|
64
81
|
|
|
65
|
-
|
|
82
|
+
@visited += 1
|
|
83
|
+
check_budget!
|
|
84
|
+
visit(path, relative)
|
|
85
|
+
rescue SystemCallError, IOError
|
|
86
|
+
error('source_tree_unavailable', relative)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def directory_entry?(path, stat)
|
|
90
|
+
stat.directory? || (stat.symlink? && File.directory?(path))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Scope each logical descendant independently. Directory names do not
|
|
94
|
+
# establish whether their children are inputs (package.yml can occur
|
|
95
|
+
# outside app/lib). External entries may be enumerated, but visit refuses
|
|
96
|
+
# any consumed file outside the root before opening its contents.
|
|
97
|
+
def descend(path, relative, pending, ancestors, symlink:)
|
|
98
|
+
real = SourcePathEncoding.utf8!(File.realpath(path))
|
|
99
|
+
return if @resolved_output && (real == @resolved_output || real.start_with?("#{@resolved_output}/"))
|
|
100
|
+
|
|
101
|
+
if ancestors.include?(real)
|
|
102
|
+
error('source_directory_cycle', relative)
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
@directory_links[path] = real if symlink
|
|
107
|
+
branch = ancestors + [real]
|
|
108
|
+
pending.concat(children(path).map { |child| [child, branch] })
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def verify_directory_links
|
|
112
|
+
@directory_links.each do |path, target|
|
|
66
113
|
check_budget!
|
|
67
|
-
|
|
114
|
+
next if File.symlink?(path) && File.directory?(path) && resolved_path(path) == target
|
|
115
|
+
|
|
116
|
+
error('source_changed_during_read', path.delete_prefix("#{@root}/"))
|
|
117
|
+
rescue SystemCallError, IOError
|
|
118
|
+
error('source_changed_during_read', path.delete_prefix("#{@root}/"))
|
|
68
119
|
end
|
|
69
120
|
end
|
|
70
121
|
|
|
122
|
+
# An unreadable entry cannot certify completeness, but must not hide
|
|
123
|
+
# accessible siblings. Keep traversal order deterministic and bounded.
|
|
124
|
+
def children(path)
|
|
125
|
+
Dir.children(path, encoding: Encoding::BINARY).sort.reverse.map { |name| File.join(path.b, name) }
|
|
126
|
+
rescue SystemCallError, IOError
|
|
127
|
+
error('source_tree_unavailable', path == @root ? nil : path.delete_prefix("#{@root}/"))
|
|
128
|
+
[]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def decoded_entry(path)
|
|
132
|
+
decoded = SourcePathEncoding.utf8(path)
|
|
133
|
+
return decoded if decoded
|
|
134
|
+
|
|
135
|
+
@visited += 1
|
|
136
|
+
check_budget!
|
|
137
|
+
relative = path.b.delete_prefix("#{@root}/".b)
|
|
138
|
+
error('undecodable_source_path', SourcePathEncoding.diagnostic(relative))
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
71
142
|
def ignored_directory?(path, relative)
|
|
72
143
|
return true if path == @output_dir || path.start_with?("#{@output_dir}/")
|
|
73
144
|
return false if declared_path?(relative)
|
|
@@ -82,17 +153,12 @@ module Woods
|
|
|
82
153
|
end
|
|
83
154
|
end
|
|
84
155
|
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
def visit(path, relative, stat)
|
|
88
|
-
if stat.symlink? && File.directory?(path)
|
|
89
|
-
error('unverified_symlink_directory', relative)
|
|
90
|
-
return
|
|
91
|
-
end
|
|
156
|
+
# Refuse external paths and unreadable inputs without hashing their bytes.
|
|
157
|
+
def visit(path, relative)
|
|
92
158
|
consumers = @scopes.for_path(relative)
|
|
93
159
|
return if consumers.empty?
|
|
94
160
|
|
|
95
|
-
real = File.realpath(path)
|
|
161
|
+
real = SourcePathEncoding.utf8!(File.realpath(path))
|
|
96
162
|
unless real.start_with?("#{@resolved_root}/")
|
|
97
163
|
error('external_source_path', relative)
|
|
98
164
|
return
|
|
@@ -104,16 +170,16 @@ module Woods
|
|
|
104
170
|
consumers.each { |scope| (@scope_paths[scope] ||= []) << relative }
|
|
105
171
|
rescue SystemCallError, IOError
|
|
106
172
|
error('source_file_unreadable', relative)
|
|
173
|
+
rescue EncodingError, SourcePathEncoding::Invalid
|
|
174
|
+
error('undecodable_source_path', SourcePathEncoding.diagnostic(relative))
|
|
107
175
|
end
|
|
108
176
|
|
|
109
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
110
|
-
|
|
111
177
|
def read_identity(path, resolved, expected) # rubocop:disable Metrics/CyclomaticComplexity -- stable descriptor and original resolution checks
|
|
112
178
|
flags = File::RDONLY | File::NONBLOCK
|
|
113
179
|
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
114
180
|
File.open(resolved, flags) do |file|
|
|
115
181
|
before = stamp(file.stat)
|
|
116
|
-
unless before == expected &&
|
|
182
|
+
unless before == expected && resolved_path(path) == resolved
|
|
117
183
|
error('source_changed_during_read', path.delete_prefix("#{@root}/"))
|
|
118
184
|
return nil
|
|
119
185
|
end
|
|
@@ -122,7 +188,7 @@ module Woods
|
|
|
122
188
|
return nil
|
|
123
189
|
end
|
|
124
190
|
identity = hash_stream(file)
|
|
125
|
-
unless before == stamp(file.stat) && before == stamp(File.stat(resolved)) &&
|
|
191
|
+
unless before == stamp(file.stat) && before == stamp(File.stat(resolved)) && resolved_path(path) == resolved
|
|
126
192
|
error('source_changed_during_read', path.delete_prefix("#{@root}/"))
|
|
127
193
|
return nil
|
|
128
194
|
end
|
|
@@ -130,6 +196,10 @@ module Woods
|
|
|
130
196
|
end
|
|
131
197
|
end
|
|
132
198
|
|
|
199
|
+
def resolved_path(path)
|
|
200
|
+
SourcePathEncoding.utf8!(File.realpath(path))
|
|
201
|
+
end
|
|
202
|
+
|
|
133
203
|
def hash_stream(file)
|
|
134
204
|
digest = OpenSSL::HMAC.new(@key.bytes, OpenSSL::Digest.new('SHA256'))
|
|
135
205
|
while (chunk = file.read(16_384))
|
|
@@ -156,6 +226,7 @@ module Woods
|
|
|
156
226
|
|
|
157
227
|
def result
|
|
158
228
|
{ 'root' => @root, 'key_id' => @key.identifier, 'rules' => @scopes.fingerprint,
|
|
229
|
+
'captured_at' => @captured_at,
|
|
159
230
|
'extra_roots' => @scopes.extra_roots, 'files' => @files.sort.to_h,
|
|
160
231
|
'scope_paths' => @scope_paths.transform_values(&:sort).sort.to_h,
|
|
161
232
|
'errors' => @errors, 'complete' => @errors.empty?,
|
|
@@ -50,7 +50,7 @@ module Woods
|
|
|
50
50
|
PathDispatcher.runtime_rules.map(&:to_h),
|
|
51
51
|
ReloadPolicy.constants(false).sort.to_h { |name| [name, ReloadPolicy.const_get(name)] },
|
|
52
52
|
Watch::Watcher::DEFAULT_IGNORED_DIRECTORIES, Watch::TreeScan::NOT_IGNORED_DOTFILES,
|
|
53
|
-
Watch::TreeScan::NOT_IGNORED_DOTFILE_PREFIXES, '
|
|
53
|
+
Watch::TreeScan::NOT_IGNORED_DOTFILE_PREFIXES, 'bounded_logical_directory_links_v2',
|
|
54
54
|
'config_ruby_and_root_gemspecs_v1', BOOT_FILES, @extra_roots]
|
|
55
55
|
Digest::SHA256.hexdigest(JSON.generate(data))
|
|
56
56
|
end
|
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../source_contributors'
|
|
4
|
+
|
|
3
5
|
require 'set'
|
|
4
6
|
require 'woods/source_inputs/scanner'
|
|
5
7
|
require 'woods/source_inputs/manifest'
|
|
6
8
|
require 'woods/source_inputs/handoff'
|
|
7
9
|
require 'woods/source_inputs/consumer_errors'
|
|
10
|
+
require 'woods/source_inputs/stable_reader'
|
|
8
11
|
|
|
9
12
|
module Woods
|
|
10
13
|
module SourceInputs
|
|
11
14
|
# Records successful consumers, never merely paths found by a final scan.
|
|
12
15
|
# An incremental run starts from the preceding generation's scope ledger.
|
|
13
16
|
class Session # rubocop:disable Metrics/ClassLength -- per-run consumer ledger and publication coverage
|
|
17
|
+
MAX_ERRORS = 30
|
|
18
|
+
|
|
14
19
|
def initialize(root:, output_dir:, baseline_path:, operation:)
|
|
15
|
-
@root =
|
|
16
|
-
@output =
|
|
20
|
+
@root = SourcePathEncoding.expand(root)
|
|
21
|
+
@output = SourcePathEncoding.expand(output_dir)
|
|
17
22
|
@operation = operation.to_s
|
|
18
23
|
@errors = []
|
|
19
24
|
@unverified = []
|
|
25
|
+
@capture_unavailable = Handoff.unavailable_evidence
|
|
20
26
|
@baseline = read_baseline(baseline_path)
|
|
21
27
|
@rules = Scopes.new(extra_roots: declared_roots)
|
|
22
28
|
@key = PrivateKey.new(output_dir: @output, create: true)
|
|
@@ -31,6 +37,48 @@ module Woods
|
|
|
31
37
|
@verified_boot = false
|
|
32
38
|
end
|
|
33
39
|
|
|
40
|
+
# Read original captured bytes, without certifying runtime consumption.
|
|
41
|
+
#
|
|
42
|
+
# @param path [String] root-relative or absolute application source path
|
|
43
|
+
# @param limits [Hash] StableReader byte/time budget overrides
|
|
44
|
+
# @return [Hash] original source bytes, relative path and captured HMAC
|
|
45
|
+
# @raise [StableReader::Error] when source cannot be read as captured
|
|
46
|
+
def read_source(path, **limits)
|
|
47
|
+
raise StableReader::Error, 'source_identity_unavailable' unless @key
|
|
48
|
+
|
|
49
|
+
identity = source_identity(path)
|
|
50
|
+
raise StableReader::Error, 'uncaptured_source_path' unless identity
|
|
51
|
+
|
|
52
|
+
StableReader.new(root: @root, key: @key).read(path, identity: identity, **limits)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param path [String] root-relative or absolute application source path
|
|
56
|
+
# @return [String, nil] captured private HMAC, never a public raw content hash
|
|
57
|
+
def source_identity(path)
|
|
58
|
+
@snapshot.fetch('files')[relative_path(path)]&.dup&.freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A fresh read cannot establish which bytes produced retained runtime facts.
|
|
62
|
+
# This checks only explicit unit consumption against this session's capture.
|
|
63
|
+
#
|
|
64
|
+
# @param key [String, Symbol] extractor ledger key
|
|
65
|
+
# @param path [String] root-relative or absolute application source path
|
|
66
|
+
# @return [Boolean] whether that unit consumer acknowledged captured bytes
|
|
67
|
+
def consumed_source?(key, path)
|
|
68
|
+
identity = source_identity(path)
|
|
69
|
+
!identity.nil? && @identities.fetch("unit:#{key}", {})[relative_path(path)] == identity
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# An oversized baseline can omit the comparison ledger while retaining a
|
|
73
|
+
# cryptographic binding to consumed reference evidence in the same payload.
|
|
74
|
+
# The caller must still prove exact typed ownership and unchanged HMACs.
|
|
75
|
+
def trusted_reference_baseline?(cache)
|
|
76
|
+
return false unless cache && compatible_baseline? && @baseline.unavailable?
|
|
77
|
+
|
|
78
|
+
expected = @baseline.data['reference_cache_sha256']
|
|
79
|
+
expected && expected == Digest::SHA256.hexdigest(JSON.generate(cache))
|
|
80
|
+
end
|
|
81
|
+
|
|
34
82
|
def consume_file(key, path)
|
|
35
83
|
consume_path("file:#{key}", path)
|
|
36
84
|
consume_path("unit:#{key}", path) if @identities.fetch("unit:#{key}", {}).key?(relative_path(path))
|
|
@@ -51,7 +99,7 @@ module Woods
|
|
|
51
99
|
def consume_extractor(key, units)
|
|
52
100
|
["file:#{key}", "whole:#{key}"].each { |scope| replace_scope(scope) }
|
|
53
101
|
@identities["unit:#{key}"] = {}
|
|
54
|
-
Array(units).each { |unit| consume_unit(key,
|
|
102
|
+
Array(units).each { |unit| SourceContributors.paths(unit).each { |path| consume_unit(key, path) } }
|
|
55
103
|
end
|
|
56
104
|
|
|
57
105
|
def unverified(scope)
|
|
@@ -62,6 +110,16 @@ module Woods
|
|
|
62
110
|
results.each do |key, units|
|
|
63
111
|
if ConsumerErrors.failed?(consumers[key])
|
|
64
112
|
unverified("extractor:#{key}")
|
|
113
|
+
# ModelExtractor's per-model rescue returns nil. Every returned
|
|
114
|
+
# model crossed that boundary successfully, even if a sibling did
|
|
115
|
+
# not. Other extractors may return partial results after a rescue.
|
|
116
|
+
if key == :models
|
|
117
|
+
Array(units).each do |unit|
|
|
118
|
+
SourceContributors.paths(unit).each do |path|
|
|
119
|
+
consume_unit(key, path)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
65
123
|
else
|
|
66
124
|
consume_extractor(key, units)
|
|
67
125
|
end
|
|
@@ -80,11 +138,28 @@ module Woods
|
|
|
80
138
|
end
|
|
81
139
|
@unverified << 'incomplete_eager_load' unless eager_load_complete
|
|
82
140
|
Manifest.build(snapshot: @snapshot, scopes: @identities, boot_verified: verified_coverage?,
|
|
83
|
-
generation: generation, errors: @errors, unverified_scopes: @unverified
|
|
141
|
+
generation: generation, errors: @errors, unverified_scopes: @unverified,
|
|
142
|
+
comparison_complete: comparison_complete?,
|
|
143
|
+
unavailable: unavailable_evidence)
|
|
84
144
|
end
|
|
85
145
|
|
|
86
146
|
private
|
|
87
147
|
|
|
148
|
+
def record_error(error)
|
|
149
|
+
# Bound path samples independently so noisy unit diagnostics cannot
|
|
150
|
+
# hide a later instability reason or its watcher catch-up paths.
|
|
151
|
+
count = @errors.count { |existing| existing['reason'] == error['reason'] }
|
|
152
|
+
@errors << error if count < MAX_ERRORS && !@errors.include?(error)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def unavailable_evidence
|
|
156
|
+
@capture_unavailable || (@baseline.data['unavailable'] if @operation != 'full' && @baseline&.unavailable?)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def comparison_complete?
|
|
160
|
+
@operation == 'full' || !!(compatible_baseline? && @baseline.comparison_complete?)
|
|
161
|
+
end
|
|
162
|
+
|
|
88
163
|
def declared_roots
|
|
89
164
|
roots = Handoff.extra_roots
|
|
90
165
|
roots.empty? && @baseline ? @baseline.data.fetch('extra_roots') : roots
|
|
@@ -99,7 +174,7 @@ module Woods
|
|
|
99
174
|
Manifest.parse(file.read(Manifest::MAX_BYTES + 1))
|
|
100
175
|
end
|
|
101
176
|
rescue Manifest::Invalid, SystemCallError, IOError
|
|
102
|
-
|
|
177
|
+
record_error('reason' => 'invalid_source_baseline') unless @operation == 'full'
|
|
103
178
|
nil
|
|
104
179
|
end
|
|
105
180
|
|
|
@@ -107,7 +182,7 @@ module Woods
|
|
|
107
182
|
return snapshot_identities if @operation == 'full'
|
|
108
183
|
return @baseline.expanded if compatible_baseline?
|
|
109
184
|
|
|
110
|
-
|
|
185
|
+
record_error('reason' => 'missing_or_incompatible_source_baseline')
|
|
111
186
|
{}
|
|
112
187
|
end
|
|
113
188
|
|
|
@@ -130,7 +205,7 @@ module Woods
|
|
|
130
205
|
if identity
|
|
131
206
|
(@identities[scope] ||= {})[relative] = identity
|
|
132
207
|
elsif File.exist?(File.join(@root, relative))
|
|
133
|
-
|
|
208
|
+
record_error('reason' => 'uncaptured_source_path', 'path' => relative)
|
|
134
209
|
else
|
|
135
210
|
@identities[scope]&.delete(relative)
|
|
136
211
|
end
|
|
@@ -139,11 +214,60 @@ module Woods
|
|
|
139
214
|
def relative_path(path)
|
|
140
215
|
return nil if path.nil? || path.to_s.empty?
|
|
141
216
|
|
|
142
|
-
|
|
217
|
+
decoded = SourcePathEncoding.utf8(path)
|
|
218
|
+
unless decoded
|
|
219
|
+
record_error('reason' => 'undecodable_source_path', 'path' => SourcePathEncoding.diagnostic(path))
|
|
220
|
+
return nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
application_source_path(File.expand_path(decoded, @root))
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def application_source_path(absolute)
|
|
143
227
|
# Installed gem/framework source is outside application-source coverage.
|
|
144
228
|
return nil unless absolute.start_with?("#{@root}/")
|
|
145
229
|
|
|
146
|
-
absolute.delete_prefix("#{@root}/")
|
|
230
|
+
relative = absolute.delete_prefix("#{@root}/")
|
|
231
|
+
return relative if @rules.extra_roots.any? { |root| relative.start_with?("#{root}/") }
|
|
232
|
+
return nil if installed_gem_roots.any? { |root| absolute.start_with?("#{root}/") }
|
|
233
|
+
|
|
234
|
+
relative
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# A vendor-shaped path alone is not ownership evidence. Local path gems
|
|
238
|
+
# have their gemspec in the checkout, not in an installation's metadata.
|
|
239
|
+
def installed_gem_roots
|
|
240
|
+
@installed_gem_roots ||= Gem.loaded_specs.values.filter_map { |spec| installed_gem_root(spec) }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def installed_gem_root(spec)
|
|
244
|
+
return unless spec.loaded_from && File.file?(spec.loaded_from)
|
|
245
|
+
|
|
246
|
+
base = File.expand_path(spec.base_dir)
|
|
247
|
+
path = File.expand_path(spec.full_gem_path)
|
|
248
|
+
return path if installed_git_gem?(spec, path)
|
|
249
|
+
return unless File.expand_path(spec.loaded_from).start_with?("#{base}/specifications/") &&
|
|
250
|
+
path.start_with?("#{base}/gems/") && File.directory?(path)
|
|
251
|
+
|
|
252
|
+
path
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Bundler git installations keep their gemspec beside the source rather
|
|
256
|
+
# than in RubyGems' specifications directory. A local override remains
|
|
257
|
+
# application source even when it retains a Git source object.
|
|
258
|
+
def installed_git_gem?(spec, path)
|
|
259
|
+
return false unless defined?(Bundler::Source::Git) && spec.respond_to?(:source)
|
|
260
|
+
|
|
261
|
+
source = spec.source
|
|
262
|
+
return false unless source.is_a?(Bundler::Source::Git) && !source.local?
|
|
263
|
+
|
|
264
|
+
installed = File.expand_path(source.path.to_s)
|
|
265
|
+
within_installed_source?(path, installed) &&
|
|
266
|
+
File.expand_path(spec.loaded_from).start_with?("#{path}/") && File.directory?(path)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def within_installed_source?(path, installed)
|
|
270
|
+
path == installed || path.start_with?("#{installed}/")
|
|
147
271
|
end
|
|
148
272
|
|
|
149
273
|
def replace_scope(scope)
|
|
@@ -168,20 +292,28 @@ module Woods
|
|
|
168
292
|
def preserve_baseline_coverage
|
|
169
293
|
return unless @baseline
|
|
170
294
|
|
|
171
|
-
@
|
|
295
|
+
@baseline.data.fetch('errors').each { |error| record_error(error) }
|
|
172
296
|
@unverified.concat(@baseline.data.fetch('unverified_scopes'))
|
|
173
|
-
|
|
297
|
+
record_error('reason' => 'incomplete_source_baseline') unless @baseline.data['complete']
|
|
174
298
|
end
|
|
175
299
|
|
|
176
300
|
def verify_stability
|
|
177
301
|
return unless @key
|
|
178
302
|
|
|
179
303
|
after = scan
|
|
180
|
-
|
|
304
|
+
after.fetch('errors').each { |error| record_error(error) }
|
|
305
|
+
changed_paths(after).first(MAX_ERRORS).each do |path|
|
|
306
|
+
record_error('reason' => 'source_changed_during_extraction', 'path' => path)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def changed_paths(after)
|
|
181
311
|
before_files = @snapshot.fetch('files')
|
|
182
312
|
after_files = after.fetch('files')
|
|
183
|
-
changed = (before_files.keys
|
|
184
|
-
changed.
|
|
313
|
+
changed = (before_files.keys & after_files.keys).reject { |path| before_files[path] == after_files[path] }
|
|
314
|
+
changed.concat(after_files.keys - before_files.keys) if @snapshot['complete']
|
|
315
|
+
changed.concat(before_files.keys - after_files.keys) if after['complete']
|
|
316
|
+
changed
|
|
185
317
|
end
|
|
186
318
|
|
|
187
319
|
# A custom loader can consume Ruby outside the standard dispatch roots.
|
|
@@ -196,7 +328,7 @@ module Woods
|
|
|
196
328
|
end.uniq
|
|
197
329
|
missing = paths - @snapshot.fetch('files').keys
|
|
198
330
|
missing.first(30).each do |path|
|
|
199
|
-
|
|
331
|
+
record_error('reason' => 'loaded_source_outside_coverage', 'path' => path)
|
|
200
332
|
end
|
|
201
333
|
end
|
|
202
334
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module SourceInputs
|
|
7
|
+
# Reads bytes only when their descriptor and captured keyed identity agree.
|
|
8
|
+
# Paths may be captured internal file symlinks, but their resolution must
|
|
9
|
+
# remain stable and inside the real application root throughout the read.
|
|
10
|
+
class StableReader
|
|
11
|
+
DEFAULT_LIMITS = { max_bytes: 8 * 1024 * 1024, max_seconds: 2.0 }.freeze
|
|
12
|
+
|
|
13
|
+
class Error < StandardError
|
|
14
|
+
attr_reader :reason
|
|
15
|
+
|
|
16
|
+
def initialize(reason)
|
|
17
|
+
@reason = reason
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param root [String] application root
|
|
23
|
+
# @param key [PrivateKey] private identity key used by the source snapshot
|
|
24
|
+
def initialize(root:, key:)
|
|
25
|
+
@root = File.expand_path(root.to_s)
|
|
26
|
+
@key = key
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Read original bytes without advancing any source-consumption ledger.
|
|
30
|
+
# The time budget is cooperative between bounded regular-file reads.
|
|
31
|
+
#
|
|
32
|
+
# @param path [String] root-relative or absolute application path
|
|
33
|
+
# @param identity [String] HMAC from the session snapshot
|
|
34
|
+
# @param max_bytes [Numeric] maximum bytes retained in memory
|
|
35
|
+
# @param max_seconds [Numeric] maximum elapsed read time
|
|
36
|
+
# @return [Hash] frozen path, binary source bytes and captured HMAC
|
|
37
|
+
# @raise [Error] if containment, stability, identity or bounds cannot be proven
|
|
38
|
+
def read(path, identity:, max_bytes: DEFAULT_LIMITS[:max_bytes], max_seconds: DEFAULT_LIMITS[:max_seconds])
|
|
39
|
+
@limits = { max_bytes: max_bytes, max_seconds: max_seconds }
|
|
40
|
+
validate_limits!
|
|
41
|
+
@started = monotonic
|
|
42
|
+
absolute = File.expand_path(path.to_s, @root)
|
|
43
|
+
resolved = resolve(absolute)
|
|
44
|
+
expected = stamp(File.stat(resolved))
|
|
45
|
+
source = read_stable(absolute, resolved, expected)
|
|
46
|
+
actual = OpenSSL::HMAC.hexdigest('SHA256', @key.bytes, source)
|
|
47
|
+
check_time!
|
|
48
|
+
raise Error, 'source_snapshot_mismatch' unless actual == identity
|
|
49
|
+
|
|
50
|
+
{ 'path' => absolute.delete_prefix("#{@root}/").freeze, 'source' => source.freeze,
|
|
51
|
+
'identity' => identity.dup.freeze }
|
|
52
|
+
rescue SystemCallError, IOError
|
|
53
|
+
raise Error, 'source_file_unreadable'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def validate_limits!
|
|
59
|
+
return if @limits.values.all? { |value| value.is_a?(Numeric) && value.finite? && value.positive? }
|
|
60
|
+
|
|
61
|
+
raise ArgumentError, 'source read limits must be finite and positive'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def resolve(path)
|
|
65
|
+
raise Error, 'source_outside_root' unless path.start_with?("#{@root}/")
|
|
66
|
+
|
|
67
|
+
@resolved_root = File.realpath(@root)
|
|
68
|
+
resolved = File.realpath(path)
|
|
69
|
+
raise Error, 'source_outside_root' unless resolved.start_with?("#{@resolved_root}/")
|
|
70
|
+
|
|
71
|
+
resolved
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def read_stable(path, resolved, expected)
|
|
75
|
+
flags = File::RDONLY | File::NONBLOCK
|
|
76
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
77
|
+
File.open(resolved, flags) do |file|
|
|
78
|
+
file.binmode
|
|
79
|
+
before = stamp(file.stat)
|
|
80
|
+
raise Error, 'nonregular_source' unless file.stat.file?
|
|
81
|
+
raise Error, 'source_changed_during_read' unless before == expected && stable_path?(path, resolved)
|
|
82
|
+
raise Error, 'source_read_byte_budget' if file.stat.size > @limits[:max_bytes]
|
|
83
|
+
|
|
84
|
+
source = read_bytes(file)
|
|
85
|
+
verify_after!(file, before, path, resolved)
|
|
86
|
+
source
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def verify_after!(file, before, path, resolved)
|
|
91
|
+
return if before == stamp(file.stat) && before == stamp(File.stat(resolved)) && stable_path?(path, resolved)
|
|
92
|
+
|
|
93
|
+
raise Error, 'source_changed_during_read'
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def read_bytes(file)
|
|
97
|
+
source = String.new(encoding: Encoding::BINARY)
|
|
98
|
+
loop do
|
|
99
|
+
check_time!
|
|
100
|
+
chunk = file.read(16_384)
|
|
101
|
+
break unless chunk
|
|
102
|
+
raise Error, 'source_read_byte_budget' if source.bytesize + chunk.bytesize > @limits[:max_bytes]
|
|
103
|
+
|
|
104
|
+
source << chunk
|
|
105
|
+
end
|
|
106
|
+
check_time!
|
|
107
|
+
source
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def stable_path?(path, resolved)
|
|
111
|
+
File.realpath(@root) == @resolved_root && File.realpath(path) == resolved
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def stamp(stat)
|
|
115
|
+
[stat.dev, stat.ino, stat.size, stat.mtime.to_r, stat.ctime.to_r]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def check_time!
|
|
119
|
+
raise Error, 'source_read_time_budget' if monotonic - @started > @limits[:max_seconds]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def monotonic
|
|
123
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|