familia 2.0.0.pre4 → 2.0.0.pre5

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.rubocop_todo.yml +17 -17
  4. data/CLAUDE.md +3 -3
  5. data/Gemfile +5 -1
  6. data/Gemfile.lock +18 -3
  7. data/README.md +36 -157
  8. data/TEST_COVERAGE.md +40 -0
  9. data/docs/overview.md +359 -0
  10. data/docs/wiki/API-Reference.md +270 -0
  11. data/docs/wiki/Encrypted-Fields-Overview.md +64 -0
  12. data/docs/wiki/Home.md +49 -0
  13. data/docs/wiki/Implementation-Guide.md +183 -0
  14. data/docs/wiki/Security-Model.md +143 -0
  15. data/lib/familia/base.rb +18 -27
  16. data/lib/familia/connection.rb +6 -5
  17. data/lib/familia/{datatype → data_type}/commands.rb +2 -5
  18. data/lib/familia/{datatype → data_type}/serialization.rb +8 -10
  19. data/lib/familia/{datatype → data_type}/types/hashkey.rb +2 -2
  20. data/lib/familia/{datatype → data_type}/types/list.rb +17 -18
  21. data/lib/familia/{datatype → data_type}/types/sorted_set.rb +17 -17
  22. data/lib/familia/{datatype → data_type}/types/string.rb +2 -1
  23. data/lib/familia/{datatype → data_type}/types/unsorted_set.rb +17 -18
  24. data/lib/familia/{datatype.rb → data_type.rb} +10 -12
  25. data/lib/familia/encryption/manager.rb +102 -0
  26. data/lib/familia/encryption/provider.rb +49 -0
  27. data/lib/familia/encryption/providers/aes_gcm_provider.rb +103 -0
  28. data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +184 -0
  29. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +118 -0
  30. data/lib/familia/encryption/registry.rb +50 -0
  31. data/lib/familia/encryption.rb +178 -0
  32. data/lib/familia/encryption_request_cache.rb +68 -0
  33. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +153 -0
  34. data/lib/familia/features/encrypted_fields.rb +28 -0
  35. data/lib/familia/features/expiration.rb +107 -77
  36. data/lib/familia/features/quantization.rb +5 -9
  37. data/lib/familia/features/relatable_objects.rb +2 -4
  38. data/lib/familia/features/safe_dump.rb +14 -17
  39. data/lib/familia/features/transient_fields/redacted_string.rb +159 -0
  40. data/lib/familia/features/transient_fields/single_use_redacted_string.rb +62 -0
  41. data/lib/familia/features/transient_fields/transient_field_type.rb +139 -0
  42. data/lib/familia/features/transient_fields.rb +47 -0
  43. data/lib/familia/features.rb +40 -24
  44. data/lib/familia/field_type.rb +270 -0
  45. data/lib/familia/horreum/connection.rb +8 -11
  46. data/lib/familia/horreum/{commands.rb → database_commands.rb} +7 -19
  47. data/lib/familia/horreum/definition_methods.rb +453 -0
  48. data/lib/familia/horreum/{class_methods.rb → management_methods.rb} +19 -243
  49. data/lib/familia/horreum/serialization.rb +46 -18
  50. data/lib/familia/horreum/settings.rb +10 -2
  51. data/lib/familia/horreum/utils.rb +9 -10
  52. data/lib/familia/horreum.rb +18 -10
  53. data/lib/familia/logging.rb +14 -14
  54. data/lib/familia/settings.rb +39 -3
  55. data/lib/familia/utils.rb +45 -0
  56. data/lib/familia/version.rb +1 -1
  57. data/lib/familia.rb +2 -1
  58. data/try/core/base_enhancements_try.rb +115 -0
  59. data/try/core/connection_try.rb +0 -1
  60. data/try/core/errors_try.rb +0 -1
  61. data/try/core/familia_extended_try.rb +3 -4
  62. data/try/core/familia_try.rb +0 -1
  63. data/try/core/pools_try.rb +2 -2
  64. data/try/core/secure_identifier_try.rb +0 -1
  65. data/try/core/settings_try.rb +0 -1
  66. data/try/core/utils_try.rb +0 -1
  67. data/try/{datatypes → data_types}/boolean_try.rb +1 -2
  68. data/try/{datatypes → data_types}/datatype_base_try.rb +2 -3
  69. data/try/{datatypes → data_types}/hash_try.rb +1 -2
  70. data/try/{datatypes → data_types}/list_try.rb +1 -2
  71. data/try/{datatypes → data_types}/set_try.rb +1 -2
  72. data/try/{datatypes → data_types}/sorted_set_try.rb +1 -2
  73. data/try/{datatypes → data_types}/string_try.rb +1 -2
  74. data/try/debugging/README.md +32 -0
  75. data/try/debugging/cache_behavior_tracer.rb +91 -0
  76. data/try/debugging/encryption_method_tracer.rb +138 -0
  77. data/try/debugging/provider_diagnostics.rb +110 -0
  78. data/try/edge_cases/hash_symbolization_try.rb +0 -1
  79. data/try/edge_cases/json_serialization_try.rb +0 -1
  80. data/try/edge_cases/reserved_keywords_try.rb +42 -11
  81. data/try/encryption/config_persistence_try.rb +192 -0
  82. data/try/encryption/encryption_core_try.rb +328 -0
  83. data/try/encryption/instance_variable_scope_try.rb +31 -0
  84. data/try/encryption/module_loading_try.rb +28 -0
  85. data/try/encryption/providers/aes_gcm_provider_try.rb +178 -0
  86. data/try/encryption/providers/xchacha20_poly1305_provider_try.rb +169 -0
  87. data/try/encryption/roundtrip_validation_try.rb +28 -0
  88. data/try/encryption/secure_memory_handling_try.rb +125 -0
  89. data/try/features/encrypted_fields_core_try.rb +117 -0
  90. data/try/features/encrypted_fields_integration_try.rb +220 -0
  91. data/try/features/encrypted_fields_no_cache_security_try.rb +205 -0
  92. data/try/features/encrypted_fields_security_try.rb +370 -0
  93. data/try/features/encryption_fields/aad_protection_try.rb +53 -0
  94. data/try/features/encryption_fields/context_isolation_try.rb +120 -0
  95. data/try/features/encryption_fields/error_conditions_try.rb +116 -0
  96. data/try/features/encryption_fields/fresh_key_derivation_try.rb +122 -0
  97. data/try/features/encryption_fields/fresh_key_try.rb +163 -0
  98. data/try/features/encryption_fields/key_rotation_try.rb +117 -0
  99. data/try/features/encryption_fields/memory_security_try.rb +37 -0
  100. data/try/features/encryption_fields/missing_current_key_version_try.rb +23 -0
  101. data/try/features/encryption_fields/nonce_uniqueness_try.rb +54 -0
  102. data/try/features/encryption_fields/thread_safety_try.rb +199 -0
  103. data/try/features/expiration_try.rb +0 -1
  104. data/try/features/feature_dependencies_try.rb +159 -0
  105. data/try/features/quantization_try.rb +0 -1
  106. data/try/features/real_feature_integration_try.rb +148 -0
  107. data/try/features/relatable_objects_try.rb +0 -1
  108. data/try/features/safe_dump_advanced_try.rb +0 -1
  109. data/try/features/safe_dump_try.rb +0 -1
  110. data/try/features/transient_fields/redacted_string_try.rb +248 -0
  111. data/try/features/transient_fields/refresh_reset_try.rb +164 -0
  112. data/try/features/transient_fields/simple_refresh_test.rb +50 -0
  113. data/try/features/transient_fields/single_use_redacted_string_try.rb +310 -0
  114. data/try/features/transient_fields_core_try.rb +181 -0
  115. data/try/features/transient_fields_integration_try.rb +260 -0
  116. data/try/helpers/test_helpers.rb +42 -0
  117. data/try/horreum/base_try.rb +157 -3
  118. data/try/horreum/enhanced_conflict_handling_try.rb +176 -0
  119. data/try/horreum/field_categories_try.rb +118 -0
  120. data/try/horreum/field_definition_try.rb +96 -0
  121. data/try/horreum/initialization_try.rb +0 -1
  122. data/try/horreum/relations_try.rb +0 -1
  123. data/try/horreum/serialization_persistent_fields_try.rb +165 -0
  124. data/try/horreum/serialization_try.rb +2 -3
  125. data/try/memory/memory_basic_test.rb +73 -0
  126. data/try/memory/memory_detailed_test.rb +121 -0
  127. data/try/memory/memory_docker_ruby_dump.sh +80 -0
  128. data/try/memory/memory_search_for_string.rb +83 -0
  129. data/try/memory/test_actual_redactedstring_protection.rb +38 -0
  130. data/try/models/customer_safe_dump_try.rb +0 -1
  131. data/try/models/customer_try.rb +0 -1
  132. data/try/models/datatype_base_try.rb +1 -2
  133. data/try/models/familia_object_try.rb +0 -1
  134. metadata +85 -18
