familia 2.0.0.pre4 → 2.0.0.pre6

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 (178) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.rubocop_todo.yml +17 -17
  4. data/CLAUDE.md +11 -8
  5. data/Gemfile +5 -1
  6. data/Gemfile.lock +19 -3
  7. data/README.md +36 -157
  8. data/docs/overview.md +359 -0
  9. data/docs/wiki/API-Reference.md +347 -0
  10. data/docs/wiki/Connection-Pooling-Guide.md +437 -0
  11. data/docs/wiki/Encrypted-Fields-Overview.md +101 -0
  12. data/docs/wiki/Expiration-Feature-Guide.md +596 -0
  13. data/docs/wiki/Feature-System-Guide.md +600 -0
  14. data/docs/wiki/Features-System-Developer-Guide.md +892 -0
  15. data/docs/wiki/Field-System-Guide.md +784 -0
  16. data/docs/wiki/Home.md +106 -0
  17. data/docs/wiki/Implementation-Guide.md +276 -0
  18. data/docs/wiki/Quantization-Feature-Guide.md +721 -0
  19. data/docs/wiki/RelatableObjects-Guide.md +563 -0
  20. data/docs/wiki/Security-Model.md +183 -0
  21. data/docs/wiki/Transient-Fields-Guide.md +280 -0
  22. data/lib/familia/base.rb +18 -27
  23. data/lib/familia/connection.rb +6 -5
  24. data/lib/familia/{datatype → data_type}/commands.rb +2 -5
  25. data/lib/familia/{datatype → data_type}/serialization.rb +8 -10
  26. data/lib/familia/data_type/types/counter.rb +38 -0
  27. data/lib/familia/{datatype → data_type}/types/hashkey.rb +20 -2
  28. data/lib/familia/{datatype → data_type}/types/list.rb +17 -18
  29. data/lib/familia/data_type/types/lock.rb +43 -0
  30. data/lib/familia/{datatype → data_type}/types/sorted_set.rb +17 -17
  31. data/lib/familia/{datatype → data_type}/types/string.rb +11 -3
  32. data/lib/familia/{datatype → data_type}/types/unsorted_set.rb +17 -18
  33. data/lib/familia/{datatype.rb → data_type.rb} +12 -14
  34. data/lib/familia/encryption/encrypted_data.rb +137 -0
  35. data/lib/familia/encryption/manager.rb +119 -0
  36. data/lib/familia/encryption/provider.rb +49 -0
  37. data/lib/familia/encryption/providers/aes_gcm_provider.rb +123 -0
  38. data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +184 -0
  39. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +138 -0
  40. data/lib/familia/encryption/registry.rb +50 -0
  41. data/lib/familia/encryption.rb +178 -0
  42. data/lib/familia/encryption_request_cache.rb +68 -0
  43. data/lib/familia/errors.rb +17 -3
  44. data/lib/familia/features/encrypted_fields/concealed_string.rb +295 -0
  45. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +221 -0
  46. data/lib/familia/features/encrypted_fields.rb +28 -0
  47. data/lib/familia/features/expiration.rb +107 -77
  48. data/lib/familia/features/quantization.rb +5 -9
  49. data/lib/familia/features/relatable_objects.rb +2 -4
  50. data/lib/familia/features/safe_dump.rb +14 -17
  51. data/lib/familia/features/transient_fields/redacted_string.rb +159 -0
  52. data/lib/familia/features/transient_fields/single_use_redacted_string.rb +62 -0
  53. data/lib/familia/features/transient_fields/transient_field_type.rb +139 -0
  54. data/lib/familia/features/transient_fields.rb +47 -0
  55. data/lib/familia/features.rb +40 -24
  56. data/lib/familia/field_type.rb +273 -0
  57. data/lib/familia/horreum/{connection.rb → core/connection.rb} +6 -15
  58. data/lib/familia/horreum/{commands.rb → core/database_commands.rb} +20 -21
  59. data/lib/familia/horreum/core/serialization.rb +535 -0
  60. data/lib/familia/horreum/{utils.rb → core/utils.rb} +9 -12
  61. data/lib/familia/horreum/core.rb +21 -0
  62. data/lib/familia/horreum/{settings.rb → shared/settings.rb} +10 -4
  63. data/lib/familia/horreum/subclass/definition.rb +469 -0
  64. data/lib/familia/horreum/{class_methods.rb → subclass/management.rb} +27 -250
  65. data/lib/familia/horreum/{related_fields_management.rb → subclass/related_fields_management.rb} +15 -10
  66. data/lib/familia/horreum.rb +30 -22
  67. data/lib/familia/logging.rb +14 -14
  68. data/lib/familia/settings.rb +39 -3
  69. data/lib/familia/utils.rb +45 -0
  70. data/lib/familia/version.rb +1 -1
  71. data/lib/familia.rb +3 -2
  72. data/try/core/base_enhancements_try.rb +115 -0
  73. data/try/core/connection_try.rb +0 -1
  74. data/try/core/create_method_try.rb +240 -0
  75. data/try/core/database_consistency_try.rb +299 -0
  76. data/try/core/errors_try.rb +25 -5
  77. data/try/core/familia_extended_try.rb +3 -4
  78. data/try/core/familia_try.rb +1 -2
  79. data/try/core/persistence_operations_try.rb +297 -0
  80. data/try/core/pools_try.rb +2 -2
  81. data/try/core/secure_identifier_try.rb +0 -1
  82. data/try/core/settings_try.rb +0 -1
  83. data/try/core/utils_try.rb +0 -1
  84. data/try/{datatypes → data_types}/boolean_try.rb +1 -2
  85. data/try/data_types/counter_try.rb +93 -0
  86. data/try/{datatypes → data_types}/datatype_base_try.rb +2 -3
  87. data/try/{datatypes → data_types}/hash_try.rb +1 -2
  88. data/try/{datatypes → data_types}/list_try.rb +1 -2
  89. data/try/data_types/lock_try.rb +133 -0
  90. data/try/{datatypes → data_types}/set_try.rb +1 -2
  91. data/try/{datatypes → data_types}/sorted_set_try.rb +1 -2
  92. data/try/{datatypes → data_types}/string_try.rb +1 -2
  93. data/try/debugging/README.md +32 -0
  94. data/try/debugging/cache_behavior_tracer.rb +91 -0
  95. data/try/debugging/debug_aad_process.rb +82 -0
  96. data/try/debugging/debug_concealed_internal.rb +59 -0
  97. data/try/debugging/debug_concealed_reveal.rb +61 -0
  98. data/try/debugging/debug_context_aad.rb +68 -0
  99. data/try/debugging/debug_context_simple.rb +80 -0
  100. data/try/debugging/debug_cross_context.rb +62 -0
  101. data/try/debugging/debug_database_load.rb +64 -0
  102. data/try/debugging/debug_encrypted_json_check.rb +53 -0
  103. data/try/debugging/debug_encrypted_json_step_by_step.rb +62 -0
  104. data/try/debugging/debug_exists_lifecycle.rb +54 -0
  105. data/try/debugging/debug_field_decrypt.rb +74 -0
  106. data/try/debugging/debug_fresh_cross_context.rb +73 -0
  107. data/try/debugging/debug_load_path.rb +66 -0
  108. data/try/debugging/debug_method_definition.rb +46 -0
  109. data/try/debugging/debug_method_resolution.rb +41 -0
  110. data/try/debugging/debug_minimal.rb +24 -0
  111. data/try/debugging/debug_provider.rb +68 -0
  112. data/try/debugging/debug_secure_behavior.rb +73 -0
  113. data/try/debugging/debug_string_class.rb +46 -0
  114. data/try/debugging/debug_test.rb +46 -0
  115. data/try/debugging/debug_test_design.rb +80 -0
  116. data/try/debugging/encryption_method_tracer.rb +138 -0
  117. data/try/debugging/provider_diagnostics.rb +110 -0
  118. data/try/edge_cases/hash_symbolization_try.rb +0 -1
  119. data/try/edge_cases/json_serialization_try.rb +0 -1
  120. data/try/edge_cases/reserved_keywords_try.rb +42 -11
  121. data/try/encryption/config_persistence_try.rb +192 -0
  122. data/try/encryption/encryption_core_try.rb +328 -0
  123. data/try/encryption/instance_variable_scope_try.rb +31 -0
  124. data/try/encryption/module_loading_try.rb +28 -0
  125. data/try/encryption/providers/aes_gcm_provider_try.rb +178 -0
  126. data/try/encryption/providers/xchacha20_poly1305_provider_try.rb +169 -0
  127. data/try/encryption/roundtrip_validation_try.rb +28 -0
  128. data/try/encryption/secure_memory_handling_try.rb +125 -0
  129. data/try/features/encrypted_fields_core_try.rb +125 -0
  130. data/try/features/encrypted_fields_integration_try.rb +216 -0
  131. data/try/features/encrypted_fields_no_cache_security_try.rb +219 -0
  132. data/try/features/encrypted_fields_security_try.rb +377 -0
  133. data/try/features/encryption_fields/aad_protection_try.rb +138 -0
  134. data/try/features/encryption_fields/concealed_string_core_try.rb +250 -0
  135. data/try/features/encryption_fields/context_isolation_try.rb +141 -0
  136. data/try/features/encryption_fields/error_conditions_try.rb +116 -0
  137. data/try/features/encryption_fields/fresh_key_derivation_try.rb +128 -0
  138. data/try/features/encryption_fields/fresh_key_try.rb +168 -0
  139. data/try/features/encryption_fields/key_rotation_try.rb +123 -0
  140. data/try/features/encryption_fields/memory_security_try.rb +37 -0
  141. data/try/features/encryption_fields/missing_current_key_version_try.rb +23 -0
  142. data/try/features/encryption_fields/nonce_uniqueness_try.rb +56 -0
  143. data/try/features/encryption_fields/secure_by_default_behavior_try.rb +310 -0
  144. data/try/features/encryption_fields/thread_safety_try.rb +199 -0
  145. data/try/features/encryption_fields/universal_serialization_safety_try.rb +174 -0
  146. data/try/features/expiration_try.rb +0 -1
  147. data/try/features/feature_dependencies_try.rb +159 -0
  148. data/try/features/quantization_try.rb +0 -1
  149. data/try/features/real_feature_integration_try.rb +148 -0
  150. data/try/features/relatable_objects_try.rb +0 -1
  151. data/try/features/safe_dump_advanced_try.rb +0 -1
  152. data/try/features/safe_dump_try.rb +0 -1
  153. data/try/features/transient_fields/redacted_string_try.rb +248 -0
  154. data/try/features/transient_fields/refresh_reset_try.rb +164 -0
  155. data/try/features/transient_fields/simple_refresh_test.rb +50 -0
  156. data/try/features/transient_fields/single_use_redacted_string_try.rb +310 -0
  157. data/try/features/transient_fields_core_try.rb +181 -0
  158. data/try/features/transient_fields_integration_try.rb +260 -0
  159. data/try/helpers/test_helpers.rb +67 -0
  160. data/try/horreum/base_try.rb +157 -3
  161. data/try/horreum/enhanced_conflict_handling_try.rb +176 -0
  162. data/try/horreum/field_categories_try.rb +118 -0
  163. data/try/horreum/field_definition_try.rb +96 -0
  164. data/try/horreum/initialization_try.rb +1 -2
  165. data/try/horreum/relations_try.rb +1 -2
  166. data/try/horreum/serialization_persistent_fields_try.rb +165 -0
  167. data/try/horreum/serialization_try.rb +41 -7
  168. data/try/memory/memory_basic_test.rb +73 -0
  169. data/try/memory/memory_detailed_test.rb +121 -0
  170. data/try/memory/memory_docker_ruby_dump.sh +80 -0
  171. data/try/memory/memory_search_for_string.rb +83 -0
  172. data/try/memory/test_actual_redactedstring_protection.rb +38 -0
  173. data/try/models/customer_safe_dump_try.rb +1 -2
  174. data/try/models/customer_try.rb +1 -2
  175. data/try/models/datatype_base_try.rb +1 -2
  176. data/try/models/familia_object_try.rb +0 -1
  177. metadata +131 -23
  178. data/lib/familia/horreum/serialization.rb +0 -445
