aboutsummaryrefslogtreecommitdiffstats
path: root/activesupport/lib/active_support
diff options
context:
space:
mode:
Diffstat (limited to 'activesupport/lib/active_support')
-rw-r--r--activesupport/lib/active_support/cache.rb2
-rw-r--r--activesupport/lib/active_support/core_ext/class/attribute.rb10
-rw-r--r--activesupport/lib/active_support/core_ext/hash/reverse_merge.rb3
-rw-r--r--activesupport/lib/active_support/duration.rb57
-rw-r--r--activesupport/lib/active_support/hash_with_indifferent_access.rb42
-rw-r--r--activesupport/lib/active_support/inflector/transliterate.rb7
-rw-r--r--activesupport/lib/active_support/message_encryptor.rb43
-rw-r--r--activesupport/lib/active_support/message_verifier.rb53
-rw-r--r--activesupport/lib/active_support/messages/metadata.rb55
-rw-r--r--activesupport/lib/active_support/testing/assertions.rb2
-rw-r--r--activesupport/lib/active_support/testing/time_helpers.rb13
11 files changed, 250 insertions, 37 deletions
diff --git a/activesupport/lib/active_support/cache.rb b/activesupport/lib/active_support/cache.rb
index e9ed60398b..49d8965cb1 100644
--- a/activesupport/lib/active_support/cache.rb
+++ b/activesupport/lib/active_support/cache.rb
@@ -101,6 +101,8 @@ module ActiveSupport
# Obtains the specified cache store class, given the name of the +store+.
# Raises an error when the store class cannot be found.
def retrieve_store_class(store)
+ # require_relative cannot be used here because the class might be
+ # provided by another gem, like redis-activesupport for example.
require "active_support/cache/#{store}"
rescue LoadError => e
raise "Could not find cache store adapter for #{store} (#{e})"
diff --git a/activesupport/lib/active_support/core_ext/class/attribute.rb b/activesupport/lib/active_support/core_ext/class/attribute.rb
index a72dbc7bf0..e5a52db36a 100644
--- a/activesupport/lib/active_support/core_ext/class/attribute.rb
+++ b/activesupport/lib/active_support/core_ext/class/attribute.rb
@@ -8,6 +8,16 @@ class Class
# Declare a class-level attribute whose value is inheritable by subclasses.
# Subclasses can change their own value and it will not impact parent class.
#
+ # ==== Options
+ #
+ # * <tt>:instance_reader</tt> - Sets the instance reader method (defaults to true).
+ # * <tt>:instance_writer</tt> - Sets the instance writer method (defaults to true).
+ # * <tt>:instance_accessor</tt> - Sets both instance methods (defaults to true).
+ # * <tt>:instance_predicate</tt> - Sets a predicate method (defaults to true).
+ # * <tt>:default</tt> - Sets a default value for the attribute (defaults to nil).
+ #
+ # ==== Examples
+ #
# class Base
# class_attribute :setting
# end
diff --git a/activesupport/lib/active_support/core_ext/hash/reverse_merge.rb b/activesupport/lib/active_support/core_ext/hash/reverse_merge.rb
index da53c29aa0..ef8d592829 100644
--- a/activesupport/lib/active_support/core_ext/hash/reverse_merge.rb
+++ b/activesupport/lib/active_support/core_ext/hash/reverse_merge.rb
@@ -18,8 +18,7 @@ class Hash
# Destructive +reverse_merge+.
def reverse_merge!(other_hash)
- # right wins if there is no left
- merge!(other_hash) { |key, left, right| left }
+ replace(reverse_merge(other_hash))
end
alias_method :reverse_update, :reverse_merge!
alias_method :with_defaults!, :reverse_merge!
diff --git a/activesupport/lib/active_support/duration.rb b/activesupport/lib/active_support/duration.rb
index 068adcea24..f411bb81df 100644
--- a/activesupport/lib/active_support/duration.rb
+++ b/activesupport/lib/active_support/duration.rb
@@ -76,15 +76,20 @@ module ActiveSupport
def /(other)
if Duration === other
- new_parts = other.parts.map { |part, other_value| [part, value / other_value] }.to_h
- new_value = new_parts.inject(0) { |total, (part, value)| total + value * Duration::PARTS_IN_SECONDS[part] }
-
- Duration.new(new_value, new_parts)
+ value / other.value
else
calculate(:/, other)
end
end
+ def %(other)
+ if Duration === other
+ Duration.build(value % other.value)
+ else
+ calculate(:%, other)
+ end
+ end
+
private
def calculate(op, other)
if Scalar === other
@@ -118,6 +123,8 @@ module ActiveSupport
years: SECONDS_PER_YEAR
}.freeze
+ PARTS = [:years, :months, :weeks, :days, :hours, :minutes, :seconds].freeze
+
attr_accessor :value, :parts
autoload :ISO8601Parser, "active_support/duration/iso8601_parser"
@@ -168,6 +175,30 @@ module ActiveSupport
new(value * SECONDS_PER_YEAR, [[:years, value]])
end
+ # Creates a new Duration from a seconds value that is converted
+ # to the individual parts:
+ #
+ # ActiveSupport::Duration.build(31556952).parts # => {:years=>1}
+ # ActiveSupport::Duration.build(2716146).parts # => {:months=>1, :days=>1}
+ #
+ def build(value)
+ parts = {}
+ remainder = value.to_f
+
+ PARTS.each do |part|
+ unless part == :seconds
+ part_in_seconds = PARTS_IN_SECONDS[part]
+ parts[part] = remainder.div(part_in_seconds)
+ remainder = (remainder % part_in_seconds).round(9)
+ end
+ end
+
+ parts[:seconds] = remainder
+ parts.reject! { |k, v| v.zero? }
+
+ new(value, parts)
+ end
+
private
def calculate_total_seconds(parts)
@@ -234,8 +265,10 @@ module ActiveSupport
# Divides this Duration by a Numeric and returns a new Duration.
def /(other)
- if Scalar === other || Duration === other
+ if Scalar === other
Duration.new(value / other.value, parts.map { |type, number| [type, number / other.value] })
+ elsif Duration === other
+ value / other.value
elsif Numeric === other
Duration.new(value / other, parts.map { |type, number| [type, number / other] })
else
@@ -243,6 +276,18 @@ module ActiveSupport
end
end
+ # Returns the modulo of this Duration by another Duration or Numeric.
+ # Numeric values are treated as seconds.
+ def %(other)
+ if Duration === other || Scalar === other
+ Duration.build(value % other.value)
+ elsif Numeric === other
+ Duration.build(value % other)
+ else
+ raise_type_error(other)
+ end
+ end
+
def -@ #:nodoc:
Duration.new(-value, parts.map { |type, number| [type, -number] })
end
@@ -327,7 +372,7 @@ module ActiveSupport
def inspect #:nodoc:
parts.
reduce(::Hash.new(0)) { |h, (l, r)| h[l] += r; h }.
- sort_by { |unit, _ | [:years, :months, :weeks, :days, :hours, :minutes, :seconds].index(unit) }.
+ sort_by { |unit, _ | PARTS.index(unit) }.
map { |unit, val| "#{val} #{val == 1 ? unit.to_s.chop : unit.to_s}" }.
to_sentence(locale: ::I18n.default_locale)
end
diff --git a/activesupport/lib/active_support/hash_with_indifferent_access.rb b/activesupport/lib/active_support/hash_with_indifferent_access.rb
index 7792b59abf..44e95f58a1 100644
--- a/activesupport/lib/active_support/hash_with_indifferent_access.rb
+++ b/activesupport/lib/active_support/hash_with_indifferent_access.rb
@@ -76,16 +76,6 @@ module ActiveSupport
end
end
- def default(*args)
- arg_key = args.first
-
- if include?(key = convert_key(arg_key))
- self[key]
- else
- super
- end
- end
-
def self.[](*args)
new.merge!(Hash[*args])
end
@@ -187,6 +177,36 @@ module ActiveSupport
super(convert_key(key), *extras)
end
+ if Hash.new.respond_to?(:dig)
+ # Same as <tt>Hash#dig</tt> where the key passed as argument can be
+ # either a string or a symbol:
+ #
+ # counters = ActiveSupport::HashWithIndifferentAccess.new
+ # counters[:foo] = { bar: 1 }
+ #
+ # counters.dig('foo', 'bar') # => 1
+ # counters.dig(:foo, :bar) # => 1
+ # counters.dig(:zoo) # => nil
+ def dig(*args)
+ args[0] = convert_key(args[0]) if args.size > 0
+ super(*args)
+ end
+ end
+
+ # Same as <tt>Hash#default</tt> where the key passed as argument can be
+ # either a string or a symbol:
+ #
+ # hash = ActiveSupport::HashWithIndifferentAccess.new(1)
+ # hash.default # => 1
+ #
+ # hash = ActiveSupport::HashWithIndifferentAccess.new { |hash, key| key }
+ # hash.default # => nil
+ # hash.default('foo') # => 'foo'
+ # hash.default(:foo) # => 'foo'
+ def default(*args)
+ super(*args.map { |arg| convert_key(arg) })
+ end
+
# Returns an array of the values at the specified indices:
#
# hash = ActiveSupport::HashWithIndifferentAccess.new
@@ -244,7 +264,7 @@ module ActiveSupport
# Same semantics as +reverse_merge+ but modifies the receiver in-place.
def reverse_merge!(other_hash)
- replace(reverse_merge(other_hash))
+ super(self.class.new(other_hash))
end
alias_method :with_defaults!, :reverse_merge!
diff --git a/activesupport/lib/active_support/inflector/transliterate.rb b/activesupport/lib/active_support/inflector/transliterate.rb
index 246fe7a916..aa7b21734e 100644
--- a/activesupport/lib/active_support/inflector/transliterate.rb
+++ b/activesupport/lib/active_support/inflector/transliterate.rb
@@ -61,9 +61,10 @@ module ActiveSupport
def transliterate(string, replacement = "?".freeze)
raise ArgumentError, "Can only transliterate strings. Received #{string.class.name}" unless string.is_a?(String)
- I18n.transliterate(ActiveSupport::Multibyte::Unicode.normalize(
- ActiveSupport::Multibyte::Unicode.tidy_bytes(string), :c),
- replacement: replacement)
+ I18n.transliterate(
+ ActiveSupport::Multibyte::Unicode.normalize(
+ ActiveSupport::Multibyte::Unicode.tidy_bytes(string), :c),
+ replacement: replacement)
end
# Replaces special characters in a string so that it may be used as part of
diff --git a/activesupport/lib/active_support/message_encryptor.rb b/activesupport/lib/active_support/message_encryptor.rb
index d5db2920b9..090d51933a 100644
--- a/activesupport/lib/active_support/message_encryptor.rb
+++ b/activesupport/lib/active_support/message_encryptor.rb
@@ -4,6 +4,7 @@ require "openssl"
require "base64"
require_relative "core_ext/array/extract_options"
require_relative "message_verifier"
+require_relative "messages/metadata"
module ActiveSupport
# MessageEncryptor is a simple way to encrypt values which get stored
@@ -21,6 +22,38 @@ module ActiveSupport
# crypt = ActiveSupport::MessageEncryptor.new(key) # => #<ActiveSupport::MessageEncryptor ...>
# encrypted_data = crypt.encrypt_and_sign('my secret data') # => "NlFBTTMwOUV5UlA1QlNEN2xkY2d6eThYWWh..."
# crypt.decrypt_and_verify(encrypted_data) # => "my secret data"
+ #
+ # === Confining messages to a specific purpose
+ #
+ # By default any message can be used throughout your app. But they can also be
+ # confined to a specific +:purpose+.
+ #
+ # token = crypt.encrypt_and_sign("this is the chair", purpose: :login)
+ #
+ # Then that same purpose must be passed when verifying to get the data back out:
+ #
+ # crypt.decrypt_and_verify(token, purpose: :login) # => "this is the chair"
+ # crypt.decrypt_and_verify(token, purpose: :shipping) # => nil
+ # crypt.decrypt_and_verify(token) # => nil
+ #
+ # Likewise, if a message has no purpose it won't be returned when verifying with
+ # a specific purpose.
+ #
+ # token = crypt.encrypt_and_sign("the conversation is lively")
+ # crypt.decrypt_and_verify(token, purpose: :scare_tactics) # => nil
+ # crypt.decrypt_and_verify(token) # => "the conversation is lively"
+ #
+ # === Making messages expire
+ #
+ # By default messages last forever and verifying one year from now will still
+ # return the original value. But messages can be set to expire at a given
+ # time with +:expires_in+ or +:expires_at+.
+ #
+ # crypt.encrypt_and_sign(parcel, expires_in: 1.month)
+ # crypt.encrypt_and_sign(doowad, expires_at: Time.now.end_of_year)
+ #
+ # Then the messages can be verified and returned upto the expire time.
+ # Thereafter, verifying returns +nil+.
class MessageEncryptor
class << self
attr_accessor :use_authenticated_message_encryption #:nodoc:
@@ -87,14 +120,15 @@ module ActiveSupport
# Encrypt and sign a message. We need to sign the message in order to avoid
# padding attacks. Reference: http://www.limited-entropy.com/padding-oracle-attacks.
- def encrypt_and_sign(value)
- verifier.generate(_encrypt(value))
+ def encrypt_and_sign(value, expires_at: nil, expires_in: nil, purpose: nil)
+ data = Messages::Metadata.wrap(value, expires_at: expires_at, expires_in: expires_in, purpose: purpose)
+ verifier.generate(_encrypt(data))
end
# Decrypt and verify a message. We need to verify the message in order to
# avoid padding attacks. Reference: http://www.limited-entropy.com/padding-oracle-attacks.
- def decrypt_and_verify(value)
- _decrypt(verifier.verify(value))
+ def decrypt_and_verify(data, purpose: nil)
+ Messages::Metadata.verify(_decrypt(verifier.verify(data)), purpose)
end
# Given a cipher, returns the key length of the cipher to help generate the key of desired size
@@ -103,7 +137,6 @@ module ActiveSupport
end
private
-
def _encrypt(value)
cipher = new_cipher
cipher.encrypt
diff --git a/activesupport/lib/active_support/message_verifier.rb b/activesupport/lib/active_support/message_verifier.rb
index b889f31f7a..fdd2185f7f 100644
--- a/activesupport/lib/active_support/message_verifier.rb
+++ b/activesupport/lib/active_support/message_verifier.rb
@@ -3,6 +3,7 @@
require "base64"
require_relative "core_ext/object/blank"
require_relative "security_utils"
+require_relative "messages/metadata"
module ActiveSupport
# +MessageVerifier+ makes it easy to generate and verify messages which are
@@ -32,6 +33,46 @@ module ActiveSupport
# `:digest` key as an option while initializing the verifier:
#
# @verifier = ActiveSupport::MessageVerifier.new('s3Krit', digest: 'SHA256')
+ #
+ # === Confining messages to a specific purpose
+ #
+ # By default any message can be used throughout your app. But they can also be
+ # confined to a specific +:purpose+.
+ #
+ # token = @verifier.generate("this is the chair", purpose: :login)
+ #
+ # Then that same purpose must be passed when verifying to get the data back out:
+ #
+ # @verifier.verified(token, purpose: :login) # => "this is the chair"
+ # @verifier.verified(token, purpose: :shipping) # => nil
+ # @verifier.verified(token) # => nil
+ #
+ # @verifier.verify(token, purpose: :login) # => "this is the chair"
+ # @verifier.verify(token, purpose: :shipping) # => ActiveSupport::MessageVerifier::InvalidSignature
+ # @verifier.verify(token) # => ActiveSupport::MessageVerifier::InvalidSignature
+ #
+ # Likewise, if a message has no purpose it won't be returned when verifying with
+ # a specific purpose.
+ #
+ # token = @verifier.generate("the conversation is lively")
+ # @verifier.verified(token, purpose: :scare_tactics) # => nil
+ # @verifier.verified(token) # => "the conversation is lively"
+ #
+ # @verifier.verify(token, purpose: :scare_tactics) # => ActiveSupport::MessageVerifier::InvalidSignature
+ # @verifier.verify(token) # => "the conversation is lively"
+ #
+ # === Making messages expire
+ #
+ # By default messages last forever and verifying one year from now will still
+ # return the original value. But messages can be set to expire at a given
+ # time with +:expires_in+ or +:expires_at+.
+ #
+ # @verifier.generate(parcel, expires_in: 1.month)
+ # @verifier.generate(doowad, expires_at: Time.now.end_of_year)
+ #
+ # Then the messages can be verified and returned upto the expire time.
+ # Thereafter, the +verified+ method returns +nil+ while +verify+ raises
+ # <tt>ActiveSupport::MessageVerifier::InvalidSignature</tt>.
class MessageVerifier
class InvalidSignature < StandardError; end
@@ -79,11 +120,11 @@ module ActiveSupport
#
# incompatible_message = "test--dad7b06c94abba8d46a15fafaef56c327665d5ff"
# verifier.verified(incompatible_message) # => TypeError: incompatible marshal file format
- def verified(signed_message)
+ def verified(signed_message, purpose: nil)
if valid_message?(signed_message)
begin
data = signed_message.split("--".freeze)[0]
- @serializer.load(decode(data))
+ Messages::Metadata.verify(@serializer.load(decode(data)), purpose)
rescue ArgumentError => argument_error
return if argument_error.message.include?("invalid base64")
raise
@@ -103,8 +144,8 @@ module ActiveSupport
#
# other_verifier = ActiveSupport::MessageVerifier.new 'd1ff3r3nt-s3Krit'
# other_verifier.verify(signed_message) # => ActiveSupport::MessageVerifier::InvalidSignature
- def verify(signed_message)
- verified(signed_message) || raise(InvalidSignature)
+ def verify(signed_message, purpose: nil)
+ verified(signed_message, purpose: purpose) || raise(InvalidSignature)
end
# Generates a signed message for the provided value.
@@ -114,8 +155,8 @@ module ActiveSupport
#
# verifier = ActiveSupport::MessageVerifier.new 's3Krit'
# verifier.generate 'a private message' # => "BAhJIhRwcml2YXRlLW1lc3NhZ2UGOgZFVA==--e2d724331ebdee96a10fb99b089508d1c72bd772"
- def generate(value)
- data = encode(@serializer.dump(value))
+ def generate(value, expires_at: nil, expires_in: nil, purpose: nil)
+ data = encode(@serializer.dump(Messages::Metadata.wrap(value, expires_at: expires_at, expires_in: expires_in, purpose: purpose)))
"#{data}--#{generate_digest(data)}"
end
diff --git a/activesupport/lib/active_support/messages/metadata.rb b/activesupport/lib/active_support/messages/metadata.rb
new file mode 100644
index 0000000000..db14ac0b1c
--- /dev/null
+++ b/activesupport/lib/active_support/messages/metadata.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+require "time"
+
+module ActiveSupport
+ module Messages #:nodoc:
+ class Metadata #:nodoc:
+ def initialize(expires_at, purpose)
+ @expires_at, @purpose = expires_at, purpose.to_s
+ end
+
+ class << self
+ def wrap(message, expires_at: nil, expires_in: nil, purpose: nil)
+ if expires_at || expires_in || purpose
+ { "value" => message, "_rails" => { "exp" => pick_expiry(expires_at, expires_in), "pur" => purpose } }
+ else
+ message
+ end
+ end
+
+ def verify(message, purpose)
+ metadata = extract_metadata(message)
+
+ if metadata.nil?
+ message if purpose.nil?
+ elsif metadata.match?(purpose) && metadata.fresh?
+ message["value"]
+ end
+ end
+
+ private
+ def pick_expiry(expires_at, expires_in)
+ if expires_at
+ expires_at.utc.iso8601(3)
+ elsif expires_in
+ Time.now.utc.advance(seconds: expires_in).iso8601(3)
+ end
+ end
+
+ def extract_metadata(message)
+ if message.is_a?(Hash) && message.key?("_rails")
+ new(message["_rails"]["exp"], message["_rails"]["pur"])
+ end
+ end
+ end
+
+ def match?(purpose)
+ @purpose == purpose.to_s
+ end
+
+ def fresh?
+ @expires_at.nil? || Time.now.utc < Time.iso8601(@expires_at)
+ end
+ end
+ end
+end
diff --git a/activesupport/lib/active_support/testing/assertions.rb b/activesupport/lib/active_support/testing/assertions.rb
index f6366bfd39..e2bc51ff7a 100644
--- a/activesupport/lib/active_support/testing/assertions.rb
+++ b/activesupport/lib/active_support/testing/assertions.rb
@@ -157,7 +157,7 @@ module ActiveSupport
after = exp.call
if to == UNTRACKED
- error = "#{expression.inspect} didn't changed"
+ error = "#{expression.inspect} didn't change"
error = "#{message}.\n#{error}" if message
assert_not_equal before, after, error
else
diff --git a/activesupport/lib/active_support/testing/time_helpers.rb b/activesupport/lib/active_support/testing/time_helpers.rb
index 182ba10765..fa5f46736c 100644
--- a/activesupport/lib/active_support/testing/time_helpers.rb
+++ b/activesupport/lib/active_support/testing/time_helpers.rb
@@ -51,8 +51,14 @@ module ActiveSupport
# Contains helpers that help you test passage of time.
module TimeHelpers
+ def after_teardown
+ travel_back
+ super
+ end
+
# Changes current time to the time in the future or in the past by a given time difference by
- # stubbing +Time.now+, +Date.today+, and +DateTime.now+.
+ # stubbing +Time.now+, +Date.today+, and +DateTime.now+. The stubs are automatically removed
+ # at the end of the test.
#
# Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00
# travel 1.day
@@ -74,6 +80,7 @@ module ActiveSupport
# Changes current time to the given time by stubbing +Time.now+,
# +Date.today+, and +DateTime.now+ to return the time or date passed into this method.
+ # The stubs are automatically removed at the end of the test.
#
# Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00
# travel_to Time.zone.local(2004, 11, 24, 01, 04, 44)
@@ -151,7 +158,7 @@ module ActiveSupport
end
# Returns the current time back to its original state, by removing the stubs added by
- # `travel` and `travel_to`.
+ # +travel+ and +travel_to+.
#
# Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00
# travel_to Time.zone.local(2004, 11, 24, 01, 04, 44)
@@ -162,7 +169,7 @@ module ActiveSupport
simple_stubs.unstub_all!
end
- # Calls `travel_to` with `Time.now`.
+ # Calls +travel_to+ with +Time.now+.
#
# Time.current # => Sun, 09 Jul 2017 15:34:49 EST -05:00
# freeze_time