@@ -0,0 +1,118 @@
1
+ # try/horreum/field_categories_try.rb
2
+
3
+ require_relative '../helpers/test_helpers'
4
+
5
+ Familia.debug = false
6
+
7
+ # Define test class with various field categories
8
+ class FieldCategoryTest < Familia::Horreum
9
+ identifier_field :id
10
+ field :id
11
+ field :name # default category (:field)
12
+ field :email, category: :encrypted # encrypted category
13
+ field :tryouts_cache_data, category: :transient # transient category
14
+ field :description, category: :persistent # explicit persistent category
15
+ field :settings, category: nil # nil category (defaults to :field)
16
+ end
17
+
18
+ # Test class with multiple transient fields
19
+ class MultiTransientTest < Familia::Horreum
20
+ identifier_field :id
21
+ field :id
22
+ field :permanent_data
23
+ field :temp1, category: :transient
24
+ field :temp2, category: :transient
25
+ field :temp3, category: :transient
26
+ end
27
+
28
+ # Field categories work with field aliasing
29
+ class AliasedCategoryTest < Familia::Horreum
30
+ identifier_field :id
31
+ field :id
32
+ field :internal_temp, as: :temp, category: :transient
33
+ field :internal_perm, as: :perm, category: :persistent
34
+ end
35
+
36
+ # Test edge case with all transient fields
37
+ class AllTransientTest < Familia::Horreum
38
+ identifier_field :id
39
+ field :id
40
+ field :temp1, category: :transient
41
+ field :temp2, category: :transient
42
+ end
43
+
44
+ ## Field types are stored correctly
45
+ @test_obj = FieldCategoryTest.new(id: 'test123')
46
+ FieldCategoryTest.field_types.size
47
+ #=> 6
48
+
49
+ ## Default category field has correct category
50
+ FieldCategoryTest.field_types[:name].category
51
+ #=> :field
52
+
53
+ ## Encrypted category field has correct category
54
+ FieldCategoryTest.field_types[:email].category
55
+ #=> :encrypted
56
+
57
+ ## Transient category field has correct category
58
+ FieldCategoryTest.field_types[:tryouts_cache_data].category
59
+ #=> :transient
60
+
61
+ ## Explicit persistent category field has correct category
62
+ FieldCategoryTest.field_types[:description].category
63
+ #=> :persistent
64
+
65
+ ## Nil category field defaults to :field
66
+ FieldCategoryTest.field_types[:settings].category
67
+ #=> :field
68
+
69
+ ## persistent_fields excludes transient fields
70
+ FieldCategoryTest.persistent_fields
71
+ #=> [:id, :name, :email, :description, :settings]
72
+
73
+ ## persistent_fields includes encrypted and persistent fields
74
+ FieldCategoryTest.persistent_fields.include?(:email)
75
+ #=> true
76
+
77
+ ## persistent_fields includes default category fields
78
+ FieldCategoryTest.persistent_fields.include?(:name)
79
+ #=> true
80
+
81
+ ## persistent_fields excludes transient fields
82
+ FieldCategoryTest.persistent_fields.include?(:tryouts_cache_data)
83
+ #=> false
84
+
85
+ ## Field definitions map provides backward compatibility
86
+ FieldCategoryTest.field_method_map[:name]
87
+ #=> :name
88
+
89
+ ## Field definitions map works for all fields
90
+ FieldCategoryTest.field_method_map[:email]
91
+ #=> :email
92
+
93
+ ## Multiple transient fields are handled correctly
94
+ MultiTransientTest.persistent_fields
95
+ #=> [:id, :permanent_data]
96
+
97
+ ## Aliased transient field is excluded from persistent_fields
98
+ AliasedCategoryTest.persistent_fields.include?(:internal_temp)
99
+ #=> false
100
+
101
+ ## Aliased persistent field is included in persistent_fields
102
+ AliasedCategoryTest.persistent_fields.include?(:internal_perm)
103
+ #=> true
104
+
105
+ ## Field type stores original field name, not alias
106
+ AliasedCategoryTest.field_types[:internal_temp].name
107
+ #=> :internal_temp
108
+
109
+ ## Field type stores alias as method name
110
+ AliasedCategoryTest.field_types[:internal_temp].method_name
111
+ #=> :temp
112
+
113
+ ## persistent_fields with mostly transient fields
114
+ AllTransientTest.persistent_fields
115
+ #=> [:id]
116
+
117
+ @test_obj.destroy! rescue nil
118
+ @test_obj = nil
@@ -0,0 +1,96 @@
1
+ # try/horreum/field_definition_try.rb
2
+
3
+ require_relative '../helpers/test_helpers'
4
+
5
+ Familia.debug = false
6
+
7
+ # Create a custom field type for testing with category support
8
+ class TestFieldType < Familia::FieldType
9
+ def initialize(name, category: :field, **kwargs)
10
+ super(name, **kwargs)
11
+ @category = category
12
+ end
13
+
14
+ def category
15
+ @category || :field
16
+ end
17
+
18
+ def persistent?
19
+ category != :transient
20
+ end
21
+
22
+ def transient?
23
+ !persistent?
24
+ end
25
+ end
26
+
27
+ # Setup a test field type (replacing the old FieldDefinition)
28
+ @field_type = TestFieldType.new(
29
+ :email,
30
+ as: :email,
31
+ fast_method: :email!,
32
+ on_conflict: :raise,
33
+ category: :encrypted
34
+ )
35
+
36
+ ## FieldType holds field name correctly
37
+ @field_type.name
38
+ #=> :email
39
+
40
+ ## FieldType holds method name correctly
41
+ @field_type.method_name
42
+ #=> :email
43
+
44
+ ## FieldType holds fast method name correctly
45
+ @field_type.fast_method_name
46
+ #=> :email!
47
+
48
+ ## FieldType holds conflict strategy correctly
49
+ @field_type.on_conflict
50
+ #=> :raise
51
+
52
+ ## FieldType holds category correctly
53
+ @field_type.category
54
+ #=> :encrypted
55
+
56
+ ## FieldType returns generated methods list
57
+ @field_type.generated_methods
58
+ #=> [:email, :email!]
59
+
60
+ ## FieldType with nil category defaults to :field
61
+ @basic_field = TestFieldType.new(
62
+ :name,
63
+ as: :name,
64
+ fast_method: :name!,
65
+ on_conflict: :skip,
66
+ category: nil
67
+ )
68
+ @basic_field.category
69
+ #=> :field
70
+
71
+ ## FieldType persistent? returns true for non-transient fields
72
+ @field_type.persistent?
73
+ #=> true
74
+
75
+ ## FieldType persistent? returns false for transient fields
76
+ @transient_field = TestFieldType.new(
77
+ :temp_data,
78
+ as: :temp_data,
79
+ fast_method: :temp_data!,
80
+ on_conflict: :raise,
81
+ category: :transient
82
+ )
83
+ @transient_field.persistent?
84
+ #=> false
85
+
86
+ ## FieldType to_s includes all attributes
87
+ @field_type.to_s
88
+ #=~>/#<.*TestFieldType name=email method_name=email fast_method_name=email! on_conflict=raise category=encrypted>/
89
+
90
+ ## FieldType inspect is same as to_s
91
+ @field_type.inspect
92
+ #=~>/#<.*TestFieldType name=email method_name=email fast_method_name=email! on_conflict=raise category=encrypted>/
93
+
94
+ @field_type = nil
95
+ @basic_field = nil
96
+ @transient_field = nil
@@ -1,6 +1,5 @@
1
1
  # try/horreum/initialization_try.rb
