sxn 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. checksums.yaml +7 -0
  2. data/.gem_rbs_collection/addressable/2.8/.rbs_meta.yaml +9 -0
  3. data/.gem_rbs_collection/addressable/2.8/addressable.rbs +62 -0
  4. data/.gem_rbs_collection/async/2.12/.rbs_meta.yaml +9 -0
  5. data/.gem_rbs_collection/async/2.12/async.rbs +119 -0
  6. data/.gem_rbs_collection/async/2.12/kernel.rbs +5 -0
  7. data/.gem_rbs_collection/async/2.12/manifest.yaml +7 -0
  8. data/.gem_rbs_collection/bcrypt/3.1/.rbs_meta.yaml +9 -0
  9. data/.gem_rbs_collection/bcrypt/3.1/bcrypt.rbs +47 -0
  10. data/.gem_rbs_collection/bcrypt/3.1/manifest.yaml +2 -0
  11. data/.gem_rbs_collection/bigdecimal/3.1/.rbs_meta.yaml +9 -0
  12. data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal-math.rbs +119 -0
  13. data/.gem_rbs_collection/bigdecimal/3.1/bigdecimal.rbs +1630 -0
  14. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
  15. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
  16. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +26 -0
  17. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
  18. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +65 -0
  19. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
  20. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
  21. data/.gem_rbs_collection/diff-lcs/1.5/.rbs_meta.yaml +9 -0
  22. data/.gem_rbs_collection/diff-lcs/1.5/diff-lcs.rbs +11 -0
  23. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
  24. data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
  25. data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
  26. data/.gem_rbs_collection/mini_mime/0.1/.rbs_meta.yaml +9 -0
  27. data/.gem_rbs_collection/mini_mime/0.1/mini_mime.rbs +14 -0
  28. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
  29. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
  30. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
  31. data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
  32. data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
  33. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
  34. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +822 -0
  35. data/.gem_rbs_collection/sqlite3/2.0/.rbs_meta.yaml +9 -0
  36. data/.gem_rbs_collection/sqlite3/2.0/database.rbs +20 -0
  37. data/.gem_rbs_collection/sqlite3/2.0/pragmas.rbs +5 -0
  38. data/.rspec +4 -0
  39. data/.rubocop.yml +121 -0
  40. data/.simplecov +51 -0
  41. data/CHANGELOG.md +49 -0
  42. data/Gemfile +24 -0
  43. data/Gemfile.lock +329 -0
  44. data/LICENSE.txt +21 -0
  45. data/README.md +225 -0
  46. data/Rakefile +54 -0
  47. data/Steepfile +50 -0
  48. data/bin/sxn +6 -0
  49. data/lib/sxn/CLI.rb +275 -0
  50. data/lib/sxn/commands/init.rb +137 -0
  51. data/lib/sxn/commands/projects.rb +350 -0
  52. data/lib/sxn/commands/rules.rb +435 -0
  53. data/lib/sxn/commands/sessions.rb +300 -0
  54. data/lib/sxn/commands/worktrees.rb +416 -0
  55. data/lib/sxn/commands.rb +13 -0
  56. data/lib/sxn/config/config_cache.rb +295 -0
  57. data/lib/sxn/config/config_discovery.rb +242 -0
  58. data/lib/sxn/config/config_validator.rb +562 -0
  59. data/lib/sxn/config.rb +259 -0
  60. data/lib/sxn/core/config_manager.rb +290 -0
  61. data/lib/sxn/core/project_manager.rb +307 -0
  62. data/lib/sxn/core/rules_manager.rb +306 -0
  63. data/lib/sxn/core/session_manager.rb +336 -0
  64. data/lib/sxn/core/worktree_manager.rb +281 -0
  65. data/lib/sxn/core.rb +13 -0
  66. data/lib/sxn/database/errors.rb +29 -0
  67. data/lib/sxn/database/session_database.rb +691 -0
  68. data/lib/sxn/database.rb +24 -0
  69. data/lib/sxn/errors.rb +76 -0
  70. data/lib/sxn/rules/base_rule.rb +367 -0
  71. data/lib/sxn/rules/copy_files_rule.rb +346 -0
  72. data/lib/sxn/rules/errors.rb +28 -0
  73. data/lib/sxn/rules/project_detector.rb +871 -0
  74. data/lib/sxn/rules/rules_engine.rb +485 -0
  75. data/lib/sxn/rules/setup_commands_rule.rb +307 -0
  76. data/lib/sxn/rules/template_rule.rb +262 -0
  77. data/lib/sxn/rules.rb +148 -0
  78. data/lib/sxn/runtime_validations.rb +96 -0
  79. data/lib/sxn/security/secure_command_executor.rb +364 -0
  80. data/lib/sxn/security/secure_file_copier.rb +478 -0
  81. data/lib/sxn/security/secure_path_validator.rb +258 -0
  82. data/lib/sxn/security.rb +15 -0
  83. data/lib/sxn/templates/common/gitignore.liquid +99 -0
  84. data/lib/sxn/templates/common/session-info.md.liquid +58 -0
  85. data/lib/sxn/templates/errors.rb +36 -0
  86. data/lib/sxn/templates/javascript/README.md.liquid +59 -0
  87. data/lib/sxn/templates/javascript/session-info.md.liquid +206 -0
  88. data/lib/sxn/templates/rails/CLAUDE.md.liquid +78 -0
  89. data/lib/sxn/templates/rails/database.yml.liquid +31 -0
  90. data/lib/sxn/templates/rails/session-info.md.liquid +144 -0
  91. data/lib/sxn/templates/template_engine.rb +346 -0
  92. data/lib/sxn/templates/template_processor.rb +279 -0
  93. data/lib/sxn/templates/template_security.rb +410 -0
  94. data/lib/sxn/templates/template_variables.rb +713 -0
  95. data/lib/sxn/templates.rb +28 -0
  96. data/lib/sxn/ui/output.rb +103 -0
  97. data/lib/sxn/ui/progress_bar.rb +91 -0
  98. data/lib/sxn/ui/prompt.rb +116 -0
  99. data/lib/sxn/ui/table.rb +183 -0
  100. data/lib/sxn/ui.rb +12 -0
  101. data/lib/sxn/version.rb +5 -0
  102. data/lib/sxn.rb +63 -0
  103. data/rbs_collection.lock.yaml +180 -0
  104. data/rbs_collection.yaml +39 -0
  105. data/scripts/test.sh +31 -0
  106. data/sig/external/liquid.rbs +116 -0
  107. data/sig/external/thor.rbs +99 -0
  108. data/sig/external/tty.rbs +71 -0
  109. data/sig/sxn/cli.rbs +46 -0
  110. data/sig/sxn/commands/init.rbs +38 -0
  111. data/sig/sxn/commands/projects.rbs +72 -0
  112. data/sig/sxn/commands/rules.rbs +95 -0
  113. data/sig/sxn/commands/sessions.rbs +62 -0
  114. data/sig/sxn/commands/worktrees.rbs +82 -0
  115. data/sig/sxn/commands.rbs +6 -0
  116. data/sig/sxn/config/config_cache.rbs +67 -0
  117. data/sig/sxn/config/config_discovery.rbs +64 -0
  118. data/sig/sxn/config/config_validator.rbs +64 -0
  119. data/sig/sxn/config.rbs +74 -0
  120. data/sig/sxn/core/config_manager.rbs +67 -0
  121. data/sig/sxn/core/project_manager.rbs +52 -0
  122. data/sig/sxn/core/rules_manager.rbs +54 -0
  123. data/sig/sxn/core/session_manager.rbs +59 -0
  124. data/sig/sxn/core/worktree_manager.rbs +50 -0
  125. data/sig/sxn/core.rbs +87 -0
  126. data/sig/sxn/database/errors.rbs +37 -0
  127. data/sig/sxn/database/session_database.rbs +151 -0
  128. data/sig/sxn/database.rbs +83 -0
  129. data/sig/sxn/errors.rbs +89 -0
  130. data/sig/sxn/rules/base_rule.rbs +137 -0
  131. data/sig/sxn/rules/copy_files_rule.rbs +65 -0
  132. data/sig/sxn/rules/errors.rbs +33 -0
  133. data/sig/sxn/rules/project_detector.rbs +115 -0
  134. data/sig/sxn/rules/rules_engine.rbs +118 -0
  135. data/sig/sxn/rules/setup_commands_rule.rbs +60 -0
  136. data/sig/sxn/rules/template_rule.rbs +44 -0
  137. data/sig/sxn/rules.rbs +287 -0
  138. data/sig/sxn/runtime_validations.rbs +16 -0
  139. data/sig/sxn/security/secure_command_executor.rbs +63 -0
  140. data/sig/sxn/security/secure_file_copier.rbs +79 -0
  141. data/sig/sxn/security/secure_path_validator.rbs +30 -0
  142. data/sig/sxn/security.rbs +128 -0
  143. data/sig/sxn/templates/errors.rbs +43 -0
  144. data/sig/sxn/templates/template_engine.rbs +50 -0
  145. data/sig/sxn/templates/template_processor.rbs +44 -0
  146. data/sig/sxn/templates/template_security.rbs +62 -0
  147. data/sig/sxn/templates/template_variables.rbs +103 -0
  148. data/sig/sxn/templates.rbs +104 -0
  149. data/sig/sxn/ui/output.rbs +50 -0
  150. data/sig/sxn/ui/progress_bar.rbs +39 -0
  151. data/sig/sxn/ui/prompt.rbs +38 -0
  152. data/sig/sxn/ui/table.rbs +43 -0
  153. data/sig/sxn/ui.rbs +63 -0
  154. data/sig/sxn/version.rbs +5 -0
  155. data/sig/sxn.rbs +29 -0
  156. metadata +635 -0
