From 2e4fa728e8564afbb1831da2384115da3d2c2200 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 1 Nov 2026 17:04:20 +0800 Subject: [PATCH] refactor(cookies): store creation times as Unix microseconds --- moli-cookie-cache/src/cache.rs | 19 ++- moli-cookie-cache/src/tests.rs | 60 ++++++- moli-cookie-db/src/codec.rs | 141 ++++++++++----- moli-cookie-db/src/tests.rs | 160 +++++++++++++++++- moli-cookie-import/src/creation_time.rs | 3 +- moli-cookie-jar/src/lib.rs | 1 + moli-cookie-store/src/cookie.rs | 8 +- moli-cookie-store/src/cookie_creation_time.rs | 74 ++++++++ moli-cookie-store/src/cookie_store/results.rs | 7 +- .../src/cookie_store/tests/query_same_site.rs | 56 +++++- moli-cookie-store/src/lib.rs | 1 + moli-protocol/src/domains/storage/tests.rs | 14 +- 12 files changed, 476 insertions(+), 68 deletions(-) create mode 100644 moli-cookie-store/src/cookie_creation_time.rs diff --git a/moli-cookie-cache/src/cache.rs b/moli-cookie-cache/src/cache.rs index 01ad0eace7..656666b4a4 100644 --- a/moli-cookie-cache/src/cache.rs +++ b/moli-cookie-cache/src/cache.rs @@ -3,7 +3,7 @@ use std::{fs, path::Path}; use anyhow::{Context, Result}; use moli_cookie_jar::{ CookiePriority, StoredCookie, StoredCookiePartitionKey, StoredCookieSameSite, - StoredCookieSourceScheme, + StoredCookieSourceScheme, cookie_creation_time, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; @@ -30,6 +30,9 @@ struct CachedCookie { http_only: bool, expires_unix: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + creation_unix_micros: Option, + // Read compatibility only. New caches always use microseconds. + #[serde(default, skip_serializing)] creation_unix_nanos: Option, same_site: CachedSameSite, priority: Option, @@ -123,9 +126,12 @@ impl CachedCookie { if self.partitioned && self.partition_key.is_none() { return None; } - let creation_time = match self.creation_unix_nanos { - Some(timestamp) => Some(OffsetDateTime::from_unix_timestamp_nanos(timestamp).ok()?), - None => None, + let creation_time = match (self.creation_unix_micros, self.creation_unix_nanos) { + (Some(timestamp), _) => Some(cookie_creation_time::from_unix_micros(timestamp)?), + (None, Some(timestamp)) => Some(cookie_creation_time::from_unix_micros( + cookie_creation_time::legacy_unix_nanos_to_micros(timestamp)?, + )?), + (None, None) => None, }; Some(StoredCookie { @@ -173,9 +179,10 @@ impl CachedCookie { secure: cookie.secure, http_only: cookie.http_only, expires_unix: cookie.expires.map(|expires| expires.unix_timestamp()), - creation_unix_nanos: cookie + creation_unix_micros: cookie .creation_time - .map(|created| created.unix_timestamp_nanos()), + .and_then(cookie_creation_time::to_unix_micros), + creation_unix_nanos: None, same_site: cookie.same_site.into(), priority: cookie.priority.map(Into::into), partitioned: partition_key.is_some(), diff --git a/moli-cookie-cache/src/tests.rs b/moli-cookie-cache/src/tests.rs index 4467160d28..bd4d91dbc0 100644 --- a/moli-cookie-cache/src/tests.rs +++ b/moli-cookie-cache/src/tests.rs @@ -19,14 +19,22 @@ use crate::{atomic_file::write_file_atomically, netscape::parse_netscape_cookie_ fn cookie_cache_preserves_the_creation_time_used_by_default_same_site_policy() -> Result<()> { let temp = TempDir::new("same-site-age"); let file = temp.path.join("cookies.json"); - let created = - OffsetDateTime::from_unix_timestamp(1_000_000_000)? + time::Duration::nanoseconds(123); + let created = OffsetDateTime::from_unix_timestamp(1_000_000_000)? + + time::Duration::nanoseconds(123_456_789); + let expected = + OffsetDateTime::from_unix_timestamp(1_000_000_000)? + time::Duration::microseconds(123_456); let mut cookie = stored_cookie("sid", "old"); cookie.creation_time = Some(created); save_cookie_cache(&file, vec![cookie])?; + let json: serde_json::Value = serde_json::from_slice(&fs::read(&file)?)?; + assert_eq!( + json["cookies"][0]["creation_unix_micros"], + 1_000_000_000_123_456_i64 + ); + assert!(json["cookies"][0].get("creation_unix_nanos").is_none()); let restored = load_cookie_cache(&file)?; assert_eq!(restored.len(), 1); - assert_eq!(restored[0].creation_time, Some(created)); + assert_eq!(restored[0].creation_time, Some(expected)); let mut store = BrowserCookieStore::default(); let url = "http://example.com/".parse()?; assert!( @@ -42,7 +50,7 @@ fn cookie_cache_preserves_the_creation_time_used_by_default_same_site_policy() - assert_eq!(report.excluded_cookies.len(), 1); assert_eq!( report.excluded_cookies[0].cookie.creation_time, - Some(created) + Some(expected) ); assert_eq!( report.excluded_cookies[0].exclusion_reasons, @@ -51,6 +59,50 @@ fn cookie_cache_preserves_the_creation_time_used_by_default_same_site_policy() - Ok(()) } +#[test] +fn cookie_cache_reads_legacy_nanoseconds_and_rewrites_only_microseconds() -> Result<()> { + let temp = TempDir::new("legacy-nanosecond-age"); + let file = temp.path.join("cookies.json"); + save_cookie_cache(&file, vec![stored_cookie("sid", "legacy")])?; + let mut json: serde_json::Value = serde_json::from_slice(&fs::read(&file)?)?; + for (nanos, micros) in [ + (1_000_000_000_123_456_789_i128, 1_000_000_000_123_456_i64), + (-1, -1), + ] { + let row = json["cookies"][0].as_object_mut().unwrap(); + row.remove("creation_unix_micros"); + row.insert("creation_unix_nanos".into(), serde_json::json!(nanos)); + fs::write(&file, serde_json::to_vec(&json)?)?; + let restored = load_cookie_cache(&file)?; + assert_eq!(restored.len(), 1); + assert_eq!(restored[0].value, "legacy"); + assert_eq!( + restored[0].creation_time.unwrap().unix_timestamp_nanos(), + i128::from(micros) * 1_000 + ); + save_cookie_cache(&file, restored)?; + let rewritten: serde_json::Value = serde_json::from_slice(&fs::read(&file)?)?; + assert_eq!(rewritten["cookies"][0]["creation_unix_micros"], micros); + assert!(rewritten["cookies"][0].get("creation_unix_nanos").is_none()); + } + Ok(()) +} + +#[test] +fn cookie_cache_prefers_the_microsecond_field_when_both_formats_are_present() -> Result<()> { + let temp = TempDir::new("mixed-creation-time-formats"); + let file = temp.path.join("cookies.json"); + save_cookie_cache(&file, vec![stored_cookie("sid", "mixed")])?; + let mut json: serde_json::Value = serde_json::from_slice(&fs::read(&file)?)?; + json["cookies"][0]["creation_unix_micros"] = serde_json::json!(0); + json["cookies"][0]["creation_unix_nanos"] = serde_json::json!(1_000_000_000_i128); + fs::write(&file, serde_json::to_vec(&json)?)?; + let restored = load_cookie_cache(&file)?; + assert_eq!(restored.len(), 1); + assert_eq!(restored[0].creation_time, Some(OffsetDateTime::UNIX_EPOCH)); + Ok(()) +} + struct TempDir { path: PathBuf, } diff --git a/moli-cookie-db/src/codec.rs b/moli-cookie-db/src/codec.rs index 6e43bfcdfb..4c640a96e1 100644 --- a/moli-cookie-db/src/codec.rs +++ b/moli-cookie-db/src/codec.rs @@ -1,8 +1,26 @@ use super::*; -use rusqlite::{Connection, params, types::Type}; +use moli_cookie_jar::cookie_creation_time; +use rusqlite::{ + Connection, params, params_from_iter, + types::{Type, Value}, +}; use time::{Duration, OffsetDateTime}; pub(super) const APPLICATION_ID: i32 = 0x4d4c434b; // MLCK +const SCHEMA_VERSION: i32 = 3; +const CREATE_COOKIES: &str = "CREATE TABLE cookies( + partition_key BLOB NOT NULL,domain TEXT NOT NULL,path TEXT NOT NULL,name TEXT NOT NULL,value TEXT NOT NULL, + host_only INTEGER NOT NULL CHECK(host_only IN (0,1)),secure INTEGER NOT NULL CHECK(secure IN (0,1)),http_only INTEGER NOT NULL CHECK(http_only IN (0,1)), + expires_seconds INTEGER,expires_nanos INTEGER CHECK(expires_nanos>=0 AND expires_nanos<1000000000), + same_site INTEGER NOT NULL CHECK(same_site BETWEEN 0 AND 3),priority INTEGER CHECK(priority BETWEEN 0 AND 2), + source_scheme INTEGER NOT NULL CHECK(source_scheme BETWEEN 0 AND 2),source_port INTEGER NOT NULL, + creation_index BLOB NOT NULL CHECK(length(creation_index)=8),last_access_index BLOB NOT NULL CHECK(length(last_access_index)=8), + creation_unix_micros INTEGER, + CHECK((expires_seconds IS NULL)=(expires_nanos IS NULL)),PRIMARY KEY(partition_key,domain,path,name)) WITHOUT ROWID;"; +const UPSERT_COOKIE: &str = "INSERT INTO cookies(partition_key,domain,path,name,value,host_only,secure,http_only,expires_seconds,expires_nanos,same_site,priority,source_scheme,source_port,creation_index,last_access_index,creation_unix_micros) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17) + ON CONFLICT(partition_key,domain,path,name) DO UPDATE SET value=excluded.value,host_only=excluded.host_only,secure=excluded.secure,http_only=excluded.http_only,expires_seconds=excluded.expires_seconds,expires_nanos=excluded.expires_nanos,same_site=excluded.same_site,priority=excluded.priority,source_scheme=excluded.source_scheme,source_port=excluded.source_port,creation_index=excluded.creation_index,last_access_index=excluded.last_access_index,creation_unix_micros=excluded.creation_unix_micros"; + pub(super) fn initialize(connection: &mut Connection) -> Result<(), WritebackError> { let application: i32 = connection.pragma_query_value(None, "application_id", |r| r.get(0))?; let version: i32 = connection.pragma_query_value(None, "user_version", |r| r.get(0))?; @@ -16,35 +34,61 @@ pub(super) fn initialize(connection: &mut Connection) -> Result<(), WritebackErr return Err(WritebackError::new("unidentified Cookie database")); } let tx = connection.transaction()?; - tx.execute_batch("CREATE TABLE cookies( - partition_key BLOB NOT NULL,domain TEXT NOT NULL,path TEXT NOT NULL,name TEXT NOT NULL,value TEXT NOT NULL, - host_only INTEGER NOT NULL CHECK(host_only IN (0,1)),secure INTEGER NOT NULL CHECK(secure IN (0,1)),http_only INTEGER NOT NULL CHECK(http_only IN (0,1)), - expires_seconds INTEGER,expires_nanos INTEGER CHECK(expires_nanos>=0 AND expires_nanos<1000000000), - same_site INTEGER NOT NULL CHECK(same_site BETWEEN 0 AND 3),priority INTEGER CHECK(priority BETWEEN 0 AND 2), - source_scheme INTEGER NOT NULL CHECK(source_scheme BETWEEN 0 AND 2),source_port INTEGER NOT NULL, - creation_index BLOB NOT NULL CHECK(length(creation_index)=8),last_access_index BLOB NOT NULL CHECK(length(last_access_index)=8), - creation_seconds INTEGER,creation_nanos INTEGER CHECK(creation_nanos>=0 AND creation_nanos<1000000000), - CHECK((creation_seconds IS NULL)=(creation_nanos IS NULL)), - CHECK((expires_seconds IS NULL)=(expires_nanos IS NULL)),PRIMARY KEY(partition_key,domain,path,name)) WITHOUT ROWID;")?; + tx.execute_batch(CREATE_COOKIES)?; tx.pragma_update(None, "application_id", APPLICATION_ID)?; - tx.pragma_update(None, "user_version", 2)?; + tx.pragma_update(None, "user_version", SCHEMA_VERSION)?; tx.commit()?; } else if application == APPLICATION_ID && version == 1 { let tx = connection.transaction()?; - tx.execute_batch( - "ALTER TABLE cookies ADD COLUMN creation_seconds INTEGER; - ALTER TABLE cookies ADD COLUMN creation_nanos INTEGER - CHECK(creation_nanos>=0 AND creation_nanos<1000000000) - CHECK((creation_seconds IS NULL)=(creation_nanos IS NULL));", - )?; - tx.pragma_update(None, "user_version", 2)?; + tx.execute_batch("ALTER TABLE cookies ADD COLUMN creation_unix_micros INTEGER;")?; + tx.pragma_update(None, "user_version", SCHEMA_VERSION)?; tx.commit()?; - } else if application != APPLICATION_ID || version != 2 { + } else if application == APPLICATION_ID && version == 2 { + migrate_creation_time_to_micros(connection)?; + } else if application != APPLICATION_ID || version != SCHEMA_VERSION { return Err(WritebackError::new(format!( "unsupported Cookie database identity/version {application}/{version}" ))); } - connection.prepare("SELECT partition_key,domain,path,name,value,host_only,secure,http_only,expires_seconds,expires_nanos,same_site,priority,source_scheme,source_port,creation_index,last_access_index,creation_seconds,creation_nanos FROM cookies LIMIT 0")?; + connection.prepare("SELECT partition_key,domain,path,name,value,host_only,secure,http_only,expires_seconds,expires_nanos,same_site,priority,source_scheme,source_port,creation_index,last_access_index,creation_unix_micros FROM cookies LIMIT 0")?; + Ok(()) +} + +fn migrate_creation_time_to_micros(connection: &mut Connection) -> Result<(), WritebackError> { + let tx = connection.transaction()?; + // Rebuild because the old pair-wise CHECK constraint refers to both columns. + // Keep conversion in Rust: SQLite integer overflow can promote to REAL. + tx.execute_batch("ALTER TABLE cookies RENAME TO cookies_before_micros;")?; + tx.execute_batch(CREATE_COOKIES)?; + { + let mut source = tx.prepare("SELECT partition_key,domain,path,name,value,host_only,secure,http_only,expires_seconds,expires_nanos,same_site,priority,source_scheme,source_port,creation_index,last_access_index,creation_seconds,creation_nanos FROM cookies_before_micros")?; + let mut destination = tx.prepare(UPSERT_COOKIE)?; + let mut rows = source.query([])?; + while let Some(row) = rows.next()? { + let micros = match ( + row.get::<_, Option>(16)?, + row.get::<_, Option>(17)?, + ) { + (None, None) => None, + (Some(seconds), Some(nanos)) if (0..1_000_000_000).contains(&nanos) => Some( + cookie_creation_time::legacy_unix_nanos_to_micros( + i128::from(seconds) * 1_000_000_000 + i128::from(nanos), + ) + .ok_or_else(|| invalid("invalid legacy Cookie creation time"))?, + ), + _ => return Err(invalid("invalid legacy Cookie creation time fields").into()), + }; + let mut values = Vec::with_capacity(17); + for index in 0..16 { + values.push(row.get::<_, Value>(index)?); + } + values.push(micros.map_or(Value::Null, Value::Integer)); + destination.execute(params_from_iter(values))?; + } + } + tx.execute_batch("DROP TABLE cookies_before_micros;")?; + tx.pragma_update(None, "user_version", SCHEMA_VERSION)?; + tx.commit()?; Ok(()) } pub(super) fn partition_bytes(key: Option<&StoredCookiePartitionKey>) -> Vec { @@ -105,7 +149,7 @@ fn flag(row: &rusqlite::Row<'_>, position: usize) -> rusqlite::Result { } } pub(super) fn read(connection: &mut Connection) -> Result, WritebackError> { - let mut statement=connection.prepare("SELECT partition_key,domain,path,name,value,host_only,secure,http_only,expires_seconds,expires_nanos,same_site,priority,source_scheme,source_port,creation_index,last_access_index,creation_seconds,creation_nanos FROM cookies ORDER BY creation_index")?; + let mut statement=connection.prepare("SELECT partition_key,domain,path,name,value,host_only,secure,http_only,expires_seconds,expires_nanos,same_site,priority,source_scheme,source_port,creation_index,last_access_index,creation_unix_micros FROM cookies ORDER BY creation_index")?; let rows = statement.query_map([], |row| { let seconds: Option = row.get(8)?; let nanos: Option = row.get(9)?; @@ -154,19 +198,13 @@ pub(super) fn read(connection: &mut Connection) -> Result, Wri source_scheme, source_port: row.get(13)?, creation_index: index(row.get(14)?)?, - creation_time: match ( - row.get::<_, Option>(16)?, - row.get::<_, Option>(17)?, - ) { - (None, None) => None, - (Some(seconds), Some(nanos)) if (0..1_000_000_000).contains(&nanos) => Some( - OffsetDateTime::from_unix_timestamp(seconds) - .ok() - .and_then(|time| time.checked_add(Duration::nanoseconds(nanos))) - .ok_or_else(|| invalid("invalid Cookie creation time"))?, - ), - _ => return Err(invalid("invalid Cookie creation time fields")), - }, + creation_time: row + .get::<_, Option>(16)? + .map(|micros| { + cookie_creation_time::from_unix_micros(micros) + .ok_or_else(|| invalid("invalid Cookie creation time")) + }) + .transpose()?, last_access_index: index(row.get(15)?)?, }) })?; @@ -193,9 +231,34 @@ pub(super) fn upsert( StoredCookieSourceScheme::NonSecure => 1, StoredCookieSourceScheme::Secure => 2, }; - connection.execute("INSERT INTO cookies(partition_key,domain,path,name,value,host_only,secure,http_only,expires_seconds,expires_nanos,same_site,priority,source_scheme,source_port,creation_index,last_access_index,creation_seconds,creation_nanos) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18) - ON CONFLICT(partition_key,domain,path,name) DO UPDATE SET value=excluded.value,host_only=excluded.host_only,secure=excluded.secure,http_only=excluded.http_only,expires_seconds=excluded.expires_seconds,expires_nanos=excluded.expires_nanos,same_site=excluded.same_site,priority=excluded.priority,source_scheme=excluded.source_scheme,source_port=excluded.source_port,creation_index=excluded.creation_index,last_access_index=excluded.last_access_index,creation_seconds=excluded.creation_seconds,creation_nanos=excluded.creation_nanos", - params![key.partition,key.domain,key.path,key.name,cookie.value,cookie.host_only,cookie.secure,cookie.http_only,cookie.expires.map(|e|e.unix_timestamp()),cookie.expires.map(|e|i64::from(e.nanosecond())),same_site,priority,source,cookie.source_port,cookie.creation_index.to_be_bytes().as_slice(),cookie.last_access_index.to_be_bytes().as_slice(),cookie.creation_time.map(|t| t.unix_timestamp()),cookie.creation_time.map(|t| i64::from(t.nanosecond()))])?; + let creation_micros = cookie + .creation_time + .map(|created| { + cookie_creation_time::to_unix_micros(created) + .ok_or_else(|| invalid("Cookie creation time exceeds microsecond range")) + }) + .transpose()?; + connection.execute( + UPSERT_COOKIE, + params![ + key.partition, + key.domain, + key.path, + key.name, + cookie.value, + cookie.host_only, + cookie.secure, + cookie.http_only, + cookie.expires.map(|e| e.unix_timestamp()), + cookie.expires.map(|e| i64::from(e.nanosecond())), + same_site, + priority, + source, + cookie.source_port, + cookie.creation_index.to_be_bytes().as_slice(), + cookie.last_access_index.to_be_bytes().as_slice(), + creation_micros + ], + )?; Ok(()) } diff --git a/moli-cookie-db/src/tests.rs b/moli-cookie-db/src/tests.rs index 59ce3fee96..b7a1fb154e 100644 --- a/moli-cookie-db/src/tests.rs +++ b/moli-cookie-db/src/tests.rs @@ -136,7 +136,11 @@ fn complete_metadata_and_unsigned_ordering_indices_survive_reopen() { ); row.creation_index = u64::MAX - 2; row.last_access_index = u64::MAX - 1; - let expected = row.clone(); + let mut expected = row.clone(); + expected.creation_time = Some( + OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap() + + TimeDuration::microseconds(123_456), + ); owner .owner .client() @@ -379,7 +383,9 @@ fn sqlite_cookie_creation_time_preserves_navigation_policy_across_reopen() { let root = tempfile::tempdir().unwrap(); let runtime = Runtime::new().unwrap(); - let now = OffsetDateTime::now_utc(); + let now = OffsetDateTime::now_utc() + .replace_nanosecond(123_456_000) + .unwrap(); let (store, owner) = open(root.path(), &runtime); for (name, created) in [ ("recent", Some(now - TimeDuration::seconds(30))), @@ -448,13 +454,17 @@ fn version_one_cookie_database_migrates_without_granting_a_new_cookie_age() { .pragma_query_value::(None, "user_version", |row| row.get(0))?) }) .unwrap(), - 2 + 3 ); let mut replacement = cookie("legacy"); replacement.creation_time = Some(OffsetDateTime::now_utc()); put(&store, replacement); let mut fresh = cookie("fresh"); - fresh.creation_time = Some(OffsetDateTime::now_utc()); + fresh.creation_time = Some( + OffsetDateTime::now_utc() + .replace_nanosecond(123_456_000) + .unwrap(), + ); put(&store, fresh.clone()); owner.close().unwrap(); let (store, owner) = open(root.path(), &runtime); @@ -475,3 +485,145 @@ fn version_one_cookie_database_migrates_without_granting_a_new_cookie_age() { ); owner.close().unwrap(); } + +fn version_two_cookie_connection(path: &std::path::Path) -> rusqlite::Connection { + let connection = rusqlite::Connection::open(path).unwrap(); + connection.execute_batch("CREATE TABLE cookies( + partition_key BLOB NOT NULL,domain TEXT NOT NULL,path TEXT NOT NULL,name TEXT NOT NULL,value TEXT NOT NULL, + host_only INTEGER NOT NULL CHECK(host_only IN (0,1)),secure INTEGER NOT NULL CHECK(secure IN (0,1)),http_only INTEGER NOT NULL CHECK(http_only IN (0,1)), + expires_seconds INTEGER,expires_nanos INTEGER CHECK(expires_nanos>=0 AND expires_nanos<1000000000), + same_site INTEGER NOT NULL CHECK(same_site BETWEEN 0 AND 3),priority INTEGER CHECK(priority BETWEEN 0 AND 2), + source_scheme INTEGER NOT NULL CHECK(source_scheme BETWEEN 0 AND 2),source_port INTEGER NOT NULL, + creation_index BLOB NOT NULL CHECK(length(creation_index)=8),last_access_index BLOB NOT NULL CHECK(length(last_access_index)=8), + creation_seconds INTEGER,creation_nanos INTEGER CHECK(creation_nanos>=0 AND creation_nanos<1000000000), + CHECK((creation_seconds IS NULL)=(creation_nanos IS NULL)), + CHECK((expires_seconds IS NULL)=(expires_nanos IS NULL)),PRIMARY KEY(partition_key,domain,path,name)) WITHOUT ROWID; + PRAGMA user_version=2;").unwrap(); + connection + .pragma_update(None, "application_id", codec::APPLICATION_ID) + .unwrap(); + connection +} + +#[test] +fn version_two_creation_times_migrate_to_integer_microseconds_without_changing_other_metadata() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("cookies.sqlite"); + let connection = version_two_cookie_connection(&path); + let mut expected = Vec::new(); + for (index, (name, seconds, nanos, expected_nanos)) in [ + ( + "positive", + Some(1_700_000_000_i64), + Some(123_456_789_i64), + Some(1_700_000_000_123_456_000_i128), + ), + ("negative", Some(-1), Some(999_999_999), Some(-1_000)), + ("unknown", None, None, None), + ] + .into_iter() + .enumerate() + { + let mut row = cookie(name); + row.value = "preserved".into(); + row.http_only = true; + row.priority = Some(CookiePriority::High); + row.expires = Some( + OffsetDateTime::from_unix_timestamp(4_000_000_000).unwrap() + + TimeDuration::nanoseconds(123_456_789), + ); + row.creation_index = u64::MAX - 5 + index as u64; + row.last_access_index = u64::MAX - 1; + row.creation_time = + expected_nanos.map(|nanos| OffsetDateTime::from_unix_timestamp_nanos(nanos).unwrap()); + connection.execute("INSERT INTO cookies VALUES (X'00','cookie.test','/',?1,'preserved',1,1,1,4000000000,123456789,0,2,2,443,?2,?3,?4,?5)", + rusqlite::params![name, row.creation_index.to_be_bytes().as_slice(), row.last_access_index.to_be_bytes().as_slice(), seconds, nanos]).unwrap(); + expected.push(row); + } + drop(connection); + let runtime = Runtime::new().unwrap(); + for _ in 0..2 { + let (store, owner) = open(root.path(), &runtime); + assert_eq!(store.lock().unwrap().cookies(), expected); + assert_eq!(owner.owner.client().query(codec::read).unwrap(), expected); + let (version, columns, storage_types) = owner + .owner + .client() + .query(|connection| { + let version = + connection + .pragma_query_value::(None, "user_version", |row| row.get(0))?; + let mut statement = + connection.prepare("SELECT name FROM pragma_table_info('cookies')")?; + let columns = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::>>()?; + let mut statement = connection.prepare( + "SELECT typeof(creation_unix_micros) FROM cookies ORDER BY creation_index", + )?; + let types = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::>>()?; + Ok((version, columns, types)) + }) + .unwrap(); + assert_eq!(version, 3); + assert!(columns.iter().any(|name| name == "creation_unix_micros")); + assert!( + !columns + .iter() + .any(|name| name == "creation_seconds" || name == "creation_nanos") + ); + assert_eq!(storage_types, ["integer", "integer", "null"]); + owner.close().unwrap(); + } +} + +#[test] +fn invalid_version_two_creation_time_rolls_back_the_entire_schema_migration() { + for (seconds, nanos) in [ + (Some(i64::MAX), Some(999_999_999_i64)), + (Some(1), None), + (Some(1), Some(1_000_000_000)), + ] { + let root = tempfile::tempdir().unwrap(); + let mut connection = version_two_cookie_connection(&root.path().join("cookies.sqlite")); + connection + .execute_batch("PRAGMA ignore_check_constraints=ON;") + .unwrap(); + connection.execute("INSERT INTO cookies VALUES (X'00','cookie.test','/','invalid','keep',1,1,0,NULL,NULL,0,NULL,2,443,X'0000000000000011',X'0000000000000022',?1,?2)", rusqlite::params![seconds, nanos]).unwrap(); + connection + .execute_batch("PRAGMA ignore_check_constraints=OFF;") + .unwrap(); + assert!(codec::initialize(&mut connection).is_err()); + assert_eq!( + connection + .pragma_query_value::(None, "user_version", |row| row.get(0)) + .unwrap(), + 2 + ); + let restored: (String, Option, Option) = connection + .query_row( + "SELECT value,creation_seconds,creation_nanos FROM cookies", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!(restored, ("keep".into(), seconds, nanos)); + assert_eq!( + connection + .query_row( + "SELECT count(*) FROM sqlite_master WHERE name='cookies_before_micros'", + [], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 0 + ); + assert!( + connection + .prepare("SELECT creation_unix_micros FROM cookies") + .is_err() + ); + } +} diff --git a/moli-cookie-import/src/creation_time.rs b/moli-cookie-import/src/creation_time.rs index 05d1d9cc8d..367f68df4a 100644 --- a/moli-cookie-import/src/creation_time.rs +++ b/moli-cookie-import/src/creation_time.rs @@ -9,8 +9,7 @@ pub(crate) fn from_browser_micros( ) -> Option { let micros = micros.filter(|micros| *micros > 0)?; let unix_micros = micros.checked_sub(epoch_offset_micros)?; - OffsetDateTime::from_unix_timestamp_nanos(i128::from(unix_micros) * 1_000) - .ok() + moli_cookie_jar::cookie_creation_time::from_unix_micros(unix_micros) .filter(|created| *created <= OffsetDateTime::now_utc()) } diff --git a/moli-cookie-jar/src/lib.rs b/moli-cookie-jar/src/lib.rs index 5d95ca76c5..5fca29566f 100644 --- a/moli-cookie-jar/src/lib.rs +++ b/moli-cookie-jar/src/lib.rs @@ -8,6 +8,7 @@ mod jar; mod model; pub use cookie_store::CookiePriority; +pub use cookie_store::cookie_creation_time; pub use jar::{ BrowserCookieObserver, BrowserCookieStore, BrowserCookieStoreHandle, CookieStoreError, SharedBrowserCookieStore, StoredCookieIdentity, StoredCookieMutation, diff --git a/moli-cookie-store/src/cookie.rs b/moli-cookie-store/src/cookie.rs index 9af99a6b4b..3c7502dd5d 100644 --- a/moli-cookie-store/src/cookie.rs +++ b/moli-cookie-store/src/cookie.rs @@ -337,7 +337,7 @@ impl<'a> Cookie<'a> { } pub(crate) fn set_creation_time(&mut self, creation_time: Option) { - self.creation_time = match creation_time { + self.creation_time = match creation_time.and_then(crate::cookie_creation_time::normalize) { Some(created) => CreationTime::Known(created), None => CreationTime::Unknown, }; @@ -345,14 +345,12 @@ impl<'a> Cookie<'a> { pub(crate) fn initialize_creation_time(&mut self) { if self.creation_time == CreationTime::Pending { - self.creation_time = CreationTime::Known(OffsetDateTime::now_utc()); + self.set_creation_time(Some(OffsetDateTime::now_utc())); } } pub(crate) fn restore_creation_time(&mut self) { - if self.creation_time == CreationTime::Pending { - self.creation_time = CreationTime::Unknown; - } + self.set_creation_time(self.creation_time()); } pub(crate) fn touch_with_access_index(&mut self, access_index: u64) { diff --git a/moli-cookie-store/src/cookie_creation_time.rs b/moli-cookie-store/src/cookie_creation_time.rs new file mode 100644 index 0000000000..7eec649d4c --- /dev/null +++ b/moli-cookie-store/src/cookie_creation_time.rs @@ -0,0 +1,74 @@ +//! Shared Unix-microsecond encoding for Cookie creation times. +//! +//! Creation order is tracked separately by the canonical Cookie's index. +//! Unknown age has no timestamp; zero is the Unix epoch, not a sentinel. + +use time::OffsetDateTime; + +/// Encode a creation time, rounding down to the containing microsecond. +pub fn to_unix_micros(created: OffsetDateTime) -> Option { + created + .unix_timestamp_nanos() + .div_euclid(1_000) + .try_into() + .ok() +} + +/// Decode a stored creation time without assigning an age to missing data. +pub fn from_unix_micros(micros: i64) -> Option { + OffsetDateTime::from_unix_timestamp_nanos(i128::from(micros) * 1_000).ok() +} + +/// Convert the previous nanosecond format without overflowing the new encoding. +pub fn legacy_unix_nanos_to_micros(nanos: i128) -> Option { + to_unix_micros(OffsetDateTime::from_unix_timestamp_nanos(nanos).ok()?) +} + +pub(crate) fn normalize(created: OffsetDateTime) -> Option { + from_unix_micros(to_unix_micros(created)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn creation_time_encoding_preserves_microseconds_on_both_sides_of_the_epoch() { + for (nanos, micros) in [ + (0, 0), + (999, 0), + (1_000, 1), + (1_999, 1), + (-1, -1), + (-999, -1), + (-1_000, -1), + (-1_001, -2), + (1_700_000_000_123_456_789, 1_700_000_000_123_456), + ] { + let original = OffsetDateTime::from_unix_timestamp_nanos(nanos).unwrap(); + assert_eq!(to_unix_micros(original), Some(micros)); + let restored = from_unix_micros(micros).unwrap(); + assert_eq!(restored.unix_timestamp_nanos(), i128::from(micros) * 1_000); + assert_eq!(legacy_unix_nanos_to_micros(nanos), Some(micros)); + } + } + + #[test] + fn out_of_range_creation_times_are_rejected_without_overflow() { + assert!(legacy_unix_nanos_to_micros(i128::MIN).is_none()); + assert!(legacy_unix_nanos_to_micros(i128::MAX).is_none()); + // The workspace also enables time's large-dates feature. Whether its + // calendar accepts an i64 endpoint or not, decoding must never wrap. + for micros in [i64::MIN, i64::MAX] { + if let Some(created) = from_unix_micros(micros) { + assert_eq!(to_unix_micros(created), Some(micros)); + } + } + for seconds in [i64::MIN / 1_000_000 - 1, i64::MAX / 1_000_000 + 1] { + if let Ok(created) = OffsetDateTime::from_unix_timestamp(seconds) { + assert!(to_unix_micros(created).is_none()); + assert!(normalize(created).is_none()); + } + } + } +} diff --git a/moli-cookie-store/src/cookie_store/results.rs b/moli-cookie-store/src/cookie_store/results.rs index b7923194cf..e745b6a73f 100644 --- a/moli-cookie-store/src/cookie_store/results.rs +++ b/moli-cookie-store/src/cookie_store/results.rs @@ -301,11 +301,16 @@ impl CookieEffectiveSameSite { creation_time: Option, now: OffsetDateTime, ) -> Self { + let creation_time = creation_time.and_then(crate::cookie_creation_time::normalize); + let now = crate::cookie_creation_time::normalize(now); match same_site { Some(SameSite::None) => Self::NoRestriction, Some(SameSite::Lax) => Self::Lax, Some(SameSite::Strict) => Self::Strict, - None if creation_time.is_some_and(|created| now - created <= Duration::minutes(2)) => { + None if creation_time + .zip(now) + .is_some_and(|(created, now)| now - created <= Duration::minutes(2)) => + { Self::LaxAllowUnsafe } None => Self::Lax, diff --git a/moli-cookie-store/src/cookie_store/tests/query_same_site.rs b/moli-cookie-store/src/cookie_store/tests/query_same_site.rs index 4a60c626f8..a6408a11d0 100644 --- a/moli-cookie-store/src/cookie_store/tests/query_same_site.rs +++ b/moli-cookie-store/src/cookie_store/tests/query_same_site.rs @@ -31,6 +31,50 @@ fn default_same_site_mode_has_the_chromium_two_minute_boundary() { } } +#[test] +fn default_cookie_age_uses_microseconds_in_memory_and_after_timestamp_roundtrip() { + let created = OffsetDateTime::from_unix_timestamp(2_000_000_000).unwrap() + + Duration::nanoseconds(123_456_789); + let request_url = test_utils::url("https://example.com/"); + let mut cookie = Cookie::parse("sid=value; Secure; Path=/", &request_url).unwrap(); + cookie.set_creation_time(Some(created)); + let canonical = cookie.creation_time().unwrap(); + assert_eq!(canonical.nanosecond(), 123_456_000); + let restored = crate::cookie_creation_time::from_unix_micros( + crate::cookie_creation_time::to_unix_micros(canonical).unwrap(), + ) + .unwrap(); + for stamp in [created, canonical, restored] { + for (now, expected) in [ + ( + canonical + Duration::minutes(2) + Duration::nanoseconds(999), + CookieEffectiveSameSite::LaxAllowUnsafe, + ), + ( + canonical + Duration::minutes(2) + Duration::microseconds(1), + CookieEffectiveSameSite::Lax, + ), + ] { + assert_eq!( + CookieEffectiveSameSite::for_cookie(None, Some(stamp), now), + expected + ); + } + } + let mut store = CookieStore::default(); + inserted!(store.insert_response_cookie_str("new=value; Secure; Path=/", &request_url)); + assert_eq!( + store + .get("example.com", "/", "new") + .unwrap() + .creation_time() + .unwrap() + .nanosecond() + % 1_000, + 0 + ); +} + #[test] fn default_cookie_navigation_allowance_is_limited_by_age_and_request_type() { let mut store = CookieStore::default(); @@ -94,7 +138,9 @@ fn default_cookie_navigation_allowance_is_limited_by_age_and_request_type() { fn overwriting_a_default_cookie_keeps_its_age_but_recreation_starts_a_new_allowance() { let mut store = CookieStore::default(); let request_url = test_utils::url("https://example.com/"); - let old_time = OffsetDateTime::now_utc() - Duration::minutes(3); + let old_time = (OffsetDateTime::now_utc() - Duration::minutes(3)) + .replace_nanosecond(123_456_000) + .unwrap(); let mut cookie = Cookie::parse("sid=old; Path=/; Secure", &request_url) .unwrap() .into_owned(); @@ -138,7 +184,9 @@ fn overwriting_a_default_cookie_keeps_its_age_but_recreation_starts_a_new_allowa fn cloning_and_rebuilding_a_store_preserve_default_cookie_age() { let mut store = CookieStore::default(); let request_url = test_utils::url("https://example.com/"); - let old_time = OffsetDateTime::now_utc() - Duration::minutes(3); + let old_time = (OffsetDateTime::now_utc() - Duration::minutes(3)) + .replace_nanosecond(123_456_000) + .unwrap(); let mut cookie = Cookie::parse("sid=old; Path=/; Secure", &request_url) .unwrap() .into_owned(); @@ -925,7 +973,9 @@ fn query_result_projects_from_access_query_result() { #[test] fn overwrite_reports_use_the_stored_age_for_http_and_chained_cdp_writes() { let request_url = test_utils::url("https://example.com/"); - let now = OffsetDateTime::now_utc(); + let now = OffsetDateTime::now_utc() + .replace_nanosecond(123_456_000) + .unwrap(); for (created, expected) in [ ( Some(now - Duration::minutes(3)), diff --git a/moli-cookie-store/src/lib.rs b/moli-cookie-store/src/lib.rs index 356d146965..d2eef09a8c 100644 --- a/moli-cookie-store/src/lib.rs +++ b/moli-cookie-store/src/lib.rs @@ -13,6 +13,7 @@ pub use ::cookie::{Cookie as RawCookie, ParseError as RawCookieParseError, SameSite}; mod cookie; +pub mod cookie_creation_time; mod cookie_date; pub use crate::cookie::Error as CookieError; pub use crate::cookie::{ diff --git a/moli-protocol/src/domains/storage/tests.rs b/moli-protocol/src/domains/storage/tests.rs index f4a00b9f1f..090a6aa7f9 100644 --- a/moli-protocol/src/domains/storage/tests.rs +++ b/moli-protocol/src/domains/storage/tests.rs @@ -3993,9 +3993,15 @@ fn cdp_cookie_overwrite_report_matches_the_canonical_creation_time() { use super::normalize::{NormalizedCdpCookieParam, normalize_cookie_param}; use moli_cookie_jar::{CookieSource, StoredCookieEffectiveSameSite}; - for created in [ - None, - Some(time::OffsetDateTime::now_utc() - time::Duration::minutes(3)), + let old = (time::OffsetDateTime::now_utc() - time::Duration::minutes(3)) + .replace_nanosecond(123_456_789) + .unwrap(); + for (created, expected) in [ + (None, None), + ( + Some(old), + Some(old.replace_nanosecond(123_456_000).unwrap()), + ), ] { let param = serde_json::from_value(json!({ "name": "sid", "value": "new", "url": "https://example.com/", "secure": true @@ -4026,7 +4032,7 @@ fn cdp_cookie_overwrite_report_matches_the_canonical_creation_time() { "Lax" ); let stored = store.cookies().pop().unwrap(); - assert_eq!(stored.creation_time, created); + assert_eq!(stored.creation_time, expected); assert_eq!( stored.effective_same_site(), StoredCookieEffectiveSameSite::Lax