woods 2.0.0 → 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 +108 -0
- data/CONTRIBUTING.md +135 -10
- 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 -18
- data/docs/CONSOLE_MCP_SETUP.md +141 -12
- 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 +59 -2
- 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 +184 -9
- data/docs/WATCH_DAEMON.md +97 -14
- data/exe/woods-console-mcp +2 -2
- data/exe/woods-mcp-http +16 -9
- 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 +68 -41
- data/lib/woods/chunking/contributor_chunks.rb +119 -0
- data/lib/woods/chunking/semantic_chunker.rb +44 -21
- data/lib/woods/console/adapter_family.rb +39 -0
- data/lib/woods/console/connection_manager.rb +56 -3
- data/lib/woods/console/credential_index.rb +33 -3
- data/lib/woods/console/embedded_executor.rb +431 -48
- data/lib/woods/console/model_validator.rb +8 -0
- data/lib/woods/console/rack_middleware.rb +68 -11
- data/lib/woods/console/redactor.rb +24 -10
- data/lib/woods/console/safe_context.rb +44 -7
- data/lib/woods/console/sql_noise_stripper.rb +41 -12
- data/lib/woods/console/sql_table_scanner.rb +45 -34
- data/lib/woods/console/sql_validator.rb +37 -2
- 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/bearer_auth.rb +1 -1
- 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/origin_guard.rb +24 -77
- data/lib/woods/mcp/origin_policy.rb +124 -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/railtie_support.rb +8 -0
- 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 +336 -2
- data/plugin/skills/woods-investigate/SKILL.md +11 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +86 -0
- data/plugin/skills/woods-setup/SKILL.md +56 -1
- metadata +34 -5
|
@@ -10,30 +10,62 @@ module Woods
|
|
|
10
10
|
# for ordinary source, so an unrecognized secret-bearing file stays protected.
|
|
11
11
|
class PrivateKey
|
|
12
12
|
FILE_NAME = '.source-inputs.key'
|
|
13
|
-
class Unavailable < StandardError
|
|
13
|
+
class Unavailable < StandardError
|
|
14
|
+
def initialize(reason, path: nil, detail: nil)
|
|
15
|
+
super(reason)
|
|
16
|
+
@path = path
|
|
17
|
+
@detail = detail
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Keep the machine-readable reason unchanged for freshness readers.
|
|
21
|
+
# @return [String] safe launcher diagnostic without key bytes
|
|
22
|
+
def diagnostic
|
|
23
|
+
"#{message}: #{@path.inspect}: #{@detail}. " \
|
|
24
|
+
'Verify the owner UID and mount permissions in the application environment; ' \
|
|
25
|
+
'use an owner-only regular 32-byte key. Woods did not repair or rotate it. ' \
|
|
26
|
+
'See docs/SOURCE_FRESHNESS.md#identity-key-recovery.'
|
|
27
|
+
end
|
|
28
|
+
end
|
|
14
29
|
|
|
15
30
|
attr_reader :bytes, :identifier
|
|
16
31
|
|
|
17
32
|
def initialize(output_dir:, create: false)
|
|
18
|
-
path = File.join(output_dir.to_s, FILE_NAME)
|
|
33
|
+
path = File.expand_path(File.join(output_dir.to_s, FILE_NAME))
|
|
19
34
|
create_key(path) if create
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
stat = file.stat
|
|
24
|
-
raise Unavailable, 'insecure_identity_key' unless private_regular?(stat)
|
|
25
|
-
|
|
26
|
-
@bytes = file.read(33)
|
|
35
|
+
@bytes = read_key(path)
|
|
36
|
+
unless @bytes&.bytesize == 32
|
|
37
|
+
raise Unavailable.new('invalid_identity_key', path: path, detail: 'expected exactly 32 bytes')
|
|
27
38
|
end
|
|
28
|
-
raise Unavailable, 'invalid_identity_key' unless @bytes&.bytesize == 32
|
|
29
39
|
|
|
30
40
|
@identifier = Digest::SHA256.hexdigest(@bytes)
|
|
31
|
-
rescue SystemCallError, IOError
|
|
32
|
-
raise Unavailable
|
|
41
|
+
rescue SystemCallError, IOError => e
|
|
42
|
+
raise Unavailable.new(
|
|
43
|
+
'identity_key_unavailable',
|
|
44
|
+
path: path,
|
|
45
|
+
detail: "cannot safely open a regular non-symlink key (#{e.class})"
|
|
46
|
+
)
|
|
33
47
|
end
|
|
34
48
|
|
|
35
49
|
private
|
|
36
50
|
|
|
51
|
+
# @param path [String] private identity-key path
|
|
52
|
+
# @return [String] bounded key bytes after safe descriptor validation
|
|
53
|
+
def read_key(path)
|
|
54
|
+
flags = File::RDONLY | File::NONBLOCK
|
|
55
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
56
|
+
File.open(path, flags) do |file|
|
|
57
|
+
unless private_regular?(file.stat)
|
|
58
|
+
raise Unavailable.new(
|
|
59
|
+
'insecure_identity_key',
|
|
60
|
+
path: path,
|
|
61
|
+
detail: 'expected a regular file owned by this UID with owner-only permissions'
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
file.read(33)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
37
69
|
def private_regular?(stat)
|
|
38
70
|
stat.file? && stat.uid == Process.uid && stat.mode.nobits?(0o077)
|
|
39
71
|
end
|
|
@@ -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
|
|