@@ -0,0 +1,478 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "openssl"
5
+ require "base64"
6
+
7
+ module Sxn
8
+ module Security
9
+ # SecureFileCopier provides secure file copying operations with strict security controls.
10
+ # It validates source and destination paths, preserves/enforces file permissions,
11
+ # supports file encryption using OpenSSL AES-256, and maintains an audit trail.
12
+ #
13
+ # @example
14
+ # copier = SecureFileCopier.new("/path/to/project")
15
+ # result = copier.copy_file("config/master.key", "session/master.key",
16
+ # permissions: 0600, encrypt: true)
17
+ #
18
+ class SecureFileCopier
19
+ # File operation result
20
+ class CopyResult
21
+ attr_reader :source_path, :destination_path, :operation, :encrypted, :checksum, :duration
22
+
23
+ def initialize(source_path, destination_path, operation, encrypted: false, checksum: nil, duration: 0)
24
+ @source_path = source_path
25
+ @destination_path = destination_path
26
+ @operation = operation
27
+ @encrypted = encrypted
28
+ @checksum = checksum
29
+ @duration = duration
30
+ end
31
+
32
+ def to_h
33
+ {
34
+ source_path: @source_path,
35
+ destination_path: @destination_path,
36
+ operation: @operation,
37
+ encrypted: @encrypted,
38
+ checksum: @checksum,
39
+ duration: @duration
40
+ }
41
+ end
42
+ end
43
+
44
+ # Patterns that identify sensitive files requiring special handling
45
+ SENSITIVE_FILE_PATTERNS = [
46
+ /master\.key$/,
47
+ /credentials.*\.key$/,
48
+ /\.env$/,
49
+ /\.env\./,
50
+ /secrets\.yml$/,
51
+ /\.pem$/,
52
+ /\.p12$/,
53
+ /\.jks$/,
54
+ /\.npmrc$/,
55
+ /auth_token/i,
56
+ /api_key/i,
57
+ /password/i,
58
+ /secret/i
59
+ ].freeze
60
+
61
+ # Default secure permissions for different file types
62
+ DEFAULT_PERMISSIONS = {
63
+ sensitive: 0o600, # Owner read/write only
64
+ config: 0o644, # Owner read/write, group/other read
65
+ executable: 0o755 # Owner all, group/other read/execute
66
+ }.freeze
67
+
68
+ # Maximum file size for operations (100MB)
69
+ MAX_FILE_SIZE = 100 * 1024 * 1024
70
+
71
+ # @param project_root [String] The absolute path to the project root directory
72
+ # @param logger [Logger] Optional logger for audit trail
73
+ def initialize(project_root, logger: nil)
74
+ @project_root = File.realpath(project_root)
75
+ @path_validator = SecurePathValidator.new(@project_root)
76
+ @logger = logger || Sxn.logger
77
+ @encryption_key = nil
78
+ rescue Errno::ENOENT
79
+ raise ArgumentError, "Project root does not exist: #{project_root}"
80
+ end
81
+
82
+ # Copies a file securely with validation and optional encryption
83
+ #
84
+ # @param source [String] Source file path (relative to project root)
85
+ # @param destination [String] Destination file path (relative to project root)
86
+ # @param permissions [Integer] File permissions to set (e.g., 0600)
87
+ # @param encrypt [Boolean] Whether to encrypt the file
88
+ # @param preserve_permissions [Boolean] Whether to preserve source permissions
89
+ # @param create_directories [Boolean] Whether to create destination directories
90
+ # @return [CopyResult] The operation result
91
+ # @raise [SecurityError] if operation violates security policies
92
+ def copy_file(source, destination, permissions: nil, encrypt: false,
93
+ preserve_permissions: false, create_directories: true)
94
+ start_time = Time.now
95
+
96
+ raw_source, raw_destination = @path_validator.validate_file_operation(
97
+ source, destination, allow_creation: true
98
+ )
99
+
100
+ # Normalize paths for consistent behavior in tests and cross-platform compatibility
101
+ validated_source = normalize_path_for_result(raw_source)
102
+ validated_destination = normalize_path_for_result(raw_destination)
103
+
104
+ validate_file_operation!(raw_source, raw_destination)
105
+
106
+ # Determine appropriate permissions (use normalized path for consistent method signatures)
107
+ target_permissions = determine_permissions(
108
+ validated_source, permissions, preserve_permissions
109
+ )
110
+
111
+ # Create destination directory if needed (use raw path for actual file operations)
112
+ create_destination_directory(raw_destination) if create_directories
113
+
114
+ # Perform the copy operation (use normalized paths for method signatures)
115
+ if encrypt
116
+ copy_with_encryption(validated_source, validated_destination, target_permissions)
117
+ encrypted = true
118
+ else
119
+ copy_without_encryption(validated_source, validated_destination, target_permissions)
120
+ encrypted = false
121
+ end
122
+
123
+ # Generate checksum for verification (use normalized path for method signature)
124
+ checksum = generate_checksum(validated_destination)
125
+ duration = Time.now - start_time
126
+
127
+ result = CopyResult.new(
128
+ normalize_path_for_result(validated_source),
129
+ normalize_path_for_result(validated_destination),
130
+ :copy,
131
+ encrypted: encrypted, checksum: checksum, duration: duration
132
+ )
133
+
134
+ audit_log("FILE_COPY", result)
135
+ result
136
+ end
137
+
138
+ # Creates a symbolic link securely
139
+ #
140
+ # @param source [String] Source file path (relative to project root)
141
+ # @param destination [String] Link path (relative to project root)
142
+ # @param force [Boolean] Whether to overwrite existing links
143
+ # @return [CopyResult] The operation result
144
+ # @raise [SecurityError] if operation violates security policies
145
+ def create_symlink(source, destination, force: false)
146
+ start_time = Time.now
147
+
148
+ validated_source, validated_destination = @path_validator.validate_file_operation(
149
+ source, destination, allow_creation: true
150
+ )
151
+
152
+ validate_file_operation!(validated_source, validated_destination)
153
+
154
+ # Remove existing symlink/file if force is true
155
+ File.unlink(validated_destination) if force && (File.exist?(validated_destination) || File.symlink?(validated_destination))
156
+
157
+ # Create the symlink
158
+ File.symlink(validated_source, validated_destination)
159
+
160
+ duration = Time.now - start_time
161
+ result = CopyResult.new(
162
+ normalize_path_for_result(validated_source),
163
+ normalize_path_for_result(validated_destination),
164
+ :symlink, duration: duration
165
+ )
166
+
167
+ audit_log("SYMLINK_CREATE", result)
168
+ result
169
+ end
170
+
171
+ # Encrypts a file in place using AES-256-GCM
172
+ #
173
+ # @param file_path [String] Path to file to encrypt (relative to project root)
174
+ # @param key [String] Encryption key (if nil, generates one)
175
+ # @return [String] Base64-encoded encryption key used
176
+ # @raise [SecurityError] if encryption fails
177
+ def encrypt_file(file_path, key: nil)
178
+ raw_path = @path_validator.validate_path(file_path)
179
+ validated_path = normalize_path_for_result(raw_path)
180
+ validate_file_exists!(validated_path)
181
+
182
+ encryption_key = key || generate_encryption_key
183
+
184
+ # Read original content (use real path for file operations)
185
+ real_path = denormalize_path_for_operations(validated_path)
186
+ original_content = File.binread(real_path)
187
+
188
+ # Encrypt content
189
+ encrypted_content = encrypt_content(original_content, encryption_key)
190
+
191
+ # Write encrypted content atomically (use real path for file operations)
192
+ temp_file = "#{real_path}.tmp"
193
+ File.binwrite(temp_file, encrypted_content)
194
+ File.rename(temp_file, real_path)
195
+
196
+ # Set secure permissions (use real path for file operations)
197
+ File.chmod(0o600, real_path)
198
+
199
+ audit_log("FILE_ENCRYPT", { file_path: validated_path })
200
+ Base64.strict_encode64(encryption_key)
201
+ rescue Sxn::PathValidationError => e
202
+ raise SecurityError, "Path validation failed: #{e.message}"
203
+ end
204
+
205
+ # Decrypts a file in place using AES-256-GCM
206
+ #
207
+ # @param file_path [String] Path to file to decrypt (relative to project root)
208
+ # @param key [String] Base64-encoded encryption key
209
+ # @return [Boolean] true if decryption successful
210
+ # @raise [SecurityError] if decryption fails
211
+ def decrypt_file(file_path, key)
212
+ raw_path = @path_validator.validate_path(file_path)
213
+ validated_path = normalize_path_for_result(raw_path)
214
+ validate_file_exists!(validated_path)
215
+
216
+ encryption_key = Base64.strict_decode64(key)
217
+
218
+ # Read encrypted content (use real path for file operations)
219
+ real_path = denormalize_path_for_operations(validated_path)
220
+ encrypted_content = File.binread(real_path)
221
+
222
+ # Decrypt content
223
+ original_content = decrypt_content(encrypted_content, encryption_key)
224
+
225
+ # Write decrypted content atomically (use real path for file operations)
226
+ temp_file = "#{real_path}.tmp"
227
+ File.binwrite(temp_file, original_content)
228
+ File.rename(temp_file, real_path)
229
+
230
+ audit_log("FILE_DECRYPT", { file_path: validated_path })
231
+ true
232
+ rescue StandardError => e
233
+ raise SecurityError, "Decryption failed: #{e.message}"
234
+ end
235
+
236
+ # Checks if a file appears to be sensitive based on its path
237
+ #
238
+ # @param file_path [String] Path to check
239
+ # @return [Boolean] true if file appears sensitive
240
+ def sensitive_file?(file_path)
241
+ SENSITIVE_FILE_PATTERNS.any? { |pattern| file_path.match?(pattern) }
242
+ end
243
+
244
+ # Validates file permissions are secure
245
+ #
246
+ # @param file_path [String] Path to check (relative to project root)
247
+ # @return [Boolean] true if permissions are secure
248
+ def secure_permissions?(file_path)
249
+ validated_path = @path_validator.validate_path(file_path, allow_creation: true)
250
+ return false unless File.exist?(validated_path)
251
+
252
+ stat = File.stat(validated_path)
253
+ mode = stat.mode & 0o777
254
+
255
+ if sensitive_file?(file_path)
256
+ # Sensitive files should not be readable by group/other
257
+ mode.nobits?(0o077)
258
+ else
259
+ # Non-sensitive files should not be world-writable
260
+ mode.nobits?(0o002)
261
+ end
262
+ rescue Sxn::PathValidationError
263
+ false
264
+ end
265
+
266
+ private
267
+
268
+ # Normalizes a path to remove system-specific symlink resolutions
269
+ # This helps maintain consistent path formats across different systems
270
+ def normalize_path_for_result(path)
271
+ # On macOS, File.realpath resolves /var to /private/var
272
+ # For consistency in tests and results, we normalize back
273
+ path.sub(%r{^/private/var/}, "/var/")
274
+ end
275
+
276
+ # Reverses path normalization to get real filesystem paths for operations
277
+ def denormalize_path_for_operations(path)
278
+ # Convert normalized paths back to real filesystem paths
279
+ # On macOS, /var is actually at /private/var
280
+ if path.start_with?("/var/") && !path.start_with?("/private/var/")
281
+ path.sub(%r{^/var/}, "/private/var/")
282
+ else
283
+ path
284
+ end
285
+ end
286
+
287
+ # Validates file operation security constraints
288
+ def validate_file_operation!(source_path, destination_path)
289
+ # Check source file exists and is readable
290
+ validate_file_exists!(source_path)
291
+ validate_file_readable!(source_path)
292
+
293
+ # Check file size limits
294
+ file_size = File.size(source_path)
295
+ raise SecurityError, "File too large for secure copying: #{file_size} bytes" if file_size > MAX_FILE_SIZE
296
+
297
+ # Check if source has dangerous permissions
298
+ Sxn.logger&.warn("Copying world-readable sensitive file: #{source_path}") if File.world_readable?(source_path) && sensitive_file?(source_path)
299
+
300
+ # Validate destination path doesn't overwrite critical files
301
+ return unless File.exist?(destination_path)
302
+
303
+ dest_stat = File.stat(destination_path)
304
+ return unless dest_stat.uid != Process.uid
305
+
306
+ raise SecurityError, "Cannot overwrite file owned by different user: #{destination_path}"
307
+ end
308
+
309
+ # Determines appropriate file permissions
310
+ def determine_permissions(source_path, explicit_permissions, preserve_permissions)
311
+ return explicit_permissions if explicit_permissions
312
+
313
+ if preserve_permissions
314
+ File.stat(source_path).mode & 0o777
315
+ elsif sensitive_file?(source_path)
316
+ DEFAULT_PERMISSIONS[:sensitive]
317
+ elsif File.executable?(source_path)
318
+ DEFAULT_PERMISSIONS[:executable]
319
+ else
320
+ DEFAULT_PERMISSIONS[:config]
321
+ end
322
+ end
323
+
324
+ # Creates destination directory with secure permissions
325
+ def create_destination_directory(destination_path)
326
+ directory = File.dirname(destination_path)
327
+ return if File.directory?(directory)
328
+
329
+ # For absolute paths under project root, convert to relative
330
+ if directory.start_with?(@project_root)
331
+ relative_directory = directory.sub("#{@project_root}/", "")
332
+ # Only validate if it's actually relative (not just the project root itself)
333
+ @path_validator.validate_path(relative_directory, allow_creation: true) unless relative_directory.empty?
334
+ end
335
+
336
+ # Create directory with secure permissions
337
+ FileUtils.mkdir_p(directory, mode: 0o755)
338
+ end
339
+
340
+ # Copies file without encryption
341
+ def copy_without_encryption(source_path, destination_path, permissions)
342
+ # Resolve paths back to real filesystem paths for actual operations
343
+ real_source = denormalize_path_for_operations(source_path)
344
+ real_destination = denormalize_path_for_operations(destination_path)
345
+
346
+ # Use atomic copy operation
347
+ temp_file = "#{real_destination}.tmp"
348
+
349
+ begin
350
+ FileUtils.cp(real_source, temp_file, preserve: false)
351
+ File.chmod(permissions, temp_file)
352
+ File.rename(temp_file, real_destination)
353
+ rescue StandardError => e
354
+ FileUtils.rm_f(temp_file)
355
+ raise SecurityError, "File copy failed: #{e.message}"
356
+ end
357
+ end
358
+
359
+ # Copies file with encryption
360
+ def copy_with_encryption(source_path, destination_path, permissions)
361
+ # Resolve paths back to real filesystem paths for actual operations
362
+ real_source = denormalize_path_for_operations(source_path)
363
+ real_destination = denormalize_path_for_operations(destination_path)
364
+
365
+ @encryption_key ||= generate_encryption_key
366
+
367
+ # Read and encrypt content
368
+ original_content = File.binread(real_source)
369
+ encrypted_content = encrypt_content(original_content, @encryption_key)
370
+
371
+ # Write encrypted content atomically
372
+ temp_file = "#{real_destination}.tmp"
373
+
374
+ begin
375
+ File.binwrite(temp_file, encrypted_content)
376
+ File.chmod(permissions, temp_file)
377
+ File.rename(temp_file, real_destination)
378
+ rescue StandardError => e
379
+ FileUtils.rm_f(temp_file)
380
+ raise SecurityError, "Encrypted file copy failed: #{e.message}"
381
+ end
382
+ end
383
+
384
+ # Encrypts content using AES-256-GCM
385
+ def encrypt_content(content, key)
386
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
387
+ cipher.encrypt
388
+ cipher.key = key
389
+
390
+ # Generate random IV
391
+ iv = cipher.random_iv
392
+ cipher.iv = iv
393
+
394
+ # Encrypt content
395
+ encrypted = cipher.update(content) + cipher.final
396
+ auth_tag = cipher.auth_tag
397
+
398
+ # Combine IV, auth tag, and encrypted content
399
+ [iv, auth_tag, encrypted].map { |part| Base64.strict_encode64(part) }.join(":")
400
+ end
401
+
402
+ # Decrypts content using AES-256-GCM
403
+ def decrypt_content(encrypted_content, key)
404
+ parts = encrypted_content.split(":")
405
+ raise SecurityError, "Invalid encrypted content format" unless parts.length == 3
406
+
407
+ iv, auth_tag, ciphertext = parts.map { |part| Base64.strict_decode64(part) }
408
+
409
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
410
+ cipher.decrypt
411
+ cipher.key = key
412
+ cipher.iv = iv
413
+ cipher.auth_tag = auth_tag
414
+
415
+ cipher.update(ciphertext) + cipher.final
416
+ rescue StandardError => e
417
+ raise SecurityError, "Decryption failed: #{e.message}"
418
+ end
419
+
420
+ # Generates a secure encryption key
421
+ def generate_encryption_key
422
+ OpenSSL::Random.random_bytes(32) # 256 bits
423
+ end
424
+
425
+ # Generates SHA-256 checksum for file verification
426
+ def generate_checksum(file_path)
427
+ # Resolve path back to real filesystem path for actual operations
428
+ real_path = denormalize_path_for_operations(file_path)
429
+ return nil unless File.exist?(real_path)
430
+
431
+ digest = OpenSSL::Digest.new("SHA256")
432
+ File.open(real_path, "rb") do |file|
433
+ while (chunk = file.read(8192))
434
+ digest.update(chunk)
435
+ end
436
+ end
437
+ digest.hexdigest
438
+ end
439
+
440
+ # Validation helpers
441
+ def validate_file_exists!(file_path)
442
+ # Use normalized path for error messages but check real path for existence
443
+ real_path = denormalize_path_for_operations(file_path)
444
+ return if File.exist?(real_path)
445
+
446
+ raise SecurityError, "Source file does not exist: #{file_path}"
447
+ end
448
+
449
+ def validate_file_readable!(file_path)
450
+ # Use normalized path for error messages but check real path for readability
451
+ real_path = denormalize_path_for_operations(file_path)
452
+ return if File.readable?(real_path)
453
+
454
+ raise SecurityError, "Source file is not readable: #{file_path}"
455
+ end
456
+
457
+ # Logs file operations for audit trail
458
+ def audit_log(event, details)
459
+ return unless @logger
460
+
461
+ log_entry = {
462
+ timestamp: Time.now.iso8601,
463
+ event: event,
464
+ pid: Process.pid,
465
+ user: ENV["USER"] || "unknown"
466
+ }
467
+
468
+ if details.is_a?(CopyResult)
469
+ log_entry.merge!(details.to_h)
470
+ else
471
+ log_entry.merge!(details)
472
+ end
473
+
474
+ @logger.info("SECURITY_AUDIT: #{log_entry.to_json}")
475
+ end
476
+ end
477
+ end
478
+ end