@@ -1,100 +1,130 @@
1
1
  # lib/familia/features/expiration.rb
2
2
 
3
+ module Familia
4
+ module Features
5
+ # Famnilia::Features::Expiration
6
+ #
7
+ module Expiration
8
+ @default_expiration = nil
3
9
 
4
- module Familia::Features
10
+ def self.included(base)
11
+ Familia.trace :LOADED!, nil, self, caller(1..1) if Familia.debug?
12
+ base.extend ClassMethods
5
13
 
6
- module Expiration
7
- @default_expiration = nil
14
+ # Optionally define default_expiration in the class to make
15
+ # sure we always have an array to work with.
16
+ return if base.instance_variable_defined?(:@default_expiration)
8
17
 
9
- module ClassMethods
18
+ base.instance_variable_set(:@default_expiration, @default_expiration) # set above
19
+ end
10
20
 
11
- attr_writer :default_expiration
21
+ # ClassMethods
22
+ #
23
+ module ClassMethods
24
+ attr_writer :default_expiration
12
25
 
13
- def default_expiration(v = nil)
14
- @default_expiration = v.to_f unless v.nil?
15
- @default_expiration || parent&.default_expiration || Familia.default_expiration
26
+ def default_expiration(num = nil)
27
+ @default_expiration = num.to_f unless num.nil?
28
+ @default_expiration || parent&.default_expiration || Familia.default_expiration
29
+ end
16
30
  end
