diff --git a/lib/wreq_ruby/cookie.rb b/lib/wreq_ruby/cookie.rb index ca7f7a6..e13fdb7 100644 --- a/lib/wreq_ruby/cookie.rb +++ b/lib/wreq_ruby/cookie.rb @@ -1,8 +1,9 @@ unless defined?(Wreq) module Wreq - # Cookie SameSite attribute. + # SameSite values for HTTP cookies. # - # Values follow the Rust enum exposed by the native extension. + # The constant names match the native Rust variants. + # standard:disable Naming/ConstantName class SameSite # Strict same-site policy. Strict = nil @@ -38,35 +39,39 @@ def eql?(other) def hash end end + # standard:enable Naming/ConstantName - # A single HTTP cookie. + # A single HTTP cookie backed by an immutable native value. # - # Thread-safe: instances are backed by an immutable Rust value and can be - # shared across threads safely. This mirrors the native `Wreq::Cookie`. - # Constructor accepts `name`, `value`, plus optional keyword arguments for - # other attributes. + # Cookie instances can be shared safely between threads. Pass optional + # attributes as keywords to {.new}. class Cookie - # Create a new Cookie instance. + # Creates a Cookie instance. # - # Note: This matches the native binding which defines `new` (not `initialize`). + # The native extension defines `.new` directly instead of `#initialize`. # # @param name [String] Cookie name # @param value [String] Cookie value # @param options [Hash] Optional keyword arguments # @option options [String] :domain Domain attribute # @option options [String] :path Path attribute - # @option options [Integer] :max_age Max-Age in seconds - # @option options [Float] :expires Unix timestamp (seconds, float) + # @option options [Integer] :max_age Signed Max-Age in seconds. + # Zero or negative values expire the cookie immediately. + # @option options [Time, Numeric] :expires A Time or finite Unix timestamp in seconds # @option options [Boolean] :http_only HttpOnly flag # @option options [Boolean] :secure Secure flag # @option options [Wreq::SameSite] :same_site SameSite attribute # @return [Wreq::Cookie] + # @raise [ArgumentError] if an option is unknown or duplicated, or if :expires is not finite + # @raise [RangeError] if :max_age or :expires is outside the supported range + # @raise [TypeError] if an option has the wrong type # @example # c = Wreq::Cookie.new( # "sid", "abc", # domain: "example.com", # path: "/", # max_age: 3600, + # expires: Time.utc(2030, 1, 1), # http_only: true, # secure: true, # same_site: Wreq::SameSite::Lax @@ -112,6 +117,11 @@ def same_site_lax? def same_site_strict? end + # Returns the SameSite setting, or nil if it was omitted. + # @return [Wreq::SameSite, nil] + def same_site + end + # @return [String, nil] Path attribute def path end @@ -120,31 +130,50 @@ def path def domain end - # @return [Integer, nil] Max-Age in seconds + # Returns the signed Max-Age lifetime in seconds. + # + # Zero and negative values expire the cookie immediately. + # @return [Integer, nil] def max_age end - # @return [Float, nil] Expires as Unix timestamp (seconds) + # Returns the expiration time in UTC. + # @return [Time, nil] + def expires_at + end + + # Returns the expiration as a Unix timestamp with fractional seconds. + # The Float return value may lose precision for large timestamps. + # @deprecated Use {#expires_at}, which returns Time. + # @return [Float, nil] def expires end + + # Returns the cookie formatted as a Set-Cookie value. + # @return [String] + def to_s + end end - # A cookie store (jar) used by the client to manage cookies across requests. + # Stores cookies for reuse across requests. + # + # Pass a Jar to Wreq::Client as `cookie_provider` to share its cookies. class Jar - # Create a new, empty cookie jar. + # Creates an empty cookie jar. # @return [Wreq::Jar] def self.new end - # Get all cookies currently stored. + # Returns all stored cookies. # @return [Array] def get_all end - # Add a cookie from a Set-Cookie string for the given URL. - # @param cookie [String, Wreq::Cookie] A Set-Cookie string - # @param url [String] + # Adds a Cookie object or Set-Cookie string for the given URL. + # @param cookie [String, Wreq::Cookie] Cookie to store + # @param url [String] URL that scopes the cookie # @return [void] + # @raise [TypeError] if cookie is neither a String nor Wreq::Cookie def add(cookie, url) end @@ -165,6 +194,8 @@ def clear module Wreq class Cookie + # Returns a short representation for debugging. + # @return [String] def inspect parts = ["#" end diff --git a/src/cookie.rs b/src/cookie.rs index 14da8ea..fdb16f6 100644 --- a/src/cookie.rs +++ b/src/cookie.rs @@ -1,18 +1,30 @@ -use std::{fmt, sync::Arc, time::SystemTime}; +//! Ruby bindings for HTTP cookies and the shared cookie jar. +//! +//! Expiration follows [RFC 6265]. The binding accepts Ruby `Time` values and +//! finite Unix timestamps without converting them through unsigned +//! `SystemTime`. A non-positive Max-Age expires the cookie immediately. +//! +//! [RFC 6265]: https://www.rfc-editor.org/rfc/rfc6265.html +use std::{fmt, sync::Arc}; + +use ::serde::Deserialize; use bytes::Bytes; -use cookie::{Cookie as RawCookie, Expiration, ParseError, time::Duration}; +use cookie::{Cookie as RawCookie, ParseError, time::Duration}; use magnus::{ - Error, Module, Object, RHash, RModule, RString, Ruby, TryConvert, Value, function, method, - r_hash::ForEach, typed_data::Obj, value::ReprValue, + Error, Module, Object, RHash, RModule, RString, Ruby, Time, TryConvert, Value, function, + method, r_hash::ForEach, typed_data::Obj, value::ReprValue, }; use wreq::header::{self, HeaderMap, HeaderValue}; use crate::{ error::{header_value_error, type_error}, gvl, + options::{NativeOption, Options}, }; +use self::helper::{CookieExpiration, to_ruby_time, to_unix_timestamp}; + // Defines constant registration, `into_ffi`/`from_ffi`, and handlers for // Ruby's `to_s`, `to_sym`, `==`, `eql?`, and `hash` methods. define_ruby_enum!( @@ -35,80 +47,103 @@ pub struct Cookie(RawCookie<'static>); #[derive(Default)] pub struct Cookies(pub Vec); -/// A good default `CookieStore` implementation. +/// Keyword options accepted by `Wreq::Cookie.new`. +#[derive(Deserialize)] +struct Builder { + /// The Domain attribute. + domain: Option, + + /// The Path attribute. + path: Option, + + /// The signed Max-Age lifetime in seconds. + #[serde(default)] + max_age: NativeOption, + + /// The expiration converted from a Ruby `Time` or Numeric value. + #[serde(default)] + expires: NativeOption, + + /// Whether the cookie is inaccessible to client-side scripts. + http_only: Option, + + /// Whether the cookie is restricted to secure connections. + secure: Option, + + /// The SameSite policy. + #[serde(default)] + same_site: NativeOption>, +} + +/// A cookie jar that can be shared with a Ruby `Wreq::Client`. /// -/// This is the implementation used when simply calling `cookie_store(true)`. -/// This type is exposed to allow creating one and filling it with some -/// existing cookies more easily, before creating a `Client`. +/// Pass a populated jar as the client's `cookie_provider` option. #[derive(Clone, Default)] #[magnus::wrap(class = "Wreq::Jar", free_immediately, size)] pub struct Jar(pub Arc); -// ===== impl Cookie ===== - -impl Cookie { - /// Create a new [`Cookie`]. - pub fn new(args: &[Value]) -> Result { - let args = - magnus::scan_args::scan_args::<(String, String), (), (), (), magnus::RHash, ()>(args)?; - #[allow(clippy::type_complexity)] - let keywords: magnus::scan_args::KwArgs< - (), - ( - Option, - Option, - Option, - Option, - Option, - Option, - Option>, - ), - (), - > = magnus::scan_args::get_kwargs( - args.keywords, - &[], - &[ - "domain", - "path", - "max_age", - "expires", - "http_only", - "secure", - "same_site", - ], - )?; - - let (name, value) = args.required; +// ===== impl Builder ===== + +impl Builder { + /// Validates and deserializes a Ruby keyword options hash. + /// + /// # Errors + /// + /// Returns `ArgumentError` for unknown or duplicate keys. Invalid values + /// return the Ruby conversion error with the option name attached. + fn from_options(options: Options<'_>) -> Result { + let mut builder = options.validate_keys::()?.deserialize::()?; + extract_native_option!(options, builder, max_age); + extract_native_option!(options, builder, expires); + extract_native_option!(options, builder, same_site); + Ok(builder) + } + /// Builds a native cookie from its name, value, and optional attributes. + fn build(mut self, name: String, value: String) -> Cookie { let mut cookie = RawCookie::new(name, value); - if let Some(domain) = keywords.optional.0 { + if let Some(domain) = self.domain { cookie.set_domain(domain); } - if let Some(path) = keywords.optional.1 { + if let Some(path) = self.path { cookie.set_path(path); } - if let Some(max_age) = keywords.optional.2 { - cookie.set_max_age(Duration::seconds(max_age as i64)); + if let Some(max_age) = self.max_age.take() { + cookie.set_max_age(Duration::seconds(max_age)); } - if let Some(expires) = keywords.optional.3 { - let duration = std::time::Duration::from_secs_f64(expires); - if let Some(system_time) = SystemTime::UNIX_EPOCH.checked_add(duration) { - cookie.set_expires(Expiration::DateTime(system_time.into())); - } + if let Some(expires) = self.expires.take() { + cookie.set_expires(expires.into_inner()); } - cookie.set_http_only(keywords.optional.4); - cookie.set_secure(keywords.optional.5); + cookie.set_http_only(self.http_only); + cookie.set_secure(self.secure); - if let Some(same_site) = keywords.optional.6 { + if let Some(same_site) = self.same_site.take() { cookie.set_same_site(same_site.into_ffi()); } - Ok(Self(cookie)) + Cookie(cookie) + } +} + +// ===== Ruby Cookie API ===== + +impl Cookie { + /// Creates a [`Cookie`] from its name, value, and keyword options. + /// + /// # Errors + /// + /// Returns `ArgumentError` for unknown or duplicate keys. Invalid values + /// return the Ruby conversion error with the option name attached. + pub fn new(ruby: &Ruby, args: &[Value]) -> Result { + let args = magnus::scan_args::scan_args::<(String, String), (), (), (), RHash, ()>(args)?; + let (name, value) = args.required; + Builder::from_options(Options::new(ruby, args.keywords)) + .map(|builder| builder.build(name, value)) } /// The name of the cookie. @@ -135,18 +170,24 @@ impl Cookie { self.0.secure().unwrap_or(false) } - /// Returns true if 'SameSite' directive is 'Lax'. + /// Returns true when the SameSite setting is Lax. #[inline] pub fn same_site_lax(&self) -> bool { self.0.same_site() == Some(cookie::SameSite::Lax) } - /// Returns true if 'SameSite' directive is 'Strict'. + /// Returns true when the SameSite setting is Strict. #[inline] pub fn same_site_strict(&self) -> bool { self.0.same_site() == Some(cookie::SameSite::Strict) } + /// Returns the SameSite setting, if present. + #[inline] + pub fn same_site(&self) -> Option { + self.0.same_site().map(SameSite::from_ffi) + } + /// Returns the path directive of the cookie, if set. #[inline] pub fn path(&self) -> Option<&str> { @@ -159,41 +200,66 @@ impl Cookie { self.0.domain() } - /// Get the Max-Age information. + /// Returns the signed Max-Age lifetime in seconds. #[inline] pub fn max_age(&self) -> Option { self.0.max_age().map(|d| d.whole_seconds()) } - /// The cookie expiration time. + /// Returns the cookie expiration as a UTC Ruby `Time`. + pub fn expires_at(ruby: &Ruby, rb_self: &Self) -> Result, Error> { + rb_self + .0 + .expires_datetime() + .map(|value| to_ruby_time(ruby, value)) + .transpose() + } + + /// Returns the cookie expiration as fractional Unix seconds. #[inline] pub fn expires(&self) -> Option { - match self.0.expires() { - Some(Expiration::DateTime(offset)) => { - let system_time = SystemTime::from(offset); - system_time - .duration_since(SystemTime::UNIX_EPOCH) - .ok() - .map(|d| d.as_secs_f64()) - } - None | Some(Expiration::Session) => None, - } + self.0.expires_datetime().map(to_unix_timestamp) + } + + /// Formats the cookie as a Set-Cookie value. + #[inline] + pub fn to_s(&self) -> String { + self.to_string() } } +// ===== Native Cookie helpers ===== + impl Cookie { + /// Clone this cookie for insertion into the native jar. + /// + /// [RFC 6265 section 5.2.2] says that zero or a negative Max-Age expires a + /// cookie immediately. The native jar recognizes zero for deletion, so + /// this clone maps negative values to zero before insertion. The Ruby + /// object keeps its original signed value. + /// + /// [RFC 6265 section 5.2.2]: https://www.rfc-editor.org/rfc/rfc6265.html#section-5.2.2 + fn clone_for_jar(&self) -> RawCookie<'static> { + let mut cookie = self.0.clone(); + if cookie.max_age().is_some_and(Duration::is_negative) { + cookie.set_max_age(Duration::ZERO); + } + + cookie + } + /// Parse cookies from a `HeaderMap`. - pub fn extract_headers_cookies(headers: &HeaderMap) -> Vec { + pub(crate) fn extract_headers_cookies(headers: &HeaderMap) -> Vec { headers .get_all(header::SET_COOKIE) .iter() - .map(Cookie::parse) - .flat_map(Result::ok) + .filter_map(|value| Self::parse(value).ok()) .map(RawCookie::into_owned) .map(Cookie) .collect() } + /// Parse one Set-Cookie header value. fn parse<'a>(value: &'a HeaderValue) -> Result, ParseError> { std::str::from_utf8(value.as_bytes()) .map_err(cookie::ParseError::from) @@ -243,7 +309,7 @@ impl TryConvert for Cookies { impl Jar { /// Create a new [`Jar`] with an empty cookie store. pub fn new() -> Self { - Jar(Arc::new(wreq::cookie::Jar::default())) + Self(Arc::new(wreq::cookie::Jar::default())) } /// Get all cookies. @@ -262,14 +328,21 @@ impl Jar { } /// Add a cookie to this jar. - pub fn add(&self, cookie: Value, url: String) { + /// + /// # Errors + /// + /// Returns `TypeError` when `cookie` is neither a [`Cookie`] nor a String. + pub fn add(&self, cookie: Value, url: String) -> Result<(), Error> { if let Ok(cookie) = Obj::::try_convert(cookie) { - gvl::nogvl(|| self.0.add(cookie.0.clone(), &url)) + gvl::nogvl(|| self.0.add(cookie.clone_for_jar(), &url)); + return Ok(()); } - if let Ok(cookie_str) = String::try_convert(cookie) { - gvl::nogvl(|| self.0.add(cookie_str.as_ref(), &url)) - } + let ruby = Ruby::get_with(cookie); + let cookie = String::try_convert(cookie) + .map_err(|_| type_error(&ruby, "cookie must be a Wreq::Cookie or String"))?; + gvl::nogvl(|| self.0.add(cookie.as_ref(), &url)); + Ok(()) } /// Remove a cookie from this jar by name and URL. @@ -283,6 +356,112 @@ impl Jar { } } +mod helper { + //! Converts between Ruby time values and native cookie timestamps. + + use cookie::time::{Duration, OffsetDateTime, error::ComponentRange}; + use magnus::{ + Error, Integer, Ruby, Time, TryConvert, Value, + time::{Offset, Timespec}, + }; + + use crate::error::{argument_error, range_error}; + + /// A validated cookie expiration converted from Ruby. + /// + /// Ruby `Time` values keep nanosecond precision. Integer timestamps are + /// converted directly; other Numeric values use finite fractional Unix + /// seconds. + pub(super) struct CookieExpiration(OffsetDateTime); + + impl CookieExpiration { + /// Unwraps the validated UTC expiration. + pub(super) fn into_inner(self) -> OffsetDateTime { + self.0 + } + } + + impl TryConvert for CookieExpiration { + fn try_convert(value: Value) -> Result { + let ruby = Ruby::get_with(value); + expiration_from_value(&ruby, value).map(Self) + } + } + + /// Converts a native cookie expiration into a UTC Ruby `Time`. + pub(super) fn to_ruby_time(ruby: &Ruby, value: OffsetDateTime) -> Result { + ruby.time_timespec_new( + Timespec { + tv_sec: value.unix_timestamp(), + tv_nsec: i64::from(value.nanosecond()), + }, + Offset::utc(), + ) + } + + /// Converts a native cookie expiration into fractional Unix seconds. + pub(super) fn to_unix_timestamp(value: OffsetDateTime) -> f64 { + value.unix_timestamp() as f64 + f64::from(value.nanosecond()) / 1_000_000_000.0 + } + + /// Converts a Ruby `Time` or Numeric value into a native UTC timestamp. + fn expiration_from_value(ruby: &Ruby, value: Value) -> Result { + if let Some(time) = Time::from_value(value) { + return expiration_from_time(ruby, time); + } + + if let Some(integer) = Integer::from_value(value) { + return integer + .to_i64() + .and_then(|seconds| expiration_from_seconds(ruby, seconds)); + } + + expiration_from_float(ruby, f64::try_convert(value)?) + } + + /// Converts a Ruby `Time` from its signed timespec without using `SystemTime`. + fn expiration_from_time(ruby: &Ruby, value: Time) -> Result { + let timespec = value.timespec()?; + let nanosecond = u32::try_from(timespec.tv_nsec) + .ok() + .filter(|value| *value < 1_000_000_000) + .ok_or_else(|| { + argument_error(ruby, "time nanoseconds are outside the supported range") + })?; + + expiration_from_seconds(ruby, timespec.tv_sec)? + .replace_nanosecond(nanosecond) + .map_err(|error| expiration_range_error(ruby, error)) + } + + /// Converts signed Unix seconds into the native cookie time range. + fn expiration_from_seconds(ruby: &Ruby, seconds: i64) -> Result { + OffsetDateTime::from_unix_timestamp(seconds) + .map_err(|error| expiration_range_error(ruby, error)) + } + + /// Converts a finite fractional Unix timestamp with checked arithmetic. + fn expiration_from_float(ruby: &Ruby, seconds: f64) -> Result { + if !seconds.is_finite() { + return Err(argument_error(ruby, "timestamp must be finite")); + } + + let duration = Duration::checked_seconds_f64(seconds) + .ok_or_else(|| range_error(ruby, "timestamp is outside the supported range"))?; + OffsetDateTime::UNIX_EPOCH + .checked_add(duration) + .ok_or_else(|| range_error(ruby, "timestamp is outside the supported range")) + } + + /// Maps a native timestamp range error to Ruby's `RangeError`. + fn expiration_range_error(ruby: &Ruby, error: ComponentRange) -> Error { + range_error( + ruby, + format!("timestamp is outside the supported range: {error}"), + ) + } +} + pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { // SameSite enum let same_site_class = gem_module.define_class("SameSite", ruby.class_object())?; @@ -304,10 +483,13 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { cookie_class.define_method("secure?", method!(Cookie::secure, 0))?; cookie_class.define_method("same_site_lax?", method!(Cookie::same_site_lax, 0))?; cookie_class.define_method("same_site_strict?", method!(Cookie::same_site_strict, 0))?; + cookie_class.define_method("same_site", method!(Cookie::same_site, 0))?; cookie_class.define_method("path", method!(Cookie::path, 0))?; cookie_class.define_method("domain", method!(Cookie::domain, 0))?; cookie_class.define_method("max_age", method!(Cookie::max_age, 0))?; + cookie_class.define_method("expires_at", method!(Cookie::expires_at, 0))?; cookie_class.define_method("expires", method!(Cookie::expires, 0))?; + cookie_class.define_method("to_s", method!(Cookie::to_s, 0))?; // Jar class let jar_class = gem_module.define_class("Jar", ruby.class_object())?; diff --git a/src/error.rs b/src/error.rs index e3a36a2..15fa60e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -192,6 +192,11 @@ pub fn argument_error(ruby: &Ruby, message: impl Into) -> MagnusError { MagnusError::new(ruby.exception_arg_error(), message.into()) } +/// Builds a Ruby `RangeError` from a validation message. +pub fn range_error(ruby: &Ruby, message: impl Into) -> MagnusError { + MagnusError::new(ruby.exception_range_error(), message.into()) +} + /// Build a `TypeError` from a conversion message. pub fn type_error(ruby: &Ruby, message: impl Into) -> MagnusError { MagnusError::new(ruby.exception_type_error(), message.into()) diff --git a/test/cookie_test.rb b/test/cookie_test.rb index ec2bdb4..6915437 100644 --- a/test/cookie_test.rb +++ b/test/cookie_test.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true require "test_helper" +require "bigdecimal" +require "open3" +require "rbconfig" class CookieTest < Minitest::Test def setup @@ -41,6 +44,14 @@ def test_add_and_get_all assert_equal true, (c.secure || c.secure?) end + def test_add_rejects_invalid_cookie_type + error = assert_raises(TypeError) do + @jar.add(Object.new, @base_url) + end + + assert_equal "cookie must be a Wreq::Cookie or String", error.message + end + def test_add_multiple_and_remove @jar.add("a=1; Path=/", @base_url) @jar.add("b=2; Path=/", @base_url) @@ -84,7 +95,9 @@ def test_max_age_and_expires_optional @jar.add("exp=1; Expires=#{t.gmtime.strftime("%a, %d %b %Y %H:%M:%S GMT")}; Path=/", @base_url) c2 = @jar.get_all.find { |c| c.name == "exp" } assert c2 - # expires returns Float (unix seconds) or nil + # expires_at returns Time. expires remains available for numeric callers. + assert_kind_of Time, c2.expires_at + assert_predicate c2.expires_at, :utc? if (e = c2.expires) assert_kind_of Float, e assert_operator e, :>, Time.now.to_f - 1_000_000 # sanity bound @@ -104,15 +117,18 @@ def test_cookie_new_minimal assert_nil c.domain assert_nil c.max_age assert_nil c.expires + assert_nil c.expires_at assert_equal false, (c.http_only || c.http_only?) assert_equal false, (c.secure || c.secure?) assert_equal false, c.same_site_lax? assert_equal false, c.same_site_strict? + assert_nil c.same_site + assert_equal "sid=abc", c.to_s end def test_cookie_new_full_attributes - exp = Time.now.to_f + 7200.0 + exp = Time.utc(2030, 1, 1, 0, 0, Rational(123_456_789, 1_000_000_000)) c = Wreq::Cookie.new("sess", "v", domain: "example.com", path: "/", @@ -130,16 +146,199 @@ def test_cookie_new_full_attributes # Max-Age returns seconds as Integer assert_equal 3600, c.max_age - # Expires returns Float seconds-since-epoch (with small tolerance) - assert c.expires - assert_kind_of Float, c.expires - assert_in_delta exp, c.expires, 2.0 + assert_equal exp, c.expires_at + assert_predicate c.expires_at, :utc? + assert_in_delta exp.to_f, c.expires, 1e-6 assert_equal true, (c.http_only || c.http_only?) assert_equal true, (c.secure || c.secure?) - # constructor currently sets SameSite to none assert_equal true, c.same_site_lax? assert_equal false, c.same_site_strict? + assert_equal Wreq::SameSite::Lax, c.same_site + end + + def test_same_site_reader_distinguishes_none_from_unset + cookie = Wreq::Cookie.new("same-site", "value", same_site: Wreq::SameSite::None) + + assert_equal Wreq::SameSite::None, cookie.same_site + refute cookie.same_site_lax? + refute cookie.same_site_strict? + end + + def test_cookie_new_uses_shared_keyword_validation + cookie = Wreq::Cookie.new("sid", "abc", **{"path" => "/"}) + assert_equal "/", cookie.path + + secret = "must-not-appear" + unknown_error = assert_raises(ArgumentError) do + Wreq::Cookie.new("sid", "abc", domian: secret) + end + assert_includes unknown_error.message, ":domian" + refute_includes unknown_error.message, secret + + duplicate_options = {path: "/one"} + duplicate_options["path"] = "/two" + duplicate_error = assert_raises(ArgumentError) do + Wreq::Cookie.new("sid", "abc", **duplicate_options) + end + assert_includes duplicate_error.message, "duplicate option: :path" + end + + def test_expires_accepts_past_time + expiration = Time.at(Rational(-5, 4)).utc + cookie = Wreq::Cookie.new("past", "value", expires: expiration) + + assert_equal expiration, cookie.expires_at + assert_in_delta(-1.25, cookie.expires, 1e-9) + end + + def test_expires_accepts_integer_and_fractional_timestamps + [ + 1_893_456_000, + 1_893_456_000.125, + Rational(-5, 4), + BigDecimal("1893456000.125") + ].each do |timestamp| + cookie = Wreq::Cookie.new("timestamp", "value", expires: timestamp) + + assert_kind_of Time, cookie.expires_at + assert_predicate cookie.expires_at, :utc? + assert_in_delta timestamp.to_f, cookie.expires_at.to_f, 1e-6 + assert_in_delta timestamp.to_f, cookie.expires, 1e-6 + end + end + + def test_expires_normalizes_time_to_utc_without_losing_precision + expiration = Time.new( + 2030, + 1, + 1, + 12, + 34, + Rational(123_456_789, 1_000_000_000), + "+09:00" + ) + actual = Wreq::Cookie.new("offset", "value", expires: expiration).expires_at + + assert_predicate actual, :utc? + assert_equal expiration.to_r, actual.to_r + end + + def test_expires_rejects_non_finite_timestamps + [Float::NAN, Float::INFINITY, -Float::INFINITY].each do |timestamp| + error = assert_raises(ArgumentError) do + Wreq::Cookie.new("invalid", "value", expires: timestamp) + end + + assert_match(/expires.*finite/, error.message) + end + end + + def test_expires_rejects_unrepresentable_times + expirations = [ + -(2**63), + 2**63 - 1, + -Float::MAX, + Float::MAX, + Time.utc(10_000, 1, 1) + ] + + expirations.each do |expiration| + error = assert_raises(RangeError) do + Wreq::Cookie.new("invalid", "value", expires: expiration) + end + + assert_match(/expires.*supported range/, error.message) + end + end + + def test_max_age_accepts_signed_boundaries_without_wrapping + [-(2**63), -1, 0, 2**63 - 1].each do |max_age| + cookie = Wreq::Cookie.new("max-age", "value", max_age: max_age) + + assert_equal max_age, cookie.max_age + end + + [-(2**63) - 1, 2**63].each do |max_age| + assert_raises(RangeError) do + Wreq::Cookie.new("max-age", "value", max_age: max_age) + end + end + end + + def test_non_positive_max_age_removes_cookie_from_jar + [-1, 0].each do |max_age| + jar = Wreq::Jar.new + jar.add("session=old; Path=/", @base_url) + deletion = Wreq::Cookie.new("session", "gone", path: "/", max_age: max_age) + + jar.add(deletion, @base_url) + + assert_equal max_age, deletion.max_age + assert_empty jar.get_all + end + end + + def test_past_expiration_removes_cookie_from_jar + @jar.add("session=old; Path=/", @base_url) + deletion = Wreq::Cookie.new( + "session", + "gone", + path: "/", + expires: Time.at(-1).utc + ) + + @jar.add(deletion, @base_url) + + assert_empty @jar.get_all + end + + def test_expiration_regressions_exit_subprocess_normally + lib_dir = File.expand_path("../lib", __dir__) + script = <<~RUBY + require "wreq" + + past = Wreq::Cookie.new("past", "value", expires: -1.0) + abort "past timestamp was not retained" unless past.expires_at == Time.at(-1).utc + + [Float::NAN, Float::INFINITY, -Float::INFINITY].each do |timestamp| + begin + Wreq::Cookie.new("invalid", "value", expires: timestamp) + rescue ArgumentError + next + end + + abort "non-finite timestamp did not raise ArgumentError" + end + + [Float::MAX, -Float::MAX].each do |timestamp| + begin + Wreq::Cookie.new("invalid", "value", expires: timestamp) + rescue RangeError + next + end + + abort "unrepresentable finite timestamp did not raise RangeError" + end + + [-(2**63) - 1, 2**63].each do |max_age| + begin + Wreq::Cookie.new("invalid", "value", max_age: max_age) + rescue RangeError + next + end + + abort "out-of-range Max-Age did not raise RangeError" + end + + puts "ok" + RUBY + + stdout, stderr, status = Open3.capture3(RbConfig.ruby, "-I", lib_dir, "-e", script) + + assert status.success?, "subprocess failed with #{status.inspect}: #{stderr}" + assert_equal "ok\n", stdout + refute_match(/panicked|fatal|access violation|cannot convert float seconds to Duration/i, stderr) end def test_same_site_flags_from_parsed_header @@ -148,10 +347,12 @@ def test_same_site_flags_from_parsed_header @jar.add("s2=1; Path=/; SameSite=Lax", @base_url) cookies = @jar.get_all - h = cookies.to_h { |ck| [ck.name, [ck.same_site_strict?, ck.same_site_lax?]] } + h = cookies.to_h do |cookie| + [cookie.name, [cookie.same_site, cookie.same_site_strict?, cookie.same_site_lax?]] + end - assert_equal [true, false], h["s1"] - assert_equal [false, true], h["s2"] + assert_equal [Wreq::SameSite::Strict, true, false], h["s1"] + assert_equal [Wreq::SameSite::Lax, false, true], h["s2"] end def test_request_uncompressed_cookies