makit 0.0.99 → 0.0.112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -0
  3. data/exe/makit +5 -0
  4. data/lib/makit/apache.rb +28 -32
  5. data/lib/makit/cli/build_commands.rb +500 -0
  6. data/lib/makit/cli/generators/base_generator.rb +74 -0
  7. data/lib/makit/cli/generators/dotnet_generator.rb +50 -0
  8. data/lib/makit/cli/generators/generator_factory.rb +49 -0
  9. data/lib/makit/cli/generators/node_generator.rb +50 -0
  10. data/lib/makit/cli/generators/ruby_generator.rb +77 -0
  11. data/lib/makit/cli/generators/rust_generator.rb +50 -0
  12. data/lib/makit/cli/generators/templates/dotnet_templates.rb +167 -0
  13. data/lib/makit/cli/generators/templates/node_templates.rb +161 -0
  14. data/lib/makit/cli/generators/templates/ruby/gemfile.rb +26 -0
  15. data/lib/makit/cli/generators/templates/ruby/gemspec.rb +40 -0
  16. data/lib/makit/cli/generators/templates/ruby/main_lib.rb +33 -0
  17. data/lib/makit/cli/generators/templates/ruby/rakefile.rb +35 -0
  18. data/lib/makit/cli/generators/templates/ruby/readme.rb +63 -0
  19. data/lib/makit/cli/generators/templates/ruby/test.rb +39 -0
  20. data/lib/makit/cli/generators/templates/ruby/test_helper.rb +29 -0
  21. data/lib/makit/cli/generators/templates/ruby/version.rb +29 -0
  22. data/lib/makit/cli/generators/templates/rust_templates.rb +128 -0
  23. data/lib/makit/cli/main.rb +62 -33
  24. data/lib/makit/cli/project_commands.rb +868 -0
  25. data/lib/makit/cli/repository_commands.rb +661 -0
  26. data/lib/makit/cli/utility_commands.rb +521 -0
  27. data/lib/makit/commands/factory.rb +359 -0
  28. data/lib/makit/commands/middleware/base.rb +73 -0
  29. data/lib/makit/commands/middleware/cache.rb +248 -0
  30. data/lib/makit/commands/middleware/command_logger.rb +320 -0
  31. data/lib/makit/commands/middleware/unified_logger.rb +243 -0
  32. data/lib/makit/commands/middleware/validator.rb +269 -0
  33. data/lib/makit/commands/request.rb +254 -0
  34. data/lib/makit/commands/result.rb +323 -0
  35. data/lib/makit/commands/runner.rb +337 -0
  36. data/lib/makit/commands/strategies/base.rb +160 -0
  37. data/lib/makit/commands/strategies/synchronous.rb +134 -0
  38. data/lib/makit/commands.rb +51 -21
  39. data/lib/makit/configuration/gitlab_helper.rb +60 -0
  40. data/lib/makit/configuration/project.rb +127 -0
  41. data/lib/makit/configuration/rakefile_helper.rb +43 -0
  42. data/lib/makit/configuration/step.rb +34 -0
  43. data/lib/makit/configuration.rb +14 -0
  44. data/lib/makit/content/default_gitignore.rb +7 -5
  45. data/lib/makit/content/default_rakefile.rb +13 -11
  46. data/lib/makit/content/gem_rakefile.rb +16 -14
  47. data/lib/makit/context.rb +1 -0
  48. data/lib/makit/data.rb +49 -50
  49. data/lib/makit/directories.rb +141 -145
  50. data/lib/makit/directory.rb +262 -276
  51. data/lib/makit/docs/files.rb +89 -94
  52. data/lib/makit/docs/rake.rb +102 -106
  53. data/lib/makit/dotnet/cli.rb +65 -0
  54. data/lib/makit/dotnet/project.rb +153 -0
  55. data/lib/makit/dotnet/solution.rb +38 -0
  56. data/lib/makit/dotnet/solution_classlib.rb +239 -0
  57. data/lib/makit/dotnet/solution_console.rb +264 -0
  58. data/lib/makit/dotnet/solution_maui.rb +354 -0
  59. data/lib/makit/dotnet/solution_wasm.rb +275 -0
  60. data/lib/makit/dotnet/solution_wpf.rb +304 -0
  61. data/lib/makit/dotnet.rb +102 -219
  62. data/lib/makit/email.rb +90 -61
  63. data/lib/makit/environment.rb +142 -139
  64. data/lib/makit/examples/runner.rb +370 -0
  65. data/lib/makit/exceptions.rb +45 -0
  66. data/lib/makit/fileinfo.rb +24 -26
  67. data/lib/makit/files.rb +43 -47
  68. data/lib/makit/gems.rb +29 -28
  69. data/lib/makit/git/cli.rb +54 -0
  70. data/lib/makit/git/repository.rb +90 -0
  71. data/lib/makit/git.rb +98 -145
  72. data/lib/makit/gitlab_runner.rb +59 -60
  73. data/lib/makit/humanize.rb +137 -129
  74. data/lib/makit/indexer.rb +47 -56
  75. data/lib/makit/logging/configuration.rb +305 -0
  76. data/lib/makit/logging/format_registry.rb +84 -0
  77. data/lib/makit/logging/formatters/base.rb +39 -0
  78. data/lib/makit/logging/formatters/console_formatter.rb +140 -0
  79. data/lib/makit/logging/formatters/json_formatter.rb +65 -0
  80. data/lib/makit/logging/formatters/plain_text_formatter.rb +71 -0
  81. data/lib/makit/logging/formatters/text_formatter.rb +64 -0
  82. data/lib/makit/logging/log_request.rb +115 -0
  83. data/lib/makit/logging/logger.rb +163 -0
  84. data/lib/makit/logging/sinks/base.rb +91 -0
  85. data/lib/makit/logging/sinks/console.rb +72 -0
  86. data/lib/makit/logging/sinks/file_sink.rb +92 -0
  87. data/lib/makit/logging/sinks/structured.rb +129 -0
  88. data/lib/makit/logging/sinks/unified_file_sink.rb +303 -0
  89. data/lib/makit/logging.rb +530 -106
  90. data/lib/makit/markdown.rb +75 -75
  91. data/lib/makit/mp/basic_object_mp.rb +17 -16
  92. data/lib/makit/mp/command_mp.rb +13 -13
  93. data/lib/makit/mp/command_request.mp.rb +17 -16
  94. data/lib/makit/mp/project_mp.rb +199 -210
  95. data/lib/makit/mp/string_mp.rb +193 -176
  96. data/lib/makit/nuget.rb +74 -72
  97. data/lib/makit/port.rb +32 -34
  98. data/lib/makit/process.rb +163 -65
  99. data/lib/makit/protoc.rb +107 -104
  100. data/lib/makit/rake/cli.rb +196 -0
  101. data/lib/makit/rake.rb +25 -25
  102. data/lib/makit/ruby/cli.rb +185 -0
  103. data/lib/makit/ruby.rb +25 -0
  104. data/lib/makit/secrets.rb +51 -51
  105. data/lib/makit/serializer.rb +130 -115
  106. data/lib/makit/services/builder.rb +186 -0
  107. data/lib/makit/services/error_handler.rb +226 -0
  108. data/lib/makit/services/repository_manager.rb +229 -0
  109. data/lib/makit/services/validator.rb +112 -0
  110. data/lib/makit/setup/classlib.rb +53 -0
  111. data/lib/makit/setup/gem.rb +263 -0
  112. data/lib/makit/setup/runner.rb +45 -0
  113. data/lib/makit/setup.rb +5 -0
  114. data/lib/makit/show.rb +110 -110
  115. data/lib/makit/storage.rb +126 -131
  116. data/lib/makit/symbols.rb +170 -149
  117. data/lib/makit/task_info.rb +128 -86
  118. data/lib/makit/tasks/at_exit.rb +13 -0
  119. data/lib/makit/tasks/build.rb +19 -0
  120. data/lib/makit/tasks/clean.rb +11 -0
  121. data/lib/makit/tasks/hook_manager.rb +393 -0
  122. data/lib/makit/tasks/init.rb +47 -0
  123. data/lib/makit/tasks/integrate.rb +17 -0
  124. data/lib/makit/tasks/pull_incoming.rb +11 -0
  125. data/lib/makit/tasks/setup.rb +6 -0
  126. data/lib/makit/tasks/sync.rb +12 -0
  127. data/lib/makit/tasks/tag.rb +15 -0
  128. data/lib/makit/tasks/task_monkey_patch.rb +79 -0
  129. data/lib/makit/tasks.rb +15 -150
  130. data/lib/makit/test_cache.rb +239 -0
  131. data/lib/makit/tree.rb +37 -37
  132. data/lib/makit/v1/makit.v1_pb.rb +3 -4
  133. data/lib/makit/v1/makit.v1_services_pb.rb +27 -25
  134. data/lib/makit/version.rb +5 -61
  135. data/lib/makit/version_util.rb +21 -0
  136. data/lib/makit/wix.rb +95 -95
  137. data/lib/makit/yaml.rb +29 -17
  138. data/lib/makit/zip.rb +17 -17
  139. data/lib/makit copy.rb +44 -0
  140. data/lib/makit.rb +40 -267
  141. metadata +117 -110
  142. data/lib/makit/cli/clean.rb +0 -14
  143. data/lib/makit/cli/clone.rb +0 -59
  144. data/lib/makit/cli/init.rb +0 -38
  145. data/lib/makit/cli/make.rb +0 -54
  146. data/lib/makit/cli/new.rb +0 -37
  147. data/lib/makit/cli/nuget_cache.rb +0 -38
  148. data/lib/makit/cli/pull.rb +0 -31
  149. data/lib/makit/cli/setup.rb +0 -71
  150. data/lib/makit/cli/work.rb +0 -21
  151. data/lib/makit/command_runner.rb +0 -404
  152. data/lib/makit/content/default_gitignore.txt +0 -222
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "uri"
5
+
6
+ module Makit
7
+ module Commands
8
+ # Custom exception for security-related command validation failures
9
+ class SecurityError < StandardError; end
10
+
11
+ module Middleware
12
+ # Validation middleware that provides input sanitization and security checks
13
+ # for command execution contexts.
14
+ #
15
+ # This middleware validates and sanitizes:
16
+ # - Command strings for shell injection attacks
17
+ # - File paths for directory traversal
18
+ # - URLs for malicious schemes
19
+ # - Environment variables for sensitive data
20
+ # - Arguments for dangerous patterns
21
+ #
22
+ # @example Basic usage
23
+ # validator = Commands::Middleware::Validator.new
24
+ # context = Commands::Context.new(command: 'ls -la')
25
+ # validated_context = validator.call(context)
26
+ #
27
+ # @example With custom validation rules
28
+ # validator = Commands::Middleware::Validator.new(
29
+ # allow_shell_operators: false,
30
+ # blocked_commands: %w[rm sudo],
31
+ # max_command_length: 500
32
+ # )
33
+ class Validator
34
+ # Default blocked commands that pose security risks
35
+ DEFAULT_BLOCKED_COMMANDS = %w[
36
+ rm rmdir
37
+ sudo su
38
+ chmod chown
39
+ wget curl
40
+ eval exec
41
+ python ruby node
42
+ ssh scp rsync
43
+ dd fdisk mount umount
44
+ iptables ufw
45
+ systemctl service
46
+ crontab
47
+ passwd
48
+ ].freeze
49
+
50
+ # Dangerous shell operators and patterns
51
+ DANGEROUS_PATTERNS = [
52
+ /[;&|`$(){}]/, # Shell operators and command substitution
53
+ %r{\.\./}, # Directory traversal
54
+ %r{/etc/|/proc/|/sys/}, # Sensitive system directories
55
+ /\$\{[^}]*\}/, # Variable expansion
56
+ %r{~[^/\s]*}, # User home directory expansion
57
+ %r{\bfile://|ftp://|https?://[^\s]*\.(sh|py|rb|js|exe|bat)\b}i, # Executable URLs
58
+ ].freeze
59
+
60
+ # Maximum allowed lengths for various inputs
61
+ DEFAULT_LIMITS = {
62
+ command_length: 1000,
63
+ argument_length: 500,
64
+ path_length: 1000,
65
+ env_var_value_length: 2000,
66
+ }.freeze
67
+
68
+ attr_reader :options
69
+
70
+ # Initialize the validator with security options
71
+ #
72
+ # @param options [Hash] Configuration options
73
+ # @option options [Boolean] :allow_shell_operators (false) Allow shell operators like ;, |, &
74
+ # @option options [Array<String>] :blocked_commands Commands to block
75
+ # @option options [Hash] :limits Length limits for various inputs
76
+ # @option options [Boolean] :strict_mode (true) Enable strict validation
77
+ # @option options [Array<String>] :allowed_schemes URL schemes to allow
78
+ def initialize(options = {})
79
+ @options = {
80
+ allow_shell_operators: false,
81
+ blocked_commands: DEFAULT_BLOCKED_COMMANDS,
82
+ limits: DEFAULT_LIMITS,
83
+ strict_mode: true,
84
+ allowed_schemes: %w[http https file],
85
+ allow_environment_access: false,
86
+ }.merge(options)
87
+ end
88
+
89
+ # Validate and sanitize the command context
90
+ #
91
+ # @param context [Commands::Context] The command execution context
92
+ # @return [Commands::Context] Validated context
93
+ # @raise [Commands::SecurityError] If validation fails
94
+ def call(context)
95
+ validate_command!(context.command) if context.command
96
+ validate_arguments!(context.arguments) if context.arguments
97
+ validate_environment!(context.environment) if context.environment
98
+ validate_working_directory!(context.working_directory) if context.working_directory
99
+
100
+ context
101
+ rescue StandardError => e
102
+ raise Commands::SecurityError, "Validation failed: #{e.message}"
103
+ end
104
+
105
+ private
106
+
107
+ # Validate the main command string
108
+ def validate_command!(command)
109
+ raise Commands::SecurityError, "Empty command" if command.nil? || command.strip.empty?
110
+
111
+ if command.length > limits[:command_length]
112
+ raise Commands::SecurityError,
113
+ "Command too long (#{command.length} > #{limits[:command_length]})"
114
+ end
115
+
116
+ # Check for blocked commands
117
+ command_parts = Shellwords.split(command)
118
+ base_command = File.basename(command_parts.first || "")
119
+
120
+ raise Commands::SecurityError, "Blocked command: #{base_command}" if blocked_commands.include?(base_command)
121
+
122
+ # Check for dangerous patterns
123
+ unless options[:allow_shell_operators]
124
+ DANGEROUS_PATTERNS.each do |pattern|
125
+ if command.match?(pattern)
126
+ raise Commands::SecurityError, "Dangerous pattern detected in command: #{pattern.inspect}"
127
+ end
128
+ end
129
+ end
130
+
131
+ # Validate shell escaping
132
+ begin
133
+ Shellwords.split(command)
134
+ rescue ArgumentError => e
135
+ raise Commands::SecurityError, "Invalid shell command syntax: #{e.message}"
136
+ end
137
+ end
138
+
139
+ # Validate command arguments
140
+ def validate_arguments!(arguments)
141
+ return unless arguments
142
+
143
+ arguments.each_with_index do |arg, index|
144
+ next unless arg
145
+
146
+ if arg.length > limits[:argument_length]
147
+ raise Commands::SecurityError, "Argument #{index} too long (#{arg.length} > #{limits[:argument_length]})"
148
+ end
149
+
150
+ # Check for dangerous patterns in arguments
151
+ if options[:strict_mode]
152
+ DANGEROUS_PATTERNS.each do |pattern|
153
+ if arg.match?(pattern)
154
+ raise Commands::SecurityError, "Dangerous pattern in argument #{index}: #{pattern.inspect}"
155
+ end
156
+ end
157
+ end
158
+
159
+ # Validate file paths in arguments
160
+ validate_path!(arg) if looks_like_path?(arg)
161
+
162
+ # Validate URLs in arguments
163
+ validate_url!(arg) if looks_like_url?(arg)
164
+ end
165
+ end
166
+
167
+ # Validate environment variables
168
+ def validate_environment!(environment)
169
+ return unless environment
170
+
171
+ environment.each do |key, value|
172
+ next unless value
173
+
174
+ if value.to_s.length > limits[:env_var_value_length]
175
+ raise Commands::SecurityError, "Environment variable #{key} value too long"
176
+ end
177
+
178
+ # Check for sensitive environment variables
179
+ if sensitive_env_var?(key) && !options[:allow_environment_access]
180
+ raise Commands::SecurityError, "Access to sensitive environment variable #{key} not allowed"
181
+ end
182
+
183
+ # Validate environment variable values
184
+ if options[:strict_mode] && contains_dangerous_content?(value.to_s)
185
+ raise Commands::SecurityError, "Dangerous content in environment variable #{key}"
186
+ end
187
+ end
188
+ end
189
+
190
+ # Validate working directory path
191
+ def validate_working_directory!(path)
192
+ validate_path!(path, "working directory")
193
+ end
194
+
195
+ # Validate file system paths
196
+ def validate_path!(path, context = "path")
197
+ return if path.nil? || path.empty?
198
+
199
+ if path.length > limits[:path_length]
200
+ raise Commands::SecurityError, "#{context.capitalize} too long (#{path.length} > #{limits[:path_length]})"
201
+ end
202
+
203
+ # Check for directory traversal
204
+ normalized_path = File.expand_path(path)
205
+ if path.include?("..") && options[:strict_mode]
206
+ raise Commands::SecurityError, "Directory traversal detected in #{context}: #{path}"
207
+ end
208
+
209
+ # Check for access to sensitive directories
210
+ sensitive_dirs = %w[/etc /proc /sys /dev /boot /root]
211
+ return unless sensitive_dirs.any? { |dir| normalized_path.start_with?(dir) } && options[:strict_mode]
212
+
213
+ raise Commands::SecurityError, "Access to sensitive directory in #{context}: #{path}"
214
+ end
215
+
216
+ # Validate URLs
217
+ def validate_url!(url)
218
+ uri = URI.parse(url)
219
+
220
+ unless options[:allowed_schemes].include?(uri.scheme)
221
+ raise Commands::SecurityError, "Disallowed URL scheme: #{uri.scheme}"
222
+ end
223
+
224
+ # Check for suspicious hosts
225
+ if uri.host&.match?(/localhost|127\.0\.0\.1|0\.0\.0\.0/)
226
+ raise Commands::SecurityError, "Local network access not allowed: #{uri.host}"
227
+ end
228
+ rescue URI::InvalidURIError => e
229
+ raise Commands::SecurityError, "Invalid URL format: #{e.message}"
230
+ end
231
+
232
+ # Check if string looks like a file path
233
+ def looks_like_path?(str)
234
+ str.start_with?("/") || str.start_with?("./") || str.start_with?("../") || str.include?(File::SEPARATOR)
235
+ end
236
+
237
+ # Check if string looks like a URL
238
+ def looks_like_url?(str)
239
+ str.match?(/\A[a-z][a-z0-9+.-]*:/i)
240
+ end
241
+
242
+ # Check if environment variable is sensitive
243
+ def sensitive_env_var?(key)
244
+ sensitive_patterns = %w[
245
+ PASSWORD SECRET TOKEN KEY API PRIVATE
246
+ AWS GCP AZURE GITHUB GITLAB
247
+ DATABASE_URL DB_PASSWORD
248
+ RAILS_MASTER_KEY
249
+ ]
250
+
251
+ sensitive_patterns.any? { |pattern| key.to_s.upcase.include?(pattern) }
252
+ end
253
+
254
+ # Check if content contains dangerous patterns
255
+ def contains_dangerous_content?(content)
256
+ DANGEROUS_PATTERNS.any? { |pattern| content.match?(pattern) }
257
+ end
258
+
259
+ def limits
260
+ options[:limits]
261
+ end
262
+
263
+ def blocked_commands
264
+ options[:blocked_commands]
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "json"
5
+
6
+ module Makit
7
+ module Commands
8
+ # Enhanced request handling with validation and type safety.
9
+ # Uses plain Ruby objects - no protobuf dependency for better performance.
10
+ #
11
+ # @example Create a simple request
12
+ # request = Request.new(command: "git", arguments: ["--version"])
13
+ #
14
+ # @example Create request from string
15
+ # request = Request.from_string("git clone https://github.com/user/repo.git")
16
+ #
17
+ # @example Create request with full options
18
+ # request = Request.new(
19
+ # command: "bundle",
20
+ # arguments: ["install"],
21
+ # directory: "/path/to/project",
22
+ # environment: { "BUNDLE_JOBS" => "4" },
23
+ # timeout: 300,
24
+ # metadata: { operation: "dependency_install" }
25
+ # )
26
+ class Request
27
+ # Command execution request with validation and metadata.
28
+ #
29
+ # @!attribute [r] command
30
+ # @return [String] the command to execute
31
+ # @!attribute [r] arguments
32
+ # @return [Array<String>] command line arguments
33
+ # @!attribute [r] environment
34
+ # @return [Hash<String,String>] environment variables
35
+ # @!attribute [r] directory
36
+ # @return [String] working directory for execution
37
+ # @!attribute [r] timeout
38
+ # @return [Integer] timeout in seconds
39
+ # @!attribute [r] metadata
40
+ # @return [Hash] additional metadata for logging/tracking
41
+ attr_reader :command, :arguments, :environment, :directory, :timeout, :metadata
42
+
43
+ # Initialize a new command request.
44
+ #
45
+ # @param command [String] the command to execute
46
+ # @param arguments [Array<String>] command arguments
47
+ # @param environment [Hash<String,String>] environment variables
48
+ # @param directory [String] working directory
49
+ # @param timeout [Integer] timeout in seconds
50
+ # @param metadata [Hash] additional metadata
51
+ # @raise [ArgumentError] if command is invalid
52
+ def initialize(command:, arguments: [], **options)
53
+ @command = validate_command(command)
54
+ @arguments = validate_arguments(arguments)
55
+ @environment = options[:environment] || {}
56
+ @directory = options[:directory] || Dir.pwd
57
+ @timeout = options[:timeout] || 30
58
+ @metadata = options[:metadata] || {}
59
+
60
+ validate_directory(@directory)
61
+ validate_timeout(@timeout)
62
+ end
63
+
64
+ # Convert request to shell-executable command string.
65
+ #
66
+ # @return [String] shell command string
67
+ def to_shell_command
68
+ cmd_parts = [command] + arguments
69
+ Shellwords.join(cmd_parts)
70
+ end
71
+
72
+ # Convert request to hash representation.
73
+ #
74
+ # @return [Hash] hash representation of the request
75
+ def to_h
76
+ {
77
+ command: command,
78
+ arguments: arguments,
79
+ environment: environment,
80
+ directory: directory,
81
+ timeout: timeout,
82
+ metadata: metadata,
83
+ }
84
+ end
85
+
86
+ # Convert request to JSON representation.
87
+ #
88
+ # @param args [Array] arguments passed to JSON.generate
89
+ # @return [String] JSON representation
90
+ def to_json(*args)
91
+ JSON.generate(to_h, *args)
92
+ end
93
+
94
+ # Create a request from a shell command string.
95
+ #
96
+ # @param command_string [String] shell command string to parse
97
+ # @param options [Hash] additional options
98
+ # @return [Request] new request object
99
+ # @raise [ArgumentError] if command string is invalid
100
+ # @example
101
+ # Request.from_string("git clone https://github.com/user/repo.git")
102
+ # Request.from_string("bundle install --jobs 4")
103
+ def self.from_string(command_string, **options)
104
+ raise ArgumentError, "Command string cannot be empty" if command_string.nil? || command_string.strip.empty?
105
+
106
+ # Parse shell command string into command and arguments
107
+ parts = Shellwords.split(command_string.strip)
108
+ command = parts.shift
109
+ arguments = parts
110
+
111
+ new(command: command, arguments: arguments, **options)
112
+ end
113
+
114
+ # Create a request from hash representation.
115
+ #
116
+ # @param command_hash [Hash] hash containing command information
117
+ # @return [Request] new request object
118
+ # @raise [ArgumentError] if hash is invalid
119
+ # @example
120
+ # Request.from_hash({
121
+ # "command" => "git",
122
+ # "arguments" => ["clone", "https://github.com/user/repo.git"],
123
+ # "timeout" => 300
124
+ # })
125
+ def self.from_hash(command_hash)
126
+ raise ArgumentError, "Command hash cannot be nil" if command_hash.nil?
127
+
128
+ # Convert string keys to symbols for consistency
129
+ hash = command_hash.is_a?(Hash) ? normalize_hash_keys(command_hash) : command_hash
130
+
131
+ new(
132
+ command: hash[:command] || hash["command"],
133
+ arguments: hash[:arguments] || hash["arguments"] || [],
134
+ environment: hash[:environment] || hash["environment"] || {},
135
+ directory: hash[:directory] || hash["directory"],
136
+ timeout: hash[:timeout] || hash["timeout"],
137
+ metadata: hash[:metadata] || hash["metadata"] || {},
138
+ )
139
+ end
140
+
141
+ # Create a request from JSON string.
142
+ #
143
+ # @param json_string [String] JSON string representation
144
+ # @return [Request] new request object
145
+ # @raise [JSON::ParserError, ArgumentError] if JSON is invalid
146
+ def self.from_json(json_string)
147
+ hash = JSON.parse(json_string)
148
+ from_hash(hash)
149
+ end
150
+
151
+ # Check if this request represents the same command.
152
+ #
153
+ # @param other [Request] another request to compare
154
+ # @return [Boolean] true if commands are equivalent
155
+ def equivalent_to?(other)
156
+ return false unless other.is_a?(Request)
157
+
158
+ command == other.command &&
159
+ arguments == other.arguments &&
160
+ environment == other.environment &&
161
+ directory == other.directory
162
+ end
163
+
164
+ # Generate a cache key for this request.
165
+ #
166
+ # @return [String] cache key based on command, arguments, and context
167
+ def cache_key
168
+ require "digest"
169
+ content = "#{command}:#{arguments.join(":")}:#{directory}"
170
+ Digest::SHA256.hexdigest(content)[0..16]
171
+ end
172
+
173
+ private
174
+
175
+ # Validate command parameter.
176
+ #
177
+ # @param command [String] command to validate
178
+ # @return [String] validated command
179
+ # @raise [ArgumentError] if command is invalid
180
+ def validate_command(command)
181
+ raise ArgumentError, "Command cannot be nil" if command.nil?
182
+ raise ArgumentError, "Command cannot be empty" if command.strip.empty?
183
+
184
+ cmd = command.strip
185
+
186
+ # Check for dangerous characters that could indicate command injection
187
+ raise ArgumentError, "Command contains potentially dangerous characters" if cmd.match?(/[;&|`$(){}]/)
188
+
189
+ cmd
190
+ end
191
+
192
+ # Validate arguments array.
193
+ #
194
+ # @param arguments [Array] arguments to validate
195
+ # @return [Array<String>] validated arguments
196
+ # @raise [ArgumentError] if arguments are invalid
197
+ def validate_arguments(arguments)
198
+ return [] if arguments.nil?
199
+ raise ArgumentError, "Arguments must be an array" unless arguments.is_a?(Array)
200
+
201
+ # Convert all arguments to strings and validate
202
+ arguments.map do |arg|
203
+ raise ArgumentError, "Argument cannot be nil" if arg.nil?
204
+
205
+ arg.to_s
206
+ end
207
+ end
208
+
209
+ # Validate directory parameter.
210
+ #
211
+ # @param directory [String] directory to validate
212
+ # @raise [ArgumentError] if directory is invalid
213
+ def validate_directory(directory)
214
+ return unless directory
215
+
216
+ raise ArgumentError, "Directory must be a string" unless directory.is_a?(String)
217
+
218
+ # NOTE: We don't validate directory existence here as it might be created later
219
+ # We just validate it's a reasonable path format
220
+ return unless directory.include?("\0")
221
+
222
+ raise ArgumentError, "Directory path contains null bytes"
223
+ end
224
+
225
+ # Validate timeout parameter.
226
+ #
227
+ # @param timeout [Integer] timeout to validate
228
+ # @raise [ArgumentError] if timeout is invalid
229
+ def validate_timeout(timeout)
230
+ raise ArgumentError, "Timeout must be a positive integer" unless timeout.is_a?(Integer) && timeout.positive?
231
+
232
+ return unless timeout > 3600 # 1 hour max
233
+
234
+ raise ArgumentError, "Timeout cannot exceed 3600 seconds"
235
+ end
236
+
237
+ # Normalize hash keys to support both string and symbol keys.
238
+ #
239
+ # @param hash [Hash] hash to normalize
240
+ # @return [Hash] hash with normalized keys
241
+ def self.normalize_hash_keys(hash)
242
+ normalized = {}
243
+ hash.each do |key, value|
244
+ normalized_key = key.is_a?(String) ? key.to_sym : key
245
+ normalized[key] = value # Keep original key
246
+ normalized[normalized_key] = value # Add symbol version
247
+ end
248
+ normalized
249
+ end
250
+
251
+ private_class_method :normalize_hash_keys
252
+ end
253
+ end
254
+ end