17
31
 
18
- end
19
-
20
- def self.included base
21
- Familia.ld "[#{base}] Loaded #{self}"
22
- base.extend ClassMethods
32
+ def default_expiration=(num)
33
+ @default_expiration = num.to_f
34
+ end
23
35
 
24
- # Optionally define default_expiration in the class to make
25
- # sure we always have an array to work with.
26
- unless base.instance_variable_defined?(:@default_expiration)
27
- base.instance_variable_set(:@default_expiration, @default_expiration) # set above
36
+ def default_expiration
37
+ @default_expiration || self.class.default_expiration
28
38
  end
29
- end
30
39
 
31
- def default_expiration=(v)
32
- @default_expiration = v.to_f
33
- end
40
+ # Sets an expiration time for the Database data associated with this object.
41
+ #
42
+ # This method allows setting a Time To Live (TTL) for the data in Redis,
43
+ # after which it will be automatically removed.
44
+ #
45
+ # @param default_expiration [Integer, nil] The Time To Live in seconds. If nil, the default
46
+ # TTL will be used.
47
+ #
48
+ # @return [Boolean] Returns true if the expiration was set successfully,
49
+ # false otherwise.
50
+ #
51
+ # @example Setting an expiration of one day
52
+ # object.update_expiration(default_expiration: 86400)
53
+ #
54
+ # @note If Default expiration is set to zero, the expiration will be removed, making the
55
+ # data persist indefinitely.
56
+ #
57
+ # @raise [Familia::Problem] Raises an error if the default expiration is not a non-negative
58
+ # integer.
59
+ #
60
+ def update_expiration(default_expiration: nil)
61
+ default_expiration ||= self.default_expiration
62
+
63
+ if self.class.has_relations?
64
+ Familia.ld "[update_expiration] #{self.class} has relations: #{self.class.related_fields.keys}"
65
+ self.class.related_fields.each do |name, definition|
66
+ next if definition.opts[:default_expiration].nil?
67
+
68
+ obj = send(name)
69
+ Familia.ld "[update_expiration] Updating expiration for #{name} (#{obj.dbkey}) to #{default_expiration}"
70
+ obj.update_expiration(default_expiration: default_expiration)
71
+ end
72
+ end
73
+
74
+ # It's important to raise exceptions here and not just log warnings. We
75
+ # don't want to silently fail at setting expirations and cause data
76
+ # retention issues (e.g. not removed in a timely fashion).
77
+ #
78
+ # For the same reason, we don't want to default to 0 bc there's not a
79
+ # good reason for the default_expiration to not be set in the first place. If the
80
+ # class doesn't have a default_expiration, the default comes from
81
+ # Familia.default_expiration (which is 0, aka no-op/skip/do nothing).
82
+ unless default_expiration.is_a?(Numeric)
83
+ raise Familia::Problem, "Default expiration must be a number (#{default_expiration.class} in #{self.class})"
84
+ end
34
85
 
