refactor(cookies): store creation times as Unix microseconds

This commit is contained in:
ldm0
2026-11-02 11:09:13 +08:00
committed by Donough Liu
parent 7af087d8ab
commit 2e4fa728e8
12 changed files with 476 additions and 68 deletions
+13 -6
View File
@@ -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<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
creation_unix_micros: Option<i64>,
// Read compatibility only. New caches always use microseconds.
#[serde(default, skip_serializing)]
creation_unix_nanos: Option<i128>,
same_site: CachedSameSite,
priority: Option<CachedCookiePriority>,
@@ -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(),
+56 -4
View File
@@ -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,
}
+102 -39
View File
@@ -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<i64>>(16)?,
row.get::<_, Option<i64>>(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<u8> {
@@ -105,7 +149,7 @@ fn flag(row: &rusqlite::Row<'_>, position: usize) -> rusqlite::Result<bool> {
}
}
pub(super) fn read(connection: &mut Connection) -> Result<Vec<StoredCookie>, 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<i64> = row.get(8)?;
let nanos: Option<i64> = row.get(9)?;
@@ -154,19 +198,13 @@ pub(super) fn read(connection: &mut Connection) -> Result<Vec<StoredCookie>, Wri
source_scheme,
source_port: row.get(13)?,
creation_index: index(row.get(14)?)?,
creation_time: match (
row.get::<_, Option<i64>>(16)?,
row.get::<_, Option<i64>>(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<i64>>(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(())
}
+156 -4
View File
@@ -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::<i32, _>(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::<i32, _>(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::<rusqlite::Result<Vec<_>>>()?;
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::<rusqlite::Result<Vec<_>>>()?;
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::<i32, _>(None, "user_version", |row| row.get(0))
.unwrap(),
2
);
let restored: (String, Option<i64>, Option<i64>) = 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()
);
}
}
+1 -2
View File
@@ -9,8 +9,7 @@ pub(crate) fn from_browser_micros(
) -> Option<OffsetDateTime> {
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())
}
+1
View File
@@ -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,
+3 -5
View File
@@ -337,7 +337,7 @@ impl<'a> Cookie<'a> {
}
pub(crate) fn set_creation_time(&mut self, creation_time: Option<OffsetDateTime>) {
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) {
@@ -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<i64> {
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> {
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<i64> {
to_unix_micros(OffsetDateTime::from_unix_timestamp_nanos(nanos).ok()?)
}
pub(crate) fn normalize(created: OffsetDateTime) -> Option<OffsetDateTime> {
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());
}
}
}
}
@@ -301,11 +301,16 @@ impl CookieEffectiveSameSite {
creation_time: Option<OffsetDateTime>,
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,
@@ -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)),
+1
View File
@@ -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::{
+10 -4
View File
@@ -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