mirror of
https://github.com/lexmount/moli.git
synced 2026-11-07 00:07:14 +00:00
refactor(cookies): store creation times as Unix microseconds
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user