familia 2.0.0.pre10 → 2.0.0.pre12
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +75 -12
- data/CLAUDE.md +4 -54
- data/Gemfile.lock +1 -1
- data/changelog.d/README.md +45 -34
- data/docs/archive/FAMILIA_RELATIONSHIPS.md +1 -1
- data/docs/archive/FAMILIA_UPDATE.md +1 -1
- data/docs/archive/README.md +15 -19
- data/docs/guides/Home.md +1 -1
- data/docs/guides/Implementation-Guide.md +1 -1
- data/docs/guides/relationships-methods.md +1 -1
- data/docs/migrating/.gitignore +2 -0
- data/docs/migrating/v2.0.0-pre.md +84 -0
- data/docs/migrating/v2.0.0-pre11.md +255 -0
- data/docs/migrating/v2.0.0-pre12.md +306 -0
- data/docs/migrating/v2.0.0-pre5.md +110 -0
- data/docs/migrating/v2.0.0-pre6.md +154 -0
- data/docs/migrating/v2.0.0-pre7.md +222 -0
- data/docs/overview.md +6 -7
- data/{examples/redis_command_validation_example.rb → docs/reference/auditing_database_commands.rb} +29 -32
- data/examples/{bit_encoding_integration.rb → permissions.rb} +30 -27
- data/examples/{relationships_basic.rb → relationships.rb} +2 -3
- data/examples/safe_dump.rb +281 -0
- data/familia.gemspec +4 -4
- data/lib/familia/base.rb +52 -0
- data/lib/familia/{encryption_request_cache.rb → encryption/request_cache.rb} +1 -1
- data/lib/familia/errors.rb +2 -0
- data/lib/familia/features/autoloader.rb +57 -0
- data/lib/familia/features/external_identifier.rb +310 -0
- data/lib/familia/features/object_identifier.rb +307 -0
- data/lib/familia/features/safe_dump.rb +66 -72
- data/lib/familia/features.rb +93 -5
- data/lib/familia/horreum/subclass/definition.rb +47 -3
- data/lib/familia/secure_identifier.rb +51 -75
- data/lib/familia/verifiable_identifier.rb +162 -0
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +1 -0
- data/setup.cfg +1 -8
- data/try/core/secure_identifier_try.rb +47 -18
- data/try/core/verifiable_identifier_try.rb +171 -0
- data/try/features/{external_identifiers/external_identifiers_try.rb → external_identifier/external_identifier_try.rb} +25 -28
- data/try/features/feature_improvements_try.rb +126 -0
- data/try/features/{object_identifiers/object_identifiers_integration_try.rb → object_identifier/object_identifier_integration_try.rb} +28 -30
- data/try/features/{object_identifiers/object_identifiers_try.rb → object_identifier/object_identifier_try.rb} +13 -13
- data/try/features/real_feature_integration_try.rb +7 -6
- data/try/features/safe_dump/safe_dump_try.rb +8 -9
- data/try/helpers/test_helpers.rb +17 -17
- metadata +30 -22
- data/changelog.d/fragments/.keep +0 -0
- data/changelog.d/template.md.j2 +0 -29
- data/lib/familia/features/external_identifiers/external_identifier_field_type.rb +0 -120
- data/lib/familia/features/external_identifiers.rb +0 -111
- data/lib/familia/features/object_identifiers/object_identifier_field_type.rb +0 -91
- data/lib/familia/features/object_identifiers.rb +0 -194
@@ -1,12 +1,18 @@
|
|
1
1
|
# lib/familia/features/safe_dump.rb
|
2
2
|
|
3
|
+
# rubocop:disable ThreadSafety/ClassInstanceVariable
|
4
|
+
#
|
5
|
+
# Class instance variables are used here for feature configuration
|
6
|
+
# (e.g., @dump_method, @load_method). These are set once and not mutated
|
7
|
+
# at runtime, so thread safety is not a concern for this feature.
|
8
|
+
#
|
3
9
|
module Familia::Features
|
4
10
|
# SafeDump is a mixin that allows models to define a list of fields that are
|
5
11
|
# safe to dump. This is useful for serializing objects to JSON or other
|
6
12
|
# formats where you want to ensure that only certain fields are exposed.
|
7
13
|
#
|
8
|
-
# To use SafeDump, include it in your model and
|
9
|
-
#
|
14
|
+
# To use SafeDump, include it in your model and use the DSL methods to define
|
15
|
+
# safe dump fields. The fields can be either symbols or hashes. If a field is
|
10
16
|
# a symbol, the method with the same name will be called on the object to
|
11
17
|
# retrieve the value. If the field is a hash, the key is the field name and
|
12
18
|
# the value is a lambda that will be called with the object as an argument.
|
@@ -19,97 +25,85 @@ module Familia::Features
|
|
19
25
|
#
|
20
26
|
# feature :safe_dump
|
21
27
|
#
|
22
|
-
#
|
23
|
-
#
|
24
|
-
#
|
25
|
-
#
|
26
|
-
# { :active => ->(obj) { obj.active? } }
|
27
|
-
# ]
|
28
|
+
# safe_dump_field :objid
|
29
|
+
# safe_dump_field :updated
|
30
|
+
# safe_dump_field :created
|
31
|
+
# safe_dump_field :active, ->(obj) { obj.active? }
|
28
32
|
#
|
29
|
-
#
|
30
|
-
# @safe_dump_field_map. `SafeDump.safe_dump_fields` returns only the list
|
31
|
-
# of symbols in the order they were defined. From the example above, it would
|
32
|
-
# return `[:objid, :updated, :created, :active]`.
|
33
|
-
#
|
34
|
-
# Standalone Usage:
|
35
|
-
#
|
36
|
-
# You can also use SafeDump by including it in your model and defining the
|
37
|
-
# safe dump fields using the class instance variable `@safe_dump_fields`.
|
38
|
-
#
|
39
|
-
# Example:
|
33
|
+
# Alternatively, you can define multiple fields at once:
|
40
34
|
#
|
41
|
-
#
|
42
|
-
#
|
35
|
+
# safe_dump_fields :objid, :updated, :created,
|
36
|
+
# { active: ->(obj) { obj.active? } }
|
43
37
|
#
|
44
|
-
#
|
45
|
-
#
|
46
|
-
#
|
47
|
-
# end
|
38
|
+
# Internally, all fields are normalized to the hash syntax and stored in
|
39
|
+
# @safe_dump_field_map. `SafeDump.safe_dump_fields` returns only the list
|
40
|
+
# of symbols in the order they were defined.
|
48
41
|
#
|
49
42
|
module SafeDump
|
50
43
|
@dump_method = :to_json
|
51
44
|
@load_method = :from_json
|
52
45
|
|
53
|
-
@safe_dump_fields = []
|
54
|
-
@safe_dump_field_map = {}
|
55
|
-
|
56
46
|
def self.included(base)
|
57
|
-
Familia.trace
|
47
|
+
Familia.trace(:LOADED, self, base, caller(1..1)) if Familia.debug?
|
58
48
|
base.extend ClassMethods
|
59
49
|
|
60
|
-
#
|
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
|
-
|
50
|
+
# Initialize the safe dump field map
|
67
51
|
base.instance_variable_set(:@safe_dump_field_map, {})
|
68
52
|
end
|
69
53
|
|
54
|
+
# SafeDump::ClassMethods
|
55
|
+
#
|
56
|
+
# These methods become available on the model class
|
70
57
|
module ClassMethods
|
71
|
-
|
72
|
-
|
58
|
+
# Define a single safe dump field
|
59
|
+
# @param field_name [Symbol] The name of the field
|
60
|
+
# @param callable [Proc, nil] Optional callable to transform the value
|
61
|
+
def safe_dump_field(field_name, callable = nil)
|
62
|
+
@safe_dump_field_map ||= {}
|
63
|
+
|
64
|
+
field_name = field_name.to_sym
|
65
|
+
field_value = callable || lambda { |obj|
|
66
|
+
if obj.respond_to?(:[]) && obj[field_name]
|
67
|
+
obj[field_name] # Familia::DataType classes
|
68
|
+
elsif obj.respond_to?(field_name)
|
69
|
+
obj.send(field_name) # Regular method calls
|
70
|
+
end
|
71
|
+
}
|
72
|
+
|
73
|
+
@safe_dump_field_map[field_name] = field_value
|
73
74
|
end
|
74
75
|
|
75
|
-
#
|
76
|
-
#
|
77
|
-
def safe_dump_fields
|
78
|
-
|
79
|
-
|
76
|
+
# Define multiple safe dump fields at once
|
77
|
+
# @param fields [Array] Mixed array of symbols and hashes
|
78
|
+
def safe_dump_fields(*fields)
|
79
|
+
# If no arguments, return field names (getter behavior)
|
80
|
+
return safe_dump_field_names if fields.empty?
|
81
|
+
|
82
|
+
# Otherwise, define fields (setter behavior)
|
83
|
+
fields.each do |field|
|
84
|
+
if field.is_a?(Symbol)
|
85
|
+
safe_dump_field(field)
|
86
|
+
elsif field.is_a?(Hash)
|
87
|
+
field.each do |name, callable|
|
88
|
+
safe_dump_field(name, callable)
|
89
|
+
end
|
90
|
+
end
|
80
91
|
end
|
81
92
|
end
|
82
93
|
|
83
|
-
#
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
#
|
89
|
-
#
|
94
|
+
# Returns an array of safe dump field names in the order they were defined
|
95
|
+
def safe_dump_field_names
|
96
|
+
(@safe_dump_field_map || {}).keys
|
97
|
+
end
|
98
|
+
|
99
|
+
# Returns the field map used for dumping
|
90
100
|
def safe_dump_field_map
|
91
|
-
|
101
|
+
@safe_dump_field_map || {}
|
102
|
+
end
|
92
103
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
# method returns only the symbols).
|
97
|
-
@safe_dump_field_map = @safe_dump_fields.each_with_object({}) do |el, map|
|
98
|
-
if el.is_a?(Symbol)
|
99
|
-
field_name = el
|
100
|
-
callable = lambda { |obj|
|
101
|
-
if obj.respond_to?(:[]) && obj[field_name]
|
102
|
-
obj[field_name] # Familia::DataType classes
|
103
|
-
elsif obj.respond_to?(field_name)
|
104
|
-
obj.send(field_name) # Onetime::Models::RedisHash classes via method_missing 😩
|
105
|
-
end
|
106
|
-
}
|
107
|
-
else
|
108
|
-
field_name = el.keys.first
|
109
|
-
callable = el.values.first
|
110
|
-
end
|
111
|
-
map[field_name] = callable
|
112
|
-
end
|
104
|
+
# Legacy method for setting safe dump fields (for backward compatibility)
|
105
|
+
def set_safe_dump_fields(*fields)
|
106
|
+
safe_dump_fields(*fields)
|
113
107
|
end
|
114
108
|
end
|
115
109
|
|
@@ -155,5 +149,5 @@ module Familia::Features
|
|
155
149
|
|
156
150
|
Familia::Base.add_feature self, :safe_dump
|
157
151
|
end
|
158
|
-
# end SafeDump
|
159
152
|
end
|
153
|
+
# rubocop:enable ThreadSafety/ClassInstanceVariable
|
data/lib/familia/features.rb
CHANGED
@@ -5,18 +5,108 @@ module Familia
|
|
5
5
|
|
6
6
|
# Familia::Features
|
7
7
|
#
|
8
|
+
# This module provides the feature system for Familia classes. Features are
|
9
|
+
# modular capabilities that can be mixed into classes with configurable options.
|
10
|
+
# Features provide a powerful way to:
|
11
|
+
#
|
12
|
+
# - **Add new methods**: Both class and instance methods can be added
|
13
|
+
# - **Override existing methods**: Extend or replace default behavior
|
14
|
+
# - **Add new fields**: Define additional data storage capabilities
|
15
|
+
# - **Manage complexity**: Large, complex model classes can use features to
|
16
|
+
# organize functionality into focused, reusable modules
|
17
|
+
#
|
18
|
+
# ## Feature Options Storage
|
19
|
+
#
|
20
|
+
# Feature options are stored **per-class** using class-level instance variables.
|
21
|
+
# This means each Familia::Horreum subclass maintains its own isolated set of
|
22
|
+
# feature options. When you enable a feature with options in different models,
|
23
|
+
# each model stores its own separate configuration without interference.
|
24
|
+
#
|
25
|
+
# ## Project Organization with Autoloader
|
26
|
+
#
|
27
|
+
# For large projects, use {Familia::Features::Autoloader} to automatically load
|
28
|
+
# project-specific features from a dedicated directory structure. This helps
|
29
|
+
# organize complex models by separating features into individual files.
|
30
|
+
#
|
31
|
+
# @example Different models with different feature options
|
32
|
+
# class UserModel < Familia::Horreum
|
33
|
+
# feature :object_identifier, generator: :uuid_v4
|
34
|
+
# end
|
35
|
+
#
|
36
|
+
# class SessionModel < Familia::Horreum
|
37
|
+
# feature :object_identifier, generator: :hex
|
38
|
+
# end
|
39
|
+
#
|
40
|
+
# UserModel.feature_options(:object_identifier) #=> {generator: :uuid_v4}
|
41
|
+
# SessionModel.feature_options(:object_identifier) #=> {generator: :hex}
|
42
|
+
#
|
43
|
+
# @example Using features for complexity management
|
44
|
+
# class ComplexModel < Familia::Horreum
|
45
|
+
# # Organize functionality using features
|
46
|
+
# feature :expiration # TTL management
|
47
|
+
# feature :safe_dump # API-safe serialization
|
48
|
+
# feature :relationships # CRUD operations for related objects
|
49
|
+
# feature :custom_validation # Project-specific validation logic
|
50
|
+
# feature :audit_trail # Change tracking
|
51
|
+
# end
|
52
|
+
#
|
53
|
+
# @example Project-specific features with autoloader
|
54
|
+
# # In your model file: app/models/customer.rb
|
55
|
+
# class Customer < Familia::Horreum
|
56
|
+
# module Features
|
57
|
+
# include Familia::Features::Autoloader
|
58
|
+
# # Automatically loads all .rb files from app/models/customer/features/
|
59
|
+
# end
|
60
|
+
# end
|
61
|
+
#
|
62
|
+
# @see Familia::Features::Autoloader For automatic feature loading
|
63
|
+
#
|
8
64
|
module Features
|
9
65
|
@features_enabled = nil
|
10
66
|
attr_reader :features_enabled
|
11
67
|
|
68
|
+
# Enables a feature for the current class with optional configuration.
|
69
|
+
#
|
70
|
+
# Features are modular capabilities that can be mixed into Familia::Horreum
|
71
|
+
# classes. Each feature can be configured with options that are stored
|
72
|
+
# **per-class**, ensuring complete isolation between different models.
|
73
|
+
#
|
74
|
+
# @param feature_name [Symbol, String, nil] the name of the feature to enable.
|
75
|
+
# If nil, returns the list of currently enabled features.
|
76
|
+
# @param options [Hash] configuration options for the feature. These are
|
77
|
+
# stored per-class and do not interfere with other models' configurations.
|
78
|
+
# @return [Array, nil] the list of enabled features if feature_name is nil,
|
79
|
+
# otherwise nil
|
80
|
+
#
|
81
|
+
# @example Enable feature without options
|
82
|
+
# class User < Familia::Horreum
|
83
|
+
# feature :expiration
|
84
|
+
# end
|
85
|
+
#
|
86
|
+
# @example Enable feature with options (per-class storage)
|
87
|
+
# class User < Familia::Horreum
|
88
|
+
# feature :object_identifier, generator: :uuid_v4
|
89
|
+
# end
|
90
|
+
#
|
91
|
+
# class Session < Familia::Horreum
|
92
|
+
# feature :object_identifier, generator: :hex # Different options
|
93
|
+
# end
|
94
|
+
#
|
95
|
+
# # Each class maintains separate options:
|
96
|
+
# User.feature_options(:object_identifier) #=> {generator: :uuid_v4}
|
97
|
+
# Session.feature_options(:object_identifier) #=> {generator: :hex}
|
98
|
+
#
|
99
|
+
# @raise [Familia::Problem] if the feature is not supported
|
100
|
+
#
|
12
101
|
def feature(feature_name = nil, **options)
|
13
102
|
@features_enabled ||= []
|
14
103
|
|
15
104
|
return features_enabled if feature_name.nil?
|
16
105
|
|
17
|
-
# If there's a value
|
106
|
+
# If there's a value provided check that it's a valid feature
|
18
107
|
feature_name = feature_name.to_sym
|
19
|
-
|
108
|
+
feature_class = Familia::Base.find_feature(feature_name, self)
|
109
|
+
unless feature_class
|
20
110
|
raise Familia::Problem, "Unsupported feature: #{feature_name}"
|
21
111
|
end
|
22
112
|
|
@@ -46,10 +136,8 @@ module Familia
|
|
46
136
|
add_feature_options(feature_name, **options)
|
47
137
|
end
|
48
138
|
|
49
|
-
klass = Familia::Base.features_available[feature_name]
|
50
|
-
|
51
139
|
# Extend the Familia::Base subclass (e.g. Customer) with the feature module
|
52
|
-
include
|
140
|
+
include feature_class
|
53
141
|
|
54
142
|
# NOTE: Do we want to extend Familia::DataType here? That would make it
|
55
143
|
# possible to call safe_dump on relations fields (e.g. list, zset, hashkey).
|
@@ -237,10 +237,36 @@ module Familia
|
|
237
237
|
field_types[field_type.name] = field_type
|
238
238
|
end
|
239
239
|
|
240
|
-
#
|
240
|
+
# Retrieves feature options for the current class.
|
241
241
|
#
|
242
|
-
#
|
243
|
-
#
|
242
|
+
# Feature options are stored **per-class** in instance variables, ensuring
|
243
|
+
# complete isolation between different Familia::Horreum subclasses. Each
|
244
|
+
# class maintains its own @feature_options hash that does not interfere
|
245
|
+
# with other classes' configurations.
|
246
|
+
#
|
247
|
+
# @param feature_name [Symbol, String, nil] the name of the feature to get options for.
|
248
|
+
# If nil, returns the entire feature options hash for this class.
|
249
|
+
# @return [Hash] the feature options hash, either for a specific feature or all features
|
250
|
+
#
|
251
|
+
# @example Getting options for a specific feature
|
252
|
+
# class MyModel < Familia::Horreum
|
253
|
+
# feature :object_identifier, generator: :uuid_v4
|
254
|
+
# end
|
255
|
+
#
|
256
|
+
# MyModel.feature_options(:object_identifier) #=> {generator: :uuid_v4}
|
257
|
+
# MyModel.feature_options #=> {object_identifier: {generator: :uuid_v4}}
|
258
|
+
#
|
259
|
+
# @example Per-class isolation
|
260
|
+
# class UserModel < Familia::Horreum
|
261
|
+
# feature :object_identifier, generator: :uuid_v4
|
262
|
+
# end
|
263
|
+
#
|
264
|
+
# class SessionModel < Familia::Horreum
|
265
|
+
# feature :object_identifier, generator: :hex
|
266
|
+
# end
|
267
|
+
#
|
268
|
+
# UserModel.feature_options(:object_identifier) #=> {generator: :uuid_v4}
|
269
|
+
# SessionModel.feature_options(:object_identifier) #=> {generator: :hex}
|
244
270
|
#
|
245
271
|
def feature_options(feature_name = nil)
|
246
272
|
@feature_options ||= {}
|
@@ -255,10 +281,28 @@ module Familia
|
|
255
281
|
# without worrying about initialization state. Similar to register_field_type
|
256
282
|
# for field types.
|
257
283
|
#
|
284
|
+
# Feature options are stored at the **class level** using instance variables,
|
285
|
+
# ensuring complete isolation between different Familia::Horreum subclasses.
|
286
|
+
# Each class maintains its own @feature_options hash.
|
287
|
+
#
|
258
288
|
# @param feature_name [Symbol] The feature name
|
259
289
|
# @param options [Hash] The options to add/merge
|
260
290
|
# @return [Hash] The updated options for the feature
|
261
291
|
#
|
292
|
+
# @note This method only sets defaults for options that don't already exist,
|
293
|
+
# using the ||= operator to prevent overwrites.
|
294
|
+
#
|
295
|
+
# @example Per-class storage behavior
|
296
|
+
# class ModelA < Familia::Horreum
|
297
|
+
# # This stores options in ModelA's @feature_options
|
298
|
+
# add_feature_options(:my_feature, key: 'value_a')
|
299
|
+
# end
|
300
|
+
#
|
301
|
+
# class ModelB < Familia::Horreum
|
302
|
+
# # This stores options in ModelB's @feature_options (separate from ModelA)
|
303
|
+
# add_feature_options(:my_feature, key: 'value_b')
|
304
|
+
# end
|
305
|
+
#
|
262
306
|
def add_feature_options(feature_name, **options)
|
263
307
|
@feature_options ||= {}
|
264
308
|
@feature_options[feature_name.to_sym] ||= {}
|
@@ -2,13 +2,14 @@
|
|
2
2
|
|
3
3
|
require 'securerandom'
|
4
4
|
|
5
|
+
# Provides a suite of tools for generating and manipulating cryptographically
|
6
|
+
# secure identifiers in various formats and lengths.
|
5
7
|
module Familia
|
6
8
|
module SecureIdentifier
|
7
|
-
|
8
9
|
# Generates a 256-bit cryptographically secure hexadecimal identifier.
|
9
10
|
#
|
10
11
|
# @return [String] A 64-character hex string representing 256 bits of entropy.
|
11
|
-
# @security Provides ~10^77 possible values, far exceeding
|
12
|
+
# @security Provides ~10^77 possible values, far exceeding UUIDv4's 128 bits.
|
12
13
|
def generate_hex_id
|
13
14
|
SecureRandom.hex(32)
|
14
15
|
end
|
@@ -26,13 +27,6 @@ module Familia
|
|
26
27
|
#
|
27
28
|
# @param base [Integer] The base for encoding the output string (2-36, default: 36).
|
28
29
|
# @return [String] A secure identifier.
|
29
|
-
#
|
30
|
-
# @example Generate a 256-bit ID in base-36 (default)
|
31
|
-
# generate_id # => "25nkfebno45yy36z47ffxef2a7vpg4qk06ylgxzwgpnz4q3os4"
|
32
|
-
#
|
33
|
-
# @example Generate a 256-bit ID in base-16 (hexadecimal)
|
34
|
-
# generate_id(16) # => "568bdb582bc5042bf435d3f126cf71593981067463709c880c91df1ad9777a34"
|
35
|
-
#
|
36
30
|
def generate_id(base = 36)
|
37
31
|
target_length = SecureIdentifier.min_length_for_bits(256, base)
|
38
32
|
generate_hex_id.to_i(16).to_s(base).rjust(target_length, '0')
|
@@ -43,87 +37,69 @@ module Familia
|
|
43
37
|
#
|
44
38
|
# @param base [Integer] The base for encoding the output string (2-36, default: 36).
|
45
39
|
# @return [String] A secure short identifier.
|
46
|
-
#
|
47
|
-
# @example Generate a 64-bit short ID in base-36 (default)
|
48
|
-
# generate_trace_id # => "lh7uap704unf"
|
49
|
-
#
|
50
|
-
# @example Generate a 64-bit short ID in base-16 (hexadecimal)
|
51
|
-
# generate_trace_id(16) # => "94cf9f8cfb0eb692"
|
52
|
-
#
|
53
40
|
def generate_trace_id(base = 36)
|
54
41
|
target_length = SecureIdentifier.min_length_for_bits(64, base)
|
55
42
|
generate_hex_trace_id.to_i(16).to_s(base).rjust(target_length, '0')
|
56
43
|
end
|
57
44
|
|
58
|
-
#
|
59
|
-
# These short, deterministic IDs are useful for secure logging. By inputting the
|
60
|
-
# full hexadecimal string, you can generate a consistent short ID that allows
|
61
|
-
# tracking an entity through logs without exposing the entity's full identifier..
|
45
|
+
# Creates a deterministic 64-bit trace identifier from a longer hex ID.
|
62
46
|
#
|
63
|
-
#
|
64
|
-
#
|
65
|
-
#
|
47
|
+
# This is a convenience method for `truncate_hex(hex_id, bits: 64)`.
|
48
|
+
# Useful for creating short, consistent IDs for logging and tracing.
|
49
|
+
#
|
50
|
+
# @param (see #truncate_hex)
|
51
|
+
# @return (see #truncate_hex)
|
66
52
|
def shorten_to_trace_id(hex_id, base: 36)
|
67
|
-
|
68
|
-
truncated = hex_id.to_i(16) >> (256 - 64) # Always 64 bits
|
69
|
-
truncated.to_s(base).rjust(target_length, '0')
|
53
|
+
truncate_hex(hex_id, bits: 64, base: base)
|
70
54
|
end
|
71
55
|
|
72
|
-
#
|
73
|
-
#
|
74
|
-
#
|
75
|
-
#
|
76
|
-
#
|
77
|
-
# @param hex_id [String]
|
78
|
-
# @param
|
79
|
-
# @
|
80
|
-
#
|
81
|
-
# @
|
82
|
-
#
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
# @security Truncation preserves the cryptographic properties of the most significant bits.
|
87
|
-
def shorten_to_external_id(hex_id, base: 36)
|
88
|
-
target_length = SecureIdentifier.min_length_for_bits(128, base)
|
89
|
-
truncated = hex_id.to_i(16) >> (256 - 128) # Always 128 bits
|
90
|
-
truncated.to_s(base).rjust(target_length, '0')
|
91
|
-
end
|
56
|
+
# Deterministically truncates a hexadecimal ID to a specified bit length.
|
57
|
+
#
|
58
|
+
# This function preserves the most significant bits of the input `hex_id` to
|
59
|
+
# create a shorter, yet still random-looking, identifier.
|
60
|
+
#
|
61
|
+
# @param hex_id [String] The input hexadecimal string.
|
62
|
+
# @param bits [Integer] The desired output bit length (e.g., 128, 64). Defaults to 128.
|
63
|
+
# @param base [Integer] The numeric base for the output string (2-36). Defaults to 36.
|
64
|
+
# @return [String] A new, shorter identifier in the specified base.
|
65
|
+
# @raise [ArgumentError] if `hex_id` is not a valid hex string, or if `input_bits`
|
66
|
+
# is less than the desired output `bits`.
|
67
|
+
def truncate_hex(hex_id, bits: 128, base: 36)
|
68
|
+
target_length = SecureIdentifier.min_length_for_bits(bits, base)
|
69
|
+
input_bits = hex_id.length * 4
|
92
70
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
# to represent a certain amount of entropy. This ensures consistent ID lengths.
|
97
|
-
#
|
98
|
-
# Formula: ceil(bits * log(2) / log(base))
|
99
|
-
#
|
100
|
-
# @example Common usage with SecureRandom
|
101
|
-
# SecureRandom.hex(32) # 32 bytes = 256 bits = 64 hex chars
|
102
|
-
# SecureRandom.hex(16) # 16 bytes = 128 bits = 32 hex chars
|
103
|
-
#
|
104
|
-
# @example Using the method
|
105
|
-
# min_length_for_bits(256, 16) # => 64 (hex)
|
106
|
-
# min_length_for_bits(256, 36) # => 50 (base36)
|
107
|
-
# min_length_for_bits(128, 10) # => 39 (decimal)
|
71
|
+
unless hex_id.match?(/\A[0-9a-fA-F]+\z/)
|
72
|
+
raise ArgumentError, "Invalid hexadecimal string: #{hex_id}"
|
73
|
+
end
|
108
74
|
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
256 => 64, # SHA-256 equivalent entropy
|
113
|
-
128 => 32, # UUID equivalent entropy
|
114
|
-
64 => 16, # Compact ID
|
115
|
-
}.freeze
|
75
|
+
if input_bits < bits
|
76
|
+
raise ArgumentError, "Input bits (#{input_bits}) cannot be less than desired output bits (#{bits})."
|
77
|
+
end
|
116
78
|
|
117
|
-
|
79
|
+
# Truncate by right-shifting to keep the most significant bits
|
80
|
+
truncated_int = hex_id.to_i(16) >> (input_bits - bits)
|
81
|
+
truncated_int.to_s(base).rjust(target_length, '0')
|
82
|
+
end
|
83
|
+
|
84
|
+
# Calculates the minimum string length required to represent a given number of
|
85
|
+
# bits in a specific numeric base.
|
86
|
+
#
|
87
|
+
# @private
|
118
88
|
#
|
119
|
-
# @param bits [Integer]
|
120
|
-
# @param base [Integer]
|
121
|
-
# @return [Integer]
|
89
|
+
# @param bits [Integer] The number of bits of entropy.
|
90
|
+
# @param base [Integer] The numeric base (2-36).
|
91
|
+
# @return [Integer] The minimum string length required.
|
122
92
|
def self.min_length_for_bits(bits, base)
|
123
|
-
|
93
|
+
# Fast lookup for hex (base 16) - our most common case
|
94
|
+
hex_lengths = {
|
95
|
+
256 => 64, # SHA-256 equivalent entropy
|
96
|
+
128 => 32, # UUID equivalent entropy
|
97
|
+
64 => 16, # Compact ID
|
98
|
+
}.freeze
|
99
|
+
return hex_lengths[bits] if base == 16 && hex_lengths.key?(bits)
|
124
100
|
|
125
|
-
@
|
126
|
-
@
|
101
|
+
@min_length_for_bits_cache ||= {}
|
102
|
+
@min_length_for_bits_cache[[bits, base]] ||= (bits * Math.log(2) / Math.log(base)).ceil
|
127
103
|
end
|
128
104
|
end
|
129
105
|
end
|