35
- def default_expiration
36
- @default_expiration || self.class.default_expiration
86
+ # If zero, simply skips setting an expiry for this key. If we were to set
87
+ # 0 the database would drop the key immediately.
88
+ return Familia.ld "[update_expiration] No expiration for #{self.class} (#{dbkey})" if default_expiration.zero?
89
+
90
+ Familia.ld "[update_expiration] Expires #{dbkey} in #{default_expiration} seconds"
91
+
92
+ # Redis' EXPIRE command returns 1 if the timeout was set, 0 if key does
93
+ # not exist or the timeout could not be set. Via redis-rb here, it's
94
+ # a bool.
95
+ expire(default_expiration)
96
+ end
97
+
98
+ Familia::Base.add_feature self, :expiration
37
99
  end
100
+ end
101
+ end
38
102
 
39
- # Sets an expiration time for the Database data associated with this object.
40
- #
41
- # This method allows setting a Time To Live (TTL) for the data in Redis,
42
- # after which it will be automatically removed.
43
- #
44
- # @param default_expiration [Integer, nil] The Time To Live in seconds. If nil, the default
45
- # TTL will be used.
103
+ module Familia
104
+ # Add a default update_expiration method for all classes that include
105
+ # Familia::Base. Since expiration is a core feature, we can confidently
106
+ # call `horreum_instance.update_expiration` without defensive programming
107
+ # even when expiration is not enabled for the horreum_instance class.
108
+ module Base
109
+ # Base implementation of update_expiration that maintains API compatibility
110
+ # with the :expiration feature's implementation.
46
111
  #
47
- # @return [Boolean] Returns true if the expiration was set successfully,
48
- # false otherwise.
112
+ # This is a no-op implementation that gets overridden by features like
113
+ # :expiration. It accepts an optional default_expiration parameter to maintain interface
114
+ # compatibility with the overriding implementations.
49
115
  #
50
- # @example Setting an expiration of one day
51
- # object.update_expiration(default_expiration: 86400)
116
+ # @param default_expiration [Integer, nil] Time To Live in seconds
117
+ # @return [nil] Always returns nil
52
118
  #
53
- # @note If Default expiration is set to zero, the expiration will be removed, making the
54
- # data persist indefinitely.
55
- #
56
- # @raise [Familia::Problem] Raises an error if the default expiration is not a non-negative
57
- # integer.
119
+ # @note This is a no-op implementation. Classes that need expiration
120
+ # functionality should include the :expiration feature.
58
121
  #
59
122
  def update_expiration(default_expiration: nil)