2
2
 
3
- require_relative '../../lib/familia'
4
3
  require_relative '../helpers/test_helpers'
5
4
 
6
5
  Familia.debug = false
@@ -1,7 +1,6 @@
1
1
  # try/horreum/relations_try.rb
2
2
  # Test Horreum Database type relations functionality
3
3
 
4
- require_relative '../../lib/familia'
5
4
  require_relative '../helpers/test_helpers'
6
5
 
7
6
  Familia.debug = false
@@ -0,0 +1,165 @@
1
+ # try/horreum/serialization_persistent_fields_try.rb
2
+
3
+ require_relative '../helpers/test_helpers'
4
+
5
+ Familia.debug = false
6
+
7
+ # Test class with mixed field categories for serialization
8
+ class SerializationCategoryTest < Familia::Horreum
9
+ identifier_field :id
10
+ field :id
11
+ field :name # persistent by default
12
+ field :email, category: :encrypted # persistent, encrypted category
13
+ field :tryouts_cache_data, category: :transient # should be excluded from serialization
14
+ field :description, category: :persistent # explicitly persistent
15
+ field :temp_settings, category: :transient # should be excluded
16
+ field :metadata, category: :persistent # explicitly persistent
17
+ end
18
+
19
+ # Class with all transient fields
20
+ class AllTransientSerializationTest < Familia::Horreum
21
+ identifier_field :id
22
+ field :id
23
+ field :temp1, category: :transient
24
+ field :temp2, category: :transient
25
+ end
26
+
27
+ # Mixed categories with aliased fields
28
+ class AliasedSerializationTest < Familia::Horreum
29
+ identifier_field :id
30
+ field :id
31
+ field :internal_name, as: :display_name, category: :persistent
32
+ field :temp_cache, as: :cache, category: :transient
33
+ field :user_data, as: :data, category: :encrypted
34
+ end
35
+
36
+ # Setup test instance with all field types
37
+ @serialization_test = SerializationCategoryTest.new(
38
+ id: 'serialize_test_1',
39
+ name: 'Test User',
40
+ email: 'test@example.com',
41
+ tryouts_cache_data: 'temporary_cache_value',
42
+ description: 'A test user description',
43
+ temp_settings: { theme: 'dark', cache: true },
44
+ metadata: { version: 1, last_login: '2025-01-01' }
45
+ )
46
+
47
+ @all_transient = AllTransientSerializationTest.new(
48
+ id: 'transient_test_1',
49
+ temp1: 'value1',
50
+ temp2: 'value2'
51
+ )
52
+
53
+ @aliased_test = AliasedSerializationTest.new(
54
+ id: 'aliased_test_1',
55
+ display_name: 'Display Name',
56
+ cache: 'cache_value',
57
+ data: { key: 'value' }
58
+ )
59
+
60
+ ## to_h excludes transient fields
61
+ @hash_result = @serialization_test.to_h
62
+ @hash_result.keys.sort
63
+ #=> [:description, :email, :id, :metadata, :name]
64
+
65
+ ## to_h includes all persistent fields
66
+ @hash_result.key?(:name)
67
+ #=> true
68
+
69
+ ## to_h includes encrypted persistent fields
70
+ @hash_result.key?(:email)
71
+ #=> true
72
+
73
+ ## to_h includes explicitly persistent fields
74
+ @hash_result.key?(:description)
75
+ #=> true
76
+
77
+ ## to_h excludes transient fields from serialization
78
+ @hash_result.key?(:tryouts_cache_data)
79
+ #=> false
80
+
81
+ ## to_h excludes all transient fields
82
+ @hash_result.key?(:temp_settings)
83
+ #=> false
84
+
85
+ ## to_h serializes complex values correctly
86
+ @hash_result[:metadata]
87
+ #=:> String
88
+
89
+ ## to_a excludes transient fields
90
+ @array_result = @serialization_test.to_a
91
+ @array_result.size
92
+ #=> 5
93
+
94
+ ## to_a maintains field order for persistent fields only
95
+ SerializationCategoryTest.persistent_fields
96
+ #=> [:id, :name, :email, :description, :metadata]
97
+
98
+ ## Save operation only persists persistent fields
99
+ @serialization_test.save
100
+ #=> true
101
+
102
+ ## Refresh loads only persistent fields
103
+ @serialization_test.refresh!
104
+ @serialization_test.name
105
+ #=> "Test User"
106
+
107
+ ## Transient field values are not persisted in redis
108
+ @serialization_test.tryouts_cache_data
109
+ #=> nil
110
+
111
+ ## When refreshed, transient fields do not retain their in-memory values
112
+ @serialization_test.refresh!
113
+ @serialization_test.tryouts_cache_data # Should still be in memory but not from redis
114
+ #=> nil
115
+
116
+ ## Field definitions are preserved during serialization
117
+ SerializationCategoryTest.field_types[:tryouts_cache_data].category
118
+ #=> :transient
119
+
120
+ ## Persistent fields filtering works correctly
121
+ SerializationCategoryTest.persistent_fields.include?(:tryouts_cache_data)
122
+ #=> false
123
+
124
+ ## All persistent fields are included in persistent_fields
125
+ SerializationCategoryTest.persistent_fields.include?(:email)
126
+ #=> true
127
+
128
+ ## to_h with only id field when all others are transient
129
+ @all_transient.to_h
130
+ #=> { id: "transient_test_1" }
131
+
132
+ ## to_a with only id field when all others are transient
133
+ @all_transient.to_a
134
+ #=> ["transient_test_1"]
135
+
136
+ ## Aliased fields serialization uses original field names
137
+ @aliased_hash = @aliased_test.to_h
138
+ @aliased_hash.keys.sort
139
+ #=> [:id, :internal_name, :user_data]
140
+
141
+ ## Aliased transient fields are excluded
142
+ @aliased_hash.key?(:temp_cache)
143
+ #=> false
144
+
145
+ ## Serialization works with accessor methods through aliases
146
+ @aliased_test.display_name = 'Updated Name'
147
+ @aliased_test.to_h[:internal_name]
148
+ #=> "Updated Name"
149
+
150
+ ## Clear fields respects field method map
151
+ @serialization_test.clear_fields!
152
+ @serialization_test.name
153
+ #=> nil
154
+
155
+ ## Clear fields affects aliased methods correctly
156
+ @aliased_test.clear_fields!
157
+ @aliased_test.display_name
158
+ #=> nil
159
+
160
+ @serialization_test.destroy! rescue nil
161
+ @all_transient.destroy! rescue nil
162
+ @aliased_test.destroy! rescue nil
163
+ @serialization_test = nil
164
+ @all_transient = nil
165
+ @aliased_test = nil
@@ -1,11 +1,10 @@
1
1
  # try/horreum/serialization_try.rb
