familia 2.0.0.pre10 → 2.0.0.pre13
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/.rubocop_todo.yml +2 -3
- data/CHANGELOG.rst +507 -0
- data/CLAUDE.md +5 -55
- data/Gemfile +1 -6
- data/Gemfile.lock +13 -7
- data/changelog.d/README.md +45 -34
- data/changelog.d/scriv.ini +5 -0
- 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/Feature-System-Autoloading.md +228 -0
- 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/guides/time-utilities.md +221 -0
- data/docs/migrating/.gitignore +2 -0
- data/docs/migrating/v2.0.0-pre.md +84 -0
- data/docs/migrating/v2.0.0-pre11.md +253 -0
- data/docs/migrating/v2.0.0-pre12.md +306 -0
- data/docs/migrating/v2.0.0-pre13.md +329 -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/autoloader/mega_customer/safe_dump_fields.rb +6 -0
- data/examples/autoloader/mega_customer.rb +17 -0
- 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 +5 -4
- data/lib/familia/autoloader.rb +53 -0
- data/lib/familia/base.rb +57 -0
- data/lib/familia/data_type.rb +4 -0
- data/lib/familia/encryption/encrypted_data.rb +4 -4
- data/lib/familia/encryption/manager.rb +6 -4
- data/lib/familia/{encryption_request_cache.rb → encryption/request_cache.rb} +1 -1
- data/lib/familia/encryption.rb +1 -1
- data/lib/familia/errors.rb +5 -0
- data/lib/familia/features/autoloadable.rb +113 -0
- data/lib/familia/features/encrypted_fields/concealed_string.rb +4 -2
- data/lib/familia/features/expiration.rb +4 -0
- data/lib/familia/features/external_identifier.rb +310 -0
- data/lib/familia/features/object_identifier.rb +307 -0
- data/lib/familia/features/quantization.rb +5 -0
- data/lib/familia/features/safe_dump.rb +74 -73
- data/lib/familia/features.rb +109 -17
- data/lib/familia/field_type.rb +2 -0
- data/lib/familia/horreum/core/serialization.rb +3 -3
- data/lib/familia/horreum/subclass/definition.rb +50 -7
- data/lib/familia/horreum.rb +2 -0
- data/lib/familia/json_serializer.rb +70 -0
- data/lib/familia/logging.rb +12 -10
- data/lib/familia/refinements/logger_trace.rb +57 -0
- data/lib/familia/refinements/snake_case.rb +40 -0
- data/lib/familia/refinements/time_utils.rb +248 -0
- data/lib/familia/refinements.rb +3 -49
- data/lib/familia/secure_identifier.rb +51 -75
- data/lib/familia/utils.rb +2 -0
- data/lib/familia/validation/{test_helpers.rb → validation_helpers.rb} +2 -2
- data/lib/familia/validation.rb +1 -1
- data/lib/familia/verifiable_identifier.rb +162 -0
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +15 -2
- data/try/core/autoloader_try.rb +112 -0
- data/try/core/extensions_try.rb +38 -21
- data/try/core/familia_extended_try.rb +4 -3
- data/try/core/secure_identifier_try.rb +47 -18
- data/try/core/time_utils_try.rb +130 -0
- data/try/core/verifiable_identifier_try.rb +171 -0
- data/try/data_types/datatype_base_try.rb +3 -2
- data/try/features/autoloadable/autoloadable_try.rb +61 -0
- data/try/features/encrypted_fields/concealed_string_core_try.rb +8 -3
- data/try/features/encrypted_fields/secure_by_default_behavior_try.rb +59 -17
- data/try/features/encrypted_fields/universal_serialization_safety_try.rb +36 -12
- data/try/features/{external_identifiers/external_identifiers_try.rb → external_identifier/external_identifier_try.rb} +25 -28
- data/try/features/feature_improvements_try.rb +127 -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 +8 -7
- data/try/features/safe_dump/safe_dump_autoloading_try.rb +111 -0
- data/try/features/safe_dump/safe_dump_try.rb +8 -9
- data/try/helpers/test_helpers.rb +41 -17
- data/try/integration/cross_component_try.rb +3 -1
- metadata +61 -26
- data/CHANGELOG.md +0 -184
- data/changelog.d/fragments/.keep +0 -0
- data/changelog.d/template.md.j2 +0 -29
- data/lib/familia/core_ext.rb +0 -135
- 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
- data/setup.cfg +0 -12
@@ -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
|
data/lib/familia/utils.rb
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
# lib/familia/validation/
|
1
|
+
# lib/familia/validation/validation_helpers.rb
|
2
2
|
|
3
3
|
module Familia
|
4
4
|
module Validation
|
@@ -7,7 +7,7 @@ module Familia
|
|
7
7
|
# and automatic setup/cleanup for command validation tests.
|
8
8
|
#
|
9
9
|
# @example Basic usage in a try file
|
10
|
-
# require_relative '../validation/
|
10
|
+
# require_relative '../validation/validation_helpers'
|
11
11
|
# extend Familia::Validation::TestHelpers
|
12
12
|
#
|
13
13
|
# ## User save should execute expected Redis commands
|
data/lib/familia/validation.rb
CHANGED
@@ -51,7 +51,7 @@
|
|
51
51
|
require_relative 'validation/command_recorder'
|
52
52
|
require_relative 'validation/expectations'
|
53
53
|
require_relative 'validation/validator'
|
54
|
-
require_relative 'validation/
|
54
|
+
require_relative 'validation/validation_helpers'
|
55
55
|
|
56
56
|
module Familia
|
57
57
|
module Validation
|
@@ -0,0 +1,162 @@
|
|
1
|
+
# lib/familia/verifiable_identifier.rb
|
2
|
+
|
3
|
+
require 'openssl'
|
4
|
+
require_relative 'secure_identifier'
|
5
|
+
|
6
|
+
module Familia
|
7
|
+
# Creates and verifies identifiers that contain an embedded HMAC signature,
|
8
|
+
# allowing for stateless verification of an identifier's authenticity.
|
9
|
+
module VerifiableIdentifier
|
10
|
+
# By extending SecureIdentifier, we gain access to its instance methods
|
11
|
+
# (like generate_hex_id) as class methods on this module.
|
12
|
+
extend Familia::SecureIdentifier
|
13
|
+
|
14
|
+
# The secret key for HMAC generation, loaded from an environment variable.
|
15
|
+
#
|
16
|
+
# This key is the root of trust for verifying identifier authenticity. It must be
|
17
|
+
# a long, random, and cryptographically strong string.
|
18
|
+
#
|
19
|
+
# @!attribute [r] SECRET_KEY
|
20
|
+
# @return [String] The secret key.
|
21
|
+
#
|
22
|
+
# @note Security Considerations:
|
23
|
+
# - **Secrecy:** This key MUST be kept secret and secure, just like a database
|
24
|
+
# password or API key. Do not commit it to version control.
|
25
|
+
# - **Consistency:** All running instances of your application must use the
|
26
|
+
# exact same key, otherwise verification will fail across different servers.
|
27
|
+
# - **Rotation:** If this key is ever compromised, it must be rotated. Be
|
28
|
+
# aware that rotating the key will invalidate all previously generated
|
29
|
+
# verifiable identifiers.
|
30
|
+
#
|
31
|
+
# @example Generating and Setting the Key
|
32
|
+
# 1. Generate a new secure key in your terminal:
|
33
|
+
# $ openssl rand -hex 32
|
34
|
+
# > cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d
|
35
|
+
#
|
36
|
+
# 2. Set it as an environment variable in your production environment:
|
37
|
+
# export VERIFIABLE_ID_HMAC_SECRET="cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d"
|
38
|
+
#
|
39
|
+
SECRET_KEY = ENV.fetch('VERIFIABLE_ID_HMAC_SECRET', 'cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d')
|
40
|
+
|
41
|
+
# The length of the random part of the ID in hex characters (256 bits).
|
42
|
+
RANDOM_HEX_LENGTH = 64
|
43
|
+
# The length of the HMAC tag in hex characters (64 bits).
|
44
|
+
# 64 bits is strong enough to prevent forgery (1 in 18 quintillion chance).
|
45
|
+
TAG_HEX_LENGTH = 16
|
46
|
+
|
47
|
+
# Generates a verifiable, base-36 encoded identifier.
|
48
|
+
#
|
49
|
+
# The final identifier contains a 256-bit random component and a 64-bit
|
50
|
+
# authentication tag.
|
51
|
+
#
|
52
|
+
# @param base [Integer] The base for encoding the output string.
|
53
|
+
# @return [String] A verifiable, signed identifier.
|
54
|
+
def self.generate_verifiable_id(base_or_scope = nil, scope: nil, base: 36)
|
55
|
+
# Handle backward compatibility with positional base argument
|
56
|
+
if base_or_scope.is_a?(Integer)
|
57
|
+
base = base_or_scope
|
58
|
+
# scope remains as passed in keyword argument
|
59
|
+
elsif base_or_scope.is_a?(String) || base_or_scope.nil?
|
60
|
+
scope = base_or_scope if scope.nil?
|
61
|
+
# base remains as passed in keyword argument or default
|
62
|
+
end
|
63
|
+
|
64
|
+
# Re-use generate_hex_id from the SecureIdentifier module.
|
65
|
+
random_hex = generate_hex_id
|
66
|
+
tag_hex = generate_tag(random_hex, scope: scope)
|
67
|
+
|
68
|
+
combined_hex = random_hex + tag_hex
|
69
|
+
|
70
|
+
# Re-use the min_length_for_bits helper from the SecureIdentifier module.
|
71
|
+
total_bits = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH) * 4
|
72
|
+
target_length = Familia::SecureIdentifier.min_length_for_bits(total_bits, base)
|
73
|
+
|
74
|
+
combined_hex.to_i(16).to_s(base).rjust(target_length, '0')
|
75
|
+
end
|
76
|
+
|
77
|
+
# Verifies the authenticity of a given identifier using a timing-safe comparison.
|
78
|
+
#
|
79
|
+
# @param verifiable_id [String] The identifier string to check.
|
80
|
+
# @param base [Integer] The base of the input string.
|
81
|
+
# @return [Boolean] True if the identifier is authentic, false otherwise.
|
82
|
+
def self.verified_identifier?(verifiable_id, base_or_scope = nil, scope: nil, base: 36)
|
83
|
+
# Handle backward compatibility with positional base argument
|
84
|
+
if base_or_scope.is_a?(Integer)
|
85
|
+
base = base_or_scope
|
86
|
+
# scope remains as passed in keyword argument
|
87
|
+
elsif base_or_scope.is_a?(String) || base_or_scope.nil?
|
88
|
+
scope = base_or_scope if scope.nil?
|
89
|
+
# base remains as passed in keyword argument or default
|
90
|
+
end
|
91
|
+
|
92
|
+
return false unless plausible_identifier?(verifiable_id, base)
|
93
|
+
|
94
|
+
expected_hex_length = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH)
|
95
|
+
combined_hex = verifiable_id.to_i(base).to_s(16).rjust(expected_hex_length, '0')
|
96
|
+
|
97
|
+
random_part = combined_hex[0...RANDOM_HEX_LENGTH]
|
98
|
+
tag_part = combined_hex[RANDOM_HEX_LENGTH..]
|
99
|
+
|
100
|
+
expected_tag = generate_tag(random_part, scope: scope)
|
101
|
+
OpenSSL.secure_compare(expected_tag, tag_part)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Checks if an identifier is plausible (correct format and length) without
|
105
|
+
# performing cryptographic verification.
|
106
|
+
#
|
107
|
+
# This can be used as a fast pre-flight check to reject obviously
|
108
|
+
# malformed identifiers.
|
109
|
+
#
|
110
|
+
# @param identifier_str [String] The identifier string to check.
|
111
|
+
# @param base [Integer] The base of the input string.
|
112
|
+
# @return [Boolean] True if the identifier has a valid format, false otherwise.
|
113
|
+
def self.plausible_identifier?(identifier_str, base = 36)
|
114
|
+
return false unless identifier_str.is_a?(::String)
|
115
|
+
|
116
|
+
# 1. Check length
|
117
|
+
total_bits = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH) * 4
|
118
|
+
expected_length = Familia::SecureIdentifier.min_length_for_bits(total_bits, base)
|
119
|
+
return false unless identifier_str.length == expected_length
|
120
|
+
|
121
|
+
# 2. Check character set
|
122
|
+
# The most efficient way to check for invalid characters is to attempt
|
123
|
+
# conversion and rescue the error.
|
124
|
+
Integer(identifier_str, base)
|
125
|
+
true
|
126
|
+
rescue ArgumentError
|
127
|
+
false
|
128
|
+
end
|
129
|
+
|
130
|
+
class << self
|
131
|
+
private
|
132
|
+
|
133
|
+
# Generates the HMAC tag for a given message.
|
134
|
+
# @private
|
135
|
+
def generate_tag(message, scope: nil)
|
136
|
+
# Include scope in HMAC calculation for domain separation if provided.
|
137
|
+
# The scope parameter enables creating cryptographically isolated identifier
|
138
|
+
# namespaces (e.g., per-domain, per-tenant, per-application) while maintaining
|
139
|
+
# all security properties of the base system.
|
140
|
+
#
|
141
|
+
# Security considerations for scope values:
|
142
|
+
# - Any string content is cryptographically safe (HMAC handles arbitrary input)
|
143
|
+
# - No length restrictions (short scopes like "a" or long scopes work equally well)
|
144
|
+
# - UTF-8 encoding is handled consistently
|
145
|
+
# - Empty string "" vs nil produce different identifiers (intentional for security)
|
146
|
+
# - Different scope values guarantee different identifier spaces
|
147
|
+
#
|
148
|
+
# Examples of scope usage:
|
149
|
+
# - Customer isolation: scope: "tenant:#{tenant_id}"
|
150
|
+
# - Environment separation: scope: "production" vs scope: "staging"
|
151
|
+
# - Domain scoping: scope: "example.com"
|
152
|
+
# - Application scoping: scope: "#{app_name}:#{version}"
|
153
|
+
hmac_input = scope ? "#{message}:scope:#{scope}" : message
|
154
|
+
|
155
|
+
digest = OpenSSL::Digest.new('sha256')
|
156
|
+
hmac = OpenSSL::HMAC.hexdigest(digest, SECRET_KEY, hmac_input)
|
157
|
+
# Truncate to the desired length for the tag.
|
158
|
+
hmac[0...TAG_HEX_LENGTH]
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
data/lib/familia/version.rb
CHANGED
data/lib/familia.rb
CHANGED
@@ -1,11 +1,12 @@
|
|
1
1
|
# lib/familia.rb
|
2
2
|
|
3
|
-
require '
|
3
|
+
require 'oj'
|
4
4
|
require 'redis'
|
5
5
|
require 'uri/valkey'
|
6
6
|
require 'connection_pool'
|
7
7
|
|
8
|
-
|
8
|
+
# OJ configuration is handled internally by Familia::JsonSerializer
|
9
|
+
|
9
10
|
require_relative 'familia/refinements'
|
10
11
|
require_relative 'familia/errors'
|
11
12
|
require_relative 'familia/version'
|
@@ -71,6 +72,7 @@ module Familia
|
|
71
72
|
require_relative 'familia/connection'
|
72
73
|
require_relative 'familia/settings'
|
73
74
|
require_relative 'familia/utils'
|
75
|
+
require_relative 'familia/json_serializer'
|
74
76
|
|
75
77
|
extend SecureIdentifier
|
76
78
|
extend Connection
|
@@ -80,7 +82,18 @@ module Familia
|
|
80
82
|
end
|
81
83
|
|
82
84
|
require_relative 'familia/base'
|
85
|
+
require_relative 'familia/features/autoloadable'
|
83
86
|
require_relative 'familia/features'
|
84
87
|
require_relative 'familia/data_type'
|
85
88
|
require_relative 'familia/horreum'
|
86
89
|
require_relative 'familia/encryption'
|
90
|
+
|
91
|
+
# Ensure JSON constant is available for backward compatibility with existing code
|
92
|
+
# This approach is safer than monkey-patching core classes globally
|
93
|
+
begin
|
94
|
+
require 'json'
|
95
|
+
rescue LoadError
|
96
|
+
# If json gem is not available, define a minimal JSON constant
|
97
|
+
# that delegates to Familia::JsonSerializer for compatibility
|
98
|
+
JSON = Familia::JsonSerializer
|
99
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
# try/core/autoloader_try.rb
|
2
|
+
|
3
|
+
require_relative '../../lib/familia'
|
4
|
+
require 'fileutils'
|
5
|
+
require 'tmpdir'
|
6
|
+
|
7
|
+
# Create test directory structure for Autoloader testing
|
8
|
+
@test_dir = Dir.mktmpdir('familia_autoloader_test')
|
9
|
+
@features_dir = File.join(@test_dir, 'features')
|
10
|
+
@test_file1 = File.join(@features_dir, 'test_feature1.rb')
|
11
|
+
@test_file2 = File.join(@features_dir, 'test_feature2.rb')
|
12
|
+
@excluded_file = File.join(@features_dir, 'autoloader.rb')
|
13
|
+
|
14
|
+
# Create directory structure
|
15
|
+
FileUtils.mkdir_p(@features_dir)
|
16
|
+
|
17
|
+
# Write test files
|
18
|
+
File.write(@test_file1, <<~RUBY)
|
19
|
+
# Test feature file 1
|
20
|
+
$test_feature1_loaded = true
|
21
|
+
RUBY
|
22
|
+
|
23
|
+
File.write(@test_file2, <<~RUBY)
|
24
|
+
# Test feature file 2
|
25
|
+
$test_feature2_loaded = true
|
26
|
+
RUBY
|
27
|
+
|
28
|
+
File.write(@excluded_file, <<~RUBY)
|
29
|
+
# This should be excluded
|
30
|
+
$autoloader_file_loaded = true
|
31
|
+
RUBY
|
32
|
+
|
33
|
+
## Test that Familia::Autoloader exists and is a module
|
34
|
+
Familia::Autoloader.is_a?(Module)
|
35
|
+
#=> true
|
36
|
+
|
37
|
+
## Test that autoload_files class method exists
|
38
|
+
Familia::Autoloader.respond_to?(:autoload_files)
|
39
|
+
#=> true
|
40
|
+
|
41
|
+
## Test that included class method exists
|
42
|
+
Familia::Autoloader.respond_to?(:included)
|
43
|
+
#=> true
|
44
|
+
|
45
|
+
## Test autoload_files with single pattern
|
46
|
+
$test_feature1_loaded = false
|
47
|
+
$test_feature2_loaded = false
|
48
|
+
$autoloader_file_loaded = false
|
49
|
+
|
50
|
+
Familia::Autoloader.autoload_files(File.join(@features_dir, '*.rb'))
|
51
|
+
$test_feature1_loaded && $test_feature2_loaded
|
52
|
+
#=> true
|
53
|
+
|
54
|
+
## Test that autoload_files respects exclusions (using fresh files)
|
55
|
+
@exclude_test_dir = Dir.mktmpdir('familia_autoloader_exclude_test')
|
56
|
+
@exclude_features_dir = File.join(@exclude_test_dir, 'features')
|
57
|
+
@include_file = File.join(@exclude_features_dir, 'include_me.rb')
|
58
|
+
@exclude_file = File.join(@exclude_features_dir, 'autoloader.rb')
|
59
|
+
|
60
|
+
FileUtils.mkdir_p(@exclude_features_dir)
|
61
|
+
File.write(@include_file, '$include_me_loaded = true')
|
62
|
+
File.write(@exclude_file, '$exclude_me_loaded = true')
|
63
|
+
|
64
|
+
$include_me_loaded = false
|
65
|
+
$exclude_me_loaded = false
|
66
|
+
|
67
|
+
Familia::Autoloader.autoload_files(
|
68
|
+
File.join(@exclude_features_dir, '*.rb'),
|
69
|
+
exclude: ['autoloader.rb']
|
70
|
+
)
|
71
|
+
|
72
|
+
# Should load include file but not the excluded one
|
73
|
+
$include_me_loaded && !$exclude_me_loaded
|
74
|
+
#=> true
|
75
|
+
|
76
|
+
## Test autoload_files with array of patterns (using fresh files)
|
77
|
+
@pattern_test_dir = Dir.mktmpdir('familia_autoloader_pattern_test')
|
78
|
+
@pattern_dir1 = File.join(@pattern_test_dir, 'dir1')
|
79
|
+
@pattern_dir2 = File.join(@pattern_test_dir, 'dir2')
|
80
|
+
@pattern_file1 = File.join(@pattern_dir1, 'file1.rb')
|
81
|
+
@pattern_file2 = File.join(@pattern_dir2, 'file2.rb')
|
82
|
+
|
83
|
+
FileUtils.mkdir_p(@pattern_dir1)
|
84
|
+
FileUtils.mkdir_p(@pattern_dir2)
|
85
|
+
File.write(@pattern_file1, '$pattern1_loaded = true')
|
86
|
+
File.write(@pattern_file2, '$pattern2_loaded = true')
|
87
|
+
|
88
|
+
$pattern1_loaded = false
|
89
|
+
$pattern2_loaded = false
|
90
|
+
|
91
|
+
Familia::Autoloader.autoload_files([
|
92
|
+
File.join(@pattern_dir1, '*.rb'),
|
93
|
+
File.join(@pattern_dir2, '*.rb')
|
94
|
+
])
|
95
|
+
|
96
|
+
$pattern1_loaded && $pattern2_loaded
|
97
|
+
#=> true
|
98
|
+
|
99
|
+
## Test that included method loads features from features directory
|
100
|
+
# Create a mock module that includes Autoloader
|
101
|
+
@mock_features_module = Module.new do
|
102
|
+
include Familia::Autoloader
|
103
|
+
end
|
104
|
+
|
105
|
+
# The Features module already includes Autoloader, so test indirectly
|
106
|
+
Familia::Features.ancestors.include?(Familia::Autoloader)
|
107
|
+
#=> true
|
108
|
+
|
109
|
+
# Cleanup test files and directories
|
110
|
+
FileUtils.rm_rf(@test_dir)
|
111
|
+
FileUtils.rm_rf(@exclude_test_dir)
|
112
|
+
FileUtils.rm_rf(@pattern_test_dir)
|
data/try/core/extensions_try.rb
CHANGED
@@ -1,59 +1,76 @@
|
|
1
1
|
require_relative '../helpers/test_helpers'
|
2
2
|
|
3
|
+
module RefinedContext
|
4
|
+
using Familia::Refinements::TimeUtils
|
5
|
+
|
6
|
+
# This helper evaluates code within the refined context using eval.
|
7
|
+
# This works because eval executes the code as if it were written
|
8
|
+
# at this location, making the refinements available.
|
9
|
+
def self.eval_in_refined_context(code)
|
10
|
+
eval(code)
|
11
|
+
end
|
12
|
+
|
13
|
+
# This helper also evaluates code in the refined context using instance_eval.
|
14
|
+
# This provides an alternative approach for testing refinements.
|
15
|
+
def self.instance_eval_in_refined_context(code)
|
16
|
+
instance_eval(code)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
3
20
|
# Test core extensions
|
4
21
|
|
5
22
|
## String time parsing - seconds
|
6
|
-
'60s'.in_seconds
|
7
|
-
#=> 60
|
23
|
+
RefinedContext.eval_in_refined_context("'60s'.in_seconds")
|
24
|
+
#=> 60.0
|
8
25
|
|
9
26
|
## String time parsing - minutes
|
10
|
-
'5m'.in_seconds
|
11
|
-
#=> 300
|
27
|
+
RefinedContext.instance_eval_in_refined_context("'5m'.in_seconds")
|
28
|
+
#=> 300.0
|
12
29
|
|
13
30
|
## String time parsing - hours
|
14
|
-
'2h'.in_seconds
|
15
|
-
#=> 7200
|
31
|
+
RefinedContext.eval_in_refined_context("'2h'.in_seconds")
|
32
|
+
#=> 7200.0
|
16
33
|
|
17
34
|
## String time parsing - days
|
18
|
-
'1d'.in_seconds
|
19
|
-
#=> 86_400
|
35
|
+
RefinedContext.instance_eval_in_refined_context("'1d'.in_seconds")
|
36
|
+
#=> 86_400.0
|
20
37
|
|
21
|
-
## String time parsing -
|
22
|
-
'1y'.in_seconds
|
23
|
-
#=>
|
38
|
+
## String time parsing - years
|
39
|
+
RefinedContext.eval_in_refined_context("'1y'.in_seconds")
|
40
|
+
#=> 31556952.0
|
24
41
|
|
25
42
|
## Time::Units - second
|
26
|
-
1.second
|
43
|
+
RefinedContext.instance_eval_in_refined_context("1.second")
|
27
44
|
#=> 1
|
28
45
|
|
29
46
|
## Time::Units - minute
|
30
|
-
1.minute
|
47
|
+
RefinedContext.eval_in_refined_context("1.minute")
|
31
48
|
#=> 60
|
32
49
|
|
33
50
|
## Time::Units - hour
|
34
|
-
1.hour
|
51
|
+
RefinedContext.instance_eval_in_refined_context("1.hour")
|
35
52
|
#=> 3600
|
36
53
|
|
37
54
|
## Time::Units - day
|
38
|
-
1.day
|
55
|
+
RefinedContext.eval_in_refined_context("1.day")
|
39
56
|
#=> 86_400
|
40
57
|
|
41
58
|
## Time::Units - week
|
42
|
-
1.week
|
59
|
+
RefinedContext.instance_eval_in_refined_context("1.week")
|
43
60
|
#=> 604_800
|
44
61
|
|
45
62
|
## Numeric extension to_ms
|
46
|
-
1000.to_ms
|
47
|
-
#=>
|
63
|
+
RefinedContext.eval_in_refined_context("1000.to_ms")
|
64
|
+
#=> 1000000.0
|
48
65
|
|
49
66
|
## Numeric extension to_bytes - single byte
|
50
|
-
1.to_bytes
|
67
|
+
RefinedContext.instance_eval_in_refined_context("1.to_bytes")
|
51
68
|
#=> '1.00 B'
|
52
69
|
|
53
70
|
## Numeric extension to_bytes - kilobytes
|
54
|
-
1024.to_bytes
|
71
|
+
RefinedContext.eval_in_refined_context("1024.to_bytes")
|
55
72
|
#=> '1.00 KiB'
|
56
73
|
|
57
74
|
## Numeric extension to_bytes - megabytes
|
58
|
-
(1024 * 1024).to_bytes
|
75
|
+
RefinedContext.instance_eval_in_refined_context("(1024 * 1024).to_bytes")
|
59
76
|
#=> '1.00 MiB'
|
@@ -44,14 +44,15 @@ parsed_time = Familia.now(Time.parse('2011-04-10 20:56:20 UTC').utc)
|
|
44
44
|
#=> [1302468980.0, true, true]
|
45
45
|
|
46
46
|
## Familia.qnow
|
47
|
-
Familia.qstamp 10.minutes, time: 1_302_468_980
|
47
|
+
RefinedContext.eval_in_refined_context("Familia.qstamp 10.minutes, time: 1_302_468_980")
|
48
48
|
#=> 1302468600
|
49
49
|
|
50
50
|
## Familia::Object.qstamp
|
51
|
-
Limiter.qstamp(10.minutes, pattern: '%H:%M', time: 1_302_468_980)
|
51
|
+
RefinedContext.eval_in_refined_context("Limiter.qstamp(10.minutes, pattern: '%H:%M', time: 1_302_468_980)")
|
52
52
|
#=> '20:50'
|
53
53
|
|
54
54
|
## Familia::Object#qstamp
|
55
55
|
limiter = Limiter.new :request
|
56
|
-
|
56
|
+
RefinedContext.instance_variable_set(:@limiter, limiter)
|
57
|
+
RefinedContext.eval_in_refined_context("@limiter.qstamp(10.minutes, pattern: '%H:%M', time: 1_302_468_980)")
|
57
58
|
#=> '20:50'
|