60
- default_expiration ||= self.default_expiration
61
-
62
- if self.class.has_relations?
63
- Familia.ld "[update_expiration] #{self.class} has relations: #{self.class.related_fields.keys}"
64
- self.class.related_fields.each do |name, definition|
65
- next if definition.opts[:default_expiration].nil?
66
- obj = send(name)
67
- Familia.ld "[update_expiration] Updating expiration for #{name} (#{obj.dbkey}) to #{default_expiration}"
68
- obj.update_expiration(default_expiration: default_expiration)
69
- end
70
- end
71
-
72
- # It's important to raise exceptions here and not just log warnings. We
73
- # don't want to silently fail at setting expirations and cause data
74
- # retention issues (e.g. not removed in a timely fashion).
75
- #
76
- # For the same reason, we don't want to default to 0 bc there's not a
77
- # good reason for the default_expiration to not be set in the first place. If the
78
- # class doesn't have a default_expiration, the default comes from Familia.default_expiration (which
79
- # is 0).
80
- unless default_expiration.is_a?(Numeric)
81
- raise Familia::Problem, "Default expiration must be a number (#{default_expiration.class} in #{self.class})"
82
- end
83
-
84
- if default_expiration.zero?
85
- return Familia.ld "[update_expiration] No expiration for #{self.class} (#{dbkey})"
86
- end
87
-
88
- Familia.ld "[update_expiration] Expires #{dbkey} in #{default_expiration} seconds"
89
-
90
- # Redis' EXPIRE command returns 1 if the timeout was set, 0 if key does
91
- # not exist or the timeout could not be set. Via redis-rb here, it's
92
- # a bool.
93
- expire(default_expiration)
123
+ Familia.ld <<~LOG
124
+ [update_expiration] Feature not enabled for #{self.class}.
125
+ Key: #{dbkey} Arg: #{default_expiration} (caller: #{caller(1..1)})
126
+ LOG
127
+ nil
94
128
  end
95
- extend ClassMethods
96
-
97
- Familia::Base.add_feature self, :expiration
98
129
  end
99
-
100
130
  end
@@ -1,8 +1,11 @@
1
1
  # lib/familia/features/quantization.rb
2
2
 
3
3
  module Familia::Features
4
-
5
4
  module Quantization
5
+ def self.included(base)
6
+ Familia.trace :included, base, self, caller(1..1) if Familia.debug?
7
+ base.extend ClassMethods
8
+ end
6
9
 
7
10
  module ClassMethods
8
11
  # Generates a quantized timestamp based on the given parameters.
@@ -25,9 +28,7 @@ module Familia::Features
25
28
  #
26
29
  def qstamp(quantum = nil, pattern: nil, time: nil)
27
30
  # Handle default values and array input
28
- if quantum.is_a?(Array)
29
- quantum, pattern = quantum
30
- end
31
+ quantum, pattern = quantum if quantum.is_a?(Array)
31
32
 
32
33
  # Previously we erronously included `@opts.fetch(:quantize, nil)` in
33
34
  # the list of default values here, but @opts is for horreum instances
@@ -46,11 +47,6 @@ module Familia::Features
46
47
  end
47
48
  end
48
49
 
49
- def self.included base
50
- Familia.ld "[#{base}] Loaded #{self}"
51
- base.extend ClassMethods
52
- end
53
-
54
50
  def qstamp(quantum = nil, pattern: nil, time: nil)
55
51
  self.class.qstamp(quantum || self.class.default_expiration, pattern: pattern, time: time)
56
52
  end
@@ -9,9 +9,6 @@ module V2
9
9
  # Provides the standard core object fields and methods.
10
10
  #
11
11
  module RelatableObject
12
- klass = self
13
- err_klass = V2::Features::RelatableObjectError
14
-
15
12
  def self.included(base)
16
13
  base.class_sorted_set :relatable_objids
17
14
  base.class_hashkey :owners
@@ -82,7 +79,8 @@ module V2
82
79
 
83
80
  def owned?
84
81
  # We can only have an owner if we are relatable ourselves.
85
- return false unless self.is_a?(RelatableObject)
82
+ return false unless is_a?(RelatableObject)
83
+
86
84
  # If our object identifier is present, we have an owner
87
85
  self.class.owners.key?(objid)
88
86
  end
@@ -1,6 +1,5 @@
1
1
  # lib/familia/features/safe_dump.rb
2
2
 
3
-
4
3
  module Familia::Features
5
4
  # SafeDump is a mixin that allows models to define a list of fields that are
6
5
  # safe to dump. This is useful for serializing objects to JSON or other
@@ -54,6 +53,20 @@ module Familia::Features
54
53
  @safe_dump_fields = []
55
54
  @safe_dump_field_map = {}
56
55
 
56
+ def self.included(base)
57
+ Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
58
+ base.extend ClassMethods
59
+
60
+ # Optionally define safe_dump_fields in the class to make
61
+ # sure we always have an array to work with.
62
+ base.instance_variable_set(:@safe_dump_fields, []) unless base.instance_variable_defined?(:@safe_dump_fields)
63
+
64
+ # Ditto for the field map
65
+ return if base.instance_variable_defined?(:@safe_dump_field_map)
66
+
67
+ base.instance_variable_set(:@safe_dump_field_map, {})
68
+ end
69
+
57
70
  module ClassMethods
58
71
  def set_safe_dump_fields(*fields)
59
72
  @safe_dump_fields = fields
@@ -100,22 +113,6 @@ module Familia::Features
100
113
  end
101
114
  end
102
115
 
103
- def self.included base
104
- Familia.ld "[#{self}] Enabled in #{base}"
105
- base.extend ClassMethods
106
-
107
- # Optionally define safe_dump_fields in the class to make
108
- # sure we always have an array to work with.
109
- unless base.instance_variable_defined?(:@safe_dump_fields)
110
- base.instance_variable_set(:@safe_dump_fields, [])
111
- end
112
-
113
- # Ditto for the field map
114
- unless base.instance_variable_defined?(:@safe_dump_field_map)
115
- base.instance_variable_set(:@safe_dump_field_map, {})
116
- end
117
- end
118
-
119
116
  # Returns a hash of safe fields and their values. This method