2
2
 
3
- require_relative '../../lib/familia'
4
3
  require_relative '../helpers/test_helpers'
5
4
 
6
5
  Familia.debug = false
7
6
 
8
- @identifier = 'tryouts-28@onetimesecret.com'
7
+ @identifier = 'tryouts-28@onetimesecret.dev'
9
8
  @customer = Customer.new @identifier
10
9
 
11
10
  ## Basic save functionality works
@@ -23,7 +22,7 @@ Familia.debug = false
23
22
 
24
23
  ## to_h includes the custid field (using symbol keys)
25
24
  @customer.to_h[:custid]
26
- #=> "tryouts-28@onetimesecret.com"
25
+ #=> "tryouts-28@onetimesecret.dev"
27
26
 
28
27
  ## to_a returns field array in definition order
29
28
  @customer.to_a.class
@@ -0,0 +1,73 @@
1
+ # try/edge_cases/memory_try.rb
2
+
3
+ require 'tempfile'
4
+ require 'json'
5
+
6
+ require_relative '../helpers/test_helpers'
7
+
8
+ class MemorySecurityTester
9
+ def self.test_redacted_string
10
+ results = {
11
+ timestamp: Time.now,
12
+ tests: []
13
+ }
14
+
15
+ # Test 1: Basic string search
16
+ secret = "SENSITIVE_#{rand(999999)}"
17
+ redacted = RedactedString.new(secret)
18
+
19
+ # Dump all strings to file
20
+ Tempfile.create('strings') do |f|
21
+ ObjectSpace.each_object(String) do |str|
22
+ f.puts str.inspect rescue nil
23
+ end
24
+ f.flush
25
+
26
+ # Check if secret appears
27
+ f.rewind
28
+ content = f.read
29
+ results[:tests] << {
30
+ name: "Basic string search",
31
+ passed: !content.include?(secret),
32
+ details: content.include?(secret) ? "Found secret in object space" : "Secret not found"
33
+ }
34
+ end
35
+
36
+ # Test 2: Memory after GC
37
+ redacted.clear!
38
+ GC.start(full_mark: true, immediate_sweep: true)
39
+ sleep 0.1
40
+
41
+ found = false
42
+ ObjectSpace.each_object(String) do |str|
43
+ found = true if str.include?(secret) rescue false
44
+ end
45
+
46
+ results[:tests] << {
47
+ name: "After clear and GC",
48
+ passed: !found,
49
+ details: found ? "Secret persists after clear" : "Secret cleared"
50
+ }
51
+
52
+ # Test 3: Check /proc/self/mem directly
53
+ begin
54
+ mem_content = File.read("/proc/self/mem", 1024*1024*10) rescue ""
55
+ results[:tests] << {
56
+ name: "Direct memory read",
57
+ passed: !mem_content.include?(secret),
58
+ details: mem_content.include?(secret) ? "Found in /proc/self/mem" : "Not in readable memory"
59
+ }
60
+ rescue => e
61
+ results[:tests] << {
62
+ name: "Direct memory read",
63
+ passed: nil,
64
+ details: "Could not read: #{e}"
65
+ }
66
+ end
67
+
68
+ puts JSON.pretty_generate(results)
69
+ end
70
+ end
71
+
72
+ # Run the test
73
+ MemorySecurityTester.test_redacted_string
@@ -0,0 +1,121 @@
1
+ # try/edge_cases/memory_detailed_test_try.rb
2
+
3
+ require 'objspace'
4
+ require 'json'
5
+
6
+ require_relative '../helpers/test_helpers'
7
+
8
+ class DetailedMemoryTester
9
+ def self.test_with_details
10
+ ObjectSpace.trace_object_allocations_start
11
+
12
+ secret = "SENSITIVE_#{rand(999999)}_DATA"
13
+ puts "Testing with secret: #{secret}"
14
+ puts "Secret object_id: #{secret.object_id}"
15
+ puts "Secret frozen?: #{secret.frozen?}\n\n"
16
+
17
+ # Track all string copies
18
+ tracker = {}
19
+
20
+ # Before creating RedactedString
21
+ find_secret_copies(secret, "BEFORE RedactedString creation", tracker)
22
+
23
+ # Create RedactedString
24
+ redacted = RedactedString.new(secret)
25
+ find_secret_copies(secret, "AFTER RedactedString creation", tracker)
26
+
27
+ # Use expose block
28
+ exposed_value = nil
29
+ redacted.expose do |plain|
30
+ exposed_value = plain.object_id
31
+ find_secret_copies(secret, "DURING expose block", tracker)
32
+ end
33
+ find_secret_copies(secret, "AFTER expose block", tracker)
34
+
35
+ # Clear and GC
36
+ redacted.clear!
37
+ original_secret = secret
38
+ secret = nil # Remove our reference
39
+ GC.start(full_mark: true, immediate_sweep: true)
40
+
41
+ find_secret_copies(original_secret, "AFTER clear! and GC", tracker)
42
+
43
+ # Final report
44
+ puts "\n" + "="*60
45
+ puts "FINAL ANALYSIS"
46
+ puts "="*60
47
+
48
+ remaining_copies = []
49
+ ObjectSpace.each_object(String) do |str|
50
+ begin
51
+ if str.include?(original_secret)
52
+ remaining_copies << {
53
+ object_id: str.object_id,
54
+ size: str.bytesize,
55
+ encoding: str.encoding.name,
56
+ frozen: str.frozen?,
57
+ tainted: (str.tainted? rescue "N/A"),
58
+ value_preview: str[0..50]
59
+ }
60
+ end
61
+ rescue => e
62
+ # Skip strings that can't be accessed
63
+ end
64
+ end
65
+
66
+ if remaining_copies.empty?
67
+ puts "✅ SUCCESS: No copies found in memory!"
68
+ else
69
+ puts "❌ FAILURE: #{remaining_copies.size} copies still in memory:"
70
+ remaining_copies.each do |copy|
71
+ puts "\n Object ID: #{copy[:object_id]}"
72
+ puts " Size: #{copy[:size]} bytes"
73
+ puts " Frozen: #{copy[:frozen]}"
74
+ puts " Encoding: #{copy[:encoding]}"
75
+ end
76
+ end
77
+
78
+ # Show memory stats
79
+ puts "\n" + "="*60
80
+ puts "MEMORY STATISTICS"
81
+ puts "="*60
82
+ puts "Total strings in ObjectSpace: #{ObjectSpace.each_object(String).count}"
83
+ puts "GC count: #{GC.count}"
84
+ puts "GC stat: #{GC.stat[:heap_live_slots]} live slots"
85
+
86
+ tracker
87
+ end
88
+
89
+ private
90
+
91
+ def self.find_secret_copies(secret, phase, tracker)
92
+ copies = []
93
+
94
+ ObjectSpace.each_object(String) do |str|
95
+ begin
96
+ if str.include?(secret)
97
+ copies << {
98
+ object_id: str.object_id,
99
+ frozen: str.frozen?,
100
+ source: ObjectSpace.allocation_sourcefile(str),
101
+ line: ObjectSpace.allocation_sourceline(str)
102
+ }
103
+ end
104
+ rescue => e
105
+ # Some strings might not be accessible
106
+ end
107
+ end
108
+
109
+ tracker[phase] = copies
110
+
111
+ puts "#{phase}: Found #{copies.size} copies"
112
+ copies.each do |copy|
113
+ source_info = copy[:source] ? "#{copy[:source]}:#{copy[:line]}" : "unknown source"
114
+ puts " - Object #{copy[:object_id]} (frozen: #{copy[:frozen]}) from #{source_info}"
115
+ end
116
+ puts ""
117
+ end
118
+ end
119
+
120
+ # Run the detailed test
121
+ DetailedMemoryTester.test_with_details
@@ -0,0 +1,80 @@
1
+ #!/bin/bash
2
+ # try/edge_cases/docker_dump.sh
3
+
4
+ # Usage: bash $0 <container_id>
5
+ #
6
+ # See example output at end.
7
+
8
+ # Set CONTAINER_ID to $CONTAINER_ID or the first argument
9
+ CONTAINER_ID=${CONTAINER_ID:-$1}
10
+
11
+ if [ -z "$CONTAINER_ID" ]; then
12
+ echo "Usage: $0 <container_id>"
13
+ echo "Or set CONTAINER_ID environment variable"
14
+ exit 1
15
+ fi
16
+
17
+ # Create a script to dump all string-like patterns
18
+ docker exec $CONTAINER_ID bash -c '
19
+ # Install required packages
20
+ apt-get update -qq && apt-get install -y -qq procps binutils
21
+
22
+ PID=$(pgrep -f ruby)
23
+
24
+ if [ -z "$PID" ]; then
25
+ echo "No Ruby process found"
26
+ exit 1
27
+ fi
28
+
29
+ echo "Dumping memory for Ruby process $PID"
30
+
31
+ # Check if maps file exists
32
+ if [ ! -f "/proc/$PID/maps" ]; then
33
+ echo "Cannot access memory maps for process $PID"
34
+ exit 1
35
+ fi
36
+
37
+ # Get memory regions
38
+ grep -E "rw-p|r--p" /proc/$PID/maps | while read line; do
39
+ start=$(echo $line | cut -d"-" -f1)
40
+ end=$(echo $line | cut -d" " -f1 | cut -d"-" -f2)
41
+
42
+ # Convert hex to decimal and dump
43
+ start_dec=$((16#$start))
44
+ end_dec=$((16#$end))
45
+ size=$((end_dec - start_dec))
46
+
47
+ # Skip if size is too large (> 10MB) to avoid hanging
48
+ if [ $size -gt 10485760 ]; then
49
+ continue
50
+ fi
51
+
52
+ dd if=/proc/$PID/mem bs=1 skip=$start_dec count=$size 2>/dev/null
53
+ done | strings | grep -i "secret\|api\|key\|token" | head -20
54
+ '
55
+
56
+ # Example Output:
57
+ #
58
+ # $ SECRET=august7th2025
59
+ # $
60
+ # $ docker run --rm -d -p 3000:3000 \
61
+ # -e SECRET=$SECRET \
62
+ # -e REDIS_URL=redis://host.docker.internal:6379/0 \
63
+ # ghcr.io/onetimesecret/devtimesecret-lite:latest
64
+ #
65
+ # abcd1234
66
+ #
67
+ # $ bash try/edge_cases/docker_ruby_dump.sh abcd1234
68
+ # ...
69
+ # Dumping memory for Ruby process 60
70
+ # SECRET
71
+ # SECRET
72
+ # SECRET=august6th2025
73
+ # done | strings | grep -i "secret...
74
+ # SECRET=august6th2025
75
+ # done | strings | grep -i "secret...
76
+ # grep -i "secret\|api\|key|token"
77
+ # done | strings | grep -i "secret...
78
+ # SECRET=august6th2025
79
+ #
80
+ # $ docker kill abcd1234