solis 0.103.0 → 0.104.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.
@@ -0,0 +1,1093 @@
1
+ require 'pathname'
2
+ require 'fileutils'
3
+ require 'set'
4
+ require 'digest'
5
+ require 'monitor'
6
+ require 'tempfile'
7
+ require 'forwardable'
8
+
9
+ module Solis
10
+ class OverlayFS
11
+ WHITEOUT_PREFIX = '.wh.'
12
+ OPAQUE_MARKER = '.wh..wh..opq'
13
+
14
+ class Error < StandardError; end
15
+ class LayerError < Error; end
16
+ class ReadOnlyError < Error; end
17
+
18
+ # IO wrapper that handles copy-up on write
19
+ class OverlayIO
20
+ extend Forwardable
21
+
22
+ def_delegators :@io, :read, :gets, :getc, :getbyte, :readlines, :readline,
23
+ :each, :each_line, :each_byte, :each_char, :eof?, :eof,
24
+ :pos, :pos=, :seek, :rewind, :tell, :lineno, :lineno=,
25
+ :sync, :sync=, :binmode, :binmode?, :close_read,
26
+ :external_encoding, :internal_encoding, :set_encoding
27
+
28
+ def initialize(io, overlay_fs: nil, relative_path: nil, mode: 'r')
29
+ @io = io
30
+ @overlay_fs = overlay_fs
31
+ @relative_path = relative_path
32
+ @mode = mode
33
+ @copied_up = false
34
+ end
35
+
36
+ def write(data)
37
+ ensure_copy_up if @overlay_fs && !@copied_up
38
+ @io.write(data)
39
+ end
40
+
41
+ def puts(*args)
42
+ ensure_copy_up if @overlay_fs && !@copied_up
43
+ @io.puts(*args)
44
+ end
45
+
46
+ def print(*args)
47
+ ensure_copy_up if @overlay_fs && !@copied_up
48
+ @io.print(*args)
49
+ end
50
+
51
+ def printf(*args)
52
+ ensure_copy_up if @overlay_fs && !@copied_up
53
+ @io.printf(*args)
54
+ end
55
+
56
+ def <<(data)
57
+ ensure_copy_up if @overlay_fs && !@copied_up
58
+ @io << data
59
+ self
60
+ end
61
+
62
+ def flush
63
+ @io.flush
64
+ end
65
+
66
+ def close
67
+ @io.close
68
+ end
69
+
70
+ def closed?
71
+ @io.closed?
72
+ end
73
+
74
+ def close_write
75
+ @io.close_write
76
+ end
77
+
78
+ def path
79
+ @io.respond_to?(:path) ? @io.path : nil
80
+ end
81
+
82
+ def to_io
83
+ @io
84
+ end
85
+
86
+ private
87
+
88
+ def ensure_copy_up
89
+ return if @copied_up
90
+ return unless @overlay_fs && @relative_path
91
+
92
+ current_pos = @io.pos rescue 0
93
+ @io.close unless @io.closed?
94
+
95
+ new_path = @overlay_fs.copy_up(@relative_path)
96
+ @io = File.open(new_path, @mode)
97
+ @io.pos = current_pos
98
+ @copied_up = true
99
+ end
100
+ end
101
+
102
+ # Stat wrapper for overlay files
103
+ class OverlayStat
104
+ attr_reader :layer, :real_path, :relative_path
105
+
106
+ def initialize(real_stat, layer:, real_path:, relative_path:)
107
+ @stat = real_stat
108
+ @layer = layer
109
+ @real_path = real_path
110
+ @relative_path = relative_path
111
+ end
112
+
113
+ # Delegate all stat methods
114
+ %i[
115
+ atime blksize blockdev? blocks chardev? ctime dev dev_major dev_minor
116
+ directory? executable? executable_real? file? ftype gid grpowned? ino
117
+ mode mtime nlink owned? pipe? rdev rdev_major rdev_minor readable?
118
+ readable_real? setgid? setuid? size size? socket? sticky? symlink?
119
+ uid world_readable? world_writable? writable? writable_real? zero?
120
+ ].each do |method|
121
+ define_method(method) { @stat.send(method) }
122
+ end
123
+
124
+ def to_s
125
+ "#<OverlayStat #{@relative_path} (#{@layer})>"
126
+ end
127
+ alias inspect to_s
128
+ end
129
+
130
+ # Directory entry for enumeration
131
+ Entry = Struct.new(:name, :path, :relative_path, :layer, :type, keyword_init: true) do
132
+ def file?
133
+ type == :file
134
+ end
135
+
136
+ def directory?
137
+ type == :directory
138
+ end
139
+
140
+ def symlink?
141
+ type == :symlink
142
+ end
143
+ end
144
+
145
+ # Event hooks
146
+ class Hooks
147
+ def initialize
148
+ @callbacks = Hash.new { |h, k| h[k] = [] }
149
+ end
150
+
151
+ def on(event, &block)
152
+ @callbacks[event] << block
153
+ end
154
+
155
+ def trigger(event, **args)
156
+ @callbacks[event].each { |cb| cb.call(**args) }
157
+ end
158
+
159
+ def clear(event = nil)
160
+ event ? @callbacks.delete(event) : @callbacks.clear
161
+ end
162
+ end
163
+
164
+ attr_reader :layers, :hooks
165
+
166
+ def initialize(cache: true)
167
+ @layers = []
168
+ @cache_enabled = cache
169
+ @resolve_cache = {}
170
+ @stat_cache = {}
171
+ @monitor = Monitor.new
172
+ @hooks = Hooks.new
173
+ end
174
+
175
+ # --- Layer Management ---
176
+
177
+ def add_layer(path, writable: false, label: nil)
178
+ @monitor.synchronize do
179
+ path = Pathname.new(path).expand_path
180
+
181
+ layer = {
182
+ path: path,
183
+ writable: writable,
184
+ label: label || path.basename.to_s
185
+ }
186
+
187
+ writable ? @layers.unshift(layer) : @layers.push(layer)
188
+ clear_cache
189
+ end
190
+ self
191
+ end
192
+
193
+ def remove_layer(label_or_path)
194
+ @monitor.synchronize do
195
+ @layers.reject! do |layer|
196
+ layer[:label] == label_or_path || layer[:path].to_s == label_or_path.to_s
197
+ end
198
+ clear_cache
199
+ end
200
+ self
201
+ end
202
+
203
+ def writable_layer
204
+ @layers.find { |l| l[:writable] }
205
+ end
206
+
207
+ def readonly_layers
208
+ @layers.reject { |l| l[:writable] }
209
+ end
210
+
211
+ # --- Path Resolution ---
212
+
213
+ def resolve(relative_path)
214
+ relative_path = normalize_path(relative_path)
215
+
216
+ return @resolve_cache[relative_path] if @cache_enabled && @resolve_cache.key?(relative_path)
217
+
218
+ result = @monitor.synchronize do
219
+ @layers.each do |layer|
220
+ # Check for whiteout first
221
+ whiteout_path = layer[:path] / whiteout_name(relative_path)
222
+ return nil if whiteout_path.exist?
223
+
224
+ # Check for opaque parent directory
225
+ return nil if opaque_parent?(layer, relative_path)
226
+
227
+ full_path = layer[:path] / relative_path
228
+ return full_path if full_path.exist? || full_path.symlink?
229
+ end
230
+ nil
231
+ end
232
+
233
+ @resolve_cache[relative_path] = result if @cache_enabled
234
+ result
235
+ end
236
+
237
+ def writable_path(relative_path)
238
+ layer = writable_layer
239
+ raise ReadOnlyError, "No writable layer configured" unless layer
240
+ layer[:path] / normalize_path(relative_path)
241
+ end
242
+
243
+ def real_path(relative_path)
244
+ resolve(relative_path)&.realpath
245
+ end
246
+
247
+ # --- File Operations ---
248
+
249
+ def read(relative_path, **options)
250
+ path = resolve(relative_path)
251
+ raise Errno::ENOENT, relative_path unless path
252
+ hooks.trigger(:before_read, path: relative_path)
253
+ content = File.read(path, **options)
254
+ hooks.trigger(:after_read, path: relative_path, content: content)
255
+ content
256
+ end
257
+
258
+ def read_binary(relative_path)
259
+ read(relative_path, mode: 'rb')
260
+ end
261
+
262
+ def write(relative_path, content, **options)
263
+ relative_path = normalize_path(relative_path)
264
+ hooks.trigger(:before_write, path: relative_path, content: content)
265
+
266
+ path = writable_path(relative_path)
267
+ ensure_directory(path.dirname)
268
+ remove_whiteout(relative_path)
269
+
270
+ File.write(path, content, **options)
271
+ clear_cache_for(relative_path)
272
+
273
+ hooks.trigger(:after_write, path: relative_path, real_path: path)
274
+ path
275
+ end
276
+
277
+ def write_binary(relative_path, content)
278
+ write(relative_path, content, mode: 'wb')
279
+ end
280
+
281
+ def append(relative_path, content)
282
+ if exist?(relative_path)
283
+ copy_up(relative_path)
284
+ end
285
+
286
+ path = writable_path(relative_path)
287
+ ensure_directory(path.dirname)
288
+ File.open(path, 'a') { |f| f.write(content) }
289
+ clear_cache_for(relative_path)
290
+ path
291
+ end
292
+
293
+ def atomic_write(relative_path, content, **options)
294
+ relative_path = normalize_path(relative_path)
295
+ path = writable_path(relative_path)
296
+ ensure_directory(path.dirname)
297
+
298
+ temp_path = "#{path}.tmp.#{Process.pid}.#{Thread.current.object_id}"
299
+ begin
300
+ File.write(temp_path, content, **options)
301
+ File.rename(temp_path, path)
302
+ remove_whiteout(relative_path)
303
+ clear_cache_for(relative_path)
304
+ hooks.trigger(:after_write, path: relative_path, real_path: path)
305
+ path
306
+ rescue
307
+ File.unlink(temp_path) if File.exist?(temp_path)
308
+ raise
309
+ end
310
+ end
311
+
312
+ def open(relative_path, mode = 'r', **options, &block)
313
+ relative_path = normalize_path(relative_path)
314
+ writing = mode_writable?(mode)
315
+
316
+ if writing
317
+ if exist?(relative_path) && !in_writable_layer?(relative_path)
318
+ # Copy-up needed, but defer until actual write
319
+ path = resolve(relative_path)
320
+ io = OverlayIO.new(
321
+ File.open(path, readable_mode(mode), **options),
322
+ overlay_fs: self,
323
+ relative_path: relative_path,
324
+ mode: mode
325
+ )
326
+ else
327
+ path = writable_path(relative_path)
328
+ ensure_directory(path.dirname)
329
+ remove_whiteout(relative_path)
330
+ io = OverlayIO.new(File.open(path, mode, **options))
331
+ end
332
+ else
333
+ path = resolve(relative_path)
334
+ raise Errno::ENOENT, relative_path unless path
335
+ io = OverlayIO.new(File.open(path, mode, **options))
336
+ end
337
+
338
+ if block_given?
339
+ begin
340
+ yield io
341
+ ensure
342
+ io.close unless io.closed?
343
+ clear_cache_for(relative_path) if writing
344
+ end
345
+ else
346
+ io
347
+ end
348
+ end
349
+
350
+ # --- Existence & Type Checks ---
351
+
352
+ def exist?(relative_path)
353
+ !resolve(relative_path).nil?
354
+ end
355
+ alias exists? exist?
356
+
357
+ def file?(relative_path)
358
+ path = resolve(relative_path)
359
+ path&.file?
360
+ end
361
+
362
+ def directory?(relative_path)
363
+ relative_path = normalize_path(relative_path)
364
+
365
+ @layers.any? do |layer|
366
+ whiteout = layer[:path] / whiteout_name(relative_path)
367
+ next false if whiteout.exist?
368
+
369
+ dir_path = layer[:path] / relative_path
370
+ dir_path.directory?
371
+ end
372
+ end
373
+
374
+ def symlink?(relative_path)
375
+ path = resolve(relative_path)
376
+ path&.symlink?
377
+ end
378
+
379
+ def readable?(relative_path)
380
+ path = resolve(relative_path)
381
+ path&.readable?
382
+ end
383
+
384
+ def writable?(relative_path)
385
+ return false unless writable_layer
386
+
387
+ if exist?(relative_path)
388
+ in_writable_layer?(relative_path) || copy_up_possible?(relative_path)
389
+ else
390
+ true # Can create new file
391
+ end
392
+ end
393
+
394
+ def executable?(relative_path)
395
+ path = resolve(relative_path)
396
+ path&.executable?
397
+ end
398
+
399
+ def empty?(relative_path)
400
+ if directory?(relative_path)
401
+ entries(relative_path).empty?
402
+ elsif file?(relative_path)
403
+ size(relative_path) == 0
404
+ else
405
+ raise Errno::ENOENT, relative_path
406
+ end
407
+ end
408
+
409
+ # --- File Info ---
410
+
411
+ def stat(relative_path)
412
+ relative_path = normalize_path(relative_path)
413
+
414
+ return @stat_cache[relative_path] if @cache_enabled && @stat_cache.key?(relative_path)
415
+
416
+ @layers.each do |layer|
417
+ whiteout = layer[:path] / whiteout_name(relative_path)
418
+ return nil if whiteout.exist?
419
+
420
+ full_path = layer[:path] / relative_path
421
+ if full_path.exist? || full_path.symlink?
422
+ stat = OverlayStat.new(
423
+ full_path.stat,
424
+ layer: layer[:label],
425
+ real_path: full_path,
426
+ relative_path: relative_path
427
+ )
428
+ @stat_cache[relative_path] = stat if @cache_enabled
429
+ return stat
430
+ end
431
+ end
432
+ nil
433
+ end
434
+
435
+ def lstat(relative_path)
436
+ relative_path = normalize_path(relative_path)
437
+
438
+ @layers.each do |layer|
439
+ whiteout = layer[:path] / whiteout_name(relative_path)
440
+ return nil if whiteout.exist?
441
+
442
+ full_path = layer[:path] / relative_path
443
+ if full_path.exist? || full_path.symlink?
444
+ return OverlayStat.new(
445
+ full_path.lstat,
446
+ layer: layer[:label],
447
+ real_path: full_path,
448
+ relative_path: relative_path
449
+ )
450
+ end
451
+ end
452
+ nil
453
+ end
454
+
455
+ def size(relative_path)
456
+ stat(relative_path)&.size || raise(Errno::ENOENT, relative_path)
457
+ end
458
+
459
+ def mtime(relative_path)
460
+ stat(relative_path)&.mtime || raise(Errno::ENOENT, relative_path)
461
+ end
462
+
463
+ def atime(relative_path)
464
+ stat(relative_path)&.atime || raise(Errno::ENOENT, relative_path)
465
+ end
466
+
467
+ def ctime(relative_path)
468
+ stat(relative_path)&.ctime || raise(Errno::ENOENT, relative_path)
469
+ end
470
+
471
+ def ftype(relative_path)
472
+ path = resolve(relative_path)
473
+ raise Errno::ENOENT, relative_path unless path
474
+ path.ftype
475
+ end
476
+
477
+ def extname(relative_path)
478
+ File.extname(relative_path)
479
+ end
480
+
481
+ def basename(relative_path, suffix = nil)
482
+ suffix ? File.basename(relative_path, suffix) : File.basename(relative_path)
483
+ end
484
+
485
+ def dirname(relative_path)
486
+ File.dirname(relative_path)
487
+ end
488
+
489
+ def checksum(relative_path, algorithm: :sha256)
490
+ content = read_binary(relative_path)
491
+ case algorithm
492
+ when :md5 then Digest::MD5.hexdigest(content)
493
+ when :sha1 then Digest::SHA1.hexdigest(content)
494
+ when :sha256 then Digest::SHA256.hexdigest(content)
495
+ when :sha512 then Digest::SHA512.hexdigest(content)
496
+ else raise ArgumentError, "Unknown algorithm: #{algorithm}"
497
+ end
498
+ end
499
+
500
+ # --- Directory Operations ---
501
+
502
+ def mkdir(relative_path, mode: 0755)
503
+ relative_path = normalize_path(relative_path)
504
+ path = writable_path(relative_path)
505
+
506
+ raise Errno::EEXIST, relative_path if directory?(relative_path)
507
+
508
+ remove_whiteout(relative_path)
509
+ FileUtils.mkdir_p(path, mode: mode)
510
+ clear_cache_for(relative_path)
511
+ hooks.trigger(:after_mkdir, path: relative_path)
512
+ path
513
+ end
514
+
515
+ def mkdir_p(relative_path, mode: 0755)
516
+ relative_path = normalize_path(relative_path)
517
+ path = writable_path(relative_path)
518
+
519
+ remove_whiteout(relative_path)
520
+ FileUtils.mkdir_p(path, mode: mode)
521
+ clear_cache_for(relative_path)
522
+ path
523
+ end
524
+
525
+ def rmdir(relative_path)
526
+ relative_path = normalize_path(relative_path)
527
+
528
+ raise Errno::ENOENT, relative_path unless directory?(relative_path)
529
+ raise Errno::ENOTEMPTY, relative_path unless empty?(relative_path)
530
+
531
+ if in_writable_layer?(relative_path)
532
+ FileUtils.rmdir(writable_path(relative_path))
533
+ end
534
+
535
+ # If exists in lower layers, create whiteout
536
+ if exists_in_lower_layers?(relative_path)
537
+ create_whiteout(relative_path)
538
+ end
539
+
540
+ clear_cache_for(relative_path)
541
+ hooks.trigger(:after_rmdir, path: relative_path)
542
+ end
543
+
544
+ def entries(relative_path = '.')
545
+ relative_path = normalize_path(relative_path)
546
+ seen = Set.new
547
+ whiteouts = Set.new
548
+ result = []
549
+
550
+ @layers.each do |layer|
551
+ dir_path = layer[:path] / relative_path
552
+ next unless dir_path.directory?
553
+
554
+ # Check if this directory is opaque - if so, don't descend to lower layers
555
+ opaque = (dir_path / OPAQUE_MARKER).exist?
556
+
557
+ dir_path.children.each do |child|
558
+ name = child.basename.to_s
559
+
560
+ # Track whiteouts
561
+ if name.start_with?(WHITEOUT_PREFIX)
562
+ if name == OPAQUE_MARKER
563
+ next
564
+ else
565
+ whiteouts << name.sub(WHITEOUT_PREFIX, '')
566
+ next
567
+ end
568
+ end
569
+
570
+ next if seen.include?(name)
571
+ next if whiteouts.include?(name)
572
+
573
+ seen << name
574
+ result << Entry.new(
575
+ name: name,
576
+ path: child,
577
+ relative_path: "#{relative_path}/#{name}".sub(%r{^\.?/}, ''),
578
+ layer: layer[:label],
579
+ type: entry_type(child)
580
+ )
581
+ end
582
+
583
+ break if opaque
584
+ end
585
+
586
+ result.sort_by(&:name)
587
+ end
588
+
589
+ def children(relative_path = '.')
590
+ entries(relative_path).map(&:name)
591
+ end
592
+
593
+ def glob(pattern, flags: 0)
594
+ pattern = normalize_path(pattern)
595
+ seen = Set.new
596
+ whiteouts = collect_whiteouts
597
+ results = []
598
+
599
+ @layers.each do |layer|
600
+ Dir.glob(layer[:path] / pattern, flags).each do |full_path|
601
+ relative = Pathname.new(full_path).relative_path_from(layer[:path]).to_s
602
+
603
+ next if seen.include?(relative)
604
+ next if whiteouts.include?(relative)
605
+ next if File.basename(relative).start_with?(WHITEOUT_PREFIX)
606
+
607
+ seen << relative
608
+ results << relative
609
+ end
610
+ end
611
+
612
+ results.sort
613
+ end
614
+
615
+ def find(relative_path = '.', &block)
616
+ results = []
617
+ _find_recursive(normalize_path(relative_path), results, &block)
618
+ results
619
+ end
620
+
621
+ def each_file(pattern = '**/*', &block)
622
+ return enum_for(:each_file, pattern) unless block_given?
623
+
624
+ glob(pattern).each do |relative_path|
625
+ next unless file?(relative_path)
626
+ yield relative_path, resolve(relative_path)
627
+ end
628
+ end
629
+
630
+ def each_directory(relative_path = '.', &block)
631
+ return enum_for(:each_directory, relative_path) unless block_given?
632
+
633
+ entries(relative_path).each do |entry|
634
+ yield entry if entry.directory?
635
+ end
636
+ end
637
+
638
+ # --- File Manipulation ---
639
+
640
+ def copy_up(relative_path)
641
+ relative_path = normalize_path(relative_path)
642
+
643
+ return writable_path(relative_path) if in_writable_layer?(relative_path)
644
+
645
+ source = resolve(relative_path)
646
+ raise Errno::ENOENT, relative_path unless source
647
+
648
+ dest = writable_path(relative_path)
649
+ ensure_directory(dest.dirname)
650
+
651
+ if source.directory?
652
+ FileUtils.mkdir_p(dest)
653
+ # Copy directory metadata
654
+ FileUtils.chmod(source.stat.mode, dest)
655
+ elsif source.symlink?
656
+ FileUtils.ln_s(File.readlink(source), dest)
657
+ else
658
+ FileUtils.cp(source, dest, preserve: true)
659
+ end
660
+
661
+ clear_cache_for(relative_path)
662
+ hooks.trigger(:after_copy_up, path: relative_path, from: source, to: dest)
663
+ dest
664
+ end
665
+
666
+ def copy(source, destination, preserve: true)
667
+ source = normalize_path(source)
668
+ destination = normalize_path(destination)
669
+
670
+ source_path = resolve(source)
671
+ raise Errno::ENOENT, source unless source_path
672
+
673
+ dest_path = writable_path(destination)
674
+ ensure_directory(dest_path.dirname)
675
+ remove_whiteout(destination)
676
+
677
+ if source_path.directory?
678
+ copy_directory(source, destination, preserve: preserve)
679
+ else
680
+ FileUtils.cp(source_path, dest_path, preserve: preserve)
681
+ end
682
+
683
+ clear_cache_for(destination)
684
+ dest_path
685
+ end
686
+
687
+ def move(source, destination)
688
+ source = normalize_path(source)
689
+ destination = normalize_path(destination)
690
+
691
+ copy(source, destination, preserve: true)
692
+ delete(source)
693
+
694
+ writable_path(destination)
695
+ end
696
+ alias rename move
697
+
698
+ def delete(relative_path, force: false)
699
+ relative_path = normalize_path(relative_path)
700
+
701
+ unless exist?(relative_path)
702
+ raise Errno::ENOENT, relative_path unless force
703
+ return
704
+ end
705
+
706
+ hooks.trigger(:before_delete, path: relative_path)
707
+
708
+ if in_writable_layer?(relative_path)
709
+ full_path = writable_path(relative_path)
710
+ if full_path.directory?
711
+ FileUtils.rm_rf(full_path)
712
+ else
713
+ FileUtils.rm(full_path)
714
+ end
715
+ end
716
+
717
+ # Create whiteout if exists in lower layers
718
+ if exists_in_lower_layers?(relative_path)
719
+ create_whiteout(relative_path)
720
+ end
721
+
722
+ clear_cache_for(relative_path)
723
+ hooks.trigger(:after_delete, path: relative_path)
724
+ end
725
+ alias rm delete
726
+ alias unlink delete
727
+
728
+ def delete_recursive(relative_path)
729
+ relative_path = normalize_path(relative_path)
730
+
731
+ if directory?(relative_path)
732
+ entries(relative_path).each do |entry|
733
+ delete_recursive(entry.relative_path)
734
+ end
735
+ end
736
+
737
+ delete(relative_path)
738
+ end
739
+ alias rm_rf delete_recursive
740
+
741
+ # --- Symlinks ---
742
+
743
+ def symlink(target, link_path)
744
+ link_path = normalize_path(link_path)
745
+ path = writable_path(link_path)
746
+
747
+ ensure_directory(path.dirname)
748
+ remove_whiteout(link_path)
749
+
750
+ FileUtils.ln_s(target, path)
751
+ clear_cache_for(link_path)
752
+ path
753
+ end
754
+ alias ln_s symlink
755
+
756
+ def readlink(relative_path)
757
+ path = resolve(relative_path)
758
+ raise Errno::ENOENT, relative_path unless path
759
+ raise Errno::EINVAL, relative_path unless path.symlink?
760
+ File.readlink(path)
761
+ end
762
+
763
+ def realpath(relative_path)
764
+ path = resolve(relative_path)
765
+ raise Errno::ENOENT, relative_path unless path
766
+ path.realpath
767
+ end
768
+
769
+ # --- Whiteouts & Opaque Directories ---
770
+
771
+ def whiteout?(relative_path)
772
+ relative_path = normalize_path(relative_path)
773
+
774
+ @layers.any? do |layer|
775
+ (layer[:path] / whiteout_name(relative_path)).exist?
776
+ end
777
+ end
778
+
779
+ def create_whiteout(relative_path)
780
+ relative_path = normalize_path(relative_path)
781
+ whiteout = writable_path(whiteout_name(relative_path))
782
+ ensure_directory(whiteout.dirname)
783
+ FileUtils.touch(whiteout)
784
+ clear_cache_for(relative_path)
785
+ end
786
+
787
+ def remove_whiteout(relative_path)
788
+ relative_path = normalize_path(relative_path)
789
+ whiteout = writable_path(whiteout_name(relative_path))
790
+ FileUtils.rm(whiteout) if whiteout.exist?
791
+ clear_cache_for(relative_path)
792
+ end
793
+
794
+ def make_opaque(relative_path)
795
+ relative_path = normalize_path(relative_path)
796
+ dir_path = writable_path(relative_path)
797
+
798
+ ensure_directory(dir_path)
799
+ FileUtils.touch(dir_path / OPAQUE_MARKER)
800
+ clear_cache_for(relative_path)
801
+ end
802
+
803
+ def remove_opaque(relative_path)
804
+ relative_path = normalize_path(relative_path)
805
+ opaque = writable_path(relative_path) / OPAQUE_MARKER
806
+ FileUtils.rm(opaque) if opaque.exist?
807
+ clear_cache_for(relative_path)
808
+ end
809
+
810
+ def opaque?(relative_path)
811
+ relative_path = normalize_path(relative_path)
812
+
813
+ @layers.any? do |layer|
814
+ (layer[:path] / relative_path / OPAQUE_MARKER).exist?
815
+ end
816
+ end
817
+
818
+ # --- Permissions ---
819
+
820
+ def chmod(mode, relative_path)
821
+ relative_path = normalize_path(relative_path)
822
+ copy_up(relative_path) unless in_writable_layer?(relative_path)
823
+ FileUtils.chmod(mode, writable_path(relative_path))
824
+ clear_cache_for(relative_path)
825
+ end
826
+
827
+ def chown(user, group, relative_path)
828
+ relative_path = normalize_path(relative_path)
829
+ copy_up(relative_path) unless in_writable_layer?(relative_path)
830
+ FileUtils.chown(user, group, writable_path(relative_path))
831
+ clear_cache_for(relative_path)
832
+ end
833
+
834
+ def touch(relative_path, mtime: nil)
835
+ relative_path = normalize_path(relative_path)
836
+
837
+ if exist?(relative_path)
838
+ copy_up(relative_path) unless in_writable_layer?(relative_path)
839
+ path = writable_path(relative_path)
840
+ else
841
+ path = writable_path(relative_path)
842
+ ensure_directory(path.dirname)
843
+ remove_whiteout(relative_path)
844
+ end
845
+
846
+ if mtime
847
+ FileUtils.touch(path, mtime: mtime)
848
+ else
849
+ FileUtils.touch(path)
850
+ end
851
+
852
+ clear_cache_for(relative_path)
853
+ path
854
+ end
855
+
856
+ # --- Comparison ---
857
+
858
+ def identical?(path1, path2)
859
+ resolved1 = resolve(path1)
860
+ resolved2 = resolve(path2)
861
+
862
+ return false unless resolved1 && resolved2
863
+ FileUtils.identical?(resolved1, resolved2)
864
+ end
865
+
866
+ def diff(relative_path)
867
+ relative_path = normalize_path(relative_path)
868
+ versions = []
869
+
870
+ @layers.each do |layer|
871
+ full_path = layer[:path] / relative_path
872
+ if full_path.exist? && full_path.file?
873
+ versions << {
874
+ layer: layer[:label],
875
+ path: full_path,
876
+ content: full_path.read,
877
+ mtime: full_path.mtime,
878
+ size: full_path.size
879
+ }
880
+ end
881
+ end
882
+
883
+ versions
884
+ end
885
+
886
+ # --- Cache Management ---
887
+
888
+ def clear_cache
889
+ @monitor.synchronize do
890
+ @resolve_cache.clear
891
+ @stat_cache.clear
892
+ end
893
+ end
894
+
895
+ def clear_cache_for(relative_path)
896
+ @monitor.synchronize do
897
+ relative_path = normalize_path(relative_path)
898
+ @resolve_cache.delete(relative_path)
899
+ @stat_cache.delete(relative_path)
900
+
901
+ # Also clear parent directories
902
+ parts = relative_path.split('/')
903
+ parts.length.times do |i|
904
+ parent = parts[0...i].join('/')
905
+ @resolve_cache.delete(parent)
906
+ @stat_cache.delete(parent)
907
+ end
908
+ end
909
+ end
910
+
911
+ def cache_stats
912
+ {
913
+ resolve_cache_size: @resolve_cache.size,
914
+ stat_cache_size: @stat_cache.size,
915
+ cache_enabled: @cache_enabled
916
+ }
917
+ end
918
+
919
+ # --- Layer Inspection ---
920
+
921
+ def which_layer(relative_path)
922
+ relative_path = normalize_path(relative_path)
923
+
924
+ @layers.each do |layer|
925
+ whiteout = layer[:path] / whiteout_name(relative_path)
926
+ return nil if whiteout.exist?
927
+
928
+ full_path = layer[:path] / relative_path
929
+ return layer[:label] if full_path.exist?
930
+ end
931
+ nil
932
+ end
933
+
934
+ def in_writable_layer?(relative_path)
935
+ layer = writable_layer
936
+ return false unless layer
937
+ (layer[:path] / normalize_path(relative_path)).exist?
938
+ end
939
+
940
+ def exists_in_lower_layers?(relative_path)
941
+ relative_path = normalize_path(relative_path)
942
+
943
+ readonly_layers.any? do |layer|
944
+ (layer[:path] / relative_path).exist?
945
+ end
946
+ end
947
+
948
+ def all_versions(relative_path)
949
+ relative_path = normalize_path(relative_path)
950
+
951
+ @layers.filter_map do |layer|
952
+ full_path = layer[:path] / relative_path
953
+ next unless full_path.exist?
954
+
955
+ {
956
+ layer: layer[:label],
957
+ path: full_path,
958
+ writable: layer[:writable]
959
+ }
960
+ end
961
+ end
962
+
963
+ # --- Utility ---
964
+
965
+ def to_s
966
+ layers_desc = @layers.map { |l| "#{l[:label]}#{l[:writable] ? ' (rw)' : ''}" }
967
+ "#<OverlayFS layers=[#{layers_desc.join(' -> ')}]>"
968
+ end
969
+ alias inspect to_s
970
+
971
+ def [](relative_path)
972
+ resolve(relative_path)
973
+ end
974
+
975
+ def tree(relative_path = '.', depth: nil, prefix: '')
976
+ output = []
977
+ _tree_recursive(normalize_path(relative_path), output, depth, prefix, 0)
978
+ output.join("\n")
979
+ end
980
+
981
+ private
982
+
983
+ def normalize_path(path)
984
+ path.to_s.sub(%r{^\.?/}, '').sub(%r{/$}, '')
985
+ end
986
+
987
+ def whiteout_name(relative_path)
988
+ dir = File.dirname(relative_path)
989
+ name = File.basename(relative_path)
990
+ dir == '.' ? "#{WHITEOUT_PREFIX}#{name}" : "#{dir}/#{WHITEOUT_PREFIX}#{name}"
991
+ end
992
+
993
+ def ensure_directory(path)
994
+ FileUtils.mkdir_p(path) unless path.exist?
995
+ end
996
+
997
+ def entry_type(path)
998
+ if path.symlink?
999
+ :symlink
1000
+ elsif path.directory?
1001
+ :directory
1002
+ else
1003
+ :file
1004
+ end
1005
+ end
1006
+
1007
+ def mode_writable?(mode)
1008
+ mode.include?('w') || mode.include?('a') || mode.include?('+')
1009
+ end
1010
+
1011
+ def readable_mode(mode)
1012
+ mode.gsub(/[wa+]/, 'r').gsub(/rr+/, 'r')
1013
+ end
1014
+
1015
+ def opaque_parent?(layer, relative_path)
1016
+ parts = relative_path.split('/')
1017
+ parts[0...-1].each_with_index do |_, i|
1018
+ parent = parts[0..i].join('/')
1019
+ return true if (layer[:path] / parent / OPAQUE_MARKER).exist?
1020
+ end
1021
+ false
1022
+ end
1023
+
1024
+ def collect_whiteouts
1025
+ whiteouts = Set.new
1026
+
1027
+ @layers.each do |layer|
1028
+ Dir.glob(layer[:path] / '**' / "#{WHITEOUT_PREFIX}*").each do |path|
1029
+ name = File.basename(path)
1030
+ next if name == OPAQUE_MARKER
1031
+
1032
+ relative_dir = Pathname.new(path).dirname.relative_path_from(layer[:path]).to_s
1033
+ original_name = name.sub(WHITEOUT_PREFIX, '')
1034
+
1035
+ whiteouts << (relative_dir == '.' ? original_name : "#{relative_dir}/#{original_name}")
1036
+ end
1037
+ end
1038
+
1039
+ whiteouts
1040
+ end
1041
+
1042
+ def copy_up_possible?(relative_path)
1043
+ resolve(relative_path) && writable_layer
1044
+ end
1045
+
1046
+ def copy_directory(source, destination, preserve: true)
1047
+ mkdir_p(destination)
1048
+
1049
+ entries(source).each do |entry|
1050
+ src_path = "#{source}/#{entry.name}"
1051
+ dst_path = "#{destination}/#{entry.name}"
1052
+
1053
+ if entry.directory?
1054
+ copy_directory(src_path, dst_path, preserve: preserve)
1055
+ else
1056
+ copy(src_path, dst_path, preserve: preserve)
1057
+ end
1058
+ end
1059
+ end
1060
+
1061
+ def _find_recursive(relative_path, results, &block)
1062
+ entries(relative_path).each do |entry|
1063
+ if block_given?
1064
+ next unless yield(entry)
1065
+ end
1066
+
1067
+ results << entry.relative_path
1068
+
1069
+ if entry.directory?
1070
+ _find_recursive(entry.relative_path, results, &block)
1071
+ end
1072
+ end
1073
+ end
1074
+
1075
+ def _tree_recursive(relative_path, output, max_depth, prefix, current_depth)
1076
+ return if max_depth && current_depth >= max_depth
1077
+
1078
+ items = entries(relative_path)
1079
+ items.each_with_index do |entry, index|
1080
+ is_last = index == items.length - 1
1081
+ connector = is_last ? '└── ' : '├── '
1082
+ layer_info = " (#{entry.layer})"
1083
+
1084
+ output << "#{prefix}#{connector}#{entry.name}#{layer_info}"
1085
+
1086
+ if entry.directory?
1087
+ next_prefix = prefix + (is_last ? ' ' : '│ ')
1088
+ _tree_recursive(entry.relative_path, output, max_depth, next_prefix, current_depth + 1)
1089
+ end
1090
+ end
1091
+ end
1092
+ end
1093
+ end