120
117
  # calls the callables defined in the safe_dump_field_map with
121
118
  # the instance object as an argument.
@@ -0,0 +1,159 @@
1
+ # lib/familia/features/transient_fields/redacted_string.rb
2
+
3
+ # RedactedString
4
+ #
5
+ # A secure wrapper for sensitive string values (e.g., API keys, passwords,
6
+ # encryption keys).
7
+ # Designed to:
8
+ # - Prevent accidental logging/inspection
9
+ # - Enable secure memory wiping
10
+ # - Encourage safe usage patterns
11
+ #
12
+ # ⚠️ IMPORTANT: This is *best-effort* protection. Ruby does not guarantee
13
+ # memory zeroing. GC, string sharing, and internal optimizations
14
+ # may leave copies in memory.
15
+ #
16
+ # ⚠️ INPUT SECURITY: The constructor calls .dup on the input, creating a copy,
17
+ # but the original input value remains in memory uncontrolled.
18
+ # The caller is responsible for securely clearing the original.
19
+ #
20
+ # Security Model:
21
+ # - The secret is *contained* from the moment it's wrapped.
22
+ # - Access is available via `.expose { }` for controlled use, or `.value` for direct access.
23
+ # - Manual `.clear!` is required when done with the value (unlike SingleUseRedactedString).
24
+ # - `.to_s` and `.inspect` return '[REDACTED]' to prevent leaks in logs,
25
+ # errors, or debugging.
26
+ #
27
+ # Critical Gotchas:
28
+ #
29
+ # 1. Ruby 3.4+ String Internals — Memory Safety Reality
30
+ # - Ruby uses "compact strings" and copy-on-write semantics.
31
+ # - Short strings (< 24 bytes on 64-bit) are *embedded* in the object
32
+ # (RSTRING_EMBED_LEN).
33
+ # - Long strings use heap-allocated buffers, but may be shared or
34
+ # duplicated silently.
35
+ # - There is *no guarantee* that GC will not copy the string before
36
+ # finalization.
37
+ #
38
+ # 2. Every .dup, .to_s, +, interpolation, or method call may create hidden
39
+ # copies:
40
+ # s = "secret"
41
+ # t = s.dup # New object, same content — now two copies
42
+ # u = s + "123" # New string — third copy
43
+ # "#{t}" # Interpolation — fourth copy
44
+ # These copies are *not* controlled by RedactedString and may persist.
45
+ #
46
+ # 3. String Freezing & Immutability
47
+ # - `.freeze` prevents mutation but does *not* prevent copying.
48
+ # - `.replace` on a frozen string raises FrozenError — so wiping fails.
49
+ #
50
+ # 4. RbNaCl::Util.zero Limitations
51
+ # - Only works on mutable byte buffers.
52
+ # - May not zero embedded strings if Ruby's internal representation is
53
+ # immutable.
54
+ # - Does *not* protect against memory dumps or GC-compacted heaps.
55
+ #
56
+ # 5. Finalizers Are Not Guaranteed
57
+ # - Ruby does not promise when (or if) `ObjectSpace.define_finalizer`
58
+ # runs.
59
+ # - Never rely on finalizers for security-critical wiping.
60
+ #
61
+ # Best Practices:
62
+ # - Wrap secrets *immediately* on input (e.g., from ENV, params, DB).
63
+ # - Clear original input after wrapping: `secret.clear!` or `secret = nil`
64
+ # - Use `.expose { }` for short-lived operations — never store plaintext.
65
+ # - Avoid passing RedactedString to logging, serialization, or debugging
66
+ # tools.
67
+ # - Prefer `.expose { }` over any "getter" method.
68
+ # - Do *not* subclass String — it leaks the underlying value in regex,
69
+ # case, etc.
70
+ #
71
+ # Example:
72
+ # password_input = params[:password] # Original value in memory
73
+ # password = RedactedString.new(password_input)
74
+ # password_input.clear! if password_input.respond_to?(:clear!)
75
+ # # or: params[:password] = nil # Clear reference (not guaranteed)
76
+ #
77
+ class RedactedString
78
+ # Wrap a sensitive value. The input is *not* wiped — ensure it's not reused.
79
+ def initialize(original_value)
80
+ # WARNING: .dup only creates a shallow copy; the original may still exist
81
+ # elsewhere in memory.
82
+ @value = original_value.to_s.dup
83
+ @cleared = false
84
+ # Do NOT freeze — we need to mutate it in `#clear!`
85
+ ObjectSpace.define_finalizer(self, self.class.finalizer_proc)
86
+ end
87
+
88
+ # Primary API: expose the value in a block.
89
+ # The value remains accessible for multiple reads until manually cleared.
90
+ # Call clear! explicitly when done with the value.
91
+ #
92
+ # ⚠️ Security Warning: Avoid .dup, string interpolation, or other operations
93
+ # that create uncontrolled copies of the sensitive value.
94
+ #
95
+ # Example:
96
+ # token.expose do |plain|
97
+ # # Good: use directly without copying
98
+ # HTTP.post('/api', headers: { 'X-Token' => plain })
99
+ # # Avoid: plain.dup, "prefix#{plain}", plain[0..-1], etc.
100
+ # end
101
+ # # Value is still accessible after block
102
+ # token.clear! # Explicitly clear when done
103
+ #
104
+ def expose
105
+ raise ArgumentError, 'Block required' unless block_given?
106
+ raise SecurityError, 'Value already cleared' if cleared?
107
+
108
+ yield @value
109
+ end
110
+
111
+ # Clear the internal buffer. Safe to call multiple times.
112
+ #
113
+ # REALITY CHECK: This doesn't actually provide security in Ruby.
114
+ # - Ruby may have already copied the string elsewhere in memory
115
+ # - Garbage collection behavior is unpredictable
116
+ # - The original input value is still in memory somewhere
117
+ # - This is primarily for API consistency and preventing reuse
118
+ def clear!
119
+ return if @value.nil? || @value.frozen? || @cleared
120
+
121
+ # Simple clear - no security theater
122
+ @value.clear if @value.respond_to?(:clear)
123
+ @value = nil
124
+ @cleared = true
125
+ freeze # one and done
126
+ end
127
+
128
+ # Get the actual value (for convenience in less sensitive contexts)
129
+ # Returns the wrapped value or nil if cleared
130
+ #
131
+ # ⚠️ Security Warning: Direct access bypasses the controlled exposure pattern.
132
+ # Prefer .expose { } for better security practices.
133
+ def value
134
+ raise SecurityError, 'Value already cleared' if cleared?
135
+
136
+ @value
137
+ end
138
+
139
+ # Always redact in logs, debugging, or string conversion
140
+ def to_s = '[REDACTED]'
141
+ def inspect = to_s
142
+ def cleared? = @cleared
143
+
144
+ # Returns true when it's literally the same object, otherwise false.
145
+ # This prevents timing attacks where an attacker could potentially
146
+ # infer information about the secret value through comparison timing
147
+ def ==(other)
148
+ object_id.equal?(other.object_id) # same object
149
+ end
150
+ alias eql? ==
151
+
152
+ # All RedactedString instances have the same hash to prevent
153
+ # hash-based timing attacks or information leakage
154
+ def hash
155
+ RedactedString.hash
156
+ end
157
+
158
+ def self.finalizer_proc = proc { |id| }
159
+ end
@@ -0,0 +1,62 @@
1
+ # lib/familia/features/transient_fields/single_use_redacted_string.rb
2
+
3
+ require_relative 'redacted_string'
4
+
5
+ # SingleUseRedactedString
6
+ #
7
+ # A high-security variant of RedactedString that automatically clears
8
+ # its value after a single use via the expose method. Unlike RedactedString,
9
+ # this provides automatic cleanup and enforces single-use semantics.
10
+ #
11
+ # ⚠️ IMPORTANT: Inherits all security limitations from RedactedString regarding
12
+ # Ruby's memory management and copy-on-write semantics.
13
+ #
14
+ # ⚠️ INPUT SECURITY: Like RedactedString, the constructor calls .dup on input,
15
+ # creating a copy, but the original input remains in memory.
16
+ # The caller is responsible for securely clearing the original.
17
+ #
18
+ # Key Differences from RedactedString:
19
+ # - Automatically clears after expose() (no manual clear! needed)
20
+ # - Blocks direct value() access (prevents accidental multi-use)
21
+ # - Raises SecurityError on second expose() attempt
22
+ #
23
+ # Use this for extremely sensitive values that should only be accessed
24
+ # once, such as:
25
+ # - One-time passwords (OTPs)
26
+ # - Temporary authentication tokens
27
+ # - Encryption keys that should be immediately discarded
28
+ #
29
+ # Example:
30
+ # otp_input = params[:otp] # Original value in memory
31
+ # otp = SingleUseRedactedString.new(otp_input)
32
+ # params[:otp] = nil # Clear reference (not guaranteed)
33
+ # otp.expose do |code|
34
+ # verify_otp(code) # Use directly without copying
35
+ # end
36
+ # # Value is automatically cleared after block
37
+ # otp.cleared? #=> true
38
+ #
39
+ class SingleUseRedactedString < RedactedString
40
+ # Override expose to automatically clear after use
41
+ #
42
+ # This ensures the value can only be accessed once via expose,
43
+ # providing maximum security for single-use secrets.
44
+ #
45
+ def expose
46
+ raise ArgumentError, 'Block required' unless block_given?
47
+ raise SecurityError, 'Value already cleared' if cleared?
48
+
49
+ yield @value
50
+ ensure
51
+ clear! # Automatically clear after single use
52
+ end
53
+
54
+ # Override value accessor to prevent direct access
55
+ #
56
+ # For single-use secrets, we don't want to allow direct value access
57
+ # to maintain the single-use guarantee.
58
+ #
59
+ def value
60
+ raise SecurityError, 'Direct value access not allowed for single-use secrets. Use #expose with a block.'
61
+ end
62
+ end
@@ -0,0 +1,139 @@
1
+ # lib/familia/features/transient_fields/transient_field_type.rb
2
+
3
+ require 'familia/field_type'
4
+
5
+ require_relative 'redacted_string'
6
+
7
+ module Familia
8
+ # TransientFieldType - Fields that are not persisted to database
9
+ #
10
+ # Transient fields automatically wrap values in RedactedString for security
11
+ # and are excluded from serialization operations. They are ideal for storing
12
+ # sensitive data like API keys, passwords, and tokens that should not be
13
+ # persisted to the database.
14
+ #
15
+ # @example Using transient fields
16
+ # class SecretService < Familia::Horreum
17
+ # field :name # Regular field
18
+ # transient_field :api_key # Wrapped in RedactedString
19
+ # transient_field :password # Not persisted to database
20
+ # end
21
+ #
22
+ # service = SecretService.new
23
+ # service.api_key = "sk-1234567890"
24
+ # service.api_key.class #=> RedactedString
25
+ # service.to_h #=> {:name => nil} (no api_key)
26
+ #
27
+ class TransientFieldType < FieldType
28
+ # Override setter to wrap values in RedactedString
29
+ #
30
+ # Values are automatically wrapped in RedactedString objects for security.
31
+ # Nil values and existing RedactedString objects are handled appropriately.
32
+ #
33
+ # @param klass [Class] The class to define the method on
34
+ #
35
+ def define_setter(klass)
36
+ field_name = @name
37
+ method_name = @method_name
38
+
39
+ handle_method_conflict(klass, :"#{method_name}=") do
40
+ klass.define_method :"#{method_name}=" do |value|
41
+ wrapped = if value.nil?
42
+ nil
43
+ elsif value.is_a?(RedactedString)
44
+ value
45
+ else
46
+ RedactedString.new(value)
47
+ end
48
+ instance_variable_set(:"@#{field_name}", wrapped)
49
+ end
50
+ end
51
+ end
52
+
53
+ # Override getter to unwrap RedactedString values
54
+ #
55
+ # Returns the actual value from the RedactedString wrapper for
56
+ # convenient access, or nil if the value is nil or cleared.
57
+ #
58
+ # @param klass [Class] The class to define the method on
59
+ #
60
+ def define_getter(klass)
61
+ field_name = @name
62
+ method_name = @method_name
63
+
64
+ handle_method_conflict(klass, method_name) do
65
+ klass.define_method method_name do
66
+ wrapped = instance_variable_get(:"@#{field_name}")
67
+ return nil if wrapped.nil? || wrapped.cleared?
68
+
69
+ wrapped
70
+ end
71
+ end
72
+ end
73
+
74
+ # Override fast writer to disable it for transient fields
75
+ #
76
+ # Transient fields should not have fast writers since they're not
77
+ # persisted to the database.
78
+ #
79
+ # @param klass [Class] The class to define the method on
80
+ #
81
+ def define_fast_writer(_klass)
82
+ # No fast writer for transient fields since they're not persisted
83
+ Familia.ld "[TransientFieldType] Skipping fast writer for transient field: #{@name}"
84
+ nil
85
+ end
86
+
87
+ # Transient fields are not persisted to database
88
+ #
89
+ # @return [Boolean] false - transient fields are never persisted
90
+ #
91
+ def persistent?
92
+ false
93
+ end
94
+
95
+ # A convenience method that wraps `persistent?`
96
+ #
97
+ def transient?
98
+ !persistent?
99
+ end
100
+
101
+ # Category for transient fields
102
+ #
103
+ # @return [Symbol] :transient
104
+ #
105
+ def category
106
+ :transient
107
+ end
108
+
109
+ # Transient fields are not serialized to database
110
+ #
111
+ # This method should not be called since transient fields are not
112
+ # persisted, but we provide it for completeness.
113
+ #
114
+ # @param value [Object] The value to serialize
115
+ # @param record [Object] The record instance
116
+ # @return [nil] Always nil since transient fields are not serialized
117
+ #
118
+ def serialize(_value, _record = nil)
119
+ # Transient fields should never be serialized
120
+ Familia.ld "[TransientFieldType] WARNING: serialize called on transient field #{@name}"
121
+ nil
122
+ end
123
+
124
+ # Transient fields are not deserialized from database
125
+ #
126
+ # This method should not be called since transient fields are not
127
+ # persisted, but we provide it for completeness.
128
+ #
129
+ # @param value [Object] The value to deserialize
130
+ # @param record [Object] The record instance
131
+ # @return [nil] Always nil since transient fields are not stored
132
+ #
133
+ def deserialize(_value, _record = nil)
134
+ # Transient fields should never be deserialized
135
+ Familia.ld "[TransientFieldType] WARNING: deserialize called on transient field #{@name}"
136
+ nil
137
+ end
138
+ end
139
+ end