From 8a477b39532bc4300951d7ee2ddea8cf77109896 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Mon, 20 Jul 2026 23:08:09 -0700 Subject: [PATCH 01/18] Add idempotent sandbox exchange retries --- crates/loc-cli/Cargo.toml | 2 +- crates/loc-cli/src/sandbox.rs | 515 +++++++++++++++++++++++++++++++++- 2 files changed, 505 insertions(+), 12 deletions(-) diff --git a/crates/loc-cli/Cargo.toml b/crates/loc-cli/Cargo.toml index e8429952..fbdcf363 100644 --- a/crates/loc-cli/Cargo.toml +++ b/crates/loc-cli/Cargo.toml @@ -30,9 +30,9 @@ reqwest = { version = "0.13", default-features = false, features = ["blocking", rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha2 = "0.10" yaml_serde = "0.10" [dev-dependencies] -sha2 = "0.10" tar = { version = "0.4", default-features = false } zstd = { version = "0.13", default-features = false } diff --git a/crates/loc-cli/src/sandbox.rs b/crates/loc-cli/src/sandbox.rs index be41d195..ab103f55 100644 --- a/crates/loc-cli/src/sandbox.rs +++ b/crates/loc-cli/src/sandbox.rs @@ -27,12 +27,16 @@ use reqwest::blocking::{Client, Response}; use reqwest::header::{ACCEPT, ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_TYPE, HeaderMap}; use serde::Serialize; use serde::de::DeserializeOwned; +use sha2::{Digest, Sha256}; const JSON_MEDIA_TYPE: &str = "application/json"; const TAR_MEDIA_TYPE: &str = "application/x-tar"; const MAX_JSON_RESPONSE_BYTES: u64 = 1024 * 1024; const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30 * 60); +const BOOTSTRAP_EXCHANGE_ATTEMPTS: usize = 2; +const BOOTSTRAP_IDEMPOTENCY_DOMAIN: &[u8] = b"locality.session-exchange-idempotency.v1\0"; +const IDEMPOTENCY_KEY_HEADER: &str = "Idempotency-Key"; static REQWEST_CRYPTO_PROVIDER: OnceLock<()> = OnceLock::new(); #[derive(Clone)] @@ -566,17 +570,45 @@ impl SandboxHttpClient { let request = OpaqueBootstrapExchangeRequest { bootstrap_token: token.expose().to_string(), }; - let response = self - .client - .post(self.sessions_url()) - .header(ACCEPT, JSON_MEDIA_TYPE) - .json(&request) - .send() - .map_err(|error| SandboxInitError::Http { - operation: "bootstrap exchange", - detail: error.without_url().to_string(), - })?; - read_json_response(response, "bootstrap exchange") + let idempotency_key = derive_idempotency_key(token); + + for attempt in 0..BOOTSTRAP_EXCHANGE_ATTEMPTS { + let response = match self + .client + .post(self.sessions_url()) + .header(ACCEPT, JSON_MEDIA_TYPE) + .header(IDEMPOTENCY_KEY_HEADER, &idempotency_key) + .json(&request) + .send() + { + Ok(response) => response, + Err(error) => { + let error = SandboxInitError::Http { + operation: "bootstrap exchange", + detail: error.without_url().to_string(), + }; + if has_retry_remaining(attempt) { + continue; + } + return Err(error); + } + }; + + if is_retriable_bootstrap_status(response.status()) && has_retry_remaining(attempt) { + continue; + } + match read_json_response(response, "bootstrap exchange") { + Ok(capability) => return Ok(capability), + Err(error) + if has_retry_remaining(attempt) && is_ambiguous_bootstrap_error(&error) => + { + continue; + } + Err(error) => return Err(error), + } + } + + unreachable!("bootstrap exchange attempt loop always returns") } fn session_status( @@ -640,6 +672,41 @@ impl SandboxHttpClient { } } +fn derive_idempotency_key(token: &SandboxBootstrapToken) -> String { + let mut hasher = Sha256::new(); + hasher.update(BOOTSTRAP_IDEMPOTENCY_DOMAIN); + hasher.update(token.expose().as_bytes()); + let digest = hasher.finalize(); + let mut encoded = [0_u8; 64]; + const LOWER_HEX: &[u8; 16] = b"0123456789abcdef"; + for (index, byte) in digest.into_iter().enumerate() { + encoded[index * 2] = LOWER_HEX[usize::from(byte >> 4)]; + encoded[index * 2 + 1] = LOWER_HEX[usize::from(byte & 0x0f)]; + } + String::from_utf8(encoded.to_vec()).expect("lowercase hexadecimal is valid UTF-8") +} + +fn has_retry_remaining(attempt: usize) -> bool { + attempt + 1 < BOOTSTRAP_EXCHANGE_ATTEMPTS +} + +fn is_retriable_bootstrap_status(status: StatusCode) -> bool { + matches!( + status, + StatusCode::BAD_GATEWAY | StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT + ) +} + +fn is_ambiguous_bootstrap_error(error: &SandboxInitError) -> bool { + matches!( + error, + SandboxInitError::Http { + operation: "bootstrap exchange", + .. + } + ) +} + fn endpoint_url(base: &reqwest::Url, segments: &[&str]) -> reqwest::Url { let mut url = base.clone(); url.set_path(""); @@ -729,3 +796,429 @@ fn encoding_name(encoding: ReplicaArchiveEncoding) -> &'static str { ReplicaArchiveEncoding::Zstd => "zstd", } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::io::{self, Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc::{self, Receiver}; + use std::thread::{self, JoinHandle}; + use std::time::{Duration, Instant}; + + use locality_core::portable::SessionId; + + use super::*; + + #[derive(Debug)] + struct CapturedRequest { + method: String, + path: String, + headers: BTreeMap, + body: Vec, + } + + enum TestResponse { + DropConnection, + Json { status: &'static str, body: Vec }, + } + + struct TestServer { + api_url: String, + requests: Receiver, + handle: JoinHandle<()>, + } + + impl TestServer { + fn start(responses: Vec, reject_extra_request: bool) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + listener + .set_nonblocking(true) + .expect("set test listener nonblocking"); + let address = listener.local_addr().expect("test server address"); + let (sender, requests) = mpsc::channel(); + let handle = thread::spawn(move || { + for response in responses { + let mut stream = + accept_before(&listener, Instant::now() + Duration::from_secs(5)); + let request = read_request(&mut stream); + sender.send(request).expect("capture request"); + match response { + TestResponse::DropConnection => {} + TestResponse::Json { status, body } => { + write_json_response(&mut stream, status, &body); + } + } + } + if reject_extra_request { + let deadline = Instant::now() + Duration::from_millis(250); + loop { + match listener.accept() { + Ok((mut stream, _)) => { + let request = read_request(&mut stream); + panic!("unexpected retry: {request:?}"); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + break; + } + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept unexpected request: {error}"), + } + } + } + }); + Self { + api_url: format!("http://{address}"), + requests, + handle, + } + } + + fn finish(self) -> Vec { + self.handle.join().expect("test server completed"); + self.requests.try_iter().collect() + } + } + + #[test] + fn dropped_bootstrap_response_retries_with_identical_key_and_body() { + let response = serde_json::to_vec(&capability()).expect("serialize capability"); + let server = TestServer::start( + vec![ + TestResponse::DropConnection, + TestResponse::Json { + status: "200 OK", + body: response, + }, + ], + true, + ); + let client = SandboxHttpClient::new(&server.api_url).expect("HTTP client"); + let token = SandboxBootstrapToken::new("bootstrap-secret").expect("bootstrap token"); + + let actual = client + .exchange_bootstrap(&token) + .expect("retry bootstrap exchange"); + + assert_eq!(actual, capability()); + let requests = server.finish(); + assert_eq!(requests.len(), 2); + assert_bootstrap_request(&requests[0], br#"{"bootstrap_token":"bootstrap-secret"}"#); + assert_bootstrap_request(&requests[1], br#"{"bootstrap_token":"bootstrap-secret"}"#); + assert_eq!(requests[0].body, requests[1].body); + assert_eq!(idempotency_key(&requests[0]), idempotency_key(&requests[1])); + assert_eq!( + idempotency_key(&requests[0]), + "fe1fd6a544a78d3a3087bf1517b0ca83b6d122bf1d88d1eddc264e883500bded" + ); + } + + #[test] + fn retriable_gateway_responses_retry_with_identical_key_and_body() { + let cases = [ + "502 Bad Gateway", + "503 Service Unavailable", + "504 Gateway Timeout", + ]; + + for status in cases { + let response = serde_json::to_vec(&capability()).expect("serialize capability"); + let server = TestServer::start( + vec![ + TestResponse::Json { + status, + body: Vec::new(), + }, + TestResponse::Json { + status: "200 OK", + body: response, + }, + ], + true, + ); + let client = SandboxHttpClient::new(&server.api_url).expect("HTTP client"); + let token = SandboxBootstrapToken::new("bootstrap-secret").expect("bootstrap token"); + + assert_eq!( + client + .exchange_bootstrap(&token) + .expect("retry gateway response"), + capability() + ); + + let requests = server.finish(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].body, requests[1].body); + assert_eq!(idempotency_key(&requests[0]), idempotency_key(&requests[1])); + } + } + + #[test] + fn bootstrap_gateway_retry_is_bounded_to_two_attempts() { + let server = TestServer::start( + vec![ + TestResponse::Json { + status: "503 Service Unavailable", + body: Vec::new(), + }, + TestResponse::Json { + status: "503 Service Unavailable", + body: Vec::new(), + }, + ], + true, + ); + let client = SandboxHttpClient::new(&server.api_url).expect("HTTP client"); + let token = SandboxBootstrapToken::new("bootstrap-secret").expect("bootstrap token"); + + let error = client + .exchange_bootstrap(&token) + .expect_err("repeated service failure must stop"); + + assert!(matches!( + &error, + SandboxInitError::HttpStatus { + operation: "bootstrap exchange", + status: StatusCode::SERVICE_UNAVAILABLE + } + )); + assert_eq!( + error.to_string(), + "bootstrap exchange returned HTTP 503 Service Unavailable" + ); + let requests = server.finish(); + assert_eq!(requests.len(), BOOTSTRAP_EXCHANGE_ATTEMPTS); + assert_eq!(requests[0].body, requests[1].body); + assert_eq!(idempotency_key(&requests[0]), idempotency_key(&requests[1])); + } + + #[test] + fn bootstrap_idempotency_keys_are_stable_per_token_and_separate_between_tokens() { + let response = serde_json::to_vec(&capability()).expect("serialize capability"); + let server = TestServer::start( + vec![ + TestResponse::Json { + status: "200 OK", + body: response.clone(), + }, + TestResponse::Json { + status: "200 OK", + body: response.clone(), + }, + TestResponse::Json { + status: "200 OK", + body: response, + }, + ], + true, + ); + let token = SandboxBootstrapToken::new("bootstrap-secret").expect("bootstrap token"); + let different_token = SandboxBootstrapToken::new("different-bootstrap-secret") + .expect("different bootstrap token"); + + assert_eq!( + SandboxHttpClient::new(&server.api_url) + .expect("first HTTP client") + .exchange_bootstrap(&token) + .expect("first exchange"), + capability() + ); + assert_eq!( + SandboxHttpClient::new(&server.api_url) + .expect("second HTTP client") + .exchange_bootstrap(&token) + .expect("second exchange"), + capability() + ); + assert_eq!( + SandboxHttpClient::new(&server.api_url) + .expect("third HTTP client") + .exchange_bootstrap(&different_token) + .expect("third exchange"), + capability() + ); + + let requests = server.finish(); + assert_eq!(requests.len(), 3); + let first = idempotency_key(&requests[0]); + let second = idempotency_key(&requests[1]); + let third = idempotency_key(&requests[2]); + assert_valid_idempotency_key(first); + assert_valid_idempotency_key(second); + assert_valid_idempotency_key(third); + assert_eq!(first, second); + assert_ne!(first, third); + assert_eq!( + first, + "fe1fd6a544a78d3a3087bf1517b0ca83b6d122bf1d88d1eddc264e883500bded" + ); + assert_eq!( + third, + "c5ffe95233e3b77d1a6170c672587fa9fdf3f7feccbf2a6e30ba3eb3bcf81b9b" + ); + assert_bootstrap_request(&requests[0], br#"{"bootstrap_token":"bootstrap-secret"}"#); + assert_bootstrap_request(&requests[1], br#"{"bootstrap_token":"bootstrap-secret"}"#); + assert_bootstrap_request( + &requests[2], + br#"{"bootstrap_token":"different-bootstrap-secret"}"#, + ); + } + + #[test] + fn deterministic_bootstrap_errors_do_not_retry_or_leak_the_token() { + let cases = [ + ("400 Bad Request", StatusCode::BAD_REQUEST), + ("401 Unauthorized", StatusCode::UNAUTHORIZED), + ("409 Conflict", StatusCode::CONFLICT), + ("422 Unprocessable Entity", StatusCode::UNPROCESSABLE_ENTITY), + ]; + + for (status_line, expected_status) in cases { + let server = TestServer::start( + vec![TestResponse::Json { + status: status_line, + body: Vec::new(), + }], + true, + ); + let client = SandboxHttpClient::new(&server.api_url).expect("HTTP client"); + let token = SandboxBootstrapToken::new("bootstrap-secret").expect("bootstrap token"); + + let error = client + .exchange_bootstrap(&token) + .expect_err("deterministic response must fail"); + + assert!(matches!( + &error, + SandboxInitError::HttpStatus { + operation: "bootstrap exchange", + status + } if *status == expected_status + )); + assert_eq!( + error.to_string(), + format!("bootstrap exchange returned HTTP {expected_status}") + ); + assert!(!format!("{error:?}").contains("bootstrap-secret")); + assert!(!error.to_string().contains("bootstrap-secret")); + assert_eq!(format!("{token:?}"), "SandboxBootstrapToken()"); + let requests = server.finish(); + assert_eq!(requests.len(), 1); + assert_bootstrap_request(&requests[0], br#"{"bootstrap_token":"bootstrap-secret"}"#); + } + } + + fn capability() -> SessionCapability { + SessionCapability { + session_id: SessionId::new("session-idempotent"), + opaque_capability: "capability-secret".to_string(), + expires_at: "2026-07-20T12:00:00Z".to_string(), + } + } + + fn assert_bootstrap_request(request: &CapturedRequest, expected_body: &[u8]) { + assert_eq!(request.method, "POST"); + assert_eq!(request.path, "/v1/sessions"); + assert_eq!( + request.headers.get("accept").map(String::as_str), + Some(JSON_MEDIA_TYPE) + ); + assert_eq!( + request.headers.get("content-type").map(String::as_str), + Some(JSON_MEDIA_TYPE) + ); + assert_eq!(request.body, expected_body); + assert_valid_idempotency_key(idempotency_key(request)); + } + + fn idempotency_key(request: &CapturedRequest) -> &str { + request + .headers + .get("idempotency-key") + .map(String::as_str) + .expect("idempotency key header") + } + + fn assert_valid_idempotency_key(key: &str) { + assert_eq!(key.len(), 64); + assert!( + key.as_bytes() + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) + ); + } + + fn accept_before(listener: &TcpListener, deadline: Instant) -> TcpStream { + loop { + match listener.accept() { + Ok((stream, _)) => return stream, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "request timed out"); + thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept request: {error}"), + } + } + } + + fn read_request(stream: &mut TcpStream) -> CapturedRequest { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set request timeout"); + let mut bytes = Vec::new(); + let header_end = loop { + let mut chunk = [0_u8; 4096]; + let read = stream.read(&mut chunk).expect("read request"); + assert!(read > 0, "request ended before headers"); + bytes.extend_from_slice(&chunk[..read]); + if let Some(position) = find_bytes(&bytes, b"\r\n\r\n") { + break position + 4; + } + }; + let headers_text = std::str::from_utf8(&bytes[..header_end]).expect("headers UTF-8"); + let mut lines = headers_text.split("\r\n"); + let mut request_line = lines.next().expect("request line").split_whitespace(); + let method = request_line.next().expect("method").to_string(); + let path = request_line.next().expect("path").to_string(); + let mut headers = BTreeMap::new(); + for line in lines.filter(|line| !line.is_empty()) { + let (name, value) = line.split_once(':').expect("header delimiter"); + headers.insert(name.to_ascii_lowercase(), value.trim().to_string()); + } + let content_length = headers + .get("content-length") + .map(|value| value.parse::().expect("content length")) + .unwrap_or(0); + while bytes.len() < header_end + content_length { + let mut chunk = [0_u8; 4096]; + let read = stream.read(&mut chunk).expect("read request body"); + assert!(read > 0, "request body ended early"); + bytes.extend_from_slice(&chunk[..read]); + } + CapturedRequest { + method, + path, + headers, + body: bytes[header_end..header_end + content_length].to_vec(), + } + } + + fn write_json_response(stream: &mut TcpStream, status: &str, body: &[u8]) { + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nContent-Type: {JSON_MEDIA_TYPE}\r\nConnection: close\r\n\r\n", + body.len() + ) + .expect("write response head"); + stream.write_all(body).expect("write response body"); + stream.flush().expect("flush response"); + } + + fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) + } +} From 86539f461adba2018e8893678be0974672157cda Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Mon, 20 Jul 2026 23:10:51 -0700 Subject: [PATCH 02/18] docs: define idempotent session recovery --- docs/cloud-sandbox-data-plane.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/cloud-sandbox-data-plane.md b/docs/cloud-sandbox-data-plane.md index 013ae960..ec43e0f8 100644 --- a/docs/cloud-sandbox-data-plane.md +++ b/docs/cloud-sandbox-data-plane.md @@ -2292,6 +2292,14 @@ POST /v1/changesets/{id}/cancel POST /v1/search # current policy, bounded results ``` +`POST /v1/sessions` uses an `Idempotency-Key` derived once from the opaque +bootstrap token and reused only for bounded ambiguous-response retries. An +exact replay during the short recovery window returns the original session, +capability, and expiry; it cannot create a second session or extend authority. +The backend derives the recoverable capability with a domain-separated keyed +hash and PostgreSQL stores only token, idempotency-key, and capability hashes, +never the plaintext values or an encrypted response blob. + An SSE/revision-delta endpoint is a later desktop/long-session extension, not a sandbox v1 endpoint. Human-review and advisory-claim endpoints are later extensions to the changeset/session state machines, not v1 placeholders. From 3e13580b609d07ba6f9469ac9c344e0363172c2d Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Tue, 21 Jul 2026 14:14:26 -0700 Subject: [PATCH 03/18] Harden Unix replica materialization paths --- Cargo.lock | 1 + crates/localityd/Cargo.toml | 3 + crates/localityd/src/replica_materializer.rs | 309 +++++++++++++++--- .../localityd/tests/replica_materializer.rs | 88 +++++ 4 files changed, 353 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bfa59eed..c24aecf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2295,6 +2295,7 @@ dependencies = [ "locality-protocol", "locality-store", "notify", + "rustix", "serde", "serde_json", "sha2", diff --git a/crates/localityd/Cargo.toml b/crates/localityd/Cargo.toml index 4e5ca383..27278ddb 100644 --- a/crates/localityd/Cargo.toml +++ b/crates/localityd/Cargo.toml @@ -35,3 +35,6 @@ sha2 = "0.10" tar = { version = "0.4", default-features = false } unicode-normalization = "0.1" zstd = { version = "0.13", default-features = false } + +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1.4", features = ["fs"] } diff --git a/crates/localityd/src/replica_materializer.rs b/crates/localityd/src/replica_materializer.rs index 4d86cbb2..4218d938 100644 --- a/crates/localityd/src/replica_materializer.rs +++ b/crates/localityd/src/replica_materializer.rs @@ -6,10 +6,17 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt::{Display, Formatter}; -use std::fs::{self, OpenOptions}; +use std::fs; +#[cfg(not(unix))] +use std::fs::OpenOptions; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; +#[cfg(unix)] +use rustix::fd::{AsFd, OwnedFd}; +#[cfg(unix)] +use rustix::fs::{AtFlags, Dir, FileType, Mode, OFlags}; + use locality_core::portable::LogicalPath; use sha2::{Digest, Sha256}; use unicode_normalization::UnicodeNormalization; @@ -321,7 +328,7 @@ fn materialize_replica_archive_inner( let (summary, decoded_tar_sha256) = match archive.encoding { ReplicaArchiveEncoding::Identity => { let mut decoded = DecodedLimitReader::new(archive.body, limits.max_decoded_bytes); - let result = extract_tar(&mut decoded, staging.path(), limits); + let result = extract_tar(&mut decoded, &staging, limits); let exceeded = decoded.exceeded(); let decoded_bytes = decoded.consumed(); let decoded_tar_sha256 = decoded.finish_sha256(); @@ -343,7 +350,7 @@ fn materialize_replica_archive_inner( let mut decoder = decoder.single_frame(); let (result, exceeded, decoded_bytes, decoded_tar_sha256) = { let mut decoded = DecodedLimitReader::new(&mut decoder, limits.max_decoded_bytes); - let result = extract_tar(&mut decoded, staging.path(), limits); + let result = extract_tar(&mut decoded, &staging, limits); let exceeded = decoded.exceeded(); let decoded_bytes = decoded.consumed(); let decoded_tar_sha256 = decoded.finish_sha256(); @@ -371,7 +378,7 @@ fn materialize_replica_archive_inner( validate_receipt(expected, summary, decoded_tar_sha256)?; } - make_tree_read_only(staging.path()).map_err(|source| ReplicaMaterializationError::Write { + make_tree_read_only(&staging).map_err(|source| ReplicaMaterializationError::Write { path: staging.path().to_path_buf(), source, })?; @@ -419,7 +426,7 @@ fn write_sha256(formatter: &mut Formatter<'_>, digest: &[u8; 32]) -> std::fmt::R fn extract_tar( reader: &mut R, - staging: &Path, + staging: &StagingDirectory, limits: ReplicaMaterializationLimits, ) -> Result { let mut state = ExtractionState::default(); @@ -455,8 +462,6 @@ fn extract_tar( .header() .mode() .map_err(|error| ReplicaMaterializationError::MalformedTar(error.to_string()))?; - let target = staging.join(Path::new(&path)); - if is_directory { if mode != READ_ONLY_DIRECTORY_MODE { return Err(ReplicaMaterializationError::InvalidDirectoryMode { path, mode }); @@ -464,7 +469,7 @@ fn extract_tar( if entry.size() != 0 { return Err(ReplicaMaterializationError::NonEmptyDirectory { path }); } - create_directory(&target)?; + staging.create_directory(&path)?; } else { if mode != READ_ONLY_FILE_MODE { return Err(ReplicaMaterializationError::InvalidFileMode { path, mode }); @@ -484,10 +489,7 @@ fn extract_tar( limit: limits.max_disk_bytes, }); } - if let Some(parent) = target.parent() { - create_directory(parent)?; - } - write_file(&target, &mut entry, size)?; + staging.write_file(&path, &mut entry, size)?; state.summary.files += 1; state.summary.materialized_bytes = disk_size; } @@ -620,14 +622,8 @@ impl ExtractionState { } } -fn create_directory(path: &Path) -> Result<(), ReplicaMaterializationError> { - fs::create_dir_all(path).map_err(|source| ReplicaMaterializationError::Write { - path: path.to_path_buf(), - source, - }) -} - -fn write_file( +#[cfg(not(unix))] +fn write_file_at_path( path: &Path, reader: &mut R, expected_size: u64, @@ -662,7 +658,44 @@ fn write_file( }) } -fn make_tree_read_only(root: &Path) -> io::Result<()> { +#[cfg(unix)] +fn make_tree_read_only(staging: &StagingDirectory) -> io::Result<()> { + // macOS refuses to rename a directory whose own mode is 0555. Finalize + // only children here; `publish` chmods the still-open root after rename. + make_child_directories_read_only(&staging.root) +} + +#[cfg(unix)] +fn make_child_directories_read_only(directory: &OwnedFd) -> io::Result<()> { + let entries = Dir::read_from(directory)?; + for entry in entries { + let entry = entry?; + let name = entry.file_name(); + if name.to_bytes() == b"." || name.to_bytes() == b".." { + continue; + } + let metadata = rustix::fs::statat(directory, name, AtFlags::SYMLINK_NOFOLLOW)?; + match FileType::from_raw_mode(metadata.st_mode) { + FileType::Directory => { + let child = open_directory_at(directory, name)?; + make_child_directories_read_only(&child)?; + rustix::fs::fchmod(&child, Mode::from_raw_mode(0o555))?; + } + FileType::RegularFile => {} + _ => { + return Err(io::Error::other(format!( + "staging tree contains a non-file, non-directory entry: {}", + name.to_string_lossy() + ))); + } + } + } + Ok(()) +} + +#[cfg(not(unix))] +fn make_tree_read_only(staging: &StagingDirectory) -> io::Result<()> { + let root = staging.path(); let mut directories = Vec::new(); collect_directories(root, &mut directories)?; directories.sort_by_key(|path| std::cmp::Reverse(path.components().count())); @@ -677,6 +710,7 @@ fn make_tree_read_only(root: &Path) -> io::Result<()> { Ok(()) } +#[cfg(not(unix))] fn collect_directories(root: &Path, directories: &mut Vec) -> io::Result<()> { directories.push(root.to_path_buf()); for entry in fs::read_dir(root)? { @@ -688,12 +722,6 @@ fn collect_directories(root: &Path, directories: &mut Vec) -> io::Resul Ok(()) } -#[cfg(unix)] -fn set_file_read_only(path: &Path) -> io::Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(READ_ONLY_FILE_MODE)) -} - #[cfg(not(unix))] fn set_file_read_only(path: &Path) -> io::Result<()> { let mut permissions = fs::metadata(path)?.permissions(); @@ -701,12 +729,6 @@ fn set_file_read_only(path: &Path) -> io::Result<()> { fs::set_permissions(path, permissions) } -#[cfg(unix)] -fn set_directory_read_only(path: &Path) -> io::Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(READ_ONLY_DIRECTORY_MODE)) -} - #[cfg(not(unix))] fn set_directory_read_only(path: &Path) -> io::Result<()> { let mut permissions = fs::metadata(path)?.permissions(); @@ -714,6 +736,7 @@ fn set_directory_read_only(path: &Path) -> io::Result<()> { fs::set_permissions(path, permissions) } +#[cfg(not(unix))] fn make_tree_removable(root: &Path) { let Ok(metadata) = fs::symlink_metadata(root) else { return; @@ -730,12 +753,6 @@ fn make_tree_removable(root: &Path) { } } -#[cfg(unix)] -fn make_directory_writable(path: &Path) -> io::Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o700)) -} - #[cfg(not(unix))] fn make_directory_writable(path: &Path) -> io::Result<()> { let mut permissions = fs::metadata(path)?.permissions(); @@ -743,12 +760,6 @@ fn make_directory_writable(path: &Path) -> io::Result<()> { fs::set_permissions(path, permissions) } -#[cfg(unix)] -fn make_file_writable(path: &Path) -> io::Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o600)) -} - #[cfg(not(unix))] fn make_file_writable(path: &Path) -> io::Result<()> { let mut permissions = fs::metadata(path)?.permissions(); @@ -756,8 +767,52 @@ fn make_file_writable(path: &Path) -> io::Result<()> { fs::set_permissions(path, permissions) } +#[cfg(unix)] +fn open_directory_at( + directory: Fd, + path: P, +) -> io::Result { + rustix::fs::openat( + directory, + path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(Into::into) +} + +#[cfg(unix)] +fn remove_directory_contents(directory: &OwnedFd) { + let _ = rustix::fs::fchmod(directory, Mode::from_raw_mode(0o700)); + let Ok(entries) = Dir::read_from(directory) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + if name.to_bytes() == b"." || name.to_bytes() == b".." { + continue; + } + let Ok(metadata) = rustix::fs::statat(directory, name, AtFlags::SYMLINK_NOFOLLOW) else { + continue; + }; + if FileType::from_raw_mode(metadata.st_mode) == FileType::Directory + && let Ok(child) = open_directory_at(directory, name) + { + remove_directory_contents(&child); + if rustix::fs::unlinkat(directory, name, AtFlags::REMOVEDIR).is_ok() { + continue; + } + } + // unlinkat without REMOVEDIR never follows symlinks and safely + // removes regular files, links, devices, fifos, and sockets. + let _ = rustix::fs::unlinkat(directory, name, AtFlags::empty()); + } +} + struct StagingDirectory { path: PathBuf, + #[cfg(unix)] + root: OwnedFd, published: bool, } @@ -773,11 +828,36 @@ impl StagingDirectory { .map(|byte| format!("{byte:02x}")) .collect::(); let path = parent.join(format!(".locality-stage-{suffix}")); - match fs::create_dir(&path) { + #[cfg(unix)] + let create_result = + rustix::fs::mkdir(&path, Mode::from_raw_mode(0o700)).map_err(io::Error::from); + #[cfg(not(unix))] + let create_result = fs::create_dir(&path); + match create_result { Ok(()) => { + #[cfg(unix)] + let root = match rustix::fs::open( + &path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(root) => root, + Err(error) => { + let _ = fs::remove_dir(&path); + return Err(ReplicaMaterializationError::Staging(error.into())); + } + }; + #[cfg(unix)] + if let Err(error) = rustix::fs::fchmod(&root, Mode::from_raw_mode(0o700)) { + let _ = fs::remove_dir(&path); + return Err(ReplicaMaterializationError::Staging(error.into())); + } + #[cfg(not(unix))] make_directory_writable(&path).map_err(ReplicaMaterializationError::Staging)?; return Ok(Self { path, + #[cfg(unix)] + root, published: false, }); } @@ -795,6 +875,131 @@ impl StagingDirectory { &self.path } + #[cfg(unix)] + fn open_or_create_directory(&self, logical_path: &str) -> io::Result { + let mut current = None; + for component in logical_path.split('/') { + let parent = current + .as_ref() + .map(AsFd::as_fd) + .unwrap_or_else(|| self.root.as_fd()); + let child = match rustix::fs::openat( + parent, + component, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(child) => child, + Err(rustix::io::Errno::NOENT) => { + match rustix::fs::mkdirat(parent, component, Mode::from_raw_mode(0o700)) { + Ok(()) | Err(rustix::io::Errno::EXIST) => {} + Err(error) => return Err(error.into()), + } + open_directory_at(parent, component)? + } + Err(error) => return Err(error.into()), + }; + current = Some(child); + } + current.ok_or_else(|| io::Error::other("empty logical directory path")) + } + + #[cfg(unix)] + fn create_directory(&self, logical_path: &str) -> Result<(), ReplicaMaterializationError> { + self.open_or_create_directory(logical_path) + .map(|_| ()) + .map_err(|source| ReplicaMaterializationError::Write { + path: self.path.join(logical_path), + source, + }) + } + + #[cfg(not(unix))] + fn create_directory(&self, logical_path: &str) -> Result<(), ReplicaMaterializationError> { + let path = self.path.join(logical_path); + fs::create_dir_all(&path) + .map_err(|source| ReplicaMaterializationError::Write { path, source }) + } + + #[cfg(unix)] + fn write_file( + &self, + logical_path: &str, + reader: &mut R, + expected_size: u64, + ) -> Result<(), ReplicaMaterializationError> { + let target = self.path.join(logical_path); + let (parent_path, name) = logical_path + .rsplit_once('/') + .map_or((None, logical_path), |(parent, name)| (Some(parent), name)); + let parent = parent_path + .map(|parent| self.open_or_create_directory(parent)) + .transpose() + .map_err(|source| ReplicaMaterializationError::Write { + path: target.clone(), + source, + })?; + let directory = parent + .as_ref() + .map(AsFd::as_fd) + .unwrap_or_else(|| self.root.as_fd()); + let descriptor = rustix::fs::openat( + directory, + name, + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::from_raw_mode(0o600), + ) + .map_err(|source| ReplicaMaterializationError::Write { + path: target.clone(), + source: source.into(), + })?; + let mut file = fs::File::from(descriptor); + let written = + io::copy(reader, &mut file).map_err(|source| ReplicaMaterializationError::Write { + path: target.clone(), + source, + })?; + if written != expected_size { + return Err(ReplicaMaterializationError::MalformedTar(format!( + "entry `{}` ended after {written} of {expected_size} bytes", + target.display() + ))); + } + file.flush() + .map_err(|source| ReplicaMaterializationError::Write { + path: target.clone(), + source, + })?; + rustix::fs::fchmod(&file, Mode::from_raw_mode(0o444)).map_err(|source| { + ReplicaMaterializationError::Write { + path: target, + source: source.into(), + } + }) + } + + #[cfg(not(unix))] + fn write_file( + &self, + logical_path: &str, + reader: &mut R, + expected_size: u64, + ) -> Result<(), ReplicaMaterializationError> { + write_file_at_path(&self.path.join(logical_path), reader, expected_size) + } + + #[cfg(unix)] + fn publish(&mut self, destination: &Path) -> Result<(), ReplicaMaterializationError> { + fs::rename(&self.path, destination).map_err(ReplicaMaterializationError::Publish)?; + if let Err(error) = rustix::fs::fchmod(&self.root, Mode::from_raw_mode(0o555)) { + let _ = fs::rename(destination, &self.path); + return Err(ReplicaMaterializationError::Publish(error.into())); + } + self.published = true; + Ok(()) + } + + #[cfg(not(unix))] fn publish(&mut self, destination: &Path) -> Result<(), ReplicaMaterializationError> { match fs::rename(&self.path, destination) { Ok(()) => { @@ -813,8 +1018,16 @@ impl StagingDirectory { impl Drop for StagingDirectory { fn drop(&mut self) { if !self.published { - make_tree_removable(&self.path); - let _ = fs::remove_dir_all(&self.path); + #[cfg(unix)] + { + remove_directory_contents(&self.root); + let _ = fs::remove_dir(&self.path); + } + #[cfg(not(unix))] + { + make_tree_removable(&self.path); + let _ = fs::remove_dir_all(&self.path); + } } } } diff --git a/crates/localityd/tests/replica_materializer.rs b/crates/localityd/tests/replica_materializer.rs index a820a36d..837ecb9c 100644 --- a/crates/localityd/tests/replica_materializer.rs +++ b/crates/localityd/tests/replica_materializer.rs @@ -1,6 +1,10 @@ use std::fs; use std::io::{Cursor, Read}; use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::sync::Arc; +#[cfg(unix)] +use std::sync::atomic::AtomicBool; use std::sync::atomic::{AtomicU64, Ordering}; use localityd::remote_truth::{ReplicaArchive, ReplicaArchiveEncoding}; @@ -219,6 +223,90 @@ impl Read for ChunkedReader { } } +#[cfg(unix)] +struct ComponentSwapReader { + inner: Cursor>, + staging_parent: PathBuf, + outside: PathBuf, + swapped: Arc, +} + +#[cfg(unix)] +impl Read for ComponentSwapReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + const FIRST_DIRECTORY_HEADER_END: u64 = 512; + if self.inner.position() == FIRST_DIRECTORY_HEADER_END + && !self.swapped.swap(true, Ordering::SeqCst) + { + let staging = fs::read_dir(&self.staging_parent)? + .filter_map(Result::ok) + .find(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".locality-stage-") + }) + .expect("private staging directory exists before archive extraction"); + let component = staging.path().join("legitimate"); + assert!(component.is_dir(), "first tar entry created its directory"); + fs::remove_dir(&component)?; + std::os::unix::fs::symlink(&self.outside, component)?; + } + + let remaining_before_swap = + FIRST_DIRECTORY_HEADER_END.saturating_sub(self.inner.position()); + let allowed = if remaining_before_swap == 0 { + buffer.len() + } else { + buffer.len().min(remaining_before_swap as usize) + }; + self.inner.read(&mut buffer[..allowed]) + } +} + +#[cfg(unix)] +#[test] +fn rejects_component_replaced_by_symlink_without_writing_or_chmodding_outside() { + use std::os::unix::fs::PermissionsExt; + + let root = TestDirectory::new("component-swap"); + let outside = TestDirectory::new("component-swap-outside"); + fs::write(outside.0.join("sentinel.txt"), b"outside\n").expect("write outside sentinel"); + fs::set_permissions(&outside.0, fs::Permissions::from_mode(0o711)) + .expect("set distinctive outside mode"); + let swapped = Arc::new(AtomicBool::new(false)); + let archive = tar_archive(&[ + TestMember::directory("legitimate/"), + TestMember::file("legitimate/escaped.txt", "must stay contained\n"), + ]); + let reader = ComponentSwapReader { + inner: Cursor::new(archive), + staging_parent: root.0.clone(), + outside: outside.0.clone(), + swapped: Arc::clone(&swapped), + }; + + let error = materialize_replica_archive( + ReplicaArchive::new(ReplicaArchiveEncoding::Identity, reader), + &root.destination(), + ReplicaMaterializationLimits::default(), + ) + .expect_err("a symlink substituted for an opened component must be rejected"); + + assert!(swapped.load(Ordering::SeqCst), "test performed the swap"); + assert!( + error.to_string().contains("legitimate/escaped.txt"), + "rejection identifies the affected logical path: {error}" + ); + root.assert_no_staging_or_destination(); + assert_eq!( + fs::read(outside.0.join("sentinel.txt")).expect("read untouched sentinel"), + b"outside\n" + ); + assert!(!outside.0.join("escaped.txt").exists()); + assert_modes(&outside.0, 0o711); +} + #[test] fn single_frame_zstd_stream_materializes_from_small_chunks() { let root = TestDirectory::new("zstd"); From 479cdf0b538edefc54615f5421f2cfbe62a44e62 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Tue, 21 Jul 2026 14:50:06 -0700 Subject: [PATCH 04/18] Restore portable replica materialization --- apps/oauth-service/package-lock.json | 8 ++++---- apps/oauth-service/package.json | 2 +- crates/localityd/src/replica_materializer.rs | 9 ++++++++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/oauth-service/package-lock.json b/apps/oauth-service/package-lock.json index ec157708..ab397bfb 100644 --- a/apps/oauth-service/package-lock.json +++ b/apps/oauth-service/package-lock.json @@ -8,7 +8,7 @@ "name": "@codeflash-ai/locality-auth-broker", "version": "0.1.0", "dependencies": { - "hono": "^4.6.0" + "hono": "^4.12.31" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260611.0", @@ -1341,9 +1341,9 @@ } }, "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/apps/oauth-service/package.json b/apps/oauth-service/package.json index fca63449..16ccf114 100644 --- a/apps/oauth-service/package.json +++ b/apps/oauth-service/package.json @@ -13,7 +13,7 @@ "check": "npm run typecheck && npm test" }, "dependencies": { - "hono": "^4.6.0" + "hono": "^4.12.31" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260611.0", diff --git a/crates/localityd/src/replica_materializer.rs b/crates/localityd/src/replica_materializer.rs index 4218d938..8dfe9ff0 100644 --- a/crates/localityd/src/replica_materializer.rs +++ b/crates/localityd/src/replica_materializer.rs @@ -985,7 +985,14 @@ impl StagingDirectory { reader: &mut R, expected_size: u64, ) -> Result<(), ReplicaMaterializationError> { - write_file_at_path(&self.path.join(logical_path), reader, expected_size) + let path = self.path.join(logical_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| ReplicaMaterializationError::Write { + path: parent.to_path_buf(), + source, + })?; + } + write_file_at_path(&path, reader, expected_size) } #[cfg(unix)] From 42997bb0594dc65a07344f750d1b0bc3c124bc05 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Tue, 21 Jul 2026 16:07:52 -0700 Subject: [PATCH 05/18] Bind replica publication to staging identity --- crates/localityd/src/replica_materializer.rs | 202 ++++++++++++++++-- .../localityd/tests/replica_materializer.rs | 74 +++++++ 2 files changed, 264 insertions(+), 12 deletions(-) diff --git a/crates/localityd/src/replica_materializer.rs b/crates/localityd/src/replica_materializer.rs index 8dfe9ff0..96bae011 100644 --- a/crates/localityd/src/replica_materializer.rs +++ b/crates/localityd/src/replica_materializer.rs @@ -5,17 +5,26 @@ //! or per-file SQLite state. use std::collections::{BTreeMap, BTreeSet}; +#[cfg(unix)] +use std::ffi::{OsStr, OsString}; use std::fmt::{Display, Formatter}; use std::fs; #[cfg(not(unix))] use std::fs::OpenOptions; use std::io::{self, Read, Write}; +#[cfg(unix)] +use std::os::unix::ffi::OsStringExt; use std::path::{Path, PathBuf}; #[cfg(unix)] use rustix::fd::{AsFd, OwnedFd}; +#[cfg(all( + unix, + any(target_vendor = "apple", target_os = "linux", target_os = "android") +))] +use rustix::fs::RenameFlags; #[cfg(unix)] -use rustix::fs::{AtFlags, Dir, FileType, Mode, OFlags}; +use rustix::fs::{AtFlags, Dir, FileType, Mode, OFlags, Stat}; use locality_core::portable::LogicalPath; use sha2::{Digest, Sha256}; @@ -809,15 +818,126 @@ fn remove_directory_contents(directory: &OwnedFd) { } } +#[cfg(unix)] +fn same_file_identity(left: &Stat, right: &Stat) -> bool { + left.st_dev == right.st_dev && left.st_ino == right.st_ino +} + +#[cfg(unix)] +fn named_entry_matches(directory: &OwnedFd, name: &OsStr, expected: &Stat) -> io::Result { + let observed = rustix::fs::statat(directory, name, AtFlags::SYMLINK_NOFOLLOW)?; + Ok(same_file_identity(&observed, expected)) +} + +#[cfg(all( + unix, + any(target_vendor = "apple", target_os = "linux", target_os = "android") +))] +fn rename_directory_noreplace( + directory: &OwnedFd, + source: &OsStr, + destination: &OsStr, +) -> io::Result<()> { + rustix::fs::renameat_with( + directory, + source, + directory, + destination, + RenameFlags::NOREPLACE, + ) + .map_err(Into::into) +} + +#[cfg(all( + unix, + not(any(target_vendor = "apple", target_os = "linux", target_os = "android")) +))] +fn rename_directory_noreplace( + _directory: &OwnedFd, + _source: &OsStr, + _destination: &OsStr, +) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "this Unix platform lacks atomic no-replace directory publication", + )) +} + +#[cfg(unix)] +fn unlink_named_entry(directory: &OwnedFd, name: &OsStr) { + let Ok(metadata) = rustix::fs::statat(directory, name, AtFlags::SYMLINK_NOFOLLOW) else { + return; + }; + let flags = if FileType::from_raw_mode(metadata.st_mode) == FileType::Directory { + AtFlags::REMOVEDIR + } else { + AtFlags::empty() + }; + let _ = rustix::fs::unlinkat(directory, name, flags); +} + +#[cfg(unix)] +fn remove_open_staging_directory(parent: &OwnedFd, root: &OwnedFd, hinted_name: &OsStr) { + remove_directory_contents(root); + let Ok(root_identity) = rustix::fs::fstat(root) else { + return; + }; + + let matching_name = Dir::read_from(parent).ok().and_then(|entries| { + entries.filter_map(Result::ok).find_map(|entry| { + let name = entry.file_name(); + if name.to_bytes() == b"." || name.to_bytes() == b".." { + return None; + } + let name = OsString::from_vec(name.to_bytes().to_vec()); + named_entry_matches(parent, &name, &root_identity) + .ok() + .filter(|matches| *matches) + .map(|_| name) + }) + }); + if let Some(name) = matching_name { + let _ = rustix::fs::unlinkat(parent, &name, AtFlags::REMOVEDIR); + } + + // A same-user substitution may leave a symlink or regular file at the + // expected name. Remove that entry without following it. A non-empty + // replacement directory is deliberately left untouched. + unlink_named_entry(parent, hinted_name); +} + struct StagingDirectory { path: PathBuf, #[cfg(unix)] + parent: OwnedFd, + #[cfg(unix)] root: OwnedFd, + #[cfg(unix)] + name: OsString, published: bool, } impl StagingDirectory { fn create(parent: &Path) -> Result { + #[cfg(unix)] + let parent_descriptor = rustix::fs::open( + parent, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|error| ReplicaMaterializationError::Staging(error.into()))?; + #[cfg(unix)] + { + let opened = rustix::fs::fstat(&parent_descriptor) + .map_err(|error| ReplicaMaterializationError::Staging(error.into()))?; + let named = rustix::fs::stat(parent) + .map_err(|error| ReplicaMaterializationError::Staging(error.into()))?; + if !same_file_identity(&opened, &named) { + return Err(ReplicaMaterializationError::Staging(io::Error::other( + "replica destination parent changed while staging was created", + ))); + } + } for _ in 0..16 { let mut random = [0_u8; 16]; getrandom::fill(&mut random).map_err(|error| { @@ -827,29 +947,37 @@ impl StagingDirectory { .iter() .map(|byte| format!("{byte:02x}")) .collect::(); + #[cfg(unix)] + let name = OsString::from(format!(".locality-stage-{suffix}")); + #[cfg(unix)] + let path = parent.join(&name); + #[cfg(not(unix))] let path = parent.join(format!(".locality-stage-{suffix}")); #[cfg(unix)] let create_result = - rustix::fs::mkdir(&path, Mode::from_raw_mode(0o700)).map_err(io::Error::from); + rustix::fs::mkdirat(&parent_descriptor, &name, Mode::from_raw_mode(0o700)) + .map_err(io::Error::from); #[cfg(not(unix))] let create_result = fs::create_dir(&path); match create_result { Ok(()) => { #[cfg(unix)] - let root = match rustix::fs::open( - &path, + let root = match rustix::fs::openat( + &parent_descriptor, + &name, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty(), ) { Ok(root) => root, Err(error) => { - let _ = fs::remove_dir(&path); + let _ = + rustix::fs::unlinkat(&parent_descriptor, &name, AtFlags::REMOVEDIR); return Err(ReplicaMaterializationError::Staging(error.into())); } }; #[cfg(unix)] if let Err(error) = rustix::fs::fchmod(&root, Mode::from_raw_mode(0o700)) { - let _ = fs::remove_dir(&path); + let _ = rustix::fs::unlinkat(&parent_descriptor, &name, AtFlags::REMOVEDIR); return Err(ReplicaMaterializationError::Staging(error.into())); } #[cfg(not(unix))] @@ -857,7 +985,11 @@ impl StagingDirectory { return Ok(Self { path, #[cfg(unix)] + parent: parent_descriptor, + #[cfg(unix)] root, + #[cfg(unix)] + name, published: false, }); } @@ -997,10 +1129,57 @@ impl StagingDirectory { #[cfg(unix)] fn publish(&mut self, destination: &Path) -> Result<(), ReplicaMaterializationError> { - fs::rename(&self.path, destination).map_err(ReplicaMaterializationError::Publish)?; - if let Err(error) = rustix::fs::fchmod(&self.root, Mode::from_raw_mode(0o555)) { - let _ = fs::rename(destination, &self.path); - return Err(ReplicaMaterializationError::Publish(error.into())); + let parent_path = self + .path + .parent() + .ok_or(ReplicaMaterializationError::InvalidDestination)? + .to_path_buf(); + if destination.parent() != Some(parent_path.as_path()) { + return Err(ReplicaMaterializationError::InvalidDestination); + } + let destination_name = destination + .file_name() + .ok_or(ReplicaMaterializationError::InvalidDestination)?; + let root_identity = rustix::fs::fstat(&self.root) + .map_err(|error| ReplicaMaterializationError::Publish(error.into()))?; + let parent_identity = rustix::fs::fstat(&self.parent) + .map_err(|error| ReplicaMaterializationError::Publish(error.into()))?; + let named_parent = rustix::fs::stat(&parent_path) + .map_err(|error| ReplicaMaterializationError::Publish(error.into()))?; + if !same_file_identity(&parent_identity, &named_parent) + || !named_entry_matches(&self.parent, &self.name, &root_identity).unwrap_or(false) + { + return Err(ReplicaMaterializationError::Publish(io::Error::other( + "replica staging root identity changed before publication", + ))); + } + + if let Err(error) = rename_directory_noreplace(&self.parent, &self.name, destination_name) { + if error.kind() == io::ErrorKind::AlreadyExists { + return Err(ReplicaMaterializationError::DestinationExists( + destination.to_path_buf(), + )); + } + return Err(ReplicaMaterializationError::Publish(error)); + } + self.name = destination_name.to_os_string(); + self.path = destination.to_path_buf(); + + if !named_entry_matches(&self.parent, &self.name, &root_identity).unwrap_or(false) { + return Err(ReplicaMaterializationError::Publish(io::Error::other( + "replica staging root identity changed during publication", + ))); + } + rustix::fs::fchmod(&self.root, Mode::from_raw_mode(0o555)) + .map_err(|error| ReplicaMaterializationError::Publish(error.into()))?; + let named_parent = rustix::fs::stat(&parent_path) + .map_err(|error| ReplicaMaterializationError::Publish(error.into()))?; + if !same_file_identity(&parent_identity, &named_parent) + || !named_entry_matches(&self.parent, &self.name, &root_identity).unwrap_or(false) + { + return Err(ReplicaMaterializationError::Publish(io::Error::other( + "replica staging root identity changed while publication was finalized", + ))); } self.published = true; Ok(()) @@ -1027,8 +1206,7 @@ impl Drop for StagingDirectory { if !self.published { #[cfg(unix)] { - remove_directory_contents(&self.root); - let _ = fs::remove_dir(&self.path); + remove_open_staging_directory(&self.parent, &self.root, &self.name); } #[cfg(not(unix))] { diff --git a/crates/localityd/tests/replica_materializer.rs b/crates/localityd/tests/replica_materializer.rs index 837ecb9c..ffceef92 100644 --- a/crates/localityd/tests/replica_materializer.rs +++ b/crates/localityd/tests/replica_materializer.rs @@ -264,6 +264,36 @@ impl Read for ComponentSwapReader { } } +#[cfg(unix)] +struct RootSwapReader { + inner: Cursor>, + staging_parent: PathBuf, + outside: PathBuf, + swapped: Arc, +} + +#[cfg(unix)] +impl Read for RootSwapReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let read = self.inner.read(buffer)?; + if read == 0 && !self.swapped.swap(true, Ordering::SeqCst) { + let staging = fs::read_dir(&self.staging_parent)? + .filter_map(Result::ok) + .find(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".locality-stage-") + }) + .expect("private staging directory exists before publication"); + let detached = self.staging_parent.join("attacker-detached-staging"); + fs::rename(staging.path(), &detached)?; + std::os::unix::fs::symlink(&self.outside, staging.path())?; + } + Ok(read) + } +} + #[cfg(unix)] #[test] fn rejects_component_replaced_by_symlink_without_writing_or_chmodding_outside() { @@ -307,6 +337,50 @@ fn rejects_component_replaced_by_symlink_without_writing_or_chmodding_outside() assert_modes(&outside.0, 0o711); } +#[cfg(unix)] +#[test] +fn rejects_staging_root_replaced_by_symlink_before_publication() { + use std::os::unix::fs::PermissionsExt; + + let root = TestDirectory::new("root-swap"); + let outside = TestDirectory::new("root-swap-outside"); + fs::write(outside.0.join("sentinel.txt"), b"outside\n").expect("write outside sentinel"); + fs::set_permissions(&outside.0, fs::Permissions::from_mode(0o711)) + .expect("set distinctive outside mode"); + let swapped = Arc::new(AtomicBool::new(false)); + let reader = RootSwapReader { + inner: Cursor::new(tar_archive(&[TestMember::file( + "payload.txt", + "must stay in the held staging root\n", + )])), + staging_parent: root.0.clone(), + outside: outside.0.clone(), + swapped: Arc::clone(&swapped), + }; + + let error = materialize_replica_archive( + ReplicaArchive::new(ReplicaArchiveEncoding::Identity, reader), + &root.destination(), + ReplicaMaterializationLimits::default(), + ) + .expect_err("a symlink substituted for the staging root must be rejected"); + + assert!(swapped.load(Ordering::SeqCst), "test performed the swap"); + assert!( + error + .to_string() + .contains("staging root identity changed before publication"), + "rejection identifies the root publication race: {error}" + ); + root.assert_no_staging_or_destination(); + assert_eq!( + fs::read(outside.0.join("sentinel.txt")).expect("read untouched sentinel"), + b"outside\n" + ); + assert!(!outside.0.join("payload.txt").exists()); + assert_modes(&outside.0, 0o711); +} + #[test] fn single_frame_zstd_stream_materializes_from_small_chunks() { let root = TestDirectory::new("zstd"); From e38ef6c6bd8663154de8913e8c4c3e3dc2ab4336 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Tue, 21 Jul 2026 16:13:42 -0700 Subject: [PATCH 06/18] Reject symlinked replica parents --- crates/localityd/src/replica_materializer.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/localityd/src/replica_materializer.rs b/crates/localityd/src/replica_materializer.rs index 96bae011..a54d1b77 100644 --- a/crates/localityd/src/replica_materializer.rs +++ b/crates/localityd/src/replica_materializer.rs @@ -919,10 +919,14 @@ struct StagingDirectory { impl StagingDirectory { fn create(parent: &Path) -> Result { + // The pre-open metadata check alone is not enough: the path can be + // replaced with a symlink before `open`. Keep the descriptor anchored + // to the requested directory and let the identity checks below detect + // replacements after this point. #[cfg(unix)] let parent_descriptor = rustix::fs::open( parent, - OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty(), ) .map_err(|error| ReplicaMaterializationError::Staging(error.into()))?; From e3a4a5315b964284f21039f8ea1003197c495e6f Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Tue, 21 Jul 2026 16:28:01 -0700 Subject: [PATCH 07/18] chore: patch OAuth service build dependencies --- apps/oauth-service/package-lock.json | 3354 +++++++++++++------------- apps/oauth-service/package.json | 7 +- 2 files changed, 1703 insertions(+), 1658 deletions(-) diff --git a/apps/oauth-service/package-lock.json b/apps/oauth-service/package-lock.json index ab397bfb..b046f31b 100644 --- a/apps/oauth-service/package-lock.json +++ b/apps/oauth-service/package-lock.json @@ -11,10 +11,10 @@ "hono": "^4.12.31" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20260611.0", + "@cloudflare/workers-types": "^5.20260721.1", "typescript": "^5.9.0", "vitest": "^4.1.9", - "wrangler": "^4.0.0" + "wrangler": "^4.113.0" }, "engines": { "node": ">=22" @@ -47,9 +47,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260617.1.tgz", - "integrity": "sha512-jWwmgEVVWbsHNrLSNXzwjJaH90VzRxq1cWkQFUidxyeUPnMxemeNE8I9qFAfrpzGgE11e9sKDcE3ettJW08swQ==", + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260721.1.tgz", + "integrity": "sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ==", "cpu": [ "x64" ], @@ -64,9 +64,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260617.1.tgz", - "integrity": "sha512-LHH7b565g9znfCUOkwbec6FG2rmRbsgCy6aJiU9KN662mNheWl5sw/iKleiFSiljPKQQP3HkjnC/NSkdgi/aSA==", + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260721.1.tgz", + "integrity": "sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA==", "cpu": [ "arm64" ], @@ -81,9 +81,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260617.1.tgz", - "integrity": "sha512-FMnaAKXe4Cfd8TQurCVd9fs2XQVBFRCsP+Id/SRdUv89MlwYu9zXfoyx6BxM+brPTIUK38SHbo8iaxiwzLi9JQ==", + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260721.1.tgz", + "integrity": "sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ==", "cpu": [ "x64" ], @@ -98,9 +98,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260617.1.tgz", - "integrity": "sha512-MRoifFYcqbxxIIQy7PqO5tFY/qPFSnjXzakWl0sO93l+HLyG35jRAgOi6jfqa4kBxc7gKKtH861DcewjxUfkjA==", + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260721.1.tgz", + "integrity": "sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ==", "cpu": [ "arm64" ], @@ -115,9 +115,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260617.1.tgz", - "integrity": "sha512-rgBV9wQrv0OSKgCTTbhFUFY3sLGNANZ88aqaLvtmEn2gmbFVb1J4PDGochVUdB7NSEp4D/ghHva6/8SZmbONpw==", + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260721.1.tgz", + "integrity": "sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ==", "cpu": [ "x64" ], @@ -132,9 +132,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20260623.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260623.1.tgz", - "integrity": "sha512-J/0POl0HeLepbwDE5Yx5c7jQrHFkvCEFu3TS+TQsDDlg/vTs5og7wdGP6eNGXOAntgWUrjcvvKTmVLTP7OrnAg==", + "version": "5.20260721.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260721.1.tgz", + "integrity": "sha512-J6HZRuQOP3gVe9G5rxHQVS8kQRjo7NIaF+Lz4kO2lVmaCkQ1E78APOOthaI7KyCQzp+A2NbXBQI6QLRIswdWbA==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -185,2514 +185,2556 @@ "tslib": "^2.4.0" } }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { "node": ">=18" } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" + "android" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" + "android" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "darwin" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "darwin" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ - "arm" + "arm64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ - "ppc64" + "arm" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ - "s390x" + "ia32" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ - "x64" + "loong64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ - "arm64" + "mips64el" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ - "x64" + "ppc64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ - "arm" + "riscv64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ - "arm64" + "s390x" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ - "ppc64" + "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "netbsd" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ - "s390x" + "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "netbsd" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ - "wasm32" + "x64" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, + "os": [ + "sunos" + ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">=18" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "node": ">=18" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.2" + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "url": "https://opencollective.com/libvips" }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, - "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@poppinss/colors": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", - "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^4.1.5" - } - }, - "node_modules/@poppinss/dumper": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", - "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@sindresorhus/is": "^7.0.2", - "supports-color": "^10.0.0" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" } }, - "node_modules/@poppinss/exception": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", - "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", - "cpu": [ - "arm64" - ], + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "android" + "freebsd" ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "darwin" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "darwin" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ - "x64" + "arm" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "freebsd" + "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ - "arm" + "arm64" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ - "arm64" + "ppc64" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ - "arm64" + "riscv64" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ - "ppc64" + "s390x" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ - "s390x" + "x64" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "openharmony" + "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ - "wasm32" + "ppc64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ - "arm64" + "s390x" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" + "node": ">=20.9.0" }, "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, - "node_modules/@speed-highlight/core": { - "version": "1.2.17", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", - "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" }, "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "url": "https://opencollective.com/libvips" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=6.0.0" } }, - "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.9", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" + "@tybys/wasm-util": "^0.10.2" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "kleur": "^4.1.5" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" } }, - "node_modules/blake3-wasm": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", - "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", "dev": true, "license": "MIT" }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/error-stack-parser-es": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", - "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12.0.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/hono": { - "version": "4.12.31", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", - "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ - "android" + "linux" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ - "arm64" + "ppc64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ - "x64" + "s390x" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ - "freebsd" + "linux" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ - "arm" + "x64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } + "license": "MIT" }, - "node_modules/miniflare": { - "version": "4.20260617.1", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260617.1.tgz", - "integrity": "sha512-Go3/gzStm99QHptsSgU+q1S+xDfLoRgwjJNY80kaTVi0ENhTyqKq+sc4xZiWBSbM7uUcJwmzm8+QFKtcYLJ9nw==", + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", "dev": true, "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "0.8.1", - "sharp": "0.34.5", - "undici": "7.28.0", - "workerd": "1.20260617.1", - "ws": "8.21.0", - "youch": "4.1.0-beta.10" - }, - "bin": { - "miniflare": "bootstrap.js" - }, "engines": { - "node": ">=22.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" } }, - "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "node_modules/@speed-highlight/core": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], "license": "MIT", - "engines": { - "node": ">=12.20.0" + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://opencollective.com/vitest" } }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, - "engines": { - "node": "^10 || ^12 || >=14" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" + "tinyrainbow": "^3.1.0" }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" }, - "engines": { - "node": ">=10" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "url": "https://opencollective.com/vitest" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, - "license": "ISC" + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12" + } }, - "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", "dev": true, "license": "MIT" }, - "node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "dev": true, "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, + "license": "Apache-2.0", "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "node": ">=8" } }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=14.0.0" + "funding": { + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, - "license": "0BSD", - "optional": true + "license": "MIT" }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, - "license": "Apache-2.0", + "hasInstallScript": true, + "license": "MIT", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/unenv": { - "version": "2.0.0-rc.24", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", - "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "pathe": "^2.0.3" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, - "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "node": ">=12.0.0" } }, - "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "node": ">=12.0.0" }, "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "picomatch": "^3 || ^4" }, "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { + "picomatch": { "optional": true - }, - "vite": { - "optional": false } } }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=8" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/workerd": { - "version": "1.20260617.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260617.1.tgz", - "integrity": "sha512-Re5pl6pdowt3ZmWUzGlOuB7jbRIIPetgKalmo4cYmucQnVhpo7/3e4MfpekbhLi2EhZZz5EY9NWRu8zFzuEZew==", + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "bin": { - "workerd": "bin/workerd" - }, + "license": "MIT", "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260617.1", - "@cloudflare/workerd-darwin-arm64": "1.20260617.1", - "@cloudflare/workerd-linux-64": "1.20260617.1", - "@cloudflare/workerd-linux-arm64": "1.20260617.1", - "@cloudflare/workerd-windows-64": "1.20260617.1" + "node": ">=6" } }, - "node_modules/wrangler": { - "version": "4.103.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.103.0.tgz", - "integrity": "sha512-3Lv1P5t2xcSEkSTKtG+Lz+3JFryuU7YPLkaCUj7gNe+CJsjZJLtUwqsh1x595QBxkIbCE0GAvDx2DCJUU4+oqw==", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, - "license": "MIT OR Apache-2.0", + "license": "MPL-2.0", "dependencies": { - "@cloudflare/kv-asset-handler": "0.5.0", - "@cloudflare/unenv-preset": "2.16.1", - "blake3-wasm": "2.1.5", - "esbuild": "0.28.1", - "miniflare": "4.20260617.1", - "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.24", - "workerd": "1.20260617.1" - }, - "bin": { - "cf-wrangler": "bin/cf-wrangler.js", - "wrangler": "bin/wrangler.js", - "wrangler2": "bin/wrangler.js" + "detect-libc": "^2.0.3" }, "engines": { - "node": ">=22.0.0" - }, - "optionalDependencies": { - "fsevents": "2.3.3" + "node": ">= 12.0.0" }, - "peerDependencies": { - "@cloudflare/workers-types": "^4.20260617.1" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "aix" + "android" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ - "arm" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "android" + "darwin" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "android" + "darwin" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "android" + "freebsd" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ - "arm64" + "arm" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "darwin" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "freebsd" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "freebsd" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ - "arm" + "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ - "ia32" + "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], + "node_modules/miniflare": { + "version": "4.20260721.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260721.0.tgz", + "integrity": "sha512-fBLaCxZ2i/nPH8iyLzvza0C8/sSF4sjD1ma1Skf+pkZVK0TlaW5ujHJlUHwcwR66v2JZt+Q28d4DCX/oaLG0cA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.34.5", + "undici": "7.28.0", + "workerd": "1.20260721.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, "engines": { - "node": ">=18" + "node": ">=22.0.0" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, "engines": { - "node": ">=18" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" ], + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=12.20.0" } }, - "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/wrangler/node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } + "license": "ISC" }, - "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], "engines": { - "node": ">=18" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, "engines": { - "node": ">=18" + "node": "^10 || ^12 || >=14" } }, - "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, - "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">=18" + "node": ">=10" } }, - "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, "engines": { - "node": ">=18" - } - }, - "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { "node": ">=18" } }, - "node_modules/wrangler/node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, "engines": { - "node": ">=18" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/wrangler/node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, - "hasInstallScript": true, "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", "bin": { - "esbuild": "bin/esbuild" + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=18" + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/workerd": { + "version": "1.20260721.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260721.1.tgz", + "integrity": "sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260721.1", + "@cloudflare/workerd-darwin-arm64": "1.20260721.1", + "@cloudflare/workerd-linux-64": "1.20260721.1", + "@cloudflare/workerd-linux-arm64": "1.20260721.1", + "@cloudflare/workerd-windows-64": "1.20260721.1" + } + }, + "node_modules/wrangler": { + "version": "4.113.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.113.0.tgz", + "integrity": "sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "4.20260721.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260721.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260721.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } } }, "node_modules/ws": { diff --git a/apps/oauth-service/package.json b/apps/oauth-service/package.json index 16ccf114..325c9f41 100644 --- a/apps/oauth-service/package.json +++ b/apps/oauth-service/package.json @@ -16,10 +16,13 @@ "hono": "^4.12.31" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20260611.0", + "@cloudflare/workers-types": "^5.20260721.1", "typescript": "^5.9.0", "vitest": "^4.1.9", - "wrangler": "^4.0.0" + "wrangler": "^4.113.0" + }, + "overrides": { + "sharp": "^0.35.3" }, "engines": { "node": ">=22" From 1c908ee3dc93a2b72b433abb2d1620427fa5371f Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Tue, 21 Jul 2026 16:37:38 -0700 Subject: [PATCH 08/18] test: normalize sandbox server sockets --- crates/loc-cli/src/sandbox.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/loc-cli/src/sandbox.rs b/crates/loc-cli/src/sandbox.rs index ab103f55..354bd3a1 100644 --- a/crates/loc-cli/src/sandbox.rs +++ b/crates/loc-cli/src/sandbox.rs @@ -1164,6 +1164,11 @@ mod tests { } fn read_request(stream: &mut TcpStream) -> CapturedRequest { + // Accepted sockets may inherit the listener's nonblocking mode on some + // platforms. Normalize both test-server accept paths before reading. + stream + .set_nonblocking(false) + .expect("set request stream blocking"); stream .set_read_timeout(Some(Duration::from_secs(5))) .expect("set request timeout"); From f978af673a08b3730505abe0a1c0b5913fa3c9a1 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Tue, 21 Jul 2026 20:33:55 -0700 Subject: [PATCH 09/18] feat(notion): support explicit multi-root sync --- crates/locality-connector/src/lib.rs | 23 + .../src/synchronize_project.rs | 10 + .../tests/synchronize_project.rs | 75 ++- crates/locality-notion/src/lib.rs | 66 ++- crates/locality-notion/src/portable.rs | 346 ++++++++++--- crates/locality-notion/src/projection.rs | 143 +++-- crates/locality-notion/tests/fetch_render.rs | 487 +++++++++++++++++- docs/notion-connector.md | 15 + 8 files changed, 1041 insertions(+), 124 deletions(-) diff --git a/crates/locality-connector/src/lib.rs b/crates/locality-connector/src/lib.rs index 1a084d8f..ec24cad8 100644 --- a/crates/locality-connector/src/lib.rs +++ b/crates/locality-connector/src/lib.rs @@ -203,6 +203,29 @@ impl PortableCompleteness { } } +/// Reserved [`SourceObject::edges`] relationship for the canonical explicit +/// scope root that owns a portable source object. +pub const PORTABLE_SCOPE_ROOT_RELATIONSHIP: &str = "locality_scope_root"; + +/// Decode the optional owning-root edge, rejecting ambiguous source objects. +pub fn portable_scope_root_remote_id( + source_object: &SourceObject, +) -> LocalityResult> { + let mut roots = source_object + .edges + .iter() + .filter(|edge| edge.relationship == PORTABLE_SCOPE_ROOT_RELATIONSHIP) + .map(|edge| &edge.target_remote_id); + let root = roots.next(); + if roots.next().is_some() { + return Err(locality_core::LocalityError::InvalidState(format!( + "portable source `{}` returned multiple owning-root edges", + source_object.remote_id.as_str() + ))); + } + Ok(root) +} + /// One provider object discovered by bootstrap or synchronization. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PortableSourceChange { diff --git a/crates/locality-engine/src/synchronize_project.rs b/crates/locality-engine/src/synchronize_project.rs index 4a2f2eeb..e7f7cd69 100644 --- a/crates/locality-engine/src/synchronize_project.rs +++ b/crates/locality-engine/src/synchronize_project.rs @@ -11,6 +11,7 @@ use locality_connector::{ PortableCompleteness, PortableContentArtifact, PortableEnumerateRequest, PortableEnumerateResult, PortableFetchReason, PortableFetchRequest, PortableProjectionArtifact, PortableRenderRequest, PortableSourceChange, PortableSyncRequest, + portable_scope_root_remote_id, }; use locality_core::model::RemoteId; use locality_core::portable::{ @@ -265,6 +266,7 @@ fn validate_changes( changes: &[PortableSourceChange], ) -> LocalityResult<()> { let mut remote_ids = BTreeSet::new(); + let mut provenance_mode = None; for change in changes { if &change.source_object.source_connection_id != source_connection_id { return Err(LocalityError::InvalidState( @@ -277,6 +279,14 @@ fn validate_changes( change.source_object.remote_id.as_str() ))); } + let has_root_provenance = portable_scope_root_remote_id(&change.source_object)?.is_some(); + if provenance_mode.is_some_and(|expected| expected != has_root_provenance) { + return Err(LocalityError::InvalidState( + "portable connector returned a batch with ambiguous owning-root provenance" + .to_string(), + )); + } + provenance_mode.get_or_insert(has_root_provenance); } Ok(()) } diff --git a/crates/locality-engine/tests/synchronize_project.rs b/crates/locality-engine/tests/synchronize_project.rs index d636caeb..3e907453 100644 --- a/crates/locality-engine/tests/synchronize_project.rs +++ b/crates/locality-engine/tests/synchronize_project.rs @@ -3,15 +3,16 @@ use std::collections::{BTreeMap, BTreeSet}; use locality_connector::{ ApplyPlanRequest, ApplyPlanResult, ApplyUndoRequest, ApplyUndoResult, Connector, ConnectorCapabilities, ConnectorKind, EnumerateRequest, FetchRequest, NativeEntity, - ParsedEntity, PortableArtifactKey, PortableBootstrapRequest, PortableChangeBatch, - PortableCheckpoint, PortableCompleteness, PortableContentArtifact, PortableFetchRequest, - PortableFetchResult, PortableIncompleteReason, PortableProjectionArtifact, - PortableRenderRequest, PortableRenderResult, PortableSourceChange, PortableSyncRequest, + PORTABLE_SCOPE_ROOT_RELATIONSHIP, ParsedEntity, PortableArtifactKey, PortableBootstrapRequest, + PortableChangeBatch, PortableCheckpoint, PortableCompleteness, PortableContentArtifact, + PortableFetchRequest, PortableFetchResult, PortableIncompleteReason, + PortableProjectionArtifact, PortableRenderRequest, PortableRenderResult, PortableSourceChange, + PortableSyncRequest, }; use locality_core::LocalityResult; use locality_core::model::{CanonicalDocument, EntityKind, RemoteId, TreeEntry}; use locality_core::portable::{ - LogicalPath, ProjectionFileKind, SourceAction, SourceConnectionId, SourceObject, + LogicalPath, ProjectionFileKind, SourceAction, SourceConnectionId, SourceEdge, SourceObject, }; use locality_engine::synchronize_project::{ bootstrap_and_project, synchronize_and_project_portable, @@ -21,6 +22,8 @@ use locality_engine::synchronize_project::{ struct FixtureConnector { incomplete: bool, duplicate_projection_key: bool, + duplicate_owning_root_edge: bool, + omit_owning_root_edge: bool, } impl FixtureConnector { @@ -28,6 +31,8 @@ impl FixtureConnector { Self { incomplete: false, duplicate_projection_key: false, + duplicate_owning_root_edge: false, + omit_owning_root_edge: false, } } } @@ -53,11 +58,21 @@ impl Connector for FixtureConnector { &self, request: PortableBootstrapRequest, ) -> LocalityResult { + let mut changes = vec![ + change(&request.source_connection_id, "page-b", "B/page.md"), + change(&request.source_connection_id, "page-a", "A/page.md"), + ]; + if self.duplicate_owning_root_edge { + changes[0].source_object.edges.push(SourceEdge { + relationship: PORTABLE_SCOPE_ROOT_RELATIONSHIP.to_string(), + target_remote_id: RemoteId::new("other-root"), + }); + } + if self.omit_owning_root_edge { + changes[1].source_object.edges.clear(); + } Ok(PortableChangeBatch { - changes: vec![ - change(&request.source_connection_id, "page-b", "B/page.md"), - change(&request.source_connection_id, "page-a", "A/page.md"), - ], + changes, next_checkpoint: PortableCheckpoint { format_version: 1, opaque: "ready".to_string(), @@ -176,6 +191,13 @@ fn deterministic_retry_returns_identical_unpersisted_candidates_and_hashes() { first.source_versions[0].canonical_sha256, "sha256:5ebbb50be5c7e33f3fa9ea6c8ae904fa32f8827011e15bbf4947897bdc53e333" ); + assert!(first.observed_changes.iter().all(|change| { + change.source_object.edges + == vec![SourceEdge { + relationship: PORTABLE_SCOPE_ROOT_RELATIONSHIP.to_string(), + target_remote_id: RemoteId::new("root"), + }] + })); } #[test] @@ -183,6 +205,8 @@ fn incomplete_connector_batch_cannot_be_published() { let connector = FixtureConnector { incomplete: true, duplicate_projection_key: false, + duplicate_owning_root_edge: false, + omit_owning_root_edge: false, }; let batch = bootstrap_and_project(&connector, request(), 1).expect("incomplete batch"); @@ -220,6 +244,8 @@ fn conflicting_artifact_keys_fail_the_whole_batch() { let connector = FixtureConnector { incomplete: false, duplicate_projection_key: true, + duplicate_owning_root_edge: false, + omit_owning_root_edge: false, }; let error = bootstrap_and_project(&connector, request(), 1) .expect_err("duplicate artifact identity must fail closed"); @@ -231,6 +257,32 @@ fn conflicting_artifact_keys_fail_the_whole_batch() { ); } +#[test] +fn multiple_owning_root_edges_fail_closed() { + let connector = FixtureConnector { + incomplete: false, + duplicate_projection_key: false, + duplicate_owning_root_edge: true, + omit_owning_root_edge: false, + }; + let error = bootstrap_and_project(&connector, request(), 1) + .expect_err("multiple owning roots must fail closed"); + assert!(error.to_string().contains("multiple owning-root edges")); +} + +#[test] +fn mixed_owning_root_provenance_fails_closed() { + let connector = FixtureConnector { + incomplete: false, + duplicate_projection_key: false, + duplicate_owning_root_edge: false, + omit_owning_root_edge: true, + }; + let error = bootstrap_and_project(&connector, request(), 1) + .expect_err("mixed owning-root provenance must fail closed"); + assert!(error.to_string().contains("ambiguous owning-root")); +} + fn request() -> PortableBootstrapRequest { PortableBootstrapRequest { source_connection_id: SourceConnectionId::new("source-fixture"), @@ -250,7 +302,10 @@ fn change( source_connection_id: source_connection_id.clone(), remote_id: RemoteId::new(remote_id), kind: EntityKind::Page, - edges: Vec::new(), + edges: vec![SourceEdge { + relationship: PORTABLE_SCOPE_ROOT_RELATIONSHIP.to_string(), + target_remote_id: RemoteId::new("root"), + }], opaque_version: Some("v1".to_string()), deleted: false, connector_metadata: BTreeMap::new(), diff --git a/crates/locality-notion/src/lib.rs b/crates/locality-notion/src/lib.rs index b63e5bc2..e5d258cc 100644 --- a/crates/locality-notion/src/lib.rs +++ b/crates/locality-notion/src/lib.rs @@ -39,7 +39,7 @@ use crate::client::{DEFAULT_NOTION_TOKEN_ENV, HttpNotionApi, NotionApi}; use crate::fetch::fetch_page_bundle; use crate::media::{MediaDownloadReport, download_media_assets}; use crate::projection::{ - enumerate_root_page_tree, enumerate_shared_pages, list_container_children, observe_entity, + enumerate_explicit_root_trees, enumerate_shared_pages, list_container_children, observe_entity, resolve_notion_object_path_entries, resolve_page_path_entries, }; use crate::render::{ @@ -102,6 +102,8 @@ impl NotionConfig { pub struct NotionConnector { config: NotionConfig, api: Arc, + explicit_root_page_ids: Vec, + explicit_root_set: bool, } impl std::fmt::Debug for NotionConnector { @@ -118,7 +120,13 @@ impl NotionConnector { } pub fn with_api(config: NotionConfig, api: Arc) -> Self { - Self { config, api } + let explicit_root_page_ids = config.root_page_id.iter().cloned().collect(); + Self { + config, + api, + explicit_root_page_ids, + explicit_root_set: false, + } } pub fn config(&self) -> &NotionConfig { @@ -127,13 +135,40 @@ impl NotionConnector { pub fn with_root_page_id(&self, root_page_id: locality_core::model::RemoteId) -> Self { let mut config = self.config.clone(); - config.root_page_id = Some(root_page_id); + config.root_page_id = Some(root_page_id.clone()); + Self { + config, + api: Arc::clone(&self.api), + explicit_root_page_ids: vec![root_page_id], + explicit_root_set: false, + } + } + + /// Select up to 16 explicit page or full-page database roots without using + /// provider search. Validation is deferred to enumeration/bootstrap so + /// malformed scopes fail through the normal connector result channel. + pub fn with_root_ids(&self, root_ids: impl IntoIterator) -> Self { + let explicit_root_page_ids = root_ids.into_iter().collect::>(); + let mut config = self.config.clone(); + config.root_page_id = + (explicit_root_page_ids.len() == 1).then(|| explicit_root_page_ids[0].clone()); Self { config, api: Arc::clone(&self.api), + explicit_root_page_ids, + explicit_root_set: true, } } + /// Compatibility alias for [`Self::with_root_ids`]. + pub fn with_root_page_ids(&self, root_page_ids: impl IntoIterator) -> Self { + self.with_root_ids(root_page_ids) + } + + pub fn explicit_root_page_ids(&self) -> &[RemoteId] { + &self.explicit_root_page_ids + } + pub fn render_native_entity( &self, entity: &NativeEntity, @@ -206,7 +241,12 @@ impl NotionConnector { impl Connector for NotionConnector { fn with_execution_policy(&self, policy: ConnectorExecutionPolicy) -> Self { - Self::new(self.config.clone().with_execution_policy(policy)) + let connector = Self::new(self.config.clone().with_execution_policy(policy)); + if self.explicit_root_set { + connector.with_root_ids(self.explicit_root_page_ids.clone()) + } else { + connector + } } fn kind(&self) -> ConnectorKind { @@ -245,8 +285,16 @@ impl Connector for NotionConnector { } fn enumerate(&self, request: EnumerateRequest) -> LocalityResult> { - if let Some(root_page_id) = &self.config.root_page_id { - enumerate_root_page_tree(self.api.as_ref(), request.mount_id, root_page_id) + if self.explicit_root_set || !self.explicit_root_page_ids.is_empty() { + portable::validate_configured_roots(&self.explicit_root_page_ids)?; + Ok(enumerate_explicit_root_trees( + self.api.as_ref(), + request.mount_id, + &self.explicit_root_page_ids, + )? + .into_iter() + .map(|projected| projected.entry) + .collect()) } else { enumerate_shared_pages(self.api.as_ref(), request.mount_id) } @@ -258,7 +306,8 @@ impl Connector for NotionConnector { ) -> LocalityResult { portable::bootstrap( self.api.as_ref(), - self.config.root_page_id.as_ref(), + &self.explicit_root_page_ids, + self.explicit_root_set, request, ) } @@ -266,7 +315,8 @@ impl Connector for NotionConnector { fn sync_portable(&self, request: PortableSyncRequest) -> LocalityResult { portable::synchronize( self.api.as_ref(), - self.config.root_page_id.as_ref(), + &self.explicit_root_page_ids, + self.explicit_root_set, request, ) } diff --git a/crates/locality-notion/src/portable.rs b/crates/locality-notion/src/portable.rs index 0b7c3b1d..7cda1a56 100644 --- a/crates/locality-notion/src/portable.rs +++ b/crates/locality-notion/src/portable.rs @@ -2,19 +2,22 @@ //! //! The provider search endpoint is intentionally absent from this module. A //! search result is not an exhaustive Notion inventory, so portable coverage is -//! available only for a configured root page. +//! available only for configured page or full-page database roots. use std::collections::BTreeMap; use locality_connector::{ - NativeEntity, PortableArtifactKey, PortableBootstrapRequest, PortableChangeBatch, - PortableCheckpoint, PortableCompleteness, PortableContentArtifact, PortableFetchRequest, - PortableFetchResult, PortableIncompleteReason, PortableProjectionArtifact, - PortableRenderRequest, PortableRenderResult, PortableSourceChange, PortableSyncRequest, + NativeEntity, PORTABLE_SCOPE_ROOT_RELATIONSHIP, PortableArtifactKey, PortableBootstrapRequest, + PortableChangeBatch, PortableCheckpoint, PortableCompleteness, PortableContentArtifact, + PortableFetchRequest, PortableFetchResult, PortableIncompleteReason, + PortableProjectionArtifact, PortableRenderRequest, PortableRenderResult, PortableSourceChange, + PortableSyncRequest, }; use locality_core::canonical::render_canonical_markdown; use locality_core::model::{EntityKind, MountId, RemoteId}; -use locality_core::portable::{LogicalPath, ProjectionFileKind, SourceAction, SourceObject}; +use locality_core::portable::{ + LogicalPath, ProjectionFileKind, SourceAction, SourceEdge, SourceObject, +}; use locality_core::{LocalityError, LocalityResult}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -22,10 +25,13 @@ use sha2::{Digest, Sha256}; use crate::client::NotionApi; use crate::dto::{BlockTreeDto, NotionPageBundle}; use crate::fetch::fetch_page_bundle; -use crate::projection::enumerate_root_page_tree; +use crate::projection::enumerate_explicit_root_trees; use crate::render::render_native_entity; -const CHECKPOINT_FORMAT_VERSION: u16 = 1; +const LEGACY_CHECKPOINT_FORMAT_VERSION: u16 = 1; +const CHECKPOINT_FORMAT_VERSION: u16 = 2; +const CHECKPOINT_COMPONENT_VERSION: u16 = 2; +const MAX_EXPLICIT_ROOTS: usize = 16; const PORTABLE_FORMAT_VERSION: u32 = 1; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -36,7 +42,7 @@ enum CheckpointOperation { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -struct NotionCheckpoint { +struct LegacyNotionCheckpoint { operation: CheckpointOperation, root_remote_id: String, inventory_sha256: String, @@ -44,19 +50,54 @@ struct NotionCheckpoint { complete: bool, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct NotionCheckpoint { + component_version: u16, + operation: CheckpointOperation, + root_set_sha256: String, + root_remote_ids: Vec, + inventory_sha256: String, + offset: u64, + complete: bool, +} + +enum DecodedCheckpoint { + Legacy(LegacyNotionCheckpoint), + Current(NotionCheckpoint), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct CanonicalRootSet { + roots: Vec, + normalized_ids: Vec, + identity: String, +} + pub(crate) fn bootstrap( api: &dyn NotionApi, - configured_root: Option<&RemoteId>, + configured_roots: &[RemoteId], + explicit_root_set: bool, request: PortableBootstrapRequest, ) -> LocalityResult { - let root = validate_explicit_root(configured_root, &request.scope.root_remote_ids)?; - let inventory = inventory(api, &request.source_connection_id, root)?; - let digest = inventory_sha256(&inventory); + let roots = validate_explicit_roots(configured_roots, &request.scope.root_remote_ids)?; + let inventory = inventory( + api, + &request.source_connection_id, + &roots.roots, + explicit_root_set, + )?; + let digest = inventory_sha256(&inventory, explicit_root_set); let offset = match request.checkpoint.as_ref() { Some(checkpoint) => { let checkpoint = decode_checkpoint(checkpoint)?; - validate_checkpoint(&checkpoint, CheckpointOperation::Bootstrap, root, &digest)?; - usize::try_from(checkpoint.offset).map_err(|_| { + validate_checkpoint( + &checkpoint, + CheckpointOperation::Bootstrap, + &roots, + Some(&digest), + explicit_root_set, + )?; + usize::try_from(checkpoint_offset(&checkpoint)).map_err(|_| { LocalityError::InvalidState( "Notion portable checkpoint offset is too large for this host".to_string(), ) @@ -67,37 +108,48 @@ pub(crate) fn bootstrap( page_batch( inventory, - root, + &roots, digest, CheckpointOperation::Bootstrap, offset, request.max_changes, + explicit_root_set, ) } pub(crate) fn synchronize( api: &dyn NotionApi, - configured_root: Option<&RemoteId>, + configured_roots: &[RemoteId], + explicit_root_set: bool, request: PortableSyncRequest, ) -> LocalityResult { - let root = validate_explicit_root(configured_root, &request.scope.root_remote_ids)?; - let inventory = inventory(api, &request.source_connection_id, root)?; - let digest = inventory_sha256(&inventory); + let roots = validate_explicit_roots(configured_roots, &request.scope.root_remote_ids)?; + let inventory = inventory( + api, + &request.source_connection_id, + &roots.roots, + explicit_root_set, + )?; + let digest = inventory_sha256(&inventory, explicit_root_set); let prior = decode_checkpoint(&request.checkpoint)?; - if !notion_ids_equal(&prior.root_remote_id, root.as_str()) { - return Err(LocalityError::InvalidState( - "Notion portable checkpoint belongs to a different explicit root".to_string(), - )); - } + validate_checkpoint( + &prior, + checkpoint_operation(&prior), + &roots, + None, + explicit_root_set, + )?; - let offset = if prior.operation == CheckpointOperation::Synchronize && !prior.complete { - if prior.inventory_sha256 != digest { + let offset = if checkpoint_operation(&prior) == CheckpointOperation::Synchronize + && !checkpoint_complete(&prior) + { + if checkpoint_inventory_sha256(&prior) != digest { return Err(LocalityError::InvalidState( "Notion portable inventory changed while synchronization was being resumed" .to_string(), )); } - usize::try_from(prior.offset).map_err(|_| { + usize::try_from(checkpoint_offset(&prior)).map_err(|_| { LocalityError::InvalidState( "Notion portable checkpoint offset is too large for this host".to_string(), ) @@ -111,11 +163,12 @@ pub(crate) fn synchronize( page_batch( inventory, - root, + &roots, digest, CheckpointOperation::Synchronize, offset, request.max_changes, + explicit_root_set, ) } @@ -207,34 +260,81 @@ fn contains_media_block(tree: &BlockTreeDto) -> bool { ) || tree.children.iter().any(contains_media_block) } -fn validate_explicit_root<'a>( - configured_root: Option<&'a RemoteId>, +pub(crate) fn validate_configured_roots(configured_roots: &[RemoteId]) -> LocalityResult<()> { + canonical_root_set(configured_roots).map(|_| ()) +} + +fn validate_explicit_roots( + configured_roots: &[RemoteId], requested_roots: &[RemoteId], -) -> LocalityResult<&'a RemoteId> { - let configured_root = configured_root.ok_or(LocalityError::Unsupported( - "Notion portable bootstrap requires a configured root page", - ))?; - if requested_roots.len() != 1 - || !notion_ids_equal(requested_roots[0].as_str(), configured_root.as_str()) - { +) -> LocalityResult { + if configured_roots.is_empty() { + return Err(LocalityError::Unsupported( + "Notion portable bootstrap requires a configured root page or explicit root set", + )); + } + let configured = canonical_root_set(configured_roots)?; + let requested = canonical_root_set(requested_roots)?; + if configured.normalized_ids != requested.normalized_ids { return Err(LocalityError::InvalidState( - "Notion portable scope must contain only the configured root page".to_string(), + "Notion portable scope must exactly match the configured explicit root set".to_string(), )); } - Ok(configured_root) + Ok(configured) +} + +fn canonical_root_set(roots: &[RemoteId]) -> LocalityResult { + if roots.is_empty() { + return Err(LocalityError::InvalidState( + "Notion explicit root set must not be empty".to_string(), + )); + } + if roots.len() > MAX_EXPLICIT_ROOTS { + return Err(LocalityError::InvalidState(format!( + "Notion explicit root set exceeds the limit of {MAX_EXPLICIT_ROOTS}" + ))); + } + let mut canonical = BTreeMap::new(); + for root in roots { + let normalized = normalize_notion_id(root.as_str()); + if normalized.is_empty() { + return Err(LocalityError::InvalidState( + "Notion explicit root IDs must not be empty".to_string(), + )); + } + if canonical.insert(normalized.clone(), root.clone()).is_some() { + return Err(LocalityError::InvalidState(format!( + "Notion explicit root set contains duplicate root `{}`", + root.as_str() + ))); + } + } + let normalized_ids = canonical.keys().cloned().collect::>(); + let mut hasher = Sha256::new(); + for root in &normalized_ids { + hash_field(&mut hasher, root); + } + let identity = format!("sha256:{:x}", hasher.finalize()); + Ok(CanonicalRootSet { + roots: canonical.into_values().collect(), + normalized_ids, + identity, + }) } fn inventory( api: &dyn NotionApi, source_connection_id: &locality_core::portable::SourceConnectionId, - root: &RemoteId, + roots: &[RemoteId], + include_root_provenance: bool, ) -> LocalityResult> { // The sentinel mount identity is consumed inside the legacy traversal and // is never returned in a portable value. - let entries = enumerate_root_page_tree(api, MountId::new("portable-notion"), root)?; + let entries = enumerate_explicit_root_trees(api, MountId::new("portable-notion"), roots)?; let mut changes = entries .into_iter() - .map(|entry| { + .map(|projected| { + let entry = projected.entry; let logical_path = entry .path .iter() @@ -261,7 +361,15 @@ fn inventory( source_connection_id: source_connection_id.clone(), remote_id: entry.remote_id, kind: entry.kind, - edges: Vec::new(), + edges: include_root_provenance + .then(|| SourceEdge { + relationship: PORTABLE_SCOPE_ROOT_RELATIONSHIP.to_string(), + target_remote_id: RemoteId::new(normalize_notion_id( + projected.scope_root_remote_id.as_str(), + )), + }) + .into_iter() + .collect(), opaque_version: entry.remote_edited_at, deleted: false, connector_metadata, @@ -290,11 +398,12 @@ fn inventory( fn page_batch( inventory: Vec, - root: &RemoteId, + roots: &CanonicalRootSet, digest: String, operation: CheckpointOperation, offset: usize, max_changes: u32, + explicit_root_set: bool, ) -> LocalityResult { if max_changes == 0 { return Err(LocalityError::InvalidState( @@ -327,17 +436,30 @@ fn page_batch( )); } - let next_checkpoint = encode_checkpoint(&NotionCheckpoint { - operation, - root_remote_id: root.as_str().to_string(), - inventory_sha256: digest, - offset: u64::try_from(end).map_err(|_| { - LocalityError::InvalidState( - "Notion portable inventory is too large to checkpoint".to_string(), - ) - })?, - complete: end == inventory.len(), + let checkpoint_offset = u64::try_from(end).map_err(|_| { + LocalityError::InvalidState( + "Notion portable inventory is too large to checkpoint".to_string(), + ) })?; + let next_checkpoint = if explicit_root_set { + encode_checkpoint(&NotionCheckpoint { + component_version: CHECKPOINT_COMPONENT_VERSION, + operation, + root_set_sha256: roots.identity.clone(), + root_remote_ids: roots.normalized_ids.clone(), + inventory_sha256: digest, + offset: checkpoint_offset, + complete: end == inventory.len(), + })? + } else { + encode_legacy_checkpoint(&LegacyNotionCheckpoint { + operation, + root_remote_id: roots.roots[0].as_str().to_string(), + inventory_sha256: digest, + offset: checkpoint_offset, + complete: end == inventory.len(), + })? + }; Ok(PortableChangeBatch { changes: inventory[offset..end].to_vec(), @@ -363,7 +485,7 @@ fn artifact_key(remote_id: &RemoteId, role: &str, format_version: u32) -> Portab )) } -fn inventory_sha256(inventory: &[PortableSourceChange]) -> String { +fn inventory_sha256(inventory: &[PortableSourceChange], include_root_provenance: bool) -> String { let mut hasher = Sha256::new(); for change in inventory { hash_field(&mut hasher, change.source_object.remote_id.as_str()); @@ -376,6 +498,15 @@ fn inventory_sha256(inventory: &[PortableSourceChange]) -> String { .as_deref() .unwrap_or_default(), ); + if include_root_provenance { + let scope_root = change + .source_object + .edges + .iter() + .find(|edge| edge.relationship == PORTABLE_SCOPE_ROOT_RELATIONSHIP) + .expect("explicit-root inventory includes owning-root edge"); + hash_field(&mut hasher, scope_root.target_remote_id.as_str()); + } hash_field( &mut hasher, change @@ -403,35 +534,106 @@ fn encode_checkpoint(checkpoint: &NotionCheckpoint) -> LocalityResult LocalityResult { - if checkpoint.format_version != CHECKPOINT_FORMAT_VERSION { - return Err(LocalityError::InvalidState(format!( - "Notion portable checkpoint version {} requires an update (supported: {CHECKPOINT_FORMAT_VERSION})", - checkpoint.format_version - ))); - } - serde_json::from_str(&checkpoint.opaque).map_err(|_| { - LocalityError::InvalidState("Notion portable checkpoint is invalid".to_string()) +fn encode_legacy_checkpoint( + checkpoint: &LegacyNotionCheckpoint, +) -> LocalityResult { + let opaque = serde_json::to_string(checkpoint).map_err(|error| { + LocalityError::Io(format!("Notion portable checkpoint encode failed: {error}")) + })?; + Ok(PortableCheckpoint { + format_version: LEGACY_CHECKPOINT_FORMAT_VERSION, + opaque, }) } fn validate_checkpoint( - checkpoint: &NotionCheckpoint, + checkpoint: &DecodedCheckpoint, operation: CheckpointOperation, - root: &RemoteId, - inventory_sha256: &str, + roots: &CanonicalRootSet, + inventory_sha256: Option<&str>, + explicit_root_set: bool, ) -> LocalityResult<()> { - if checkpoint.operation != operation - || !notion_ids_equal(&checkpoint.root_remote_id, root.as_str()) - || checkpoint.inventory_sha256 != inventory_sha256 + if let DecodedCheckpoint::Current(checkpoint) = checkpoint + && checkpoint.component_version > CHECKPOINT_COMPONENT_VERSION { + return Err(LocalityError::InvalidState(format!( + "Notion portable checkpoint component version {} requires an update", + checkpoint.component_version + ))); + } + let matches = match checkpoint { + DecodedCheckpoint::Legacy(checkpoint) => { + !explicit_root_set + && roots.roots.len() == 1 + && checkpoint.operation == operation + && notion_ids_equal(&checkpoint.root_remote_id, roots.roots[0].as_str()) + && inventory_sha256.is_none_or(|digest| checkpoint.inventory_sha256 == digest) + } + DecodedCheckpoint::Current(checkpoint) => { + explicit_root_set + && checkpoint.component_version == CHECKPOINT_COMPONENT_VERSION + && checkpoint.operation == operation + && checkpoint.root_remote_ids == roots.normalized_ids + && checkpoint.root_set_sha256 == roots.identity + && inventory_sha256.is_none_or(|digest| checkpoint.inventory_sha256 == digest) + } + }; + if !matches { return Err(LocalityError::InvalidState( - "Notion portable checkpoint does not match the current inventory".to_string(), + "Notion portable checkpoint does not match the current root set and inventory" + .to_string(), )); } Ok(()) } +fn decode_checkpoint(checkpoint: &PortableCheckpoint) -> LocalityResult { + let decoded = match checkpoint.format_version { + LEGACY_CHECKPOINT_FORMAT_VERSION => { + serde_json::from_str(&checkpoint.opaque).map(DecodedCheckpoint::Legacy) + } + CHECKPOINT_FORMAT_VERSION => { + serde_json::from_str(&checkpoint.opaque).map(DecodedCheckpoint::Current) + } + version => { + return Err(LocalityError::InvalidState(format!( + "Notion portable checkpoint version {version} requires an update (supported: {LEGACY_CHECKPOINT_FORMAT_VERSION}, {CHECKPOINT_FORMAT_VERSION})" + ))); + } + }; + decoded.map_err(|_| { + LocalityError::InvalidState("Notion portable checkpoint is invalid".to_string()) + }) +} + +fn checkpoint_operation(checkpoint: &DecodedCheckpoint) -> CheckpointOperation { + match checkpoint { + DecodedCheckpoint::Legacy(checkpoint) => checkpoint.operation, + DecodedCheckpoint::Current(checkpoint) => checkpoint.operation, + } +} + +fn checkpoint_inventory_sha256(checkpoint: &DecodedCheckpoint) -> &str { + match checkpoint { + DecodedCheckpoint::Legacy(checkpoint) => &checkpoint.inventory_sha256, + DecodedCheckpoint::Current(checkpoint) => &checkpoint.inventory_sha256, + } +} + +fn checkpoint_offset(checkpoint: &DecodedCheckpoint) -> u64 { + match checkpoint { + DecodedCheckpoint::Legacy(checkpoint) => checkpoint.offset, + DecodedCheckpoint::Current(checkpoint) => checkpoint.offset, + } +} + +fn checkpoint_complete(checkpoint: &DecodedCheckpoint) -> bool { + match checkpoint { + DecodedCheckpoint::Legacy(checkpoint) => checkpoint.complete, + DecodedCheckpoint::Current(checkpoint) => checkpoint.complete, + } +} + fn normalize_notion_id(value: &str) -> String { value .chars() diff --git a/crates/locality-notion/src/projection.rs b/crates/locality-notion/src/projection.rs index 369cc668..371260f1 100644 --- a/crates/locality-notion/src/projection.rs +++ b/crates/locality-notion/src/projection.rs @@ -17,35 +17,74 @@ use crate::client::NotionApi; use crate::dto::{BlockDto, DatabaseDto, PageDto, ParentDto}; use crate::render::{page_frontmatter, page_title}; +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ExplicitRootTreeEntry { + pub entry: TreeEntry, + pub scope_root_remote_id: RemoteId, +} + pub fn enumerate_root_page_tree( api: &dyn NotionApi, mount_id: MountId, root_page_id: &RemoteId, ) -> LocalityResult> { - let root_page = api.retrieve_page(root_page_id.as_str())?; + Ok( + enumerate_explicit_root_trees(api, mount_id, std::slice::from_ref(root_page_id))? + .into_iter() + .map(|projected| projected.entry) + .collect(), + ) +} + +pub(crate) fn enumerate_explicit_root_trees( + api: &dyn NotionApi, + mount_id: MountId, + root_page_ids: &[RemoteId], +) -> LocalityResult> { let mut used_paths = BTreeSet::new(); let mut entries = Vec::new(); - let root_title = page_title(&root_page); - let root_path = allocate_page_path(Path::new(""), &root_title, &root_page.id, &mut used_paths); - - entries.push(page_entry( - mount_id.clone(), - &root_page, - root_title, - root_path.clone(), - )); - enumerate_page_children( - api, - &mount_id, - root_page.id.as_str(), - page_child_dir(&root_path), - &mut used_paths, - &mut entries, - )?; + let mut owners = BTreeMap::new(); + let mut root_children = root_page_ids + .iter() + .map(|root_id| retrieve_explicit_root(api, root_id)) + .collect::>>()?; + root_children.sort_by(|left, right| { + explicit_root_identity_key(left.remote_id()) + .cmp(&explicit_root_identity_key(right.remote_id())) + }); + + for projected in allocate_child_paths(Path::new(""), root_children, &mut used_paths) { + let scope_root_remote_id = RemoteId::new(projected.child.remote_id().to_string()); + let mut sink = ExplicitRootSink { + scope_root_remote_id: &scope_root_remote_id, + entries: &mut entries, + owners: &mut owners, + }; + push_projected_tree_entry(api, &mount_id, projected, &mut used_paths, &mut sink)?; + } Ok(entries) } +fn retrieve_explicit_root( + api: &dyn NotionApi, + root_id: &RemoteId, +) -> LocalityResult { + match api.retrieve_page(root_id.as_str()) { + Ok(page) => { + let title = page_title(&page); + Ok(ProjectedChild::Page { page, title }) + } + Err(LocalityError::RemoteNotFound(_)) => { + let database = api.retrieve_database(root_id.as_str())?; + let title = + database_title(&database).unwrap_or_else(|| "Untitled database".to_string()); + Ok(ProjectedChild::Database { database, title }) + } + Err(error) => Err(error), + } +} + pub fn enumerate_shared_pages( api: &dyn NotionApi, mount_id: MountId, @@ -407,21 +446,65 @@ fn push_projected_listing_entry( } } -fn push_projected_tree_entry( +trait TreeEntrySink { + fn push_entry(&mut self, entry: TreeEntry) -> LocalityResult<()>; +} + +impl TreeEntrySink for Vec { + fn push_entry(&mut self, entry: TreeEntry) -> LocalityResult<()> { + self.push(entry); + Ok(()) + } +} + +struct ExplicitRootSink<'a> { + scope_root_remote_id: &'a RemoteId, + entries: &'a mut Vec, + owners: &'a mut BTreeMap, +} + +impl TreeEntrySink for ExplicitRootSink<'_> { + fn push_entry(&mut self, entry: TreeEntry) -> LocalityResult<()> { + let key = explicit_root_identity_key(entry.remote_id.as_str()); + if let Some(existing_owner) = self.owners.insert(key, self.scope_root_remote_id.clone()) { + return Err(LocalityError::InvalidState(format!( + "Notion explicit roots overlap or project object `{}` ambiguously between `{}` and `{}`", + entry.remote_id.as_str(), + existing_owner.as_str(), + self.scope_root_remote_id.as_str() + ))); + } + self.entries.push(ExplicitRootTreeEntry { + entry, + scope_root_remote_id: self.scope_root_remote_id.clone(), + }); + Ok(()) + } +} + +fn explicit_root_identity_key(value: &str) -> String { + value + .chars() + .filter(|character| *character != '-') + .flat_map(char::to_lowercase) + .collect() +} + +fn push_projected_tree_entry( api: &dyn NotionApi, mount_id: &MountId, projected: ProjectedChildWithPath, used_paths: &mut BTreeSet, - entries: &mut Vec, + entries: &mut S, ) -> LocalityResult<()> { match projected.child { ProjectedChild::Page { page, title } => { - entries.push(page_entry( + entries.push_entry(page_entry( mount_id.clone(), &page, title, projected.path.clone(), - )); + ))?; enumerate_page_children( api, mount_id, @@ -432,12 +515,12 @@ fn push_projected_tree_entry( )?; } ProjectedChild::Database { database, title } => { - entries.push(database_entry( + entries.push_entry(database_entry( mount_id.clone(), &database, title, projected.path.clone(), - )); + ))?; enumerate_database_rows( api, mount_id, @@ -714,13 +797,13 @@ fn list_database_rows( Ok(entries) } -fn enumerate_page_children( +fn enumerate_page_children( api: &dyn NotionApi, mount_id: &MountId, block_id: &str, parent_dir: PathBuf, used_paths: &mut BTreeSet, - entries: &mut Vec, + entries: &mut S, ) -> LocalityResult<()> { let children = collect_page_child_projections(api, block_id)?; for projected in allocate_child_paths(&parent_dir, children, used_paths) { @@ -730,23 +813,23 @@ fn enumerate_page_children( Ok(()) } -fn enumerate_database_rows( +fn enumerate_database_rows( api: &dyn NotionApi, mount_id: &MountId, database: &DatabaseDto, database_dir: &Path, used_paths: &mut BTreeSet, - entries: &mut Vec, + entries: &mut S, ) -> LocalityResult<()> { let rows = collect_database_row_projections(api, database)?; for projected in allocate_child_paths(database_dir, rows, used_paths) { if let ProjectedChild::Page { page, title } = projected.child { - entries.push(page_entry( + entries.push_entry(page_entry( mount_id.clone(), &page, title, projected.path.clone(), - )); + ))?; enumerate_page_children( api, mount_id, diff --git a/crates/locality-notion/tests/fetch_render.rs b/crates/locality-notion/tests/fetch_render.rs index 654ad59c..afc5cade 100644 --- a/crates/locality-notion/tests/fetch_render.rs +++ b/crates/locality-notion/tests/fetch_render.rs @@ -4,12 +4,13 @@ use std::sync::Arc; use locality_connector::{ ChildContainer, Connector, EnumerateRequest, FetchRequest, ListChildrenRequest, NativeEntity, - PortableBootstrapRequest, PortableFetchReason, PortableFetchRequest, PortableIncompleteReason, - PortableRenderRequest, PortableSourceScope, PortableSyncRequest, + PORTABLE_SCOPE_ROOT_RELATIONSHIP, PortableBootstrapRequest, PortableFetchReason, + PortableFetchRequest, PortableIncompleteReason, PortableRenderRequest, PortableSourceScope, + PortableSyncRequest, }; use locality_core::canonical::render_canonical_markdown; use locality_core::model::{EntityKind, MountId, RemoteId}; -use locality_core::portable::{LogicalPath, SourceConnectionId}; +use locality_core::portable::{LogicalPath, SourceConnectionId, SourceEdge}; use locality_core::shadow::MarkdownBlockKind; use locality_notion::client::NotionApi; use locality_notion::dto::{ @@ -2135,6 +2136,355 @@ fn portable_bootstrap_requires_the_configured_explicit_root_without_search_fallb assert!(error.to_string().contains("configured root page")); } +#[test] +fn explicit_multi_root_paths_match_workspace_projection_and_are_order_invariant() { + let first_root = RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let second_root = RemoteId::new("ffffffffffffffffffffffffffffffff"); + let workspace = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(FixtureNotionApi::multi_root_workspace()), + ); + let workspace_entries = workspace + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect("workspace enumerate"); + + let explicit = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::multi_root_workspace())), + ) + .with_root_ids([second_root.clone(), first_root.clone()]); + let explicit_entries = explicit + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect("explicit enumerate"); + let paths = |entries: Vec| { + entries + .into_iter() + .map(|entry| (entry.remote_id, entry.path)) + .collect::>() + }; + assert_eq!(paths(explicit_entries), paths(workspace_entries)); + + let request = |roots| PortableBootstrapRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + scope: PortableSourceScope::explicit_roots(roots), + checkpoint: None, + max_changes: 2, + }; + let first = explicit + .bootstrap_portable(request([first_root.clone(), second_root.clone()])) + .expect("multi-root bootstrap"); + let reversed = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::multi_root_workspace())), + ) + .with_root_ids([first_root.clone(), second_root.clone()]) + .bootstrap_portable(request([second_root, first_root])) + .expect("reversed multi-root bootstrap"); + + assert_eq!(first, reversed); + assert_eq!(first.next_checkpoint.format_version, 2); + assert_eq!(first.changes.len(), 2, "max_changes is aggregate"); + assert!(first.changes.iter().all(|change| { + change.source_object.edges.len() == 1 + && change.source_object.edges[0].relationship == PORTABLE_SCOPE_ROOT_RELATIONSHIP + })); + assert_eq!( + first + .changes + .iter() + .map(|change| ( + change.source_object.remote_id.as_str(), + change + .source_object + .edges + .first() + .expect("owning-root edge") + .target_remote_id + .as_str(), + change.logical_path.as_ref().expect("logical path").as_str(), + )) + .collect::>(), + vec![ + ( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "Roadmap aaaaaa/page.md", + ), + ( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "Roadmap aaaaaa/Notes/page.md", + ), + ] + ); + let checkpoint: serde_json::Value = + serde_json::from_str(&first.next_checkpoint.opaque).expect("v2 checkpoint json"); + assert_eq!(checkpoint["component_version"], 2); + assert_eq!( + checkpoint["root_remote_ids"], + json!([ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "ffffffffffffffffffffffffffffffff" + ]) + ); + + let one_root = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::multi_root_workspace())), + ) + .with_root_ids([RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]); + let mismatch = one_root + .bootstrap_portable(PortableBootstrapRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + scope: PortableSourceScope::explicit_roots([RemoteId::new( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + )]), + checkpoint: Some(first.next_checkpoint.clone()), + max_changes: 2, + }) + .expect_err("checkpoint root set must match exactly"); + assert!(mismatch.to_string().contains("does not match")); + + let mut newer_checkpoint = first.next_checkpoint; + let mut newer_opaque: serde_json::Value = + serde_json::from_str(&newer_checkpoint.opaque).expect("v2 checkpoint json"); + newer_opaque["component_version"] = json!(3); + newer_checkpoint.opaque = serde_json::to_string(&newer_opaque).expect("checkpoint json"); + let newer_error = explicit + .bootstrap_portable(PortableBootstrapRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + scope: PortableSourceScope::explicit_roots([ + RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + RemoteId::new("ffffffffffffffffffffffffffffffff"), + ]), + checkpoint: Some(newer_checkpoint), + max_changes: 2, + }) + .expect_err("newer component version must fail cleanly"); + assert!(newer_error.to_string().contains("requires an update")); +} + +#[test] +fn explicit_multi_root_rejects_overlap_duplicates_empty_and_scope_mismatch() { + let first_root = RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let nested_root = RemoteId::new("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + let overlapping = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::multi_root_workspace())), + ) + .with_root_ids([first_root.clone(), nested_root]); + let overlap_error = overlapping + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect_err("nested roots must be rejected"); + assert!(overlap_error.to_string().contains("overlap")); + + let duplicate = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::new())), + ) + .with_root_ids([RemoteId::new("page-1"), RemoteId::new("PAGE1")]); + assert!( + duplicate + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect_err("duplicates must fail") + .to_string() + .contains("duplicate") + ); + + let empty = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::new())), + ) + .with_root_ids([]); + assert!( + empty + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect_err("empty set must fail") + .to_string() + .contains("must not be empty") + ); + + let mismatch = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::multi_root_workspace())), + ) + .with_root_ids([first_root]); + assert!( + mismatch + .bootstrap_portable(PortableBootstrapRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + scope: PortableSourceScope::explicit_roots([RemoteId::new( + "ffffffffffffffffffffffffffffffff", + )]), + checkpoint: None, + max_changes: 10, + }) + .expect_err("scope mismatch must fail") + .to_string() + .contains("exactly match") + ); + + let sixteen_ids = (0..16) + .map(|index| RemoteId::new(format!("root-{index:02}"))) + .collect::>(); + let sixteen = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::many_roots(16))), + ) + .with_root_ids(sixteen_ids.clone()); + assert_eq!( + sixteen + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect("sixteen roots are supported") + .len(), + 16 + ); + let seventeen = + sixteen.with_root_ids(sixteen_ids.into_iter().chain([RemoteId::new("root-16")])); + assert_eq!( + seventeen + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect_err("seventeen roots exceed the bound") + .to_string(), + "invalid state: Notion explicit root set exceeds the limit of 16" + ); +} + +#[test] +fn explicit_database_root_matches_workspace_projection_without_search() { + let database_id = RemoteId::new("root-db"); + let workspace = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(FixtureNotionApi::workspace()), + ); + let workspace_database = workspace + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect("workspace enumerate") + .into_iter() + .find(|entry| entry.remote_id == database_id) + .expect("workspace database root"); + + let explicit = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::workspace())), + ) + .with_root_ids([database_id.clone()]); + let entries = explicit + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect("explicit database enumerate"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].kind, EntityKind::Database); + assert_eq!(entries[0].path, workspace_database.path); + assert_eq!(entries[0].path, Path::new("Tasks")); + + let batch = explicit + .bootstrap_portable(PortableBootstrapRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + scope: PortableSourceScope::explicit_roots([database_id.clone()]), + checkpoint: None, + max_changes: 10, + }) + .expect("explicit database bootstrap"); + assert_eq!(batch.changes.len(), 1); + assert_eq!(batch.changes[0].source_object.remote_id, database_id); + assert_eq!( + batch.changes[0].source_object.edges, + vec![SourceEdge { + relationship: PORTABLE_SCOPE_ROOT_RELATIONSHIP.to_string(), + target_remote_id: RemoteId::new("rootdb"), + }] + ); + assert!(!batch.changes[0].requires_fetch); + assert!(batch.completeness.incomplete_reasons().iter().any( + |reason| matches!(reason, PortableIncompleteReason::UnsupportedSourceKind { + source_kind, + .. + } if source_kind == "database") + )); +} + +#[test] +fn explicit_root_page_non_not_found_error_does_not_fall_back_to_database() { + let connector = + NotionConnector::with_api(NotionConfig::default(), Arc::new(NonNotFoundPageErrorApi)) + .with_root_ids([RemoteId::new("database-id")]); + let error = connector + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect_err("non-not-found page failure must be preserved"); + assert_eq!( + error.to_string(), + "invalid state: injected page retrieval failure" + ); +} + +#[test] +fn legacy_single_root_keeps_v1_checkpoint_and_v2_set_mode_rejects_it() { + let root = RemoteId::new("page-1"); + let legacy = NotionConnector::with_api( + NotionConfig::default().with_root_page_id(root.clone()), + Arc::new(NoSearchNotionApi(FixtureNotionApi::new())), + ); + let legacy_batch = legacy + .bootstrap_portable(PortableBootstrapRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + scope: PortableSourceScope::explicit_roots([root.clone()]), + checkpoint: None, + max_changes: 10, + }) + .expect("legacy bootstrap"); + assert_eq!(legacy_batch.next_checkpoint.format_version, 1); + assert_eq!( + legacy_batch.next_checkpoint.opaque, + "{\"operation\":\"bootstrap\",\"root_remote_id\":\"page-1\",\"inventory_sha256\":\"sha256:a870998d440a30dccfac26d79f3b2345e1bbec1aa1bcdb26e3682ac92680b3dc\",\"offset\":1,\"complete\":true}" + ); + assert!(legacy_batch.changes[0].source_object.edges.is_empty()); + + let set_mode = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(FixtureNotionApi::new())), + ) + .with_root_page_ids([root.clone()]); + let error = set_mode + .bootstrap_portable(PortableBootstrapRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + scope: PortableSourceScope::explicit_roots([root]), + checkpoint: Some(legacy_batch.next_checkpoint), + max_changes: 10, + }) + .expect_err("set mode must not accept legacy checkpoints"); + assert!(error.to_string().contains("does not match")); +} + #[test] fn portable_render_matches_direct_canonical_bytes_and_keys_survive_rename() { let root_page_id = RemoteId::new("page-1"); @@ -2498,6 +2848,26 @@ impl FixtureNotionApi { } } + fn many_roots(count: usize) -> Self { + let pages = (0..count) + .map(|index| { + let id = format!("root-{index:02}"); + (id.clone(), page(&id, &format!("Root {index:02}"))) + }) + .collect::>(); + let children = pages + .keys() + .map(|id| ((id.clone(), None), PaginatedListDto::default())) + .collect(); + Self { + pages, + children, + databases: BTreeMap::new(), + data_sources: BTreeMap::new(), + data_source_pages: BTreeMap::new(), + } + } + fn tree(root_page_id: &str) -> Self { let child_page_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let database_id = "cccccccccccccccccccccccccccccccc"; @@ -2695,6 +3065,65 @@ impl FixtureNotionApi { } } + fn multi_root_workspace() -> Self { + let first_root_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let nested_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let second_root_id = "ffffffffffffffffffffffffffffffff"; + let first_root = page_with_parent( + first_root_id, + "Roadmap", + Some(ParentDto { + kind: "workspace".to_string(), + workspace: Some(true), + ..Default::default() + }), + ); + let nested = page_with_parent( + nested_id, + "Notes", + Some(ParentDto { + kind: "page_id".to_string(), + page_id: Some(first_root_id.to_string()), + ..Default::default() + }), + ); + let second_root = page_with_parent( + second_root_id, + "Roadmap", + Some(ParentDto { + kind: "workspace".to_string(), + workspace: Some(true), + ..Default::default() + }), + ); + let children = BTreeMap::from([ + ( + (first_root_id.to_string(), None), + PaginatedListDto { + results: vec![child_page_block(nested_id, "Notes")], + next_cursor: None, + has_more: false, + }, + ), + ((nested_id.to_string(), None), PaginatedListDto::default()), + ( + (second_root_id.to_string(), None), + PaginatedListDto::default(), + ), + ]); + Self { + pages: BTreeMap::from([ + (first_root.id.clone(), first_root), + (nested.id.clone(), nested), + (second_root.id.clone(), second_root), + ]), + children, + databases: BTreeMap::new(), + data_sources: BTreeMap::new(), + data_source_pages: BTreeMap::new(), + } + } + fn workspace_nested_tree() -> Self { let root_page_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; let child_page_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; @@ -2968,7 +3397,7 @@ impl FixtureNotionApi { impl NotionApi for FixtureNotionApi { fn retrieve_page(&self, page_id: &str) -> locality_core::LocalityResult { self.pages.get(page_id).cloned().ok_or_else(|| { - locality_core::LocalityError::InvalidState(format!("missing fixture page {page_id}")) + locality_core::LocalityError::RemoteNotFound(format!("missing fixture page {page_id}")) }) } @@ -3067,6 +3496,56 @@ impl NotionApi for FixtureNotionApi { } } +#[derive(Debug)] +struct NonNotFoundPageErrorApi; + +impl NotionApi for NonNotFoundPageErrorApi { + fn retrieve_page(&self, _page_id: &str) -> locality_core::LocalityResult { + Err(locality_core::LocalityError::InvalidState( + "injected page retrieval failure".to_string(), + )) + } + + fn retrieve_database(&self, _database_id: &str) -> locality_core::LocalityResult { + panic!("database fallback must only follow RemoteNotFound") + } + + fn retrieve_block_children( + &self, + _block_id: &str, + _start_cursor: Option<&str>, + ) -> locality_core::LocalityResult { + unreachable!("page failure must stop traversal") + } + + fn search_pages( + &self, + _start_cursor: Option<&str>, + ) -> locality_core::LocalityResult { + panic!("explicit-root traversal must not call search") + } + + fn update_block( + &self, + _block_id: &str, + _body: serde_json::Value, + ) -> locality_core::LocalityResult { + unreachable!("page failure must stop traversal") + } + + fn append_block_children( + &self, + _block_id: &str, + _body: serde_json::Value, + ) -> locality_core::LocalityResult { + unreachable!("page failure must stop traversal") + } + + fn delete_block(&self, _block_id: &str) -> locality_core::LocalityResult { + unreachable!("page failure must stop traversal") + } +} + #[derive(Debug)] struct NoSearchNotionApi(FixtureNotionApi); diff --git a/docs/notion-connector.md b/docs/notion-connector.md index e56c7f26..2ddd7f85 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -198,6 +198,21 @@ to the same filesystem-equivalent path, all colliding siblings use a short remote ID suffix such as `Exact Page Title aaaaaa/page.md`. The suffix lengthens only when needed to keep sibling names unique. +Portable and direct enumeration can also use an explicit set of up to 16 page +or full-page database roots. Those roots are retrieved by ID without search and +are projected together by the same root allocator and recursive traversal as +the desktop workspace view, including title collision suffixes. The configured +and requested root sets must match exactly; empty, duplicate, overlapping, or +ambiguous roots fail closed. Every set-mode source change carries exactly one +`SourceObject.edges` entry with relationship `locality_scope_root` and the +canonical owning root as its target, including a self-edge for each root. +Consumers decode it with `portable_scope_root_remote_id`; mixed or multiple +owning-root edges fail closed. +Notion does not expose reliable teamspace/private membership here, so Locality +does not infer it. Database and media coverage remains explicitly incomplete. +Legacy `with_root_page_id` synchronization keeps its v1 checkpoint format; +`with_root_ids` uses component-versioned v2 root-set checkpoints. + Each Notion page is a directory. The page body lives in `page.md`; sibling entries in the same directory are child Notion content: ```text From 62c1da873e83c9330814cbb0d1677be603d5e6ea Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Tue, 21 Jul 2026 23:23:38 -0700 Subject: [PATCH 10/18] feat(notion): add deterministic database bundles --- crates/locality-notion/src/database.rs | 561 ++++++++++++++++++++++++- crates/locality-notion/src/dto.rs | 11 + 2 files changed, 564 insertions(+), 8 deletions(-) diff --git a/crates/locality-notion/src/database.rs b/crates/locality-notion/src/database.rs index 976a0e50..60ea0f75 100644 --- a/crates/locality-notion/src/database.rs +++ b/crates/locality-notion/src/database.rs @@ -4,10 +4,16 @@ //! sources provide the row pages and the property schema that `_schema.yaml` //! mirrors for future frontmatter validation and row creation. +use std::collections::BTreeMap; + +use locality_core::LocalityError; use locality_core::LocalityResult; +use serde::Serialize; use crate::client::NotionApi; -use crate::dto::{DataSourceDto, DataSourcePropertyDto, DatabaseDto, SelectOptionDto}; +use crate::dto::{ + DataSourceDto, DataSourcePropertyDto, DatabaseDto, NotionDatabaseBundle, SelectOptionDto, +}; use crate::render::rich_text_plain_text; pub fn database_schema_yaml(api: &dyn NotionApi, database_id: &str) -> LocalityResult { @@ -21,6 +27,201 @@ pub fn database_schema_yaml(api: &dyn NotionApi, database_id: &str) -> LocalityR Ok(render_database_schema(&database, &data_sources)) } +/// Retrieves a database and every data-source schema it declares without search. +/// +/// Notion can spell the same UUID with or without hyphens. Equivalent duplicate +/// summaries are retrieved once and retain their first declared position; +/// conflicting duplicates and responses that do not belong to the requested +/// database fail closed. +pub fn fetch_database_bundle( + api: &dyn NotionApi, + database_id: &str, +) -> LocalityResult { + let requested_database_id = canonical_notion_uuid(database_id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion database bundle requires a canonical UUID, got `{database_id}`" + )) + })?; + + let database = api.retrieve_database(database_id)?; + let returned_database_id = canonical_notion_uuid(&database.id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion database bundle returned a non-canonical database ID `{}`", + database.id + )) + })?; + if returned_database_id != requested_database_id { + return Err(LocalityError::InvalidState(format!( + "Notion database bundle returned database `{}` for requested database `{database_id}`", + database.id + ))); + } + + let mut summaries = Vec::new(); + let mut summary_positions = BTreeMap::::new(); + for summary in &database.data_sources { + if summary.id.trim().is_empty() { + return Err(LocalityError::InvalidState(format!( + "Notion database `{}` contains a data source without an ID", + database.id + ))); + } + let canonical_id = canonical_notion_uuid(&summary.id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion database `{}` contains non-canonical data source ID `{}`", + database.id, summary.id + )) + })?; + if let Some(position) = summary_positions.get(&canonical_id).copied() { + let existing: &crate::dto::DataSourceSummaryDto = &summaries[position]; + if existing.name != summary.name { + return Err(LocalityError::InvalidState(format!( + "Notion database `{}` contains conflicting summaries for data source `{}`", + database.id, summary.id + ))); + } + continue; + } + summary_positions.insert(canonical_id, summaries.len()); + summaries.push(summary.clone()); + } + + let mut data_sources = Vec::with_capacity(summaries.len()); + for summary in summaries { + let data_source = api.retrieve_data_source(&summary.id)?; + let returned_data_source_id = canonical_notion_uuid(&data_source.id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion database `{}` returned non-canonical data source ID `{}`", + database.id, data_source.id + )) + })?; + let declared_data_source_id = + canonical_notion_uuid(&summary.id).expect("validated database data-source summary ID"); + if returned_data_source_id != declared_data_source_id { + return Err(LocalityError::InvalidState(format!( + "Notion database `{}` returned data source `{}` for declared data source `{}`", + database.id, data_source.id, summary.id + ))); + } + let parent_database_id = data_source + .parent + .as_ref() + .and_then(|parent| parent.database_id.as_deref()) + .ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion data source `{}` did not expose its parent database", + data_source.id + )) + })?; + let parent_database_id_canonical = canonical_notion_uuid(parent_database_id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion data source `{}` exposed non-canonical parent database ID `{parent_database_id}`", + data_source.id + )) + })?; + if parent_database_id_canonical != returned_database_id { + return Err(LocalityError::InvalidState(format!( + "Notion data source `{}` belongs to database `{parent_database_id}`, not `{}`", + data_source.id, database.id + ))); + } + data_sources.push(data_source); + } + + Ok(NotionDatabaseBundle { + database, + data_sources, + }) +} + +/// Renders the same exact `_schema.yaml` bytes as the direct database path. +pub fn render_database_bundle_schema(bundle: &NotionDatabaseBundle) -> String { + render_database_schema(&bundle.database, &bundle.data_sources) +} + +/// Returns the opaque, deterministic provider-version material for a bundle. +/// +/// A database container's edit time alone does not cover changes to its data +/// source schemas, so the material includes the exact IDs and edit times for +/// both layers in bundle order. +pub fn database_bundle_provider_version(bundle: &NotionDatabaseBundle) -> LocalityResult { + #[derive(Serialize)] + struct ObjectVersion<'a> { + id: &'a str, + last_edited_time: Option<&'a str>, + } + + #[derive(Serialize)] + struct DatabaseVersion<'a> { + format_version: u16, + database: ObjectVersion<'a>, + data_sources: Vec>, + } + + let database_id = canonical_notion_uuid(&bundle.database.id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion database bundle contains non-canonical database ID `{}`", + bundle.database.id + )) + })?; + let data_source_ids = bundle + .data_sources + .iter() + .map(|data_source| { + canonical_notion_uuid(&data_source.id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion database bundle contains non-canonical data source ID `{}`", + data_source.id + )) + }) + }) + .collect::>>()?; + let material = DatabaseVersion { + format_version: 1, + database: ObjectVersion { + id: &database_id, + last_edited_time: bundle.database.last_edited_time.as_deref(), + }, + data_sources: bundle + .data_sources + .iter() + .zip(&data_source_ids) + .map(|(data_source, canonical_id)| ObjectVersion { + id: canonical_id, + last_edited_time: data_source.last_edited_time.as_deref(), + }) + .collect(), + }; + + serde_json::to_string(&material).map_err(|error| { + LocalityError::Io(format!( + "Notion database provider version encode failed: {error}" + )) + }) +} + +fn canonical_notion_uuid(value: &str) -> Option { + let bytes = value.as_bytes(); + let valid = match bytes.len() { + 32 => bytes.iter().all(u8::is_ascii_hexdigit), + 36 => bytes.iter().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + *byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }), + _ => false, + }; + valid.then(|| { + value + .bytes() + .filter(|byte| *byte != b'-') + .map(|byte| char::from(byte.to_ascii_lowercase())) + .collect() + }) +} + fn render_database_schema(database: &DatabaseDto, data_sources: &[DataSourceDto]) -> String { let mut out = String::new(); out.push_str("loc:\n"); @@ -117,12 +318,21 @@ fn yaml_string(value: &str) -> String { #[cfg(test)] mod tests { use std::collections::BTreeMap; + use std::sync::Mutex; + + use locality_core::{LocalityError, LocalityResult}; + use crate::client::NotionApi; use crate::dto::{ - DataSourceDto, DataSourcePropertyDto, DatabaseDto, SelectOptionDto, SelectPropertySchemaDto, + BlockDto, BlockListDto, DataSourceDto, DataSourcePropertyDto, DataSourceSummaryDto, + DatabaseDto, PageDto, PageListDto, ParentDto, RichTextDto, SelectOptionDto, + SelectPropertySchemaDto, }; - use super::render_database_schema; + use super::{ + database_bundle_provider_version, fetch_database_bundle, render_database_bundle_schema, + render_database_schema, + }; #[test] fn renders_database_schema_with_select_options() { @@ -153,10 +363,345 @@ mod tests { let schema = render_database_schema(&database, &[data_source]); - assert!(schema.contains("database_id: \"database-1\"")); - assert!(schema.contains("name: \"Tasks\"")); - assert!(schema.contains("\"Status\":")); - assert!(schema.contains("type: \"select\"")); - assert!(schema.contains("name: \"Todo\"")); + assert_eq!( + schema, + concat!( + "loc:\n", + " type: notion_database_schema\n", + " database_id: \"database-1\"\n", + "title: \"\"\n", + "data_sources:\n", + " - id: \"source-1\"\n", + " name: \"Tasks\"\n", + " properties:\n", + " \"Status\":\n", + " id: \"status-id\"\n", + " type: \"select\"\n", + " options:\n", + " - name: \"Todo\"\n", + " id: \"todo-id\"\n", + " color: \"red\"\n", + ) + ); + } + + #[test] + fn database_bundle_is_ordered_deduplicated_and_exactly_versioned() { + let database_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + let database = DatabaseDto { + id: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA".to_string(), + last_edited_time: Some("2026-07-21T01:02:03.000Z".to_string()), + title: vec![plain_text("Roadmap")], + data_sources: vec![ + DataSourceSummaryDto { + id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb".to_string(), + name: Some("Tasks".to_string()), + }, + DataSourceSummaryDto { + id: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(), + name: Some("Tasks".to_string()), + }, + DataSourceSummaryDto { + id: "cccccccc-cccc-cccc-cccc-cccccccccccc".to_string(), + name: Some("Archive".to_string()), + }, + ], + ..Default::default() + }; + let api = FixtureApi::new( + database, + [ + ( + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + data_source( + "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "Tasks", + Some("2026-07-21T01:03:00.000Z"), + BTreeMap::from([( + "Status".to_string(), + DataSourcePropertyDto { + id: "status-id".to_string(), + kind: "select".to_string(), + select: Some(SelectPropertySchemaDto { + options: vec![SelectOptionDto { + id: "todo-id".to_string(), + name: "Todo".to_string(), + color: Some("red".to_string()), + }], + }), + ..Default::default() + }, + )]), + ), + ), + ( + "cccccccc-cccc-cccc-cccc-cccccccccccc", + data_source( + "cccccccccccccccccccccccccccccccc", + database_id, + "Archive", + None, + BTreeMap::new(), + ), + ), + ], + ); + + let bundle = fetch_database_bundle(&api, database_id).expect("database bundle"); + + assert_eq!( + api.retrieved_data_sources.into_inner().expect("calls"), + vec![ + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb".to_string(), + "cccccccc-cccc-cccc-cccc-cccccccccccc".to_string(), + ] + ); + assert_eq!( + bundle + .data_sources + .iter() + .map(|data_source| data_source.id.as_str()) + .collect::>(), + vec![ + "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + "cccccccccccccccccccccccccccccccc", + ] + ); + assert_eq!( + render_database_bundle_schema(&bundle), + concat!( + "loc:\n", + " type: notion_database_schema\n", + " database_id: \"AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA\"\n", + "title: \"Roadmap\"\n", + "data_sources:\n", + " - id: \"BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB\"\n", + " name: \"Tasks\"\n", + " properties:\n", + " \"Status\":\n", + " id: \"status-id\"\n", + " type: \"select\"\n", + " options:\n", + " - name: \"Todo\"\n", + " id: \"todo-id\"\n", + " color: \"red\"\n", + " - id: \"cccccccccccccccccccccccccccccccc\"\n", + " name: \"Archive\"\n", + " properties: {}\n", + ) + ); + let provider_version = database_bundle_provider_version(&bundle).expect("provider version"); + assert_eq!( + provider_version, + concat!( + "{\"format_version\":1,", + "\"database\":{\"id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",", + "\"last_edited_time\":\"2026-07-21T01:02:03.000Z\"},", + "\"data_sources\":[", + "{\"id\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",", + "\"last_edited_time\":\"2026-07-21T01:03:00.000Z\"},", + "{\"id\":\"cccccccccccccccccccccccccccccccc\",", + "\"last_edited_time\":null}]}", + ) + ); + + let mut alternate_spelling = bundle.clone(); + alternate_spelling.database.id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(); + alternate_spelling.data_sources[0].id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(); + alternate_spelling.data_sources[1].id = "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC".to_string(); + assert_eq!( + database_bundle_provider_version(&alternate_spelling).expect("alternate version"), + provider_version + ); + } + + #[test] + fn database_bundle_rejects_conflicting_duplicate_summaries() { + let database = DatabaseDto { + id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + data_sources: vec![ + DataSourceSummaryDto { + id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb".to_string(), + name: Some("Tasks".to_string()), + }, + DataSourceSummaryDto { + id: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_string(), + name: Some("Other".to_string()), + }, + ], + ..Default::default() + }; + let api = FixtureApi::new(database, []); + + let error = fetch_database_bundle(&api, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + .expect_err("conflicting summary"); + + assert_eq!( + error, + LocalityError::InvalidState( + "Notion database `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa` contains conflicting summaries for data source `BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB`".to_string() + ) + ); + assert!( + api.retrieved_data_sources + .into_inner() + .expect("calls") + .is_empty() + ); + } + + #[test] + fn database_bundle_rejects_data_source_from_another_database() { + let database = DatabaseDto { + id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + data_sources: vec![DataSourceSummaryDto { + id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + name: Some("Tasks".to_string()), + }], + ..Default::default() + }; + let api = FixtureApi::new( + database, + [( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + data_source( + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "cccccccc-cccc-cccc-cccc-cccccccccccc", + "Tasks", + Some("version-1"), + BTreeMap::new(), + ), + )], + ); + + let error = fetch_database_bundle(&api, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + .expect_err("foreign data source"); + + assert_eq!( + error, + LocalityError::InvalidState( + "Notion data source `bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb` belongs to database `cccccccc-cccc-cccc-cccc-cccccccccccc`, not `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`".to_string() + ) + ); + } + + #[test] + fn database_bundle_rejects_non_uuid_identity_spelling() { + let api = FixtureApi::new(DatabaseDto::default(), []); + + let error = fetch_database_bundle(&api, "database-1").expect_err("non-UUID database ID"); + + assert_eq!( + error, + LocalityError::InvalidState( + "Notion database bundle requires a canonical UUID, got `database-1`".to_string() + ) + ); + } + + fn plain_text(value: &str) -> RichTextDto { + RichTextDto { + kind: "text".to_string(), + plain_text: value.to_string(), + ..Default::default() + } + } + + fn data_source( + id: &str, + database_id: &str, + name: &str, + last_edited_time: Option<&str>, + properties: BTreeMap, + ) -> DataSourceDto { + DataSourceDto { + id: id.to_string(), + parent: Some(ParentDto { + kind: "database_id".to_string(), + database_id: Some(database_id.to_string()), + ..Default::default() + }), + name: Some(name.to_string()), + last_edited_time: last_edited_time.map(str::to_string), + properties, + ..Default::default() + } + } + + #[derive(Debug)] + struct FixtureApi { + database: DatabaseDto, + data_sources: BTreeMap, + retrieved_data_sources: Mutex>, + } + + impl FixtureApi { + fn new<'a>( + database: DatabaseDto, + data_sources: impl IntoIterator, + ) -> Self { + Self { + database, + data_sources: data_sources + .into_iter() + .map(|(id, data_source)| (id.to_string(), data_source)) + .collect(), + retrieved_data_sources: Mutex::new(Vec::new()), + } + } + } + + impl NotionApi for FixtureApi { + fn retrieve_page(&self, page_id: &str) -> LocalityResult { + Err(LocalityError::RemoteNotFound(page_id.to_string())) + } + + fn retrieve_database(&self, _database_id: &str) -> LocalityResult { + Ok(self.database.clone()) + } + + fn retrieve_data_source(&self, data_source_id: &str) -> LocalityResult { + self.retrieved_data_sources + .lock() + .expect("record data-source call") + .push(data_source_id.to_string()); + self.data_sources + .get(data_source_id) + .cloned() + .ok_or_else(|| LocalityError::RemoteNotFound(data_source_id.to_string())) + } + + fn retrieve_block_children( + &self, + _block_id: &str, + _start_cursor: Option<&str>, + ) -> LocalityResult { + Err(LocalityError::NotImplemented("fixture block children")) + } + + fn search_pages(&self, _start_cursor: Option<&str>) -> LocalityResult { + Err(LocalityError::NotImplemented("fixture page search")) + } + + fn update_block( + &self, + _block_id: &str, + _body: serde_json::Value, + ) -> LocalityResult { + Err(LocalityError::NotImplemented("fixture block update")) + } + + fn append_block_children( + &self, + _block_id: &str, + _body: serde_json::Value, + ) -> LocalityResult { + Err(LocalityError::NotImplemented("fixture block append")) + } + + fn delete_block(&self, _block_id: &str) -> LocalityResult { + Err(LocalityError::NotImplemented("fixture block delete")) + } } } diff --git a/crates/locality-notion/src/dto.rs b/crates/locality-notion/src/dto.rs index 4f5d5f04..203a0f0c 100644 --- a/crates/locality-notion/src/dto.rs +++ b/crates/locality-notion/src/dto.rs @@ -13,6 +13,17 @@ pub struct NotionPageBundle { pub blocks: Vec, } +/// Native database container plus the authoritative schemas for its data sources. +/// +/// The data sources retain the order declared by the database response after +/// equivalent duplicate references have been removed. This makes the bundle a +/// stable input for both `_schema.yaml` rendering and portable synchronization. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotionDatabaseBundle { + pub database: DatabaseDto, + pub data_sources: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BlockTreeDto { pub block: BlockDto, From 1480ad3a71f3644adb0c559fb4a4db800dd768f2 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Tue, 21 Jul 2026 23:58:11 -0700 Subject: [PATCH 11/18] feat(notion): compose portable database schemas --- crates/locality-notion/src/fetch.rs | 12 +- crates/locality-notion/src/portable.rs | 213 +++++++- crates/locality-notion/tests/fetch_render.rs | 484 ++++++++++++++++++- 3 files changed, 681 insertions(+), 28 deletions(-) diff --git a/crates/locality-notion/src/fetch.rs b/crates/locality-notion/src/fetch.rs index 71d599b2..f35d125d 100644 --- a/crates/locality-notion/src/fetch.rs +++ b/crates/locality-notion/src/fetch.rs @@ -3,12 +3,20 @@ use locality_core::LocalityResult; use crate::client::NotionApi; -use crate::dto::{BlockTreeDto, NotionPageBundle}; +use crate::dto::{BlockTreeDto, NotionPageBundle, PageDto}; pub fn fetch_page_bundle(api: &dyn NotionApi, page_id: &str) -> LocalityResult { let page = api.retrieve_page(page_id)?; - let blocks = fetch_block_trees(api, page_id)?; + fetch_known_page_bundle(api, page_id, page) +} +/// Fetches block content after the caller has already classified an object as a page. +pub(crate) fn fetch_known_page_bundle( + api: &dyn NotionApi, + page_id: &str, + page: PageDto, +) -> LocalityResult { + let blocks = fetch_block_trees(api, page_id)?; Ok(NotionPageBundle { page, blocks }) } diff --git a/crates/locality-notion/src/portable.rs b/crates/locality-notion/src/portable.rs index 7cda1a56..f5bad4fb 100644 --- a/crates/locality-notion/src/portable.rs +++ b/crates/locality-notion/src/portable.rs @@ -23,8 +23,11 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::client::NotionApi; -use crate::dto::{BlockTreeDto, NotionPageBundle}; -use crate::fetch::fetch_page_bundle; +use crate::database::{ + database_bundle_provider_version, fetch_database_bundle, render_database_bundle_schema, +}; +use crate::dto::{BlockTreeDto, NotionDatabaseBundle, NotionPageBundle}; +use crate::fetch::fetch_known_page_bundle; use crate::projection::enumerate_explicit_root_trees; use crate::render::render_native_entity; @@ -176,10 +179,40 @@ pub(crate) fn fetch( api: &dyn NotionApi, request: PortableFetchRequest, ) -> LocalityResult { - let bundle = fetch_page_bundle(api, request.remote_id.as_str())?; + match api.retrieve_page(request.remote_id.as_str()) { + Ok(page) => { + let bundle = fetch_known_page_bundle(api, request.remote_id.as_str(), page)?; + fetch_page_result(bundle, &request.remote_id) + } + Err(LocalityError::RemoteNotFound(_)) => { + let bundle = fetch_database_bundle(api, request.remote_id.as_str())?; + let provider_version = Some(database_bundle_provider_version(&bundle)?); + let remote_id = RemoteId::new(bundle.database.id.clone()); + let raw = serde_json::to_vec(&bundle).map_err(|error| { + LocalityError::Io(format!("notion database native encode failed: {error}")) + })?; + + Ok(PortableFetchResult { + native: NativeEntity { + remote_id, + kind: "notion_database".to_string(), + raw, + }, + provider_version, + completeness: PortableCompleteness::complete(), + }) + } + Err(error) => Err(error), + } +} + +fn fetch_page_result( + bundle: NotionPageBundle, + requested_remote_id: &RemoteId, +) -> LocalityResult { let provider_version = bundle.page.last_edited_time.clone(); let remote_id = RemoteId::new(bundle.page.id.clone()); - if !notion_ids_equal(remote_id.as_str(), request.remote_id.as_str()) { + if !notion_ids_equal(remote_id.as_str(), requested_remote_id.as_str()) { return Err(LocalityError::InvalidState( "Notion portable fetch returned a different remote object".to_string(), )); @@ -204,8 +237,23 @@ pub(crate) fn render(request: &PortableRenderRequest) -> LocalityResult render_page(request), + "notion_database" => render_database(request), + kind => Err(LocalityError::InvalidState(format!( + "Notion portable render received unsupported native kind `{kind}`" + ))), + } +} + +fn render_page(request: &PortableRenderRequest) -> LocalityResult { let native_bundle = serde_json::from_slice::(&request.native.raw) .map_err(|error| LocalityError::Io(format!("notion native decode failed: {error}")))?; + if !notion_ids_equal(&native_bundle.page.id, request.native.remote_id.as_str()) { + return Err(LocalityError::InvalidState( + "Notion portable page native payload does not match its remote ID".to_string(), + )); + } let contains_media = native_bundle.blocks.iter().any(contains_media_block); let rendered = render_native_entity(&request.native)?; let canonical_bytes = render_canonical_markdown(&rendered.document).into_bytes(); @@ -253,6 +301,124 @@ pub(crate) fn render(request: &PortableRenderRequest) -> LocalityResult LocalityResult { + let bundle = + serde_json::from_slice::(&request.native.raw).map_err(|error| { + LocalityError::Io(format!("notion database native decode failed: {error}")) + })?; + validate_database_bundle(&bundle, &request.native.remote_id)?; + let body = render_database_bundle_schema(&bundle).into_bytes(); + let canonical = PortableContentArtifact { + artifact_key: database_artifact_key( + &request.native.remote_id, + "canonical_schema", + request.format_version, + )?, + media_type: "application/yaml; charset=utf-8".to_string(), + body: body.clone(), + }; + let projection = PortableProjectionArtifact { + artifact: PortableContentArtifact { + artifact_key: database_artifact_key( + &request.native.remote_id, + "database_schema", + request.format_version, + )?, + media_type: "application/yaml; charset=utf-8".to_string(), + body, + }, + logical_path: request.logical_path.clone(), + file_kind: ProjectionFileKind::Yaml, + format_version: request.format_version, + supported_actions: [SourceAction::Read, SourceAction::Search] + .into_iter() + .collect(), + }; + + Ok(PortableRenderResult { + canonical, + projections: vec![projection], + completeness: PortableCompleteness::complete(), + }) +} + +fn validate_database_bundle( + bundle: &NotionDatabaseBundle, + remote_id: &RemoteId, +) -> LocalityResult<()> { + let database_id = canonical_notion_uuid(&bundle.database.id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion portable database payload contains non-canonical database ID `{}`", + bundle.database.id + )) + })?; + let native_remote_id = canonical_notion_uuid(remote_id.as_str()).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion portable database native entity contains non-canonical remote ID `{}`", + remote_id.as_str() + )) + })?; + if database_id != native_remote_id { + return Err(LocalityError::InvalidState( + "Notion portable database native payload does not match its remote ID".to_string(), + )); + } + + let mut declared_data_source_ids = Vec::new(); + for summary in &bundle.database.data_sources { + let data_source_id = canonical_notion_uuid(&summary.id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion portable database payload contains non-canonical data source ID `{}`", + summary.id + )) + })?; + if !declared_data_source_ids.contains(&data_source_id) { + declared_data_source_ids.push(data_source_id); + } + } + + let mut fetched_data_source_ids = Vec::with_capacity(bundle.data_sources.len()); + for data_source in &bundle.data_sources { + let data_source_id = canonical_notion_uuid(&data_source.id).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion portable database payload contains non-canonical data source ID `{}`", + data_source.id + )) + })?; + if fetched_data_source_ids.contains(&data_source_id) { + return Err(LocalityError::InvalidState(format!( + "Notion portable database payload contains duplicate data source `{}`", + data_source.id + ))); + } + let parent_database_id = data_source + .parent + .as_ref() + .and_then(|parent| parent.database_id.as_deref()) + .and_then(canonical_notion_uuid) + .ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion portable data source `{}` does not expose a canonical parent database", + data_source.id + )) + })?; + if parent_database_id != database_id { + return Err(LocalityError::InvalidState(format!( + "Notion portable data source `{}` belongs to a different database", + data_source.id + ))); + } + fetched_data_source_ids.push(data_source_id); + } + + if declared_data_source_ids != fetched_data_source_ids { + return Err(LocalityError::InvalidState( + "Notion portable database payload data sources do not match its summaries".to_string(), + )); + } + Ok(()) +} + fn contains_media_block(tree: &BlockTreeDto) -> bool { matches!( tree.block.kind.as_str(), @@ -348,12 +514,17 @@ fn inventory( }) .collect::>>()? .join("/"); + let logical_path = if entry.kind == EntityKind::Database { + format!("{logical_path}/_schema.yaml") + } else { + logical_path + }; let logical_path = LogicalPath::new(logical_path).map_err(|error| { LocalityError::InvalidState(format!( "Notion portable projection produced an invalid logical path: {error}" )) })?; - let requires_fetch = entry.kind == EntityKind::Page; + let requires_fetch = matches!(entry.kind, EntityKind::Page | EntityKind::Database); let mut connector_metadata = BTreeMap::new(); connector_metadata.insert("title".to_string(), entry.title); Ok(PortableSourceChange { @@ -485,6 +656,22 @@ fn artifact_key(remote_id: &RemoteId, role: &str, format_version: u32) -> Portab )) } +fn database_artifact_key( + remote_id: &RemoteId, + role: &str, + format_version: u32, +) -> LocalityResult { + let canonical_id = canonical_notion_uuid(remote_id.as_str()).ok_or_else(|| { + LocalityError::InvalidState(format!( + "Notion database artifact key requires a canonical remote ID, got `{}`", + remote_id.as_str() + )) + })?; + Ok(PortableArtifactKey::new(format!( + "notion:database:{canonical_id}:{role}:v{format_version}" + ))) +} + fn inventory_sha256(inventory: &[PortableSourceChange], include_root_provenance: bool) -> String { let mut hasher = Sha256::new(); for change in inventory { @@ -642,6 +829,22 @@ fn normalize_notion_id(value: &str) -> String { .collect() } +fn canonical_notion_uuid(value: &str) -> Option { + let bytes = value.as_bytes(); + let valid = match bytes.len() { + 32 => bytes.iter().all(u8::is_ascii_hexdigit), + 36 => bytes.iter().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + *byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }), + _ => false, + }; + valid.then(|| normalize_notion_id(value)) +} + fn notion_ids_equal(left: &str, right: &str) -> bool { normalize_notion_id(left) == normalize_notion_id(right) } diff --git a/crates/locality-notion/tests/fetch_render.rs b/crates/locality-notion/tests/fetch_render.rs index afc5cade..32237f9c 100644 --- a/crates/locality-notion/tests/fetch_render.rs +++ b/crates/locality-notion/tests/fetch_render.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use std::path::Path; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use locality_connector::{ ChildContainer, Connector, EnumerateRequest, FetchRequest, ListChildrenRequest, NativeEntity, @@ -10,7 +11,9 @@ use locality_connector::{ }; use locality_core::canonical::render_canonical_markdown; use locality_core::model::{EntityKind, MountId, RemoteId}; -use locality_core::portable::{LogicalPath, SourceConnectionId, SourceEdge}; +use locality_core::portable::{ + LogicalPath, ProjectionFileKind, SourceAction, SourceConnectionId, SourceEdge, +}; use locality_core::shadow::MarkdownBlockKind; use locality_notion::client::NotionApi; use locality_notion::dto::{ @@ -18,10 +21,10 @@ use locality_notion::dto::{ DataSourceSummaryDto, DatabaseDto, DatabaseListDto, DateMentionDto, EmptyBlockDto, EquationBlockDto, EquationRichTextDto, ExternalFileDto, FileBlockDto, FilePropertyDto, HostedFileDto, IdRefDto, LinkDto, LinkToPageBlockDto, MeetingNotesBlockDto, MentionRichTextDto, - PageDto, PageListDto, PagePropertyDto, PaginatedListDto, ParentDto, RichTextAnnotationsDto, - RichTextBlockDto, RichTextDto, SelectOptionDto, SelectPropertySchemaDto, SyncedBlockDto, - SyncedFromDto, TableBlockDto, TableRowBlockDto, TextRichTextDto, TitleBlockDto, - UniqueIdPropertyDto, UrlBlockDto, VerificationPropertyDto, + NotionDatabaseBundle, PageDto, PageListDto, PagePropertyDto, PaginatedListDto, ParentDto, + RichTextAnnotationsDto, RichTextBlockDto, RichTextDto, SelectOptionDto, + SelectPropertySchemaDto, SyncedBlockDto, SyncedFromDto, TableBlockDto, TableRowBlockDto, + TextRichTextDto, TitleBlockDto, UniqueIdPropertyDto, UrlBlockDto, VerificationPropertyDto, }; use locality_notion::{NotionConfig, NotionConnector}; use serde_json::json; @@ -2018,7 +2021,7 @@ fn enumerate_projects_root_page_tree_to_stable_paths() { } #[test] -fn portable_bootstrap_resumes_and_never_claims_unsupported_database_coverage() { +fn portable_bootstrap_resumes_and_completes_database_coverage() { let root_page_id = RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); let connector = NotionConnector::with_api( NotionConfig::default().with_root_page_id(root_page_id.clone()), @@ -2053,19 +2056,14 @@ fn portable_bootstrap_resumes_and_never_claims_unsupported_database_coverage() { }) .expect("resumed checkpoint"); assert_eq!(second.changes.len(), 2); - assert!(!second.completeness.is_complete()); + assert!(second.completeness.is_complete()); assert!( !second .completeness .incomplete_reasons() .contains(&PortableIncompleteReason::CheckpointContinuation) ); - assert!(second.completeness.incomplete_reasons().iter().any( - |reason| matches!(reason, PortableIncompleteReason::UnsupportedSourceKind { - source_kind, - .. - } if source_kind == "database") - )); + assert!(second.completeness.incomplete_reasons().is_empty()); let synchronized = connector .sync_portable(PortableSyncRequest { @@ -2079,7 +2077,7 @@ fn portable_bootstrap_resumes_and_never_claims_unsupported_database_coverage() { }) .expect("scheduled explicit-root synchronization"); assert_eq!(synchronized.changes.len(), 4); - assert!(!synchronized.completeness.is_complete()); + assert!(synchronized.completeness.is_complete()); } #[test] @@ -2421,13 +2419,16 @@ fn explicit_database_root_matches_workspace_projection_without_search() { target_remote_id: RemoteId::new("rootdb"), }] ); - assert!(!batch.changes[0].requires_fetch); - assert!(batch.completeness.incomplete_reasons().iter().any( - |reason| matches!(reason, PortableIncompleteReason::UnsupportedSourceKind { - source_kind, - .. - } if source_kind == "database") - )); + assert!(batch.changes[0].requires_fetch); + assert_eq!( + batch.changes[0] + .logical_path + .as_ref() + .expect("database schema path") + .as_str(), + "Tasks/_schema.yaml" + ); + assert!(batch.completeness.is_complete()); } #[test] @@ -2537,6 +2538,378 @@ fn portable_render_matches_direct_canonical_bytes_and_keys_survive_rename() { ); } +#[test] +fn portable_database_root_fetches_and_renders_exact_shared_schema_projection() { + let root_page_id = RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let compact_database_id = "cccccccccccccccccccccccccccccccc"; + let provider_database_id = "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"; + let compact_data_source_id = "dddddddddddddddddddddddddddddddd"; + let provider_data_source_id = "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"; + let mut api = FixtureNotionApi::tree(root_page_id.as_str()); + let mut database = api + .databases + .get(compact_database_id) + .cloned() + .expect("database fixture"); + database.id = provider_database_id.to_string(); + api.databases + .insert(compact_database_id.to_string(), database.clone()); + api.databases + .insert(provider_database_id.to_string(), database.clone()); + let data_source = api + .data_sources + .get_mut(compact_data_source_id) + .expect("data-source fixture"); + data_source.id = provider_data_source_id.to_string(); + data_source.parent = Some(ParentDto { + kind: "database_id".to_string(), + database_id: Some(provider_database_id.to_string()), + ..Default::default() + }); + let expected_bundle = NotionDatabaseBundle { + database: database.clone(), + data_sources: vec![data_source.clone()], + }; + let connector = NotionConnector::with_api( + NotionConfig::default().with_root_page_id(root_page_id.clone()), + Arc::new(NoSearchNotionApi(api)), + ); + + let direct_entries = connector + .enumerate(EnumerateRequest { + mount_id: MountId::new("notion-main"), + cursor: None, + }) + .expect("direct enumerate"); + let direct_database = direct_entries + .iter() + .find(|entry| entry.kind == EntityKind::Database) + .expect("direct database"); + let direct_row = direct_entries + .iter() + .find(|entry| entry.remote_id == RemoteId::new("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee")) + .expect("direct row"); + + let batch = connector + .bootstrap_portable(PortableBootstrapRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + scope: PortableSourceScope::explicit_roots([root_page_id]), + checkpoint: None, + max_changes: 10, + }) + .expect("portable bootstrap"); + assert!(batch.completeness.is_complete()); + let database_change = batch + .changes + .iter() + .find(|change| change.source_object.kind == EntityKind::Database) + .expect("portable database change"); + let row_change = batch + .changes + .iter() + .find(|change| { + change.source_object.remote_id == RemoteId::new("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") + }) + .expect("portable row change"); + assert!(database_change.requires_fetch); + assert_eq!( + database_change + .logical_path + .as_ref() + .expect("database schema path") + .as_str(), + format!("{}/_schema.yaml", direct_database.path.display()) + ); + assert_eq!( + row_change.logical_path.as_ref().expect("row path").as_str(), + direct_row.path.to_str().expect("UTF-8 direct row path") + ); + + let fetched = connector + .fetch_portable(PortableFetchRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + remote_id: database_change.source_object.remote_id.clone(), + reason: PortableFetchReason::Bootstrap, + }) + .expect("portable database fetch"); + assert_eq!(fetched.native.kind, "notion_database"); + assert_eq!( + fetched.native.remote_id, + RemoteId::new(provider_database_id) + ); + assert_eq!( + fetched.native.raw, + serde_json::to_vec(&expected_bundle).expect("exact native fixture") + ); + assert_eq!( + fetched.provider_version.as_deref(), + Some(concat!( + "{\"format_version\":1,", + "\"database\":{\"id\":\"cccccccccccccccccccccccccccccccc\",", + "\"last_edited_time\":\"2026-06-10T01:00:00.000Z\"},", + "\"data_sources\":[{", + "\"id\":\"dddddddddddddddddddddddddddddddd\",", + "\"last_edited_time\":\"2026-06-10T01:01:00.000Z\"}]}" + )) + ); + + let rendered = connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: database_change + .logical_path + .clone() + .expect("database schema path"), + native: fetched.native.clone(), + format_version: 1, + }) + .expect("portable database render"); + let exact_schema = concat!( + "loc:\n", + " type: notion_database_schema\n", + " database_id: \"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC\"\n", + "title: \"Tasks\"\n", + "data_sources:\n", + " - id: \"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD\"\n", + " name: \"Tasks\"\n", + " properties:\n", + " \"Name\":\n", + " id: \"title\"\n", + " type: \"title\"\n", + " \"Status\":\n", + " id: \"status-id\"\n", + " type: \"select\"\n", + " options:\n", + " - name: \"Todo\"\n", + " id: \"todo-id\"\n", + ); + assert_eq!(rendered.canonical.body, exact_schema.as_bytes()); + assert_eq!( + rendered.projections[0].artifact.body, + exact_schema.as_bytes() + ); + assert_eq!( + connector + .database_schema_yaml(&RemoteId::new(provider_database_id)) + .expect("direct schema"), + exact_schema + ); + assert_eq!( + rendered.canonical.artifact_key.as_str(), + "notion:database:cccccccccccccccccccccccccccccccc:canonical_schema:v1" + ); + assert_eq!( + rendered.projections[0].artifact.artifact_key.as_str(), + "notion:database:cccccccccccccccccccccccccccccccc:database_schema:v1" + ); + assert_eq!( + rendered.canonical.media_type, + "application/yaml; charset=utf-8" + ); + assert_eq!(rendered.projections[0].file_kind, ProjectionFileKind::Yaml); + assert_eq!(rendered.projections[0].supported_actions.len(), 2); + assert!( + rendered.projections[0] + .supported_actions + .contains(&SourceAction::Read) + ); + assert!( + rendered.projections[0] + .supported_actions + .contains(&SourceAction::Search) + ); + assert!(rendered.completeness.is_complete()); + + let renamed = connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new("Renamed Tasks/_schema.yaml").expect("renamed path"), + native: fetched.native, + format_version: 1, + }) + .expect("renamed database render"); + assert_eq!( + rendered.canonical.artifact_key, + renamed.canonical.artifact_key + ); + assert_eq!( + rendered.projections[0].artifact.artifact_key, + renamed.projections[0].artifact.artifact_key + ); + assert_ne!( + rendered.projections[0].logical_path, + renamed.projections[0].logical_path + ); +} + +#[test] +fn portable_database_fetch_and_render_fail_closed_on_identity_and_ownership_mismatch() { + let database_id = "cccccccccccccccccccccccccccccccc"; + let data_source_id = "dddddddddddddddddddddddddddddddd"; + let mut mismatched_api = FixtureNotionApi::tree("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + mismatched_api + .databases + .get_mut(database_id) + .expect("database") + .id = "ffffffffffffffffffffffffffffffff".to_string(); + let mismatched_connector = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(mismatched_api)), + ); + let returned_id_error = mismatched_connector + .fetch_portable(PortableFetchRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + remote_id: RemoteId::new(database_id), + reason: PortableFetchReason::Bootstrap, + }) + .expect_err("different returned database ID"); + assert_eq!( + returned_id_error.to_string(), + concat!( + "invalid state: Notion database bundle returned database ", + "`ffffffffffffffffffffffffffffffff` for requested database ", + "`cccccccccccccccccccccccccccccccc`" + ) + ); + + let mut api = FixtureNotionApi::tree("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + api.data_sources + .get_mut(data_source_id) + .expect("data source") + .parent = Some(ParentDto { + kind: "database_id".to_string(), + database_id: Some("ffffffffffffffffffffffffffffffff".to_string()), + ..Default::default() + }); + let connector = + NotionConnector::with_api(NotionConfig::default(), Arc::new(NoSearchNotionApi(api))); + + let ownership_error = connector + .fetch_portable(PortableFetchRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + remote_id: RemoteId::new(database_id), + reason: PortableFetchReason::Bootstrap, + }) + .expect_err("foreign data-source owner"); + assert_eq!( + ownership_error.to_string(), + concat!( + "invalid state: Notion data source `dddddddddddddddddddddddddddddddd` ", + "belongs to database `ffffffffffffffffffffffffffffffff`, ", + "not `cccccccccccccccccccccccccccccccc`" + ) + ); + + let valid_api = FixtureNotionApi::tree("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let valid_connector = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(NoSearchNotionApi(valid_api)), + ); + let fetched = valid_connector + .fetch_portable(PortableFetchRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + remote_id: RemoteId::new(database_id), + reason: PortableFetchReason::Bootstrap, + }) + .expect("valid database fetch"); + let identity_error = valid_connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new("Tasks/_schema.yaml").expect("path"), + native: NativeEntity { + remote_id: RemoteId::new("ffffffffffffffffffffffffffffffff"), + ..fetched.native.clone() + }, + format_version: 1, + }) + .expect_err("mismatched native remote ID"); + assert_eq!( + identity_error.to_string(), + "invalid state: Notion portable database native payload does not match its remote ID" + ); + + let unsupported_kind = valid_connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new("Tasks/_schema.yaml").expect("path"), + native: NativeEntity { + remote_id: RemoteId::new(database_id), + kind: "notion_database_view".to_string(), + raw: Vec::new(), + }, + format_version: 1, + }) + .expect_err("unsupported native kind"); + assert_eq!( + unsupported_kind.to_string(), + "invalid state: Notion portable render received unsupported native kind `notion_database_view`" + ); + + let malformed = valid_connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new("Tasks/_schema.yaml").expect("path"), + native: NativeEntity { + remote_id: RemoteId::new(database_id), + kind: "notion_database".to_string(), + raw: b"{}".to_vec(), + }, + format_version: 1, + }) + .expect_err("malformed database bundle"); + assert!( + malformed + .to_string() + .starts_with("io error: notion database native decode failed:") + ); +} + +#[test] +fn portable_fetch_does_not_fall_back_to_database_after_non_not_found_page_error() { + let connector = + NotionConnector::with_api(NotionConfig::default(), Arc::new(NonNotFoundPageErrorApi)); + + let error = connector + .fetch_portable(PortableFetchRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + remote_id: RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + reason: PortableFetchReason::Bootstrap, + }) + .expect_err("page error must not trigger database fallback"); + + assert_eq!( + error.to_string(), + "invalid state: injected page retrieval failure" + ); +} + +#[test] +fn portable_fetch_preserves_descendant_not_found_without_database_fallback() { + let retrieve_page_calls = Arc::new(AtomicUsize::new(0)); + let connector = NotionConnector::with_api( + NotionConfig::default(), + Arc::new(DescendantNotFoundApi { + retrieve_page_calls: Arc::clone(&retrieve_page_calls), + }), + ); + + let error = connector + .fetch_portable(PortableFetchRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + remote_id: RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + reason: PortableFetchReason::Bootstrap, + }) + .expect_err("descendant block lookup must fail as a page fetch"); + + assert_eq!( + error, + locality_core::LocalityError::RemoteNotFound( + "missing descendant block `bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`".to_string() + ) + ); + assert_eq!(retrieve_page_calls.load(Ordering::SeqCst), 1); +} + #[test] fn portable_render_marks_media_incomplete_instead_of_silently_omitting_it() { let bundle = locality_notion::dto::NotionPageBundle { @@ -2903,6 +3276,7 @@ impl FixtureNotionApi { database_id.to_string(), DatabaseDto { id: database_id.to_string(), + last_edited_time: Some("2026-06-10T01:00:00.000Z".to_string()), title: vec![rich_text("Tasks")], data_sources: vec![DataSourceSummaryDto { id: data_source_id.to_string(), @@ -2915,7 +3289,13 @@ impl FixtureNotionApi { data_source_id.to_string(), DataSourceDto { id: data_source_id.to_string(), + parent: Some(ParentDto { + kind: "database_id".to_string(), + database_id: Some(database_id.to_string()), + ..Default::default() + }), name: Some("Tasks".to_string()), + last_edited_time: Some("2026-06-10T01:01:00.000Z".to_string()), properties: BTreeMap::from([ ( "Name".to_string(), @@ -3546,6 +3926,68 @@ impl NotionApi for NonNotFoundPageErrorApi { } } +#[derive(Debug)] +struct DescendantNotFoundApi { + retrieve_page_calls: Arc, +} + +impl NotionApi for DescendantNotFoundApi { + fn retrieve_page(&self, page_id: &str) -> locality_core::LocalityResult { + self.retrieve_page_calls.fetch_add(1, Ordering::SeqCst); + Ok(page(page_id, "Known page")) + } + + fn retrieve_database(&self, _database_id: &str) -> locality_core::LocalityResult { + panic!("a descendant block error must not trigger database fallback") + } + + fn retrieve_block_children( + &self, + block_id: &str, + _start_cursor: Option<&str>, + ) -> locality_core::LocalityResult { + if block_id == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { + return Ok(PaginatedListDto { + results: vec![ + toggle_block("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "Nested").with_children(), + ], + next_cursor: None, + has_more: false, + }); + } + Err(locality_core::LocalityError::RemoteNotFound(format!( + "missing descendant block `{block_id}`" + ))) + } + + fn search_pages( + &self, + _start_cursor: Option<&str>, + ) -> locality_core::LocalityResult { + panic!("portable fetch must not search") + } + + fn update_block( + &self, + _block_id: &str, + _body: serde_json::Value, + ) -> locality_core::LocalityResult { + unreachable!("not used by portable read tests") + } + + fn append_block_children( + &self, + _block_id: &str, + _body: serde_json::Value, + ) -> locality_core::LocalityResult { + unreachable!("not used by portable read tests") + } + + fn delete_block(&self, _block_id: &str) -> locality_core::LocalityResult { + unreachable!("not used by portable read tests") + } +} + #[derive(Debug)] struct NoSearchNotionApi(FixtureNotionApi); From 6a878df3b1b916698d9057c259e3af8986ae150f Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Wed, 22 Jul 2026 00:07:57 -0700 Subject: [PATCH 12/18] fix(notion): bind explicit root identities --- crates/locality-notion/src/projection.rs | 68 +++++++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/locality-notion/src/projection.rs b/crates/locality-notion/src/projection.rs index 371260f1..1b8980be 100644 --- a/crates/locality-notion/src/projection.rs +++ b/crates/locality-notion/src/projection.rs @@ -72,11 +72,13 @@ fn retrieve_explicit_root( ) -> LocalityResult { match api.retrieve_page(root_id.as_str()) { Ok(page) => { + validate_explicit_root_identity(root_id, &page.id, "page")?; let title = page_title(&page); Ok(ProjectedChild::Page { page, title }) } Err(LocalityError::RemoteNotFound(_)) => { let database = api.retrieve_database(root_id.as_str())?; + validate_explicit_root_identity(root_id, &database.id, "database")?; let title = database_title(&database).unwrap_or_else(|| "Untitled database".to_string()); Ok(ProjectedChild::Database { database, title }) @@ -85,6 +87,20 @@ fn retrieve_explicit_root( } } +fn validate_explicit_root_identity( + requested: &RemoteId, + returned: &str, + kind: &str, +) -> LocalityResult<()> { + if explicit_root_identity_key(requested.as_str()) != explicit_root_identity_key(returned) { + return Err(LocalityError::InvalidState(format!( + "Notion explicit root request `{}` returned {kind} `{returned}`", + requested.as_str() + ))); + } + Ok(()) +} + pub fn enumerate_shared_pages( api: &dyn NotionApi, mount_id: MountId, @@ -1335,8 +1351,8 @@ mod tests { use std::path::Path; use super::{ - ProjectedChild, allocate_child_paths, allocate_page_path, projected_title_stem, - resolve_notion_object_path_entries, resolve_page_path_entries, + ProjectedChild, allocate_child_paths, allocate_page_path, enumerate_explicit_root_trees, + projected_title_stem, resolve_notion_object_path_entries, resolve_page_path_entries, }; use locality_core::model::{EntityKind, MountId, RemoteId}; use locality_core::path_projection::PAGE_DOCUMENT_FILENAME; @@ -1712,6 +1728,54 @@ mod tests { )); } + #[test] + fn explicit_root_rejects_page_identity_mismatch() { + let mut api = FakeNotionApi::new(); + api.pages.insert( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + page("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + ); + + let error = enumerate_explicit_root_trees( + &api, + MountId::new("notion-main"), + &[RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")], + ) + .expect_err("mismatched page identity"); + + assert_eq!( + error, + LocalityError::InvalidState( + "Notion explicit root request `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa` returned page `bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`" + .to_string() + ) + ); + } + + #[test] + fn explicit_root_rejects_database_identity_mismatch() { + let mut api = FakeNotionApi::new(); + api.databases.insert( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + database("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + ); + + let error = enumerate_explicit_root_trees( + &api, + MountId::new("notion-main"), + &[RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")], + ) + .expect_err("mismatched database identity"); + + assert_eq!( + error, + LocalityError::InvalidState( + "Notion explicit root request `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa` returned database `bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`" + .to_string() + ) + ); + } + fn page(id: &str) -> PageDto { PageDto { id: id.to_string(), From 5594799de249594deff251f3965cf4bc1447a157 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Wed, 22 Jul 2026 00:16:08 -0700 Subject: [PATCH 13/18] test(notion): normalize portable database paths --- crates/locality-notion/tests/fetch_render.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/locality-notion/tests/fetch_render.rs b/crates/locality-notion/tests/fetch_render.rs index 32237f9c..dd67d1e7 100644 --- a/crates/locality-notion/tests/fetch_render.rs +++ b/crates/locality-notion/tests/fetch_render.rs @@ -2611,6 +2611,12 @@ fn portable_database_root_fetches_and_renders_exact_shared_schema_projection() { change.source_object.remote_id == RemoteId::new("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee") }) .expect("portable row change"); + let portable_path = |path: &Path| { + path.iter() + .map(|component| component.to_str().expect("UTF-8 direct path component")) + .collect::>() + .join("/") + }; assert!(database_change.requires_fetch); assert_eq!( database_change @@ -2618,11 +2624,11 @@ fn portable_database_root_fetches_and_renders_exact_shared_schema_projection() { .as_ref() .expect("database schema path") .as_str(), - format!("{}/_schema.yaml", direct_database.path.display()) + format!("{}/_schema.yaml", portable_path(&direct_database.path)) ); assert_eq!( row_change.logical_path.as_ref().expect("row path").as_str(), - direct_row.path.to_str().expect("UTF-8 direct row path") + portable_path(&direct_row.path) ); let fetched = connector From c530a970a5a09d34b6b570afb257f92aab7b13c3 Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Wed, 22 Jul 2026 00:48:28 -0700 Subject: [PATCH 14/18] Add portable Notion media capture --- Cargo.lock | 1 + crates/locality-notion/Cargo.toml | 1 + crates/locality-notion/src/dto.rs | 121 ++++ crates/locality-notion/src/lib.rs | 56 +- crates/locality-notion/src/media.rs | 722 ++++++++++++++++++- crates/locality-notion/src/portable.rs | 649 ++++++++++++++++- crates/locality-notion/tests/fetch_render.rs | 479 +++++++++++- docs/notion-connector.md | 32 + 8 files changed, 2047 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c24aecf7..af277331 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2235,6 +2235,7 @@ dependencies = [ name = "locality-notion" version = "0.3.3" dependencies = [ + "base64 0.22.1", "locality-connector", "locality-core", "mime_guess", diff --git a/crates/locality-notion/Cargo.toml b/crates/locality-notion/Cargo.toml index fe663dfb..39b7cb27 100644 --- a/crates/locality-notion/Cargo.toml +++ b/crates/locality-notion/Cargo.toml @@ -11,6 +11,7 @@ name = "locality_notion" path = "src/lib.rs" [dependencies] +base64 = "0.22" locality-core.workspace = true locality-connector.workspace = true mime_guess = "2.0" diff --git a/crates/locality-notion/src/dto.rs b/crates/locality-notion/src/dto.rs index 203a0f0c..b00ea092 100644 --- a/crates/locality-notion/src/dto.rs +++ b/crates/locality-notion/src/dto.rs @@ -13,6 +13,127 @@ pub struct NotionPageBundle { pub blocks: Vec, } +/// Versioned native page payload used only by opt-in portable hosted-media capture. +/// +/// `page` contains a sanitized Notion bundle: hosted URLs have no query or +/// fragment, expiries are removed, and uncaptured media URLs are empty. Captured +/// bytes are keyed by stable block identity rather than by signed provider URL. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotionPortablePageBundleV1 { + pub format_version: u16, + pub page: NotionPageBundle, + pub captured_media: Vec, + pub incomplete_media: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotionPortableCapturedMediaV1 { + pub block_id: String, + pub kind: String, + pub media_type: String, + #[serde(with = "portable_media_base64")] + pub bytes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotionPortableIncompleteMediaV1 { + pub block_id: String, + pub kind: String, + pub code: String, +} + +mod portable_media_base64 { + use base64::Engine; + use base64::engine::general_purpose::STANDARD; + use serde::{Deserialize, Deserializer, Serializer}; + + use crate::media::PORTABLE_MEDIA_MAX_ASSET_BYTES; + + pub fn serialize(bytes: &[u8], serializer: S) -> Result + where + S: Serializer, + { + if bytes.len() > PORTABLE_MEDIA_MAX_ASSET_BYTES { + return Err(serde::ser::Error::custom( + "portable media bytes exceed the decoded asset limit", + )); + } + serializer.serialize_str(&STANDARD.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let encoded = String::deserialize(deserializer)?; + let maximum_encoded_len = PORTABLE_MEDIA_MAX_ASSET_BYTES.div_ceil(3) * 4; + if encoded.len() > maximum_encoded_len { + return Err(serde::de::Error::custom( + "portable media base64 exceeds the encoded asset limit", + )); + } + let bytes = STANDARD.decode(&encoded).map_err(|_| { + serde::de::Error::custom("portable media base64 is invalid or non-canonical") + })?; + if bytes.len() > PORTABLE_MEDIA_MAX_ASSET_BYTES || STANDARD.encode(&bytes) != encoded { + return Err(serde::de::Error::custom( + "portable media base64 exceeds the decoded limit or is non-canonical", + )); + } + Ok(bytes) + } +} + +#[cfg(test)] +mod portable_media_tests { + use super::NotionPortableCapturedMediaV1; + use crate::media::PORTABLE_MEDIA_MAX_ASSET_BYTES; + + #[test] + fn portable_media_base64_roundtrips_near_cap_with_bounded_overhead() { + let decoded_len = PORTABLE_MEDIA_MAX_ASSET_BYTES - 17; + let bytes = vec![0xa5; decoded_len]; + let asset = NotionPortableCapturedMediaV1 { + block_id: "block-1".to_string(), + kind: "file".to_string(), + media_type: "application/octet-stream".to_string(), + bytes: bytes.clone(), + }; + + let encoded = serde_json::to_vec(&asset).expect("near-cap media encode"); + let base64_len = decoded_len.div_ceil(3) * 4; + assert!(encoded.len() <= base64_len + 160); + assert!(encoded.len() < decoded_len * 2); + + let decoded: NotionPortableCapturedMediaV1 = + serde_json::from_slice(&encoded).expect("near-cap media decode"); + assert_eq!(decoded.bytes.len(), decoded_len); + assert!(decoded.bytes == bytes); + } + + #[test] + fn portable_media_base64_rejects_noncanonical_input() { + let error = serde_json::from_str::( + r#"{"block_id":"block-1","kind":"file","media_type":"application/octet-stream","bytes":"Zh=="}"#, + ) + .expect_err("non-canonical trailing bits"); + assert!(error.to_string().contains("non-canonical")); + } + + #[test] + fn portable_media_base64_refuses_to_serialize_oversized_bytes() { + let asset = NotionPortableCapturedMediaV1 { + block_id: "block-1".to_string(), + kind: "file".to_string(), + media_type: "application/octet-stream".to_string(), + bytes: vec![0; PORTABLE_MEDIA_MAX_ASSET_BYTES + 1], + }; + + let error = serde_json::to_vec(&asset).expect_err("oversized media encode"); + assert!(error.to_string().contains("decoded asset limit")); + } +} + /// Native database container plus the authoritative schemas for its data sources. /// /// The data sources retain the order declared by the database response after diff --git a/crates/locality-notion/src/lib.rs b/crates/locality-notion/src/lib.rs index e5d258cc..5179b2d6 100644 --- a/crates/locality-notion/src/lib.rs +++ b/crates/locality-notion/src/lib.rs @@ -37,7 +37,10 @@ use locality_core::{LocalityError, LocalityResult}; use crate::apply::{apply_plan, apply_undo, check_concurrency}; use crate::client::{DEFAULT_NOTION_TOKEN_ENV, HttpNotionApi, NotionApi}; use crate::fetch::fetch_page_bundle; -use crate::media::{MediaDownloadReport, download_media_assets}; +use crate::media::{ + MediaDownloadReport, PortableMediaCaptureFetcher, PortableMediaCapturePolicy, + download_media_assets, +}; use crate::projection::{ enumerate_explicit_root_trees, enumerate_shared_pages, list_container_children, observe_entity, resolve_notion_object_path_entries, resolve_page_path_entries, @@ -104,12 +107,18 @@ pub struct NotionConnector { api: Arc, explicit_root_page_ids: Vec, explicit_root_set: bool, + portable_media_capture_policy: PortableMediaCapturePolicy, + portable_media_fetcher: Option>, } impl std::fmt::Debug for NotionConnector { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("NotionConnector") .field("config", &self.config) + .field( + "portable_media_capture_policy", + &self.portable_media_capture_policy, + ) .finish_non_exhaustive() } } @@ -126,6 +135,8 @@ impl NotionConnector { api, explicit_root_page_ids, explicit_root_set: false, + portable_media_capture_policy: PortableMediaCapturePolicy::Disabled, + portable_media_fetcher: None, } } @@ -141,6 +152,8 @@ impl NotionConnector { api: Arc::clone(&self.api), explicit_root_page_ids: vec![root_page_id], explicit_root_set: false, + portable_media_capture_policy: self.portable_media_capture_policy, + portable_media_fetcher: self.portable_media_fetcher.clone(), } } @@ -157,6 +170,8 @@ impl NotionConnector { api: Arc::clone(&self.api), explicit_root_page_ids, explicit_root_set: true, + portable_media_capture_policy: self.portable_media_capture_policy, + portable_media_fetcher: self.portable_media_fetcher.clone(), } } @@ -169,6 +184,31 @@ impl NotionConnector { &self.explicit_root_page_ids } + /// Enable or disable the portable hosted-media capture policy. + /// + /// This does not change direct fetch/render or desktop media materialization. + pub fn with_portable_media_capture(&self, policy: PortableMediaCapturePolicy) -> Self { + let mut connector = self.clone(); + connector.portable_media_capture_policy = policy; + connector + } + + /// Inject a deterministic portable media fetcher while retaining the same + /// connector-side URL validation and pilot byte limits. + pub fn with_portable_media_capture_fetcher( + &self, + policy: PortableMediaCapturePolicy, + fetcher: Arc, + ) -> Self { + let mut connector = self.with_portable_media_capture(policy); + connector.portable_media_fetcher = Some(fetcher); + connector + } + + pub fn portable_media_capture_policy(&self) -> PortableMediaCapturePolicy { + self.portable_media_capture_policy + } + pub fn render_native_entity( &self, entity: &NativeEntity, @@ -242,11 +282,14 @@ impl NotionConnector { impl Connector for NotionConnector { fn with_execution_policy(&self, policy: ConnectorExecutionPolicy) -> Self { let connector = Self::new(self.config.clone().with_execution_policy(policy)); - if self.explicit_root_set { + let mut connector = if self.explicit_root_set { connector.with_root_ids(self.explicit_root_page_ids.clone()) } else { connector - } + }; + connector.portable_media_capture_policy = self.portable_media_capture_policy; + connector.portable_media_fetcher = self.portable_media_fetcher.clone(); + connector } fn kind(&self) -> ConnectorKind { @@ -322,7 +365,12 @@ impl Connector for NotionConnector { } fn fetch_portable(&self, request: PortableFetchRequest) -> LocalityResult { - portable::fetch(self.api.as_ref(), request) + portable::fetch( + self.api.as_ref(), + self.portable_media_capture_policy, + self.portable_media_fetcher.as_deref(), + request, + ) } fn render_portable( diff --git a/crates/locality-notion/src/media.rs b/crates/locality-notion/src/media.rs index 45c2310a..59916bc7 100644 --- a/crates/locality-notion/src/media.rs +++ b/crates/locality-notion/src/media.rs @@ -7,13 +7,17 @@ //! Notion page/database names. use std::collections::BTreeMap; +use std::io::Read; use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, Mutex}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use locality_core::path_projection::page_container_path; use locality_core::{LocalityError, LocalityResult}; +use reqwest::StatusCode; use reqwest::blocking::Client; +use reqwest::header::{ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, LOCATION}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -25,6 +29,382 @@ const MEDIA_MANIFEST: &str = "manifest.json"; const MEDIA_FETCH_ATTEMPTS: usize = 3; const MEDIA_FETCH_RETRY_DELAY: Duration = Duration::from_millis(50); +pub const PORTABLE_MEDIA_MAX_ASSETS: usize = 128; +pub const PORTABLE_MEDIA_MAX_ASSET_BYTES: usize = 20 * 1024 * 1024; +pub const PORTABLE_MEDIA_MAX_AGGREGATE_BYTES: usize = 100 * 1024 * 1024; +const PORTABLE_MEDIA_READ_BUFFER_BYTES: usize = 64 * 1024; +const PORTABLE_MEDIA_MAX_REDIRECTS: usize = 3; +const PORTABLE_MEDIA_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const PORTABLE_MEDIA_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Opt-in hosted-media behavior for portable Notion fetches. +/// +/// The default preserves the existing direct connector behavior. `HostedPilot` +/// captures only Notion-hosted file payloads under the fixed pilot limits above. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PortableMediaCapturePolicy { + #[default] + Disabled, + HostedPilot, +} + +impl PortableMediaCapturePolicy { + pub(crate) fn captures_hosted_media(self) -> bool { + self == Self::HostedPilot + } +} + +/// A bounded hosted-media response returned to portable capture. +/// +/// Custom implementations are intended for deterministic tests. The connector +/// validates the initial provider URL before invoking the fetcher and enforces +/// the same byte limits on every returned body. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PortableMediaCapture { + pub bytes: Vec, + pub media_type: String, +} + +pub trait PortableMediaCaptureFetcher: Send + Sync { + fn fetch(&self, hosted_url: &str, max_bytes: usize) -> LocalityResult; +} + +#[derive(Default)] +struct SecurePortableMediaCaptureFetcher { + transport: Mutex>, +} + +impl PortableMediaCaptureFetcher for SecurePortableMediaCaptureFetcher { + fn fetch(&self, hosted_url: &str, max_bytes: usize) -> LocalityResult { + let mut transport = self.transport.lock().map_err(|_| { + LocalityError::InvalidState("portable media HTTP client lock is poisoned".to_string()) + })?; + if transport.is_none() { + *transport = Some(ReqwestPortableMediaTransport::new()?); + } + fetch_portable_media_with_transport( + transport + .as_ref() + .expect("portable media HTTP client was initialized above"), + hosted_url, + max_bytes, + ) + } +} + +pub(crate) fn default_portable_media_fetcher() -> Arc { + Arc::new(SecurePortableMediaCaptureFetcher::default()) +} + +struct PortableMediaHttpResponse { + status: StatusCode, + location: Option, + content_encoding: Option, + content_length: Option, + content_type: Option, + body: Box, +} + +trait PortableMediaHttpTransport { + fn get(&self, url: &str, timeout: Duration) -> LocalityResult; +} + +struct ReqwestPortableMediaTransport { + client: Client, +} + +impl ReqwestPortableMediaTransport { + fn new() -> LocalityResult { + // Install the crate's selected rustls provider before building a + // separately hardened client. + drop(notion_http_client()); + let client = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(PORTABLE_MEDIA_CONNECT_TIMEOUT) + .timeout(PORTABLE_MEDIA_REQUEST_TIMEOUT) + .build() + .map_err(|_| { + LocalityError::Io("portable media HTTP client initialization failed".to_string()) + })?; + Ok(Self { client }) + } +} + +impl PortableMediaHttpTransport for ReqwestPortableMediaTransport { + fn get(&self, url: &str, timeout: Duration) -> LocalityResult { + let response = self + .client + .get(url) + .header(ACCEPT_ENCODING, "identity") + .timeout(timeout) + .send() + .map_err(|_| LocalityError::Io("portable media request failed".to_string()))?; + let headers = response.headers(); + let location = headers + .get(LOCATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let content_encoding = headers + .get(CONTENT_ENCODING) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let content_length = headers + .get(CONTENT_LENGTH) + .map(|value| { + value + .to_str() + .ok() + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| { + LocalityError::InvalidState( + "portable media content length is invalid".to_string(), + ) + }) + }) + .transpose()?; + let content_type = headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let status = response.status(); + Ok(PortableMediaHttpResponse { + status, + location, + content_encoding, + content_length, + content_type, + body: Box::new(response), + }) + } +} + +fn fetch_portable_media_with_transport( + transport: &dyn PortableMediaHttpTransport, + hosted_url: &str, + max_bytes: usize, +) -> LocalityResult { + let mut current = validate_portable_hosted_media_url(hosted_url)?; + let mut visited = std::collections::BTreeSet::new(); + let started = Instant::now(); + + for redirects in 0..=PORTABLE_MEDIA_MAX_REDIRECTS { + if !visited.insert(current.as_str().to_string()) { + return Err(LocalityError::InvalidState( + "portable media redirect loop rejected".to_string(), + )); + } + let timeout = PORTABLE_MEDIA_REQUEST_TIMEOUT + .checked_sub(started.elapsed()) + .ok_or_else(|| LocalityError::Io("portable media request timed out".to_string()))?; + let mut response = transport.get(current.as_str(), timeout)?; + + if response.status.is_redirection() { + if redirects == PORTABLE_MEDIA_MAX_REDIRECTS { + return Err(LocalityError::InvalidState( + "portable media redirect limit exceeded".to_string(), + )); + } + let location = response.location.as_deref().ok_or_else(|| { + LocalityError::InvalidState( + "portable media redirect omitted its destination".to_string(), + ) + })?; + let destination = current.join(location).map_err(|_| { + LocalityError::InvalidState( + "portable media redirect destination is invalid".to_string(), + ) + })?; + current = validate_portable_hosted_media_url(destination.as_str())?; + continue; + } + if !response.status.is_success() { + return Err(LocalityError::Io(format!( + "portable media request returned HTTP {}", + response.status.as_u16() + ))); + } + if let Some(encoding) = response.content_encoding.as_deref() + && !encoding.eq_ignore_ascii_case("identity") + { + return Err(LocalityError::InvalidState( + "portable media content encoding is unsupported".to_string(), + )); + } + if response + .content_length + .is_some_and(|length| length > max_bytes as u64) + { + return Err(LocalityError::InvalidState( + "portable media content length exceeds the asset limit".to_string(), + )); + } + + let mut bytes = Vec::with_capacity( + response + .content_length + .and_then(|length| usize::try_from(length).ok()) + .unwrap_or(0) + .min(max_bytes), + ); + let mut buffer = [0_u8; PORTABLE_MEDIA_READ_BUFFER_BYTES]; + loop { + let read = response.body.read(&mut buffer).map_err(|_| { + LocalityError::Io("portable media response body failed".to_string()) + })?; + if read == 0 { + break; + } + if bytes.len().saturating_add(read) > max_bytes { + return Err(LocalityError::InvalidState( + "portable media response exceeded the asset limit".to_string(), + )); + } + bytes.extend_from_slice(&buffer[..read]); + } + if response + .content_length + .is_some_and(|length| length != bytes.len() as u64) + { + return Err(LocalityError::InvalidState( + "portable media content length did not match the response body".to_string(), + )); + } + return Ok(PortableMediaCapture { + bytes, + media_type: sanitize_portable_media_type(response.content_type.as_deref()), + }); + } + + unreachable!("redirect loop always returns or continues within its fixed bound") +} + +pub(crate) fn validate_portable_hosted_media_url(url: &str) -> LocalityResult { + let parsed = reqwest::Url::parse(url) + .map_err(|_| LocalityError::InvalidState("portable media URL is invalid".to_string()))?; + if parsed.scheme() != "https" + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.port().is_some_and(|port| port != 443) + { + return Err(LocalityError::InvalidState( + "portable media URL violates the HTTPS origin policy".to_string(), + )); + } + let host = parsed.host_str().ok_or_else(|| { + LocalityError::InvalidState("portable media URL has no allowed host".to_string()) + })?; + let allowed = matches!( + host, + "secure.notion-static.com" | "prod-files-secure.s3.us-west-2.amazonaws.com" + ) || (host == "s3.us-west-2.amazonaws.com" + && parsed.path().starts_with("/secure.notion-static.com/")); + if !allowed { + return Err(LocalityError::InvalidState( + "portable media URL host is not allowed".to_string(), + )); + } + Ok(parsed) +} + +pub(crate) fn sanitize_portable_hosted_media_url(url: &str) -> LocalityResult { + let mut parsed = validate_portable_hosted_media_url(url)?; + parsed.set_query(None); + parsed.set_fragment(None); + Ok(parsed.to_string()) +} + +pub(crate) fn portable_media_expired(expiry_time: &str) -> LocalityResult { + let expiry = parse_rfc3339_utc_seconds(expiry_time).ok_or_else(|| { + LocalityError::InvalidState("portable media expiry is invalid".to_string()) + })?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| LocalityError::InvalidState("system clock is before Unix time".to_string()))? + .as_secs(); + Ok(expiry <= now) +} + +fn parse_rfc3339_utc_seconds(value: &str) -> Option { + let bytes = value.as_bytes(); + if bytes.len() < 20 + || bytes[4] != b'-' + || bytes[7] != b'-' + || bytes[10] != b'T' + || bytes[13] != b':' + || bytes[16] != b':' + || *bytes.last()? != b'Z' + { + return None; + } + if bytes.len() > 20 { + let fraction = &bytes[20..bytes.len() - 1]; + if bytes[19] != b'.' || fraction.is_empty() || !fraction.iter().all(u8::is_ascii_digit) { + return None; + } + } + let number = |start: usize, end: usize| -> Option { + std::str::from_utf8(&bytes[start..end]).ok()?.parse().ok() + }; + let year = number(0, 4)?; + let month = number(5, 7)?; + let day = number(8, 10)?; + let hour = number(11, 13)?; + let minute = number(14, 16)?; + let second = number(17, 19)?; + if !(1..=12).contains(&month) + || day < 1 + || day > days_in_month(year, month) + || !(0..=23).contains(&hour) + || !(0..=59).contains(&minute) + || !(0..=59).contains(&second) + { + return None; + } + let days = days_from_civil(year, month, day); + if days < 0 { + return None; + } + u64::try_from(days * 86_400 + hour * 3_600 + minute * 60 + second).ok() +} + +fn days_in_month(year: i64, month: i64) -> i64 { + match month { + 2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + } +} + +fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { + let year = year - i64::from(month <= 2); + let era = year.div_euclid(400); + let year_of_era = year - era * 400; + let adjusted_month = month + if month > 2 { -3 } else { 9 }; + let day_of_year = (153 * adjusted_month + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era - 719_468 +} + +pub(crate) fn sanitize_portable_media_type(value: Option<&str>) -> String { + let essence = value + .and_then(|value| value.split(';').next()) + .map(str::trim) + .filter(|value| { + let mut parts = value.split('/'); + let valid_part = |part: &str| { + !part.is_empty() + && part.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-') + }) + }; + matches!((parts.next(), parts.next(), parts.next()), (Some(kind), Some(subtype), None) if valid_part(kind) && valid_part(subtype)) + }); + essence + .unwrap_or("application/octet-stream") + .to_ascii_lowercase() +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct MediaAsset { pub block_id: String, @@ -631,11 +1011,18 @@ mod tests { #[cfg(windows)] use super::resolve_media_href_with_content_root; use super::{ - MediaAsset, local_media_href, media_local_path, replace_media_manifest, resolve_media_href, + MediaAsset, PORTABLE_MEDIA_READ_BUFFER_BYTES, PortableMediaHttpResponse, + PortableMediaHttpTransport, fetch_portable_media_with_transport, local_media_href, + media_local_path, portable_media_expired, replace_media_manifest, resolve_media_href, + sanitize_portable_hosted_media_url, validate_portable_hosted_media_url, }; + use reqwest::StatusCode; + use std::collections::VecDeque; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::Path; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; use std::thread; #[test] @@ -785,6 +1172,337 @@ mod tests { assert!(!super::should_download(&relative)); } + #[test] + fn portable_hosted_media_url_policy_is_exact_and_sanitizes_signatures() { + for url in [ + "https://secure.notion-static.com/image.png", + "https://prod-files-secure.s3.us-west-2.amazonaws.com/image.png", + "https://s3.us-west-2.amazonaws.com/secure.notion-static.com/image.png", + "https://secure.notion-static.com:443/image.png", + ] { + validate_portable_hosted_media_url(url) + .unwrap_or_else(|error| panic!("allowed URL {url}: {error}")); + } + for url in [ + "http://secure.notion-static.com/image.png", + "https://127.0.0.1/image.png", + "https://[::1]/image.png", + "https://user@secure.notion-static.com/image.png", + "https://user:pass@secure.notion-static.com/image.png", + "https://secure.notion-static.com:444/image.png", + "https://notion-static.com/image.png", + "https://sub.secure.notion-static.com/image.png", + "https://s3.amazonaws.com/secure.notion-static.com/image.png", + "https://s3.us-west-2.amazonaws.com/other/image.png", + ] { + assert!( + validate_portable_hosted_media_url(url).is_err(), + "denied URL {url}" + ); + } + assert_eq!( + sanitize_portable_hosted_media_url(concat!( + "https://secure.notion-static.com/image.png?", + "X-Amz-Signature=secret&token=secret#fragment" + )) + .expect("sanitize"), + "https://secure.notion-static.com/image.png" + ); + } + + #[test] + fn portable_media_follows_only_three_revalidated_redirects() { + let transport = ScriptedPortableMediaTransport::new([ + scripted_response( + StatusCode::FOUND, + Some("https://prod-files-secure.s3.us-west-2.amazonaws.com/second.png"), + None, + None, + None, + Vec::new(), + ), + scripted_response( + StatusCode::OK, + None, + None, + Some(4), + Some("image/png"), + b"data".to_vec(), + ), + ]); + let captured = fetch_portable_media_with_transport( + &transport, + "https://secure.notion-static.com/first.png?X-Amz-Signature=secret", + 1024, + ) + .expect("allowed redirect"); + assert_eq!(captured.bytes, b"data"); + assert_eq!(captured.media_type, "image/png"); + assert_eq!(transport.requests().len(), 2); + + let disallowed = ScriptedPortableMediaTransport::new([scripted_response( + StatusCode::FOUND, + Some("https://example.com/stolen.png?token=secret"), + None, + None, + None, + Vec::new(), + )]); + let error = fetch_portable_media_with_transport( + &disallowed, + "https://secure.notion-static.com/first.png", + 1024, + ) + .expect_err("disallowed redirect"); + assert_eq!( + error.to_string(), + "invalid state: portable media URL host is not allowed" + ); + assert!(!error.to_string().contains("token=secret")); + + let redirect_loop = ScriptedPortableMediaTransport::new([scripted_response( + StatusCode::FOUND, + Some("/first.png"), + None, + None, + None, + Vec::new(), + )]); + assert_eq!( + fetch_portable_media_with_transport( + &redirect_loop, + "https://secure.notion-static.com/first.png", + 1024, + ) + .expect_err("redirect loop") + .to_string(), + "invalid state: portable media redirect loop rejected" + ); + + let too_many = ScriptedPortableMediaTransport::new((0..=3).map(|index| { + scripted_response( + StatusCode::FOUND, + Some(&format!( + "https://secure.notion-static.com/redirect-{}.png", + index + 1 + )), + None, + None, + None, + Vec::new(), + ) + })); + assert_eq!( + fetch_portable_media_with_transport( + &too_many, + "https://secure.notion-static.com/start.png", + 1024, + ) + .expect_err("redirect bound") + .to_string(), + "invalid state: portable media redirect limit exceeded" + ); + } + + #[test] + fn portable_media_rejects_encoding_length_and_stream_overflow() { + let encoded = ScriptedPortableMediaTransport::new([scripted_response( + StatusCode::OK, + None, + Some("gzip"), + Some(4), + Some("image/png"), + b"data".to_vec(), + )]); + assert_eq!( + portable_transport_error(&encoded, 4), + "invalid state: portable media content encoding is unsupported" + ); + + let declared_oversize = ScriptedPortableMediaTransport::new([scripted_response( + StatusCode::OK, + None, + None, + Some(5), + Some("image/png"), + b"data".to_vec(), + )]); + assert_eq!( + portable_transport_error(&declared_oversize, 4), + "invalid state: portable media content length exceeds the asset limit" + ); + + let mismatched = ScriptedPortableMediaTransport::new([scripted_response( + StatusCode::OK, + None, + Some("identity"), + Some(3), + Some("image/png"), + b"data".to_vec(), + )]); + assert_eq!( + portable_transport_error(&mismatched, 4), + "invalid state: portable media content length did not match the response body" + ); + + let streamed_oversize = ScriptedPortableMediaTransport::new([scripted_response( + StatusCode::OK, + None, + None, + None, + Some("image/png"), + b"12345".to_vec(), + )]); + assert_eq!( + portable_transport_error(&streamed_oversize, 4), + "invalid state: portable media response exceeded the asset limit" + ); + } + + #[test] + fn portable_media_stream_reads_are_bounded_to_64_kib() { + let body = vec![7_u8; PORTABLE_MEDIA_READ_BUFFER_BYTES * 2 + 1]; + let transport = ScriptedPortableMediaTransport::new([scripted_response( + StatusCode::OK, + None, + None, + Some(body.len() as u64), + Some("APPLICATION/OCTET-STREAM; charset=binary"), + body.clone(), + )]); + let capture = fetch_portable_media_with_transport( + &transport, + "https://secure.notion-static.com/data.bin", + body.len(), + ) + .expect("bounded stream"); + assert_eq!(capture.bytes, body); + assert_eq!(capture.media_type, "application/octet-stream"); + assert!(transport.max_read_size() <= 64 * 1024); + } + + #[test] + fn portable_media_expiry_is_strict_and_utc() { + assert!(portable_media_expired("2000-01-01T00:00:00.000Z").expect("past")); + assert!(!portable_media_expired("2099-01-01T00:00:00Z").expect("future")); + assert!(portable_media_expired("2099-13-01T00:00:00Z").is_err()); + assert!(portable_media_expired("2099-01-01T00:00:00.Z").is_err()); + assert!(portable_media_expired("2099-01-01T00:00:00+01:00").is_err()); + } + + struct ScriptedPortableMediaTransport { + responses: Mutex>, + requests: Mutex>, + max_read_size: Arc, + } + + impl ScriptedPortableMediaTransport { + fn new(responses: impl IntoIterator) -> Self { + let max_read_size = Arc::new(AtomicUsize::new(0)); + let responses = responses + .into_iter() + .map(|response| PortableMediaHttpResponse { + status: response.status, + location: response.location, + content_encoding: response.content_encoding, + content_length: response.content_length, + content_type: response.content_type, + body: Box::new(TrackingReader { + bytes: std::io::Cursor::new(response.body), + max_read_size: Arc::clone(&max_read_size), + }), + }) + .collect(); + Self { + responses: Mutex::new(responses), + requests: Mutex::new(Vec::new()), + max_read_size, + } + } + + fn requests(&self) -> Vec { + self.requests.lock().expect("requests").clone() + } + + fn max_read_size(&self) -> usize { + self.max_read_size.load(Ordering::SeqCst) + } + } + + impl PortableMediaHttpTransport for ScriptedPortableMediaTransport { + fn get( + &self, + url: &str, + _timeout: std::time::Duration, + ) -> locality_core::LocalityResult { + self.requests + .lock() + .expect("requests") + .push(url.to_string()); + self.responses + .lock() + .expect("responses") + .pop_front() + .ok_or_else(|| { + locality_core::LocalityError::InvalidState( + "scripted transport exhausted".to_string(), + ) + }) + } + } + + struct ScriptedResponse { + status: StatusCode, + location: Option, + content_encoding: Option, + content_length: Option, + content_type: Option, + body: Vec, + } + + fn scripted_response( + status: StatusCode, + location: Option<&str>, + content_encoding: Option<&str>, + content_length: Option, + content_type: Option<&str>, + body: Vec, + ) -> ScriptedResponse { + ScriptedResponse { + status, + location: location.map(str::to_string), + content_encoding: content_encoding.map(str::to_string), + content_length, + content_type: content_type.map(str::to_string), + body, + } + } + + struct TrackingReader { + bytes: std::io::Cursor>, + max_read_size: Arc, + } + + impl Read for TrackingReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + self.max_read_size.fetch_max(buffer.len(), Ordering::SeqCst); + self.bytes.read(buffer) + } + } + + fn portable_transport_error( + transport: &ScriptedPortableMediaTransport, + max_bytes: usize, + ) -> String { + fetch_portable_media_with_transport( + transport, + "https://secure.notion-static.com/image.png", + max_bytes, + ) + .expect_err("transport must reject") + .to_string() + } + #[test] fn media_fetch_retries_transient_connection_failures() { let bytes = b"retry-media-bytes".to_vec(); diff --git a/crates/locality-notion/src/portable.rs b/crates/locality-notion/src/portable.rs index f5bad4fb..c3b79d10 100644 --- a/crates/locality-notion/src/portable.rs +++ b/crates/locality-notion/src/portable.rs @@ -4,7 +4,8 @@ //! search result is not an exhaustive Notion inventory, so portable coverage is //! available only for configured page or full-page database roots. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; use locality_connector::{ NativeEntity, PORTABLE_SCOPE_ROOT_RELATIONSHIP, PortableArtifactKey, PortableBootstrapRequest, @@ -26,16 +27,26 @@ use crate::client::NotionApi; use crate::database::{ database_bundle_provider_version, fetch_database_bundle, render_database_bundle_schema, }; -use crate::dto::{BlockTreeDto, NotionDatabaseBundle, NotionPageBundle}; +use crate::dto::{ + BlockDto, BlockTreeDto, FileBlockDto, NotionDatabaseBundle, NotionPageBundle, + NotionPortableCapturedMediaV1, NotionPortableIncompleteMediaV1, NotionPortablePageBundleV1, +}; use crate::fetch::fetch_known_page_bundle; +use crate::media::{ + PORTABLE_MEDIA_MAX_AGGREGATE_BYTES, PORTABLE_MEDIA_MAX_ASSET_BYTES, PORTABLE_MEDIA_MAX_ASSETS, + PortableMediaCaptureFetcher, PortableMediaCapturePolicy, default_portable_media_fetcher, + portable_media_expired, sanitize_portable_hosted_media_url, sanitize_portable_media_type, +}; use crate::projection::enumerate_explicit_root_trees; -use crate::render::render_native_entity; +use crate::render::{RenderOptions, render_native_entity, render_native_entity_with_options}; const LEGACY_CHECKPOINT_FORMAT_VERSION: u16 = 1; const CHECKPOINT_FORMAT_VERSION: u16 = 2; const CHECKPOINT_COMPONENT_VERSION: u16 = 2; const MAX_EXPLICIT_ROOTS: usize = 16; const PORTABLE_FORMAT_VERSION: u32 = 1; +const PORTABLE_MEDIA_NATIVE_FORMAT_VERSION: u16 = 1; +const PORTABLE_MEDIA_NATIVE_KIND: &str = "notion_page_portable_media_v1"; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -177,12 +188,18 @@ pub(crate) fn synchronize( pub(crate) fn fetch( api: &dyn NotionApi, + media_policy: PortableMediaCapturePolicy, + media_fetcher: Option<&dyn PortableMediaCaptureFetcher>, request: PortableFetchRequest, ) -> LocalityResult { match api.retrieve_page(request.remote_id.as_str()) { Ok(page) => { let bundle = fetch_known_page_bundle(api, request.remote_id.as_str(), page)?; - fetch_page_result(bundle, &request.remote_id) + if media_policy.captures_hosted_media() { + fetch_portable_media_page_result(bundle, &request.remote_id, media_fetcher) + } else { + fetch_page_result(bundle, &request.remote_id) + } } Err(LocalityError::RemoteNotFound(_)) => { let bundle = fetch_database_bundle(api, request.remote_id.as_str())?; @@ -206,6 +223,257 @@ pub(crate) fn fetch( } } +fn fetch_portable_media_page_result( + mut bundle: NotionPageBundle, + requested_remote_id: &RemoteId, + media_fetcher: Option<&dyn PortableMediaCaptureFetcher>, +) -> LocalityResult { + let provider_version = bundle.page.last_edited_time.clone(); + let remote_id = RemoteId::new(bundle.page.id.clone()); + if !notion_ids_equal(remote_id.as_str(), requested_remote_id.as_str()) { + return Err(LocalityError::InvalidState( + "Notion portable fetch returned a different remote object".to_string(), + )); + } + + let default_fetcher = default_portable_media_fetcher(); + let fetcher = media_fetcher.unwrap_or(default_fetcher.as_ref()); + let mut capture = PortableMediaCaptureState::new(fetcher); + for tree in &mut bundle.blocks { + capture.capture_tree(tree)?; + } + capture.sanitize_page_file_properties(&mut bundle)?; + + let completeness = portable_media_completeness(&capture.incomplete); + let portable_bundle = NotionPortablePageBundleV1 { + format_version: PORTABLE_MEDIA_NATIVE_FORMAT_VERSION, + page: bundle, + captured_media: capture.captured, + incomplete_media: capture.incomplete, + }; + let raw = serde_json::to_vec(&portable_bundle) + .map_err(|_| LocalityError::Io("Notion portable media native encode failed".to_string()))?; + + Ok(PortableFetchResult { + native: NativeEntity { + remote_id, + kind: PORTABLE_MEDIA_NATIVE_KIND.to_string(), + raw, + }, + provider_version, + completeness, + }) +} + +struct PortableMediaCaptureState<'a> { + fetcher: &'a dyn PortableMediaCaptureFetcher, + captured: Vec, + incomplete: Vec, + seen_block_ids: BTreeSet, + asset_count: usize, + aggregate_bytes: usize, +} + +impl<'a> PortableMediaCaptureState<'a> { + fn new(fetcher: &'a dyn PortableMediaCaptureFetcher) -> Self { + Self { + fetcher, + captured: Vec::new(), + incomplete: Vec::new(), + seen_block_ids: BTreeSet::new(), + asset_count: 0, + aggregate_bytes: 0, + } + } + + fn capture_tree(&mut self, tree: &mut BlockTreeDto) -> LocalityResult<()> { + let block_id = tree.block.id.clone(); + let kind = tree.block.kind.clone(); + if is_media_kind(&kind) { + if !self.seen_block_ids.insert(block_id.clone()) { + return Err(LocalityError::InvalidState( + "Notion portable media contains a duplicate block identity".to_string(), + )); + } + self.asset_count += 1; + if self.asset_count > PORTABLE_MEDIA_MAX_ASSETS { + return Err(LocalityError::InvalidState(format!( + "Notion portable media exceeds the {PORTABLE_MEDIA_MAX_ASSETS}-asset pilot limit" + ))); + } + let payload = ensure_media_payload_mut(&mut tree.block).ok_or_else(|| { + LocalityError::InvalidState( + "Notion portable media kind cannot select its payload".to_string(), + ) + })?; + self.capture_payload(&block_id, &kind, payload)?; + } + for child in &mut tree.children { + self.capture_tree(child)?; + } + Ok(()) + } + + fn capture_payload( + &mut self, + block_id: &str, + kind: &str, + payload: &mut FileBlockDto, + ) -> LocalityResult<()> { + let external_present = payload.external.is_some(); + if let Some(external) = payload.external.as_mut() { + external.url.clear(); + } + let Some(hosted) = payload.file.as_mut() else { + self.record_incomplete( + block_id, + kind, + if external_present { + "external_media" + } else { + "missing_file" + }, + ); + return Ok(()); + }; + let original_url = hosted.url.clone(); + let expiry_time = hosted.expiry_time.take(); + hosted.url.clear(); + if external_present { + self.record_incomplete(block_id, kind, "ambiguous_file_source"); + return Ok(()); + } + if original_url.is_empty() { + self.record_incomplete(block_id, kind, "missing_url"); + return Ok(()); + } + let sanitized_url = match sanitize_portable_hosted_media_url(&original_url) { + Ok(url) => url, + Err(_) => { + self.record_incomplete(block_id, kind, "url_denied"); + return Ok(()); + } + }; + if let Some(expiry_time) = expiry_time.as_deref() { + match portable_media_expired(expiry_time) { + Ok(false) => {} + Ok(true) => { + self.record_incomplete(block_id, kind, "expired"); + return Ok(()); + } + Err(_) => { + self.record_incomplete(block_id, kind, "invalid_expiry"); + return Ok(()); + } + } + } + let captured = match self + .fetcher + .fetch(&original_url, PORTABLE_MEDIA_MAX_ASSET_BYTES) + { + Ok(captured) => captured, + Err(_) => { + self.record_incomplete(block_id, kind, "fetch_failed"); + return Ok(()); + } + }; + if captured.bytes.len() > PORTABLE_MEDIA_MAX_ASSET_BYTES { + self.record_incomplete(block_id, kind, "asset_too_large"); + return Ok(()); + } + let Some(aggregate_bytes) = + checked_portable_media_aggregate(self.aggregate_bytes, captured.bytes.len()) + else { + self.record_incomplete(block_id, kind, "aggregate_too_large"); + return Ok(()); + }; + self.aggregate_bytes = aggregate_bytes; + hosted.url = sanitized_url; + self.captured.push(NotionPortableCapturedMediaV1 { + block_id: block_id.to_string(), + kind: kind.to_string(), + media_type: sanitize_portable_media_type(Some(&captured.media_type)), + bytes: captured.bytes, + }); + Ok(()) + } + + fn sanitize_page_file_properties( + &mut self, + bundle: &mut NotionPageBundle, + ) -> LocalityResult<()> { + let mut property_asset_index = 0_usize; + for property in bundle.page.properties.values_mut() { + for file in &mut property.files { + property_asset_index += 1; + self.asset_count += 1; + if self.asset_count > PORTABLE_MEDIA_MAX_ASSETS { + return Err(LocalityError::InvalidState(format!( + "Notion portable media exceeds the {PORTABLE_MEDIA_MAX_ASSETS}-asset pilot limit" + ))); + } + if let Some(external) = file.external.as_mut() { + external.url.clear(); + } + if let Some(hosted) = file.file.as_mut() { + hosted.url.clear(); + hosted.expiry_time = None; + } + self.record_incomplete( + &format!("page-property-file-{property_asset_index}"), + "file_property", + "unsupported_page_property_media", + ); + } + } + Ok(()) + } + + fn record_incomplete(&mut self, block_id: &str, kind: &str, code: &str) { + self.incomplete.push(NotionPortableIncompleteMediaV1 { + block_id: block_id.to_string(), + kind: kind.to_string(), + code: code.to_string(), + }); + } +} + +fn portable_media_completeness( + incomplete: &[NotionPortableIncompleteMediaV1], +) -> PortableCompleteness { + let mut completeness = PortableCompleteness::complete(); + for media in incomplete { + completeness.merge(PortableCompleteness::incomplete( + PortableIncompleteReason::ConnectorLimitation { + code: format!("notion_media_{}", media.code), + remote_id: Some(RemoteId::new(media.block_id.clone())), + }, + )); + } + completeness +} + +fn ensure_media_payload_mut(block: &mut BlockDto) -> Option<&mut FileBlockDto> { + match block.kind.as_str() { + "image" => Some(block.image.get_or_insert_with(FileBlockDto::default)), + "video" => Some(block.video.get_or_insert_with(FileBlockDto::default)), + "file" => Some(block.file.get_or_insert_with(FileBlockDto::default)), + "pdf" => Some(block.pdf.get_or_insert_with(FileBlockDto::default)), + "audio" => Some(block.audio.get_or_insert_with(FileBlockDto::default)), + _ => None, + } +} + +fn is_media_kind(kind: &str) -> bool { + matches!(kind, "image" | "video" | "file" | "pdf" | "audio") +} + +fn checked_portable_media_aggregate(current: usize, next: usize) -> Option { + current + .checked_add(next) + .filter(|total| *total <= PORTABLE_MEDIA_MAX_AGGREGATE_BYTES) +} + fn fetch_page_result( bundle: NotionPageBundle, requested_remote_id: &RemoteId, @@ -239,6 +507,7 @@ pub(crate) fn render(request: &PortableRenderRequest) -> LocalityResult render_page(request), + PORTABLE_MEDIA_NATIVE_KIND => render_portable_media_page(request), "notion_database" => render_database(request), kind => Err(LocalityError::InvalidState(format!( "Notion portable render received unsupported native kind `{kind}`" @@ -246,6 +515,303 @@ pub(crate) fn render(request: &PortableRenderRequest) -> LocalityResult LocalityResult { + let bundle = serde_json::from_slice::(&request.native.raw) + .map_err(|_| LocalityError::Io("Notion portable media native decode failed".to_string()))?; + if bundle.format_version != PORTABLE_MEDIA_NATIVE_FORMAT_VERSION { + return Err(LocalityError::InvalidState( + "Notion portable media native format version is unsupported".to_string(), + )); + } + if !notion_ids_equal(&bundle.page.page.id, request.native.remote_id.as_str()) { + return Err(LocalityError::InvalidState( + "Notion portable media page native payload does not match its remote ID".to_string(), + )); + } + validate_portable_media_bundle(&bundle)?; + + let captured_block_ids = bundle + .captured_media + .iter() + .map(|media| media.block_id.clone()) + .collect::>(); + let page_native = NativeEntity { + remote_id: request.native.remote_id.clone(), + kind: "notion_page".to_string(), + raw: serde_json::to_vec(&bundle.page).map_err(|_| { + LocalityError::Io("Notion sanitized page native encode failed".to_string()) + })?, + }; + let rendered = render_native_entity_with_options( + &page_native, + &RenderOptions::with_page_path(Path::new(request.logical_path.as_str())) + .with_local_media_block_ids(captured_block_ids), + )?; + let canonical_bytes = render_canonical_markdown(&rendered.document).into_bytes(); + let canonical = PortableContentArtifact { + artifact_key: artifact_key( + &request.native.remote_id, + "canonical_markdown", + request.format_version, + ), + media_type: "text/markdown; charset=utf-8".to_string(), + body: canonical_bytes.clone(), + }; + let mut projections = vec![PortableProjectionArtifact { + artifact: PortableContentArtifact { + artifact_key: artifact_key( + &request.native.remote_id, + "page_markdown", + request.format_version, + ), + media_type: "text/markdown; charset=utf-8".to_string(), + body: canonical_bytes, + }, + logical_path: request.logical_path.clone(), + file_kind: ProjectionFileKind::Markdown, + format_version: request.format_version, + supported_actions: [SourceAction::Read, SourceAction::Search] + .into_iter() + .collect(), + }]; + + let mut captured_by_block = bundle + .captured_media + .into_iter() + .map(|media| (media.block_id.clone(), media)) + .collect::>(); + let mut projected_paths = BTreeSet::new(); + for rendered_asset in rendered.media_assets { + let Some(captured) = captured_by_block.remove(&rendered_asset.block_id) else { + return Err(LocalityError::InvalidState( + "Notion portable media render produced an uncaptured asset".to_string(), + )); + }; + if captured.kind != rendered_asset.kind { + return Err(LocalityError::InvalidState( + "Notion portable media kind does not match its block".to_string(), + )); + } + let logical_path = rendered_asset + .local_path + .to_str() + .ok_or_else(|| { + LocalityError::InvalidState( + "Notion portable media path is not valid UTF-8".to_string(), + ) + })? + .replace('\\', "/"); + if !projected_paths.insert(logical_path.clone()) { + return Err(LocalityError::InvalidState( + "Notion portable media paths collide".to_string(), + )); + } + projections.push(PortableProjectionArtifact { + artifact: PortableContentArtifact { + artifact_key: media_artifact_key( + &request.native.remote_id, + &captured.block_id, + request.format_version, + ), + media_type: captured.media_type, + body: captured.bytes, + }, + logical_path: LogicalPath::new(logical_path).map_err(|_| { + LocalityError::InvalidState( + "Notion portable media path is not a valid logical path".to_string(), + ) + })?, + file_kind: ProjectionFileKind::Binary, + format_version: request.format_version, + supported_actions: [SourceAction::Read, SourceAction::DownloadAttachment] + .into_iter() + .collect(), + }); + } + if !captured_by_block.is_empty() { + return Err(LocalityError::InvalidState( + "Notion portable media native payload contains an unrendered asset".to_string(), + )); + } + + Ok(PortableRenderResult { + canonical, + projections, + completeness: portable_media_completeness(&bundle.incomplete_media), + }) +} + +fn validate_portable_media_bundle(bundle: &NotionPortablePageBundleV1) -> LocalityResult<()> { + let captured = bundle + .captured_media + .iter() + .map(|media| media.block_id.clone()) + .collect::>(); + if captured.len() != bundle.captured_media.len() { + return Err(LocalityError::InvalidState( + "Notion portable media native payload has duplicate captures".to_string(), + )); + } + let incomplete = bundle + .incomplete_media + .iter() + .map(|media| media.block_id.clone()) + .collect::>(); + if incomplete.len() != bundle.incomplete_media.len() || !captured.is_disjoint(&incomplete) { + return Err(LocalityError::InvalidState( + "Notion portable media native payload has ambiguous outcomes".to_string(), + )); + } + if bundle.captured_media.len() + bundle.incomplete_media.len() > PORTABLE_MEDIA_MAX_ASSETS { + return Err(LocalityError::InvalidState( + "Notion portable media native payload exceeds the asset limit".to_string(), + )); + } + let aggregate_bytes = bundle + .captured_media + .iter() + .try_fold(0_usize, |total, media| { + if media.bytes.len() > PORTABLE_MEDIA_MAX_ASSET_BYTES { + return None; + } + total.checked_add(media.bytes.len()) + }) + .filter(|total| *total <= PORTABLE_MEDIA_MAX_AGGREGATE_BYTES) + .ok_or_else(|| { + LocalityError::InvalidState( + "Notion portable media native payload exceeds its byte limits".to_string(), + ) + })?; + let _ = aggregate_bytes; + for media in &bundle.captured_media { + if sanitize_portable_media_type(Some(&media.media_type)) != media.media_type { + return Err(LocalityError::InvalidState( + "Notion portable media type is not canonical".to_string(), + )); + } + } + + let mut media_blocks = BTreeMap::new(); + collect_media_blocks(&bundle.page.blocks, &mut media_blocks)?; + for (block_id, (kind, payload)) in &media_blocks { + let is_captured = captured.contains(block_id); + let is_incomplete = incomplete.contains(block_id); + if !is_captured && !is_incomplete { + return Err(LocalityError::InvalidState( + "Notion portable media block has no capture outcome".to_string(), + )); + } + if payload + .external + .as_ref() + .is_some_and(|file| !file.url.is_empty()) + || payload + .file + .as_ref() + .is_some_and(|file| file.expiry_time.is_some()) + { + return Err(LocalityError::InvalidState( + "Notion portable media native payload is not sanitized".to_string(), + )); + } + let hosted_url = payload + .file + .as_ref() + .map(|file| file.url.as_str()) + .unwrap_or(""); + if is_captured { + let sanitized = sanitize_portable_hosted_media_url(hosted_url).map_err(|_| { + LocalityError::InvalidState( + "Notion portable captured media URL is not allowed".to_string(), + ) + })?; + if sanitized != hosted_url { + return Err(LocalityError::InvalidState( + "Notion portable captured media URL is not sanitized".to_string(), + )); + } + let captured_kind = bundle + .captured_media + .iter() + .find(|media| media.block_id == *block_id) + .map(|media| media.kind.as_str()); + if captured_kind != Some(kind.as_str()) { + return Err(LocalityError::InvalidState( + "Notion portable captured media kind does not match its block".to_string(), + )); + } + } else if !hosted_url.is_empty() { + return Err(LocalityError::InvalidState( + "Notion portable incomplete media retained a remote URL".to_string(), + )); + } + } + for media in &bundle.captured_media { + if !media_blocks.contains_key(&media.block_id) { + return Err(LocalityError::InvalidState( + "Notion portable media capture has no matching block".to_string(), + )); + } + } + + for property in bundle.page.page.properties.values() { + for file in &property.files { + if file + .external + .as_ref() + .is_some_and(|file| !file.url.is_empty()) + || file + .file + .as_ref() + .is_some_and(|file| !file.url.is_empty() || file.expiry_time.is_some()) + { + return Err(LocalityError::InvalidState( + "Notion portable page property media is not sanitized".to_string(), + )); + } + } + } + Ok(()) +} + +fn collect_media_blocks<'a>( + trees: &'a [BlockTreeDto], + media: &mut BTreeMap, +) -> LocalityResult<()> { + for tree in trees { + if is_media_kind(&tree.block.kind) { + let payload = media_payload(&tree.block).ok_or_else(|| { + LocalityError::InvalidState( + "Notion portable media block is missing its payload".to_string(), + ) + })?; + if media + .insert(tree.block.id.clone(), (tree.block.kind.clone(), payload)) + .is_some() + { + return Err(LocalityError::InvalidState( + "Notion portable media block identity is duplicated".to_string(), + )); + } + } + collect_media_blocks(&tree.children, media)?; + } + Ok(()) +} + +fn media_payload(block: &BlockDto) -> Option<&FileBlockDto> { + match block.kind.as_str() { + "image" => block.image.as_ref(), + "video" => block.video.as_ref(), + "file" => block.file.as_ref(), + "pdf" => block.pdf.as_ref(), + "audio" => block.audio.as_ref(), + _ => None, + } +} + fn render_page(request: &PortableRenderRequest) -> LocalityResult { let native_bundle = serde_json::from_slice::(&request.native.raw) .map_err(|error| LocalityError::Io(format!("notion native decode failed: {error}")))?; @@ -656,6 +1222,18 @@ fn artifact_key(remote_id: &RemoteId, role: &str, format_version: u32) -> Portab )) } +fn media_artifact_key( + remote_id: &RemoteId, + block_id: &str, + format_version: u32, +) -> PortableArtifactKey { + PortableArtifactKey::new(format!( + "notion:page:{}:block:{}:media:v{format_version}", + normalize_notion_id(remote_id.as_str()), + normalize_notion_id(block_id) + )) +} + fn database_artifact_key( remote_id: &RemoteId, role: &str, @@ -848,3 +1426,66 @@ fn canonical_notion_uuid(value: &str) -> Option { fn notion_ids_equal(left: &str, right: &str) -> bool { normalize_notion_id(left) == normalize_notion_id(right) } + +#[cfg(test)] +mod tests { + use super::{ + FileBlockDto, PORTABLE_MEDIA_MAX_AGGREGATE_BYTES, PortableMediaCaptureFetcher, + PortableMediaCaptureState, checked_portable_media_aggregate, + }; + use crate::dto::HostedFileDto; + use crate::media::PortableMediaCapture; + + #[test] + fn portable_media_aggregate_limit_is_exact_and_overflow_safe() { + assert_eq!( + checked_portable_media_aggregate(PORTABLE_MEDIA_MAX_AGGREGATE_BYTES - 1, 1), + Some(PORTABLE_MEDIA_MAX_AGGREGATE_BYTES) + ); + assert_eq!( + checked_portable_media_aggregate(PORTABLE_MEDIA_MAX_AGGREGATE_BYTES - 1, 2), + None + ); + assert_eq!(checked_portable_media_aggregate(usize::MAX, 1), None); + } + + #[test] + fn portable_media_aggregate_overflow_clears_url_and_records_incompleteness() { + struct OneByteFetcher; + + impl PortableMediaCaptureFetcher for OneByteFetcher { + fn fetch( + &self, + _hosted_url: &str, + _max_bytes: usize, + ) -> locality_core::LocalityResult { + Ok(PortableMediaCapture { + bytes: vec![1], + media_type: "image/png".to_string(), + }) + } + } + + let fetcher = OneByteFetcher; + let mut state = PortableMediaCaptureState::new(&fetcher); + state.aggregate_bytes = PORTABLE_MEDIA_MAX_AGGREGATE_BYTES; + let mut payload = FileBlockDto { + kind: "file".to_string(), + external: None, + file: Some(HostedFileDto { + url: "https://secure.notion-static.com/image.png?X-Amz-Signature=secret" + .to_string(), + expiry_time: None, + }), + caption: Vec::new(), + }; + + state + .capture_payload("block-1", "image", &mut payload) + .expect("aggregate capture"); + + assert!(state.captured.is_empty()); + assert_eq!(state.incomplete[0].code, "aggregate_too_large"); + assert_eq!(payload.file.expect("hosted").url, ""); + } +} diff --git a/crates/locality-notion/tests/fetch_render.rs b/crates/locality-notion/tests/fetch_render.rs index dd67d1e7..4a7ea25e 100644 --- a/crates/locality-notion/tests/fetch_render.rs +++ b/crates/locality-notion/tests/fetch_render.rs @@ -1,13 +1,14 @@ use std::collections::BTreeMap; use std::path::Path; use std::sync::Arc; +use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; use locality_connector::{ - ChildContainer, Connector, EnumerateRequest, FetchRequest, ListChildrenRequest, NativeEntity, - PORTABLE_SCOPE_ROOT_RELATIONSHIP, PortableBootstrapRequest, PortableFetchReason, - PortableFetchRequest, PortableIncompleteReason, PortableRenderRequest, PortableSourceScope, - PortableSyncRequest, + ChildContainer, Connector, ConnectorExecutionPolicy, EnumerateRequest, FetchRequest, + ListChildrenRequest, NativeEntity, PORTABLE_SCOPE_ROOT_RELATIONSHIP, PortableBootstrapRequest, + PortableFetchReason, PortableFetchRequest, PortableIncompleteReason, PortableRenderRequest, + PortableSourceScope, PortableSyncRequest, }; use locality_core::canonical::render_canonical_markdown; use locality_core::model::{EntityKind, MountId, RemoteId}; @@ -26,6 +27,10 @@ use locality_notion::dto::{ SelectPropertySchemaDto, SyncedBlockDto, SyncedFromDto, TableBlockDto, TableRowBlockDto, TextRichTextDto, TitleBlockDto, UniqueIdPropertyDto, UrlBlockDto, VerificationPropertyDto, }; +use locality_notion::media::{ + PORTABLE_MEDIA_MAX_ASSET_BYTES, PortableMediaCapture, PortableMediaCaptureFetcher, + PortableMediaCapturePolicy, +}; use locality_notion::{NotionConfig, NotionConnector}; use serde_json::json; @@ -2951,6 +2956,388 @@ fn portable_render_marks_media_incomplete_instead_of_silently_omitting_it() { )); } +#[test] +fn portable_hosted_media_capture_is_sanitized_local_and_exact() { + let page_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let block_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let signed_url = concat!( + "https://secure.notion-static.com/assets/Cover.PNG?", + "X-Amz-Signature=signature-secret&token=token-secret&", + "Authorization=Bearer%20authorization-secret#fragment-secret" + ); + let calls = Arc::new(Mutex::new(Vec::new())); + let fetcher = Arc::new(FixturePortableMediaFetcher { + outcomes: BTreeMap::from([( + signed_url.to_string(), + FixturePortableMediaOutcome::Success(PortableMediaCapture { + bytes: vec![0x89, b'P', b'N', b'G'], + media_type: "image/png; charset=binary".to_string(), + }), + )]), + calls: Arc::clone(&calls), + }); + let connector = portable_media_connector( + page_id, + vec![hosted_file_block( + block_id, + "image", + signed_url, + Some("2099-06-12T10:00:00.000Z"), + )], + ) + .with_portable_media_capture_fetcher(PortableMediaCapturePolicy::HostedPilot, fetcher.clone()); + + assert_eq!( + connector.portable_media_capture_policy(), + PortableMediaCapturePolicy::HostedPilot + ); + let fetcher_references = Arc::strong_count(&fetcher); + let rooted = connector.with_root_page_id(RemoteId::new(page_id)); + assert_eq!(Arc::strong_count(&fetcher), fetcher_references + 1); + assert_eq!( + rooted.portable_media_capture_policy(), + PortableMediaCapturePolicy::HostedPilot + ); + drop(rooted); + let deferred = connector.with_execution_policy(ConnectorExecutionPolicy::DeferProviderCooldown); + assert_eq!(Arc::strong_count(&fetcher), fetcher_references + 1); + assert_eq!( + deferred.portable_media_capture_policy(), + PortableMediaCapturePolicy::HostedPilot + ); + drop(deferred); + + let fetched = connector + .fetch_portable(portable_fetch_request(page_id)) + .expect("portable media fetch"); + assert!(fetched.completeness.is_complete()); + assert_eq!(fetched.native.kind, "notion_page_portable_media_v1"); + assert_eq!( + calls.lock().expect("calls").as_slice(), + [(signed_url.to_string(), PORTABLE_MEDIA_MAX_ASSET_BYTES)] + ); + let native: locality_notion::dto::NotionPortablePageBundleV1 = + serde_json::from_slice(&fetched.native.raw).expect("portable media native"); + assert_eq!(native.format_version, 1); + assert_eq!( + native.captured_media, + vec![locality_notion::dto::NotionPortableCapturedMediaV1 { + block_id: block_id.to_string(), + kind: "image".to_string(), + media_type: "image/png".to_string(), + bytes: vec![0x89, b'P', b'N', b'G'], + }] + ); + assert!(native.incomplete_media.is_empty()); + let hosted = native.page.blocks[0] + .block + .image + .as_ref() + .and_then(|file| file.file.as_ref()) + .expect("sanitized hosted file"); + assert_eq!( + hosted.url, + "https://secure.notion-static.com/assets/Cover.PNG" + ); + assert_eq!(hosted.expiry_time, None); + let native_raw = String::from_utf8_lossy(&fetched.native.raw); + assert!(native_raw.contains(r#""bytes":"iVBORw==""#)); + assert!(!native_raw.contains(signed_url)); + let desktop_paths = locality_notion::render::render_page_bundle_with_options( + &native.page, + &locality_notion::render::RenderOptions::with_page_path("Docs/Coverage/page.md"), + ) + .expect("desktop path render"); + assert_eq!( + desktop_paths.media_assets[0].local_path, + Path::new(".loc/media/Docs/Coverage/image-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.png") + ); + for forbidden in [ + "X-Amz-Signature", + "signature-secret", + "token-secret", + "Authorization", + "authorization-secret", + "fragment-secret", + "2099-06-12", + ] { + assert!(!native_raw.contains(forbidden)); + assert!(!format!("{fetched:?}").contains(forbidden)); + } + + let rendered = connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new("Docs/Coverage/page.md").expect("path"), + native: fetched.native, + format_version: 1, + }) + .expect("portable media render"); + let exact_markdown = concat!( + "---\n", + "loc:\n", + " id: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", + " type: page\n", + " synced_at: \"2026-06-10T00:00:00.000Z\"\n", + " remote_edited_at: \"2026-06-10T00:00:00.000Z\"\n", + "title: \"Coverage\"\n", + "---\n", + "![Image](../../.loc/media/Docs/Coverage/image-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.png)\n", + ); + assert_eq!(rendered.canonical.body, exact_markdown.as_bytes()); + assert_eq!(rendered.projections.len(), 2); + assert_eq!( + rendered.projections[0].artifact.body, + exact_markdown.as_bytes() + ); + let binary = &rendered.projections[1]; + assert_eq!( + binary.artifact.artifact_key.as_str(), + concat!( + "notion:page:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:", + "block:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:media:v1" + ) + ); + assert_eq!(binary.artifact.media_type, "image/png"); + assert_eq!(binary.artifact.body, vec![0x89, b'P', b'N', b'G']); + assert_eq!( + binary.logical_path.as_str(), + ".loc/media/Docs/Coverage/image-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.png" + ); + assert_eq!(binary.file_kind, ProjectionFileKind::Binary); + assert_eq!( + binary.supported_actions, + [SourceAction::Read, SourceAction::DownloadAttachment] + .into_iter() + .collect() + ); + assert!(rendered.completeness.is_complete()); + for projection in &rendered.projections { + let body = String::from_utf8_lossy(&projection.artifact.body); + assert!(!body.contains("X-Amz-Signature")); + assert!(!body.contains("token-secret")); + assert!(!body.contains("fragment-secret")); + } +} + +#[test] +fn portable_media_default_preserves_native_and_remains_incomplete() { + let page_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let signed_url = "https://secure.notion-static.com/image.png?X-Amz-Signature=direct"; + let block = hosted_file_block( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "image", + signed_url, + Some("2099-06-12T10:00:00.000Z"), + ); + let connector = portable_media_connector(page_id, vec![block.clone()]); + let direct = connector + .fetch(FetchRequest { + remote_id: RemoteId::new(page_id), + }) + .expect("direct fetch"); + let fetched = connector + .fetch_portable(portable_fetch_request(page_id)) + .expect("default portable fetch"); + assert_eq!(fetched.native.kind, "notion_page"); + assert_eq!(fetched.native.raw, direct.raw); + assert!(String::from_utf8_lossy(&fetched.native.raw).contains(signed_url)); + + let rendered = connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new("Coverage/page.md").expect("path"), + native: fetched.native, + format_version: 1, + }) + .expect("default portable render"); + assert!(!rendered.completeness.is_complete()); + assert_eq!(rendered.projections.len(), 1); +} + +#[test] +fn portable_capture_keeps_pages_without_media_byte_exact() { + let page_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let blocks = vec![paragraph_block( + "paragraph-1", + vec![rich_text("No media here.")], + )]; + let direct_connector = portable_media_connector(page_id, blocks.clone()); + let direct_native = direct_connector + .fetch(FetchRequest { + remote_id: RemoteId::new(page_id), + }) + .expect("direct fetch"); + let direct_document = direct_connector + .render(&direct_native) + .expect("direct render"); + let connector = portable_media_connector(page_id, blocks) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + let fetched = connector + .fetch_portable(portable_fetch_request(page_id)) + .expect("portable fetch"); + assert!(fetched.completeness.is_complete()); + let rendered = connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new("Coverage/page.md").expect("path"), + native: fetched.native, + format_version: 1, + }) + .expect("portable render"); + let direct_bytes = render_canonical_markdown(&direct_document).into_bytes(); + assert_eq!(rendered.canonical.body, direct_bytes); + assert_eq!(rendered.projections.len(), 1); + assert_eq!(rendered.projections[0].artifact.body, direct_bytes); + assert!(rendered.completeness.is_complete()); +} + +#[test] +fn portable_media_denials_are_incomplete_and_never_publish_remote_urls() { + let denied = [ + ("external", "https://example.com/external.png"), + ("http", "http://secure.notion-static.com/image.png"), + ("ip", "https://127.0.0.1/image.png"), + ( + "userinfo", + "https://user:pass@secure.notion-static.com/image.png", + ), + ("bad_port", "https://secure.notion-static.com:444/image.png"), + ("unlisted", "https://notion-static.com/image.png"), + ( + "s3_prefix", + "https://s3.us-west-2.amazonaws.com/other/image.png", + ), + ]; + for (index, (case, url)) in denied.into_iter().enumerate() { + let page_id = format!("page-denied-{index}"); + let block_id = format!("block-denied-{index}"); + let block = if case == "external" { + file_block(&block_id, "image", url, "Image") + } else { + hosted_file_block(&block_id, "image", url, None) + }; + let connector = portable_media_connector(&page_id, vec![block]) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + let fetched = connector + .fetch_portable(portable_fetch_request(&page_id)) + .unwrap_or_else(|error| panic!("{case} fetch failed closed unexpectedly: {error}")); + assert!(!fetched.completeness.is_complete(), "{case}"); + let raw = String::from_utf8_lossy(&fetched.native.raw); + assert!(!raw.contains(url), "{case}: {raw}"); + let rendered = connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new(format!("Denied {index}/page.md")).expect("path"), + native: fetched.native, + format_version: 1, + }) + .unwrap_or_else(|error| panic!("{case} render: {error}")); + assert!(!rendered.completeness.is_complete(), "{case}"); + assert_eq!(rendered.projections.len(), 1, "{case}"); + assert!(!String::from_utf8_lossy(&rendered.canonical.body).contains(url)); + } +} + +#[test] +fn portable_media_expired_failed_and_oversized_captures_are_redacted() { + let cases = [ + ( + "expired", + Some("2000-01-01T00:00:00.000Z"), + FixturePortableMediaOutcome::Success(PortableMediaCapture { + bytes: b"unused".to_vec(), + media_type: "image/png".to_string(), + }), + ), + ( + "failed", + Some("2099-01-01T00:00:00.000Z"), + FixturePortableMediaOutcome::Failure( + "X-Amz-Signature=must-not-escape token-secret".to_string(), + ), + ), + ( + "oversized", + Some("2099-01-01T00:00:00.000Z"), + FixturePortableMediaOutcome::Success(PortableMediaCapture { + bytes: vec![0; PORTABLE_MEDIA_MAX_ASSET_BYTES + 1], + media_type: "image/png".to_string(), + }), + ), + ]; + for (case, expiry, outcome) in cases { + let page_id = format!("page-{case}"); + let block_id = format!("block-{case}"); + let url = + format!("https://secure.notion-static.com/{case}.png?X-Amz-Signature=signature-secret"); + let fetcher = Arc::new(FixturePortableMediaFetcher { + outcomes: BTreeMap::from([(url.clone(), outcome)]), + calls: Arc::new(Mutex::new(Vec::new())), + }); + let connector = portable_media_connector( + &page_id, + vec![hosted_file_block(&block_id, "image", &url, expiry)], + ) + .with_portable_media_capture_fetcher(PortableMediaCapturePolicy::HostedPilot, fetcher); + let fetched = connector + .fetch_portable(portable_fetch_request(&page_id)) + .unwrap_or_else(|error| panic!("{case}: {error}")); + assert!(!fetched.completeness.is_complete(), "{case}"); + let raw = String::from_utf8_lossy(&fetched.native.raw); + assert!(!raw.contains("X-Amz-Signature"), "{case}"); + assert!(!raw.contains("signature-secret"), "{case}"); + assert!(!raw.contains("token-secret"), "{case}"); + let rendered = connector + .render_portable(&PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new(format!("{case}/page.md")).expect("path"), + native: fetched.native, + format_version: 1, + }) + .expect("render incomplete media"); + assert_eq!(rendered.projections.len(), 1, "{case}"); + assert!(!String::from_utf8_lossy(&rendered.canonical.body).contains("https://")); + } +} + +#[test] +fn portable_media_duplicate_and_count_limits_fail_closed() { + let duplicate = hosted_file_block( + "duplicate-block", + "image", + "https://secure.notion-static.com/image.png", + None, + ); + let duplicate_connector = + portable_media_connector("duplicate-page", vec![duplicate.clone(), duplicate]) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + assert_eq!( + duplicate_connector + .fetch_portable(portable_fetch_request("duplicate-page")) + .expect_err("duplicate media must fail") + .to_string(), + "invalid state: Notion portable media contains a duplicate block identity" + ); + + let too_many = (0..=128) + .map(|index| { + let mut block = block(&format!("media-{index}"), "image"); + block.image = Some(FileBlockDto::default()); + block + }) + .collect(); + let count_connector = portable_media_connector("count-page", too_many) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + assert_eq!( + count_connector + .fetch_portable(portable_fetch_request("count-page")) + .expect_err("asset count must fail") + .to_string(), + "invalid state: Notion portable media exceeds the 128-asset pilot limit" + ); +} + #[test] fn enumerate_suffixes_every_colliding_sibling_name() { let root_page_id = RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); @@ -3173,6 +3560,68 @@ fn normalize_notion_id(input: &str) -> String { .collect::() } +#[derive(Clone)] +enum FixturePortableMediaOutcome { + Success(PortableMediaCapture), + Failure(String), +} + +struct FixturePortableMediaFetcher { + outcomes: BTreeMap, + calls: Arc>>, +} + +impl PortableMediaCaptureFetcher for FixturePortableMediaFetcher { + fn fetch( + &self, + hosted_url: &str, + max_bytes: usize, + ) -> locality_core::LocalityResult { + self.calls + .lock() + .expect("media fetch calls") + .push((hosted_url.to_string(), max_bytes)); + match self.outcomes.get(hosted_url) { + Some(FixturePortableMediaOutcome::Success(capture)) => Ok(capture.clone()), + Some(FixturePortableMediaOutcome::Failure(error)) => { + Err(locality_core::LocalityError::Io(error.clone())) + } + None => Err(locality_core::LocalityError::InvalidState( + "unexpected fixture media URL".to_string(), + )), + } + } +} + +fn portable_media_connector(page_id: &str, blocks: Vec) -> NotionConnector { + let api = FixtureNotionApi { + pages: BTreeMap::from([(page_id.to_string(), page(page_id, "Coverage"))]), + children: BTreeMap::from([( + (page_id.to_string(), None), + PaginatedListDto { + results: blocks, + next_cursor: None, + has_more: false, + }, + )]), + databases: BTreeMap::new(), + data_sources: BTreeMap::new(), + data_source_pages: BTreeMap::new(), + }; + NotionConnector::with_api( + NotionConfig::default().with_root_page_id(RemoteId::new(page_id)), + Arc::new(NoSearchNotionApi(api)), + ) +} + +fn portable_fetch_request(page_id: &str) -> PortableFetchRequest { + PortableFetchRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + remote_id: RemoteId::new(page_id), + reason: PortableFetchReason::Bootstrap, + } +} + #[derive(Debug)] struct FixtureNotionApi { pages: BTreeMap, @@ -4246,6 +4695,28 @@ fn file_block(id: &str, kind: &str, url: &str, caption: &str) -> BlockDto { block } +fn hosted_file_block(id: &str, kind: &str, url: &str, expiry_time: Option<&str>) -> BlockDto { + let mut block = block(id, kind); + let payload = Some(FileBlockDto { + kind: "file".to_string(), + external: None, + file: Some(HostedFileDto { + url: url.to_string(), + expiry_time: expiry_time.map(str::to_string), + }), + caption: Vec::new(), + }); + match kind { + "image" => block.image = payload, + "video" => block.video = payload, + "file" => block.file = payload, + "pdf" => block.pdf = payload, + "audio" => block.audio = payload, + _ => panic!("unsupported hosted fixture file block kind: {kind}"), + } + block +} + fn synced_block(id: &str, source_block_id: &str) -> BlockDto { let mut block = block(id, "synced_block"); block.synced_block = Some(SyncedBlockDto { diff --git a/docs/notion-connector.md b/docs/notion-connector.md index 2ddd7f85..0c92350b 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -188,6 +188,38 @@ When Locality writes a Notion page into a local projection, media blocks with `e The media tree mirrors the Notion page directory under the reserved `.loc/` namespace in the projection output root. This keeps binary files out of content directories while giving agents a stable local file they can open, and avoids collision with a projected Notion page or database named `media`. Locality records downloaded media metadata and checksums in `.loc/media/manifest.json` using mount-relative paths. `loc status`, `loc inspect`, `loc diff`, and `loc push` treat equivalent relative and projection-output-root absolute media hrefs as the same asset. If the resolved local media path, bytes, or caption changes, `loc diff` plans an `update_media` operation and `loc push` uploads the local file to the existing Notion media block. Appending a new Markdown image or link whose href resolves under the projection output root's `.loc/media/` tree uploads that file and creates a Notion image, video, audio, PDF, or generic file block based on the file MIME type. Single-part uploads are capped at 20 MB until multipart upload support exists. +Portable hosted-media capture is a separate, opt-in read policy. The default +portable path and every direct desktop/headless path keep the behavior above: +portable pages containing media remain explicitly incomplete, while direct +projection continues to download through the existing media manifest workflow. +Enabling the hosted-media pilot on `NotionConnector` does not create or consume +that manifest. Instead, portable fetch captures Notion-hosted `file` payloads +into a versioned sanitized native page bundle using canonical padded base64 for +bounded byte overhead, and portable render emits one binary projection per +successful asset at the exact same +`.loc/media//...` path used by desktop rendering. The page Markdown +uses that local path, and each binary supports only `read` and +`download_attachment`. + +Signed query parameters, URL fragments, and Notion expiry timestamps are +removed before native serialization. Failed or uncaptured payloads have no +remote URL in native or rendered Markdown and make the result incomplete. +External media stays unsupported and incomplete in this policy. Production +capture accepts HTTPS on the default port only, rejects user information and IP +literals, and permits exactly these origins: + +- `secure.notion-static.com`; +- `prod-files-secure.s3.us-west-2.amazonaws.com`; +- `s3.us-west-2.amazonaws.com` only below `/secure.notion-static.com/`. + +Redirects are disabled in the HTTP client and followed manually at most three +times, with every destination revalidated. Responses must use absent or +`identity` content encoding. The pilot uses a five-second connection timeout, a +30-second overall request deadline, reads at most 64 KiB per streaming read, +and enforces 128 assets per page, 20 MiB per asset, and 100 MiB total captured +bytes. Missing, expired, duplicate, oversized, denied, or failed media cannot +silently fall back to a provider URL. + ## Path Projection Root-page mounts use a stable directory shape based on the remote title: From 9be964cb762ba7546310ae7d1767d5fa5f18512a Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Wed, 22 Jul 2026 01:12:50 -0700 Subject: [PATCH 15/18] Harden portable Notion media outcomes --- crates/locality-notion/src/portable.rs | 567 ++++++++++++++++--- crates/locality-notion/tests/fetch_render.rs | 389 ++++++++++++- docs/notion-connector.md | 11 +- 3 files changed, 869 insertions(+), 98 deletions(-) diff --git a/crates/locality-notion/src/portable.rs b/crates/locality-notion/src/portable.rs index c3b79d10..ee0eb21a 100644 --- a/crates/locality-notion/src/portable.rs +++ b/crates/locality-notion/src/portable.rs @@ -47,6 +47,8 @@ const MAX_EXPLICIT_ROOTS: usize = 16; const PORTABLE_FORMAT_VERSION: u32 = 1; const PORTABLE_MEDIA_NATIVE_FORMAT_VERSION: u16 = 1; const PORTABLE_MEDIA_NATIVE_KIND: &str = "notion_page_portable_media_v1"; +const PORTABLE_MEDIA_LIMIT_OUTCOME_ID: &str = "__locality_portable_media_limit_v1"; +const PORTABLE_MEDIA_SANITIZED_MARKER: &str = "_locality_portable_media_sanitized_v1"; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -236,13 +238,22 @@ fn fetch_portable_media_page_result( )); } + let asset_count = portable_media_asset_count(&bundle)?; + let limit_exceeded = asset_count > PORTABLE_MEDIA_MAX_ASSETS; let default_fetcher = default_portable_media_fetcher(); let fetcher = media_fetcher.unwrap_or(default_fetcher.as_ref()); - let mut capture = PortableMediaCaptureState::new(fetcher); + let mut capture = PortableMediaCaptureState::new(fetcher, limit_exceeded); for tree in &mut bundle.blocks { capture.capture_tree(tree)?; } capture.sanitize_page_file_properties(&mut bundle)?; + if limit_exceeded { + capture.record_incomplete( + PORTABLE_MEDIA_LIMIT_OUTCOME_ID, + "page", + "asset_limit_exceeded", + ); + } let completeness = portable_media_completeness(&capture.incomplete); let portable_bundle = NotionPortablePageBundleV1 { @@ -270,40 +281,36 @@ struct PortableMediaCaptureState<'a> { captured: Vec, incomplete: Vec, seen_block_ids: BTreeSet, - asset_count: usize, aggregate_bytes: usize, + limit_exceeded: bool, } impl<'a> PortableMediaCaptureState<'a> { - fn new(fetcher: &'a dyn PortableMediaCaptureFetcher) -> Self { + fn new(fetcher: &'a dyn PortableMediaCaptureFetcher, limit_exceeded: bool) -> Self { Self { fetcher, captured: Vec::new(), incomplete: Vec::new(), seen_block_ids: BTreeSet::new(), - asset_count: 0, aggregate_bytes: 0, + limit_exceeded, } } fn capture_tree(&mut self, tree: &mut BlockTreeDto) -> LocalityResult<()> { let block_id = tree.block.id.clone(); let kind = tree.block.kind.clone(); + validate_exclusive_typed_media_payload(&tree.block)?; + self.sanitize_block_arbitrary_json(&mut tree.block); if is_media_kind(&kind) { if !self.seen_block_ids.insert(block_id.clone()) { return Err(LocalityError::InvalidState( "Notion portable media contains a duplicate block identity".to_string(), )); } - self.asset_count += 1; - if self.asset_count > PORTABLE_MEDIA_MAX_ASSETS { - return Err(LocalityError::InvalidState(format!( - "Notion portable media exceeds the {PORTABLE_MEDIA_MAX_ASSETS}-asset pilot limit" - ))); - } - let payload = ensure_media_payload_mut(&mut tree.block).ok_or_else(|| { + let payload = selected_media_payload_mut(&mut tree.block).ok_or_else(|| { LocalityError::InvalidState( - "Notion portable media kind cannot select its payload".to_string(), + "Notion portable media block is missing its selected payload".to_string(), ) })?; self.capture_payload(&block_id, &kind, payload)?; @@ -314,6 +321,26 @@ impl<'a> PortableMediaCaptureState<'a> { Ok(()) } + fn sanitize_block_arbitrary_json(&mut self, block: &mut BlockDto) { + for (field, value) in [ + ("tab", block.tab.as_mut()), + ("ai_block", block.ai_block.as_mut()), + ("custom_block", block.custom_block.as_mut()), + ("button", block.button.as_mut()), + ] { + if let Some(value) = value + && sanitize_arbitrary_json_media_secrets(value) + { + mark_sanitized_arbitrary_json(value); + self.record_incomplete( + &format!("{}:json:{field}", block.id), + "arbitrary_json", + "sanitized_embedded_media_secret", + ); + } + } + } + fn capture_payload( &mut self, block_id: &str, @@ -344,13 +371,17 @@ impl<'a> PortableMediaCaptureState<'a> { return Ok(()); } if original_url.is_empty() { - self.record_incomplete(block_id, kind, "missing_url"); + self.record_incomplete(block_id, kind, "unavailable_hosted_media"); + return Ok(()); + } + if self.limit_exceeded { + self.record_incomplete(block_id, kind, "unavailable_hosted_media"); return Ok(()); } let sanitized_url = match sanitize_portable_hosted_media_url(&original_url) { Ok(url) => url, Err(_) => { - self.record_incomplete(block_id, kind, "url_denied"); + self.record_incomplete(block_id, kind, "unavailable_hosted_media"); return Ok(()); } }; @@ -358,11 +389,11 @@ impl<'a> PortableMediaCaptureState<'a> { match portable_media_expired(expiry_time) { Ok(false) => {} Ok(true) => { - self.record_incomplete(block_id, kind, "expired"); + self.record_incomplete(block_id, kind, "unavailable_hosted_media"); return Ok(()); } Err(_) => { - self.record_incomplete(block_id, kind, "invalid_expiry"); + self.record_incomplete(block_id, kind, "unavailable_hosted_media"); return Ok(()); } } @@ -373,18 +404,18 @@ impl<'a> PortableMediaCaptureState<'a> { { Ok(captured) => captured, Err(_) => { - self.record_incomplete(block_id, kind, "fetch_failed"); + self.record_incomplete(block_id, kind, "unavailable_hosted_media"); return Ok(()); } }; if captured.bytes.len() > PORTABLE_MEDIA_MAX_ASSET_BYTES { - self.record_incomplete(block_id, kind, "asset_too_large"); + self.record_incomplete(block_id, kind, "unavailable_hosted_media"); return Ok(()); } let Some(aggregate_bytes) = checked_portable_media_aggregate(self.aggregate_bytes, captured.bytes.len()) else { - self.record_incomplete(block_id, kind, "aggregate_too_large"); + self.record_incomplete(block_id, kind, "unavailable_hosted_media"); return Ok(()); }; self.aggregate_bytes = aggregate_bytes; @@ -403,15 +434,9 @@ impl<'a> PortableMediaCaptureState<'a> { bundle: &mut NotionPageBundle, ) -> LocalityResult<()> { let mut property_asset_index = 0_usize; - for property in bundle.page.properties.values_mut() { + for (property_index, property) in bundle.page.properties.values_mut().enumerate() { for file in &mut property.files { property_asset_index += 1; - self.asset_count += 1; - if self.asset_count > PORTABLE_MEDIA_MAX_ASSETS { - return Err(LocalityError::InvalidState(format!( - "Notion portable media exceeds the {PORTABLE_MEDIA_MAX_ASSETS}-asset pilot limit" - ))); - } if let Some(external) = file.external.as_mut() { external.url.clear(); } @@ -425,6 +450,21 @@ impl<'a> PortableMediaCaptureState<'a> { "unsupported_page_property_media", ); } + for (field, value) in [ + ("formula", property.formula.as_mut()), + ("rollup", property.rollup.as_mut()), + ] { + if let Some(value) = value + && sanitize_arbitrary_json_media_secrets(value) + { + mark_sanitized_arbitrary_json(value); + self.record_incomplete( + &format!("page-property-json-{property_index}:{field}"), + "arbitrary_json", + "sanitized_embedded_media_secret", + ); + } + } } Ok(()) } @@ -453,13 +493,13 @@ fn portable_media_completeness( completeness } -fn ensure_media_payload_mut(block: &mut BlockDto) -> Option<&mut FileBlockDto> { +fn selected_media_payload_mut(block: &mut BlockDto) -> Option<&mut FileBlockDto> { match block.kind.as_str() { - "image" => Some(block.image.get_or_insert_with(FileBlockDto::default)), - "video" => Some(block.video.get_or_insert_with(FileBlockDto::default)), - "file" => Some(block.file.get_or_insert_with(FileBlockDto::default)), - "pdf" => Some(block.pdf.get_or_insert_with(FileBlockDto::default)), - "audio" => Some(block.audio.get_or_insert_with(FileBlockDto::default)), + "image" => block.image.as_mut(), + "video" => block.video.as_mut(), + "file" => block.file.as_mut(), + "pdf" => block.pdf.as_mut(), + "audio" => block.audio.as_mut(), _ => None, } } @@ -468,6 +508,282 @@ fn is_media_kind(kind: &str) -> bool { matches!(kind, "image" | "video" | "file" | "pdf" | "audio") } +fn portable_media_asset_count(bundle: &NotionPageBundle) -> LocalityResult { + fn count_blocks( + trees: &[BlockTreeDto], + seen_media_ids: &mut BTreeSet, + ) -> LocalityResult { + let mut count = 0_usize; + for tree in trees { + validate_exclusive_typed_media_payload(&tree.block)?; + if is_media_kind(&tree.block.kind) { + if !seen_media_ids.insert(tree.block.id.clone()) { + return Err(LocalityError::InvalidState( + "Notion portable media contains a duplicate block identity".to_string(), + )); + } + count = count.checked_add(1).ok_or_else(|| { + LocalityError::InvalidState( + "Notion portable media asset count overflowed".to_string(), + ) + })?; + } + count = count + .checked_add(count_blocks(&tree.children, seen_media_ids)?) + .ok_or_else(|| { + LocalityError::InvalidState( + "Notion portable media asset count overflowed".to_string(), + ) + })?; + } + Ok(count) + } + + let mut seen_media_ids = BTreeSet::new(); + bundle.page.properties.values().try_fold( + count_blocks(&bundle.blocks, &mut seen_media_ids)?, + |count, property| { + count.checked_add(property.files.len()).ok_or_else(|| { + LocalityError::InvalidState( + "Notion portable media asset count overflowed".to_string(), + ) + }) + }, + ) +} + +fn validate_exclusive_typed_media_payload(block: &BlockDto) -> LocalityResult<()> { + let present = [ + ("image", block.image.is_some()), + ("video", block.video.is_some()), + ("file", block.file.is_some()), + ("pdf", block.pdf.is_some()), + ("audio", block.audio.is_some()), + ]; + let present_count = present.iter().filter(|(_, present)| *present).count(); + if is_media_kind(&block.kind) { + let selected_present = present + .iter() + .any(|(kind, present)| *kind == block.kind && *present); + if !selected_present || present_count != 1 { + return Err(LocalityError::InvalidState( + "Notion portable media block must contain exactly its selected typed payload" + .to_string(), + )); + } + } else if present_count != 0 { + return Err(LocalityError::InvalidState( + "Notion portable non-media block contains a typed media payload".to_string(), + )); + } + Ok(()) +} + +fn sanitize_arbitrary_json_media_secrets(value: &mut serde_json::Value) -> bool { + sanitize_arbitrary_json_media_secrets_in_context(value, false) +} + +fn sanitize_arbitrary_json_media_secrets_in_context( + value: &mut serde_json::Value, + inherited_media_context: bool, +) -> bool { + match value { + serde_json::Value::String(string) => { + if sanitize_portable_hosted_media_url(string).is_ok() + || looks_like_media_secret_value(string) + || (inherited_media_context && media_url_has_query_or_fragment(string)) + { + string.clear(); + true + } else { + false + } + } + serde_json::Value::Array(values) => { + let mut changed = false; + for value in values { + changed |= sanitize_arbitrary_json_media_secrets_in_context( + value, + inherited_media_context, + ); + } + changed + } + serde_json::Value::Object(object) => { + let media_context = inherited_media_context + || arbitrary_json_object_looks_like_media(object) + || object.values().any(arbitrary_json_contains_provider_url); + let sensitive_keys = object + .keys() + .filter(|key| { + key.as_str() == PORTABLE_MEDIA_SANITIZED_MARKER + || is_unconditional_secret_key(key) + || (media_context && is_media_metadata_key(key)) + }) + .cloned() + .collect::>(); + let mut changed = !sensitive_keys.is_empty(); + for key in sensitive_keys { + object.remove(&key); + } + for child in object.values_mut() { + changed |= sanitize_arbitrary_json_media_secrets_in_context(child, media_context); + } + changed + } + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => { + false + } + } +} + +fn arbitrary_json_contains_provider_url(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::String(value) => sanitize_portable_hosted_media_url(value).is_ok(), + serde_json::Value::Array(values) => values.iter().any(arbitrary_json_contains_provider_url), + serde_json::Value::Object(object) => { + object.values().any(arbitrary_json_contains_provider_url) + } + serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => { + false + } + } +} + +fn arbitrary_json_object_looks_like_media( + object: &serde_json::Map, +) -> bool { + object.get("file").is_some_and(serde_json::Value::is_object) + || object + .get("external") + .is_some_and(serde_json::Value::is_object) + || object.contains_key("expiry_time") + || matches!( + object.get("type").and_then(serde_json::Value::as_str), + Some("file" | "files") + ) +} + +fn normalized_json_key(key: &str) -> String { + key.chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() +} + +fn is_unconditional_secret_key(key: &str) -> bool { + let normalized = normalized_json_key(key); + normalized.contains("xamz") + || normalized.contains("signature") + || normalized.contains("credential") + || normalized.contains("authorization") + || normalized.contains("secret") + || normalized == "token" + || normalized.ends_with("token") +} + +fn is_media_metadata_key(key: &str) -> bool { + let normalized = normalized_json_key(key); + normalized == "expirytime" || normalized == "query" || normalized == "fragment" +} + +fn looks_like_media_secret_value(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + lower.contains("x-amz-") + || lower.contains("signature=") + || lower.contains("token=") + || lower.contains("secret=") + || lower.contains("authorization") + || lower.contains("bearer ") +} + +fn media_url_has_query_or_fragment(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + (lower.starts_with("https://") || lower.starts_with("http://")) + && (lower.contains('?') || lower.contains('#')) +} + +fn mark_sanitized_arbitrary_json(value: &mut serde_json::Value) { + let marker = || { + serde_json::Value::Object(serde_json::Map::from_iter([( + PORTABLE_MEDIA_SANITIZED_MARKER.to_string(), + serde_json::Value::Bool(true), + )])) + }; + match value { + serde_json::Value::Object(object) => { + object.insert( + PORTABLE_MEDIA_SANITIZED_MARKER.to_string(), + serde_json::Value::Bool(true), + ); + } + serde_json::Value::Array(values) => values.push(marker()), + _ => *value = marker(), + } +} + +fn sanitized_marker_count(value: &serde_json::Value) -> usize { + match value { + serde_json::Value::Array(values) => values + .iter() + .map(sanitized_marker_count) + .fold(0_usize, usize::saturating_add), + serde_json::Value::Object(object) => { + usize::from(object.contains_key(PORTABLE_MEDIA_SANITIZED_MARKER)) + + object + .values() + .map(sanitized_marker_count) + .fold(0_usize, usize::saturating_add) + } + _ => 0, + } +} + +fn take_top_sanitized_marker(value: &mut serde_json::Value) -> LocalityResult { + let marker_count = sanitized_marker_count(value); + let has_valid_top_marker = match value { + serde_json::Value::Object(object) => object + .get(PORTABLE_MEDIA_SANITIZED_MARKER) + .is_some_and(|marker| marker == &serde_json::Value::Bool(true)), + serde_json::Value::Array(values) => values.last().is_some_and(|last| { + last.as_object().is_some_and(|object| { + object.len() == 1 + && object.get(PORTABLE_MEDIA_SANITIZED_MARKER) + == Some(&serde_json::Value::Bool(true)) + }) + }), + _ => false, + }; + if marker_count != usize::from(has_valid_top_marker) { + return Err(LocalityError::InvalidState( + "Notion portable arbitrary JSON has an invalid sanitization marker".to_string(), + )); + } + if has_valid_top_marker { + match value { + serde_json::Value::Object(object) => { + object.remove(PORTABLE_MEDIA_SANITIZED_MARKER); + } + serde_json::Value::Array(values) => { + values.pop(); + } + _ => unreachable!("validated marker has a container"), + } + } + Ok(has_valid_top_marker) +} + +fn validate_sanitized_arbitrary_json(value: &serde_json::Value) -> LocalityResult { + let mut sanitized = value.clone(); + let has_marker = take_top_sanitized_marker(&mut sanitized)?; + if sanitize_arbitrary_json_media_secrets(&mut sanitized) { + return Err(LocalityError::InvalidState( + "Notion portable arbitrary JSON retained media credentials".to_string(), + )); + } + Ok(has_marker) +} + fn checked_portable_media_aggregate(current: usize, next: usize) -> Option { current .checked_add(next) @@ -644,30 +960,13 @@ fn render_portable_media_page( } fn validate_portable_media_bundle(bundle: &NotionPortablePageBundleV1) -> LocalityResult<()> { - let captured = bundle - .captured_media - .iter() - .map(|media| media.block_id.clone()) - .collect::>(); - if captured.len() != bundle.captured_media.len() { - return Err(LocalityError::InvalidState( - "Notion portable media native payload has duplicate captures".to_string(), - )); - } - let incomplete = bundle - .incomplete_media - .iter() - .map(|media| media.block_id.clone()) - .collect::>(); - if incomplete.len() != bundle.incomplete_media.len() || !captured.is_disjoint(&incomplete) { - return Err(LocalityError::InvalidState( - "Notion portable media native payload has ambiguous outcomes".to_string(), - )); - } - if bundle.captured_media.len() + bundle.incomplete_media.len() > PORTABLE_MEDIA_MAX_ASSETS { - return Err(LocalityError::InvalidState( - "Notion portable media native payload exceeds the asset limit".to_string(), - )); + let mut captured = BTreeMap::new(); + for media in &bundle.captured_media { + if captured.insert(media.block_id.clone(), media).is_some() { + return Err(LocalityError::InvalidState( + "Notion portable media native payload has duplicate captures".to_string(), + )); + } } let aggregate_bytes = bundle .captured_media @@ -694,15 +993,13 @@ fn validate_portable_media_bundle(bundle: &NotionPortablePageBundleV1) -> Locali } let mut media_blocks = BTreeMap::new(); - collect_media_blocks(&bundle.page.blocks, &mut media_blocks)?; + let mut expected_incomplete = BTreeMap::new(); + collect_media_blocks( + &bundle.page.blocks, + &mut media_blocks, + &mut expected_incomplete, + )?; for (block_id, (kind, payload)) in &media_blocks { - let is_captured = captured.contains(block_id); - let is_incomplete = incomplete.contains(block_id); - if !is_captured && !is_incomplete { - return Err(LocalityError::InvalidState( - "Notion portable media block has no capture outcome".to_string(), - )); - } if payload .external .as_ref() @@ -721,7 +1018,12 @@ fn validate_portable_media_bundle(bundle: &NotionPortablePageBundleV1) -> Locali .as_ref() .map(|file| file.url.as_str()) .unwrap_or(""); - if is_captured { + if let Some(capture) = captured.get(block_id) { + if payload.external.is_some() || payload.file.is_none() || hosted_url.is_empty() { + return Err(LocalityError::InvalidState( + "Notion portable captured media source is ambiguous".to_string(), + )); + } let sanitized = sanitize_portable_hosted_media_url(hosted_url).map_err(|_| { LocalityError::InvalidState( "Notion portable captured media URL is not allowed".to_string(), @@ -732,20 +1034,24 @@ fn validate_portable_media_bundle(bundle: &NotionPortablePageBundleV1) -> Locali "Notion portable captured media URL is not sanitized".to_string(), )); } - let captured_kind = bundle - .captured_media - .iter() - .find(|media| media.block_id == *block_id) - .map(|media| media.kind.as_str()); - if captured_kind != Some(kind.as_str()) { + if capture.kind != *kind { return Err(LocalityError::InvalidState( "Notion portable captured media kind does not match its block".to_string(), )); } - } else if !hosted_url.is_empty() { - return Err(LocalityError::InvalidState( - "Notion portable incomplete media retained a remote URL".to_string(), - )); + } else { + if !hosted_url.is_empty() { + return Err(LocalityError::InvalidState( + "Notion portable incomplete media retained a remote URL".to_string(), + )); + } + let code = match (payload.external.is_some(), payload.file.is_some()) { + (true, true) => "ambiguous_file_source", + (true, false) => "external_media", + (false, true) => "unavailable_hosted_media", + (false, false) => "missing_file", + }; + insert_expected_incomplete(&mut expected_incomplete, block_id, kind, code)?; } } for media in &bundle.captured_media { @@ -756,8 +1062,10 @@ fn validate_portable_media_bundle(bundle: &NotionPortablePageBundleV1) -> Locali } } - for property in bundle.page.page.properties.values() { + let mut property_asset_index = 0_usize; + for (property_index, property) in bundle.page.page.properties.values().enumerate() { for file in &property.files { + property_asset_index += 1; if file .external .as_ref() @@ -771,7 +1079,89 @@ fn validate_portable_media_bundle(bundle: &NotionPortablePageBundleV1) -> Locali "Notion portable page property media is not sanitized".to_string(), )); } + insert_expected_incomplete( + &mut expected_incomplete, + &format!("page-property-file-{property_asset_index}"), + "file_property", + "unsupported_page_property_media", + )?; + } + for (field, value) in [ + ("formula", property.formula.as_ref()), + ("rollup", property.rollup.as_ref()), + ] { + if let Some(value) = value + && validate_sanitized_arbitrary_json(value)? + { + insert_expected_incomplete( + &mut expected_incomplete, + &format!("page-property-json-{property_index}:{field}"), + "arbitrary_json", + "sanitized_embedded_media_secret", + )?; + } + } + } + + let asset_count = portable_media_asset_count(&bundle.page)?; + if asset_count > PORTABLE_MEDIA_MAX_ASSETS { + if !bundle.captured_media.is_empty() { + return Err(LocalityError::InvalidState( + "Notion portable over-limit native payload contains captured media".to_string(), + )); } + insert_expected_incomplete( + &mut expected_incomplete, + PORTABLE_MEDIA_LIMIT_OUTCOME_ID, + "page", + "asset_limit_exceeded", + )?; + } + + if captured + .keys() + .any(|block_id| expected_incomplete.contains_key(block_id)) + { + return Err(LocalityError::InvalidState( + "Notion portable media native payload has ambiguous outcomes".to_string(), + )); + } + let mut actual_incomplete = BTreeMap::new(); + for outcome in &bundle.incomplete_media { + if actual_incomplete + .insert( + outcome.block_id.clone(), + (outcome.kind.clone(), outcome.code.clone()), + ) + .is_some() + { + return Err(LocalityError::InvalidState( + "Notion portable media native payload has duplicate incomplete outcomes" + .to_string(), + )); + } + } + if actual_incomplete != expected_incomplete { + return Err(LocalityError::InvalidState( + "Notion portable media native payload has invalid incomplete outcomes".to_string(), + )); + } + Ok(()) +} + +fn insert_expected_incomplete( + expected: &mut BTreeMap, + block_id: &str, + kind: &str, + code: &str, +) -> LocalityResult<()> { + if expected + .insert(block_id.to_string(), (kind.to_string(), code.to_string())) + .is_some() + { + return Err(LocalityError::InvalidState( + "Notion portable media native payload has colliding outcome identities".to_string(), + )); } Ok(()) } @@ -779,8 +1169,27 @@ fn validate_portable_media_bundle(bundle: &NotionPortablePageBundleV1) -> Locali fn collect_media_blocks<'a>( trees: &'a [BlockTreeDto], media: &mut BTreeMap, + expected_incomplete: &mut BTreeMap, ) -> LocalityResult<()> { for tree in trees { + validate_exclusive_typed_media_payload(&tree.block)?; + for (field, value) in [ + ("tab", tree.block.tab.as_ref()), + ("ai_block", tree.block.ai_block.as_ref()), + ("custom_block", tree.block.custom_block.as_ref()), + ("button", tree.block.button.as_ref()), + ] { + if let Some(value) = value + && validate_sanitized_arbitrary_json(value)? + { + insert_expected_incomplete( + expected_incomplete, + &format!("{}:json:{field}", tree.block.id), + "arbitrary_json", + "sanitized_embedded_media_secret", + )?; + } + } if is_media_kind(&tree.block.kind) { let payload = media_payload(&tree.block).ok_or_else(|| { LocalityError::InvalidState( @@ -796,7 +1205,7 @@ fn collect_media_blocks<'a>( )); } } - collect_media_blocks(&tree.children, media)?; + collect_media_blocks(&tree.children, media, expected_incomplete)?; } Ok(()) } @@ -1467,7 +1876,7 @@ mod tests { } let fetcher = OneByteFetcher; - let mut state = PortableMediaCaptureState::new(&fetcher); + let mut state = PortableMediaCaptureState::new(&fetcher, false); state.aggregate_bytes = PORTABLE_MEDIA_MAX_AGGREGATE_BYTES; let mut payload = FileBlockDto { kind: "file".to_string(), @@ -1485,7 +1894,7 @@ mod tests { .expect("aggregate capture"); assert!(state.captured.is_empty()); - assert_eq!(state.incomplete[0].code, "aggregate_too_large"); + assert_eq!(state.incomplete[0].code, "unavailable_hosted_media"); assert_eq!(payload.file.expect("hosted").url, ""); } } diff --git a/crates/locality-notion/tests/fetch_render.rs b/crates/locality-notion/tests/fetch_render.rs index 4a7ea25e..1dfa9803 100644 --- a/crates/locality-notion/tests/fetch_render.rs +++ b/crates/locality-notion/tests/fetch_render.rs @@ -22,10 +22,11 @@ use locality_notion::dto::{ DataSourceSummaryDto, DatabaseDto, DatabaseListDto, DateMentionDto, EmptyBlockDto, EquationBlockDto, EquationRichTextDto, ExternalFileDto, FileBlockDto, FilePropertyDto, HostedFileDto, IdRefDto, LinkDto, LinkToPageBlockDto, MeetingNotesBlockDto, MentionRichTextDto, - NotionDatabaseBundle, PageDto, PageListDto, PagePropertyDto, PaginatedListDto, ParentDto, - RichTextAnnotationsDto, RichTextBlockDto, RichTextDto, SelectOptionDto, - SelectPropertySchemaDto, SyncedBlockDto, SyncedFromDto, TableBlockDto, TableRowBlockDto, - TextRichTextDto, TitleBlockDto, UniqueIdPropertyDto, UrlBlockDto, VerificationPropertyDto, + NotionDatabaseBundle, NotionPortableIncompleteMediaV1, NotionPortablePageBundleV1, PageDto, + PageListDto, PagePropertyDto, PaginatedListDto, ParentDto, RichTextAnnotationsDto, + RichTextBlockDto, RichTextDto, SelectOptionDto, SelectPropertySchemaDto, SyncedBlockDto, + SyncedFromDto, TableBlockDto, TableRowBlockDto, TextRichTextDto, TitleBlockDto, + UniqueIdPropertyDto, UrlBlockDto, VerificationPropertyDto, }; use locality_notion::media::{ PORTABLE_MEDIA_MAX_ASSET_BYTES, PortableMediaCapture, PortableMediaCaptureFetcher, @@ -3301,6 +3302,304 @@ fn portable_media_expired_failed_and_oversized_captures_are_redacted() { } } +#[test] +fn portable_media_sanitizes_nested_arbitrary_json_and_preserves_ordinary_values() { + let page_id = "arbitrary-json-page"; + let signed_url = concat!( + "https://secure.notion-static.com/nested/file.pdf?", + "X-Amz-Credential=credential-secret&X-Amz-Signature=signature-secret&", + "token=token-secret#fragment-secret" + ); + let ordinary_formula = json!({ + "type": "string", + "string": "ordinary formula result", + "nested": [1, true, { "status": "ready" }] + }); + let mut fixture_page = page(page_id, "Arbitrary JSON"); + fixture_page.properties.insert( + "formula".to_string(), + PagePropertyDto { + kind: "formula".to_string(), + formula: Some(ordinary_formula.clone()), + ..Default::default() + }, + ); + fixture_page.properties.insert( + "rollup".to_string(), + PagePropertyDto { + kind: "rollup".to_string(), + rollup: Some(json!({ + "type": "array", + "array": [{ + "type": "files", + "files": [{ + "name": "private.pdf", + "type": "file", + "file": { + "url": signed_url, + "expiry_time": "2099-01-01T00:00:00.000Z", + "authorization": "Bearer authorization-secret", + "secret": "nested-secret" + } + }] + }] + })), + ..Default::default() + }, + ); + let mut custom = block("custom-secret", "custom_block"); + custom.custom_block = Some(json!({ + "payload": [{ + "file": { + "url": signed_url, + "expiry_time": "2099-01-01T00:00:00.000Z", + "token": "custom-token-secret" + } + }] + })); + let connector = portable_media_connector_with_page(fixture_page, vec![custom]) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + + let fetched = connector + .fetch_portable(portable_fetch_request(page_id)) + .expect("sanitized arbitrary JSON fetch"); + assert!(!fetched.completeness.is_complete()); + let native: NotionPortablePageBundleV1 = + serde_json::from_slice(&fetched.native.raw).expect("portable native"); + assert_eq!( + native.page.page.properties["formula"].formula.as_ref(), + Some(&ordinary_formula) + ); + assert_eq!( + native.page.page.properties["rollup"].rollup, + Some(json!({ + "type": "array", + "array": [{ + "type": "files", + "files": [{ + "name": "private.pdf", + "type": "file", + "file": { "url": "" } + }] + }], + "_locality_portable_media_sanitized_v1": true + })) + ); + assert_eq!( + native.page.blocks[0].block.custom_block, + Some(json!({ + "payload": [{ "file": { "url": "" } }], + "_locality_portable_media_sanitized_v1": true + })) + ); + assert_eq!(native.incomplete_media.len(), 2); + assert!(native.incomplete_media.iter().all(|outcome| { + outcome.kind == "arbitrary_json" && outcome.code == "sanitized_embedded_media_secret" + })); + let raw = String::from_utf8_lossy(&fetched.native.raw); + for forbidden in [ + signed_url, + "secure.notion-static.com", + "X-Amz", + "credential-secret", + "signature-secret", + "token-secret", + "authorization-secret", + "nested-secret", + "fragment-secret", + "expiry_time", + "2099-01-01", + ] { + assert!(!raw.contains(forbidden), "native retained {forbidden}"); + assert!(!format!("{fetched:?}").contains(forbidden)); + } + assert_eq!( + raw.matches("_locality_portable_media_sanitized_v1").count(), + 2 + ); + + let mut tampered = native.clone(); + tampered + .page + .page + .properties + .get_mut("rollup") + .and_then(|property| property.rollup.as_mut()) + .and_then(serde_json::Value::as_object_mut) + .expect("rollup object") + .insert("leaked_url".to_string(), json!(signed_url)); + let tampered_native = NativeEntity { + remote_id: RemoteId::new(page_id), + kind: "notion_page_portable_media_v1".to_string(), + raw: serde_json::to_vec(&tampered).expect("tampered arbitrary native"), + }; + let error = connector + .render_portable(&portable_render_request(page_id, tampered_native)) + .expect_err("render must revalidate arbitrary JSON"); + assert_eq!( + error.to_string(), + "invalid state: Notion portable arbitrary JSON retained media credentials" + ); + for forbidden in [signed_url, "signature-secret", "token-secret"] { + assert!(!format!("{error:?}").contains(forbidden)); + } + + let rendered = connector + .render_portable(&portable_render_request(page_id, fetched.native)) + .expect("render sanitized arbitrary JSON"); + assert!(!rendered.completeness.is_complete()); + assert_eq!(rendered.projections.len(), 1); + let rendered_debug = format!("{rendered:?}"); + for forbidden in ["https://", "X-Amz", "token-secret", "authorization-secret"] { + assert!(!rendered_debug.contains(forbidden)); + } +} + +#[test] +fn portable_media_rejects_extra_typed_payloads_without_echoing_credentials() { + let first_url = "https://secure.notion-static.com/image.png?X-Amz-Signature=first-secret"; + let second_url = "https://secure.notion-static.com/video.mp4?X-Amz-Signature=second-secret"; + let mut malformed = hosted_file_block("malformed", "image", first_url, None); + malformed.video = hosted_file_block("unused", "video", second_url, None).video; + let connector = portable_media_connector("malformed-page", vec![malformed]) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + let error = connector + .fetch_portable(portable_fetch_request("malformed-page")) + .expect_err("extra typed media payload must fail"); + assert_eq!( + error.to_string(), + "invalid state: Notion portable media block must contain exactly its selected typed payload" + ); + let error_debug = format!("{error:?}"); + for forbidden in [first_url, second_url, "first-secret", "second-secret"] { + assert!(!error_debug.contains(forbidden)); + } + + let mut non_media = paragraph_block("paragraph", vec![rich_text("hello")]); + non_media.image = hosted_file_block("unused", "image", first_url, None).image; + let connector = portable_media_connector("non-media-page", vec![non_media]) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + assert_eq!( + connector + .fetch_portable(portable_fetch_request("non-media-page")) + .expect_err("typed media on non-media block must fail") + .to_string(), + "invalid state: Notion portable non-media block contains a typed media payload" + ); +} + +#[test] +fn portable_media_render_binds_every_incomplete_outcome_exactly() { + let page_id = "outcome-binding-page"; + let mut fixture_page = page(page_id, "Outcome Binding"); + fixture_page.properties.insert( + "attachments".to_string(), + PagePropertyDto { + kind: "files".to_string(), + files: vec![FilePropertyDto { + name: Some("private.pdf".to_string()), + kind: "file".to_string(), + external: None, + file: Some(HostedFileDto { + url: "https://secure.notion-static.com/private.pdf?X-Amz-Signature=secret" + .to_string(), + expiry_time: Some("2099-01-01T00:00:00.000Z".to_string()), + }), + }], + ..Default::default() + }, + ); + let connector = portable_media_connector_with_page(fixture_page, Vec::new()) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + let fetched = connector + .fetch_portable(portable_fetch_request(page_id)) + .expect("property media fetch"); + let native: NotionPortablePageBundleV1 = + serde_json::from_slice(&fetched.native.raw).expect("portable native"); + assert_eq!( + native.incomplete_media, + vec![NotionPortableIncompleteMediaV1 { + block_id: "page-property-file-1".to_string(), + kind: "file_property".to_string(), + code: "unsupported_page_property_media".to_string(), + }] + ); + + let mut variants = Vec::new(); + let mut missing = native.clone(); + missing.incomplete_media.clear(); + variants.push(missing); + let mut wrong_kind = native.clone(); + wrong_kind.incomplete_media[0].kind = "image".to_string(); + variants.push(wrong_kind); + let mut wrong_code = native.clone(); + wrong_code.incomplete_media[0].code = "external_media".to_string(); + variants.push(wrong_code); + let mut spurious = native.clone(); + spurious + .incomplete_media + .push(NotionPortableIncompleteMediaV1 { + block_id: "spurious".to_string(), + kind: "file_property".to_string(), + code: "unsupported_page_property_media".to_string(), + }); + variants.push(spurious); + for variant in variants { + let native = NativeEntity { + remote_id: RemoteId::new(page_id), + kind: "notion_page_portable_media_v1".to_string(), + raw: serde_json::to_vec(&variant).expect("tampered native"), + }; + assert_eq!( + connector + .render_portable(&portable_render_request(page_id, native)) + .expect_err("tampered outcome must fail") + .to_string(), + "invalid state: Notion portable media native payload has invalid incomplete outcomes" + ); + } + + let mut duplicate = native.clone(); + duplicate + .incomplete_media + .push(duplicate.incomplete_media[0].clone()); + let duplicate_native = NativeEntity { + remote_id: RemoteId::new(page_id), + kind: "notion_page_portable_media_v1".to_string(), + raw: serde_json::to_vec(&duplicate).expect("duplicate outcome native"), + }; + assert_eq!( + connector + .render_portable(&portable_render_request(page_id, duplicate_native)) + .expect_err("duplicate outcome must fail") + .to_string(), + "invalid state: Notion portable media native payload has duplicate incomplete outcomes" + ); + + let mut retained_secret = native; + retained_secret + .page + .page + .properties + .get_mut("attachments") + .expect("attachments") + .files[0] + .file + .as_mut() + .expect("hosted") + .url = "https://secure.notion-static.com/private.pdf?X-Amz-Signature=secret".to_string(); + let tampered = NativeEntity { + remote_id: RemoteId::new(page_id), + kind: "notion_page_portable_media_v1".to_string(), + raw: serde_json::to_vec(&retained_secret).expect("tampered secret native"), + }; + let error = connector + .render_portable(&portable_render_request(page_id, tampered)) + .expect_err("render must independently reject retained credentials"); + assert!(!format!("{error:?}").contains("X-Amz-Signature")); + assert!(!format!("{error:?}").contains("secret")); +} + #[test] fn portable_media_duplicate_and_count_limits_fail_closed() { let duplicate = hosted_file_block( @@ -3310,8 +3609,15 @@ fn portable_media_duplicate_and_count_limits_fail_closed() { None, ); let duplicate_connector = - portable_media_connector("duplicate-page", vec![duplicate.clone(), duplicate]) - .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + portable_media_connector("duplicate-page", vec![duplicate.clone(), duplicate]); + let duplicate_calls = Arc::new(Mutex::new(Vec::new())); + let duplicate_connector = duplicate_connector.with_portable_media_capture_fetcher( + PortableMediaCapturePolicy::HostedPilot, + Arc::new(FixturePortableMediaFetcher { + outcomes: BTreeMap::new(), + calls: Arc::clone(&duplicate_calls), + }), + ); assert_eq!( duplicate_connector .fetch_portable(portable_fetch_request("duplicate-page")) @@ -3319,23 +3625,55 @@ fn portable_media_duplicate_and_count_limits_fail_closed() { .to_string(), "invalid state: Notion portable media contains a duplicate block identity" ); + assert!(duplicate_calls.lock().expect("duplicate calls").is_empty()); let too_many = (0..=128) .map(|index| { - let mut block = block(&format!("media-{index}"), "image"); - block.image = Some(FileBlockDto::default()); - block + hosted_file_block( + &format!("media-{index}"), + "image", + &format!( + "https://secure.notion-static.com/{index}.png?X-Amz-Signature=secret-{index}" + ), + Some("2099-01-01T00:00:00.000Z"), + ) }) .collect(); + let calls = Arc::new(Mutex::new(Vec::new())); + let fetcher = Arc::new(FixturePortableMediaFetcher { + outcomes: BTreeMap::new(), + calls: Arc::clone(&calls), + }); let count_connector = portable_media_connector("count-page", too_many) - .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + .with_portable_media_capture_fetcher(PortableMediaCapturePolicy::HostedPilot, fetcher); + let fetched = count_connector + .fetch_portable(portable_fetch_request("count-page")) + .expect("over-limit capture must become explicitly incomplete"); + assert!(calls.lock().expect("fetch calls").is_empty()); + assert!(!fetched.completeness.is_complete()); + let raw = String::from_utf8_lossy(&fetched.native.raw); + for forbidden in ["secure.notion-static.com", "X-Amz", "secret-", "2099-01-01"] { + assert!(!raw.contains(forbidden)); + } + let native: NotionPortablePageBundleV1 = + serde_json::from_slice(&fetched.native.raw).expect("over-limit native"); + assert!(native.captured_media.is_empty()); + assert_eq!(native.incomplete_media.len(), 130); + assert!(native.incomplete_media.iter().any(|outcome| { + outcome.block_id == "__locality_portable_media_limit_v1" + && outcome.kind == "page" + && outcome.code == "asset_limit_exceeded" + })); + let rendered = count_connector + .render_portable(&portable_render_request("count-page", fetched.native)) + .expect("render over-limit incomplete page"); + assert!(!rendered.completeness.is_complete()); + assert_eq!(rendered.projections.len(), 1); assert_eq!( - count_connector - .fetch_portable(portable_fetch_request("count-page")) - .expect_err("asset count must fail") - .to_string(), - "invalid state: Notion portable media exceeds the 128-asset pilot limit" + rendered.projections[0].file_kind, + ProjectionFileKind::Markdown ); + assert!(!format!("{rendered:?}").contains("X-Amz")); } #[test] @@ -3594,10 +3932,18 @@ impl PortableMediaCaptureFetcher for FixturePortableMediaFetcher { } fn portable_media_connector(page_id: &str, blocks: Vec) -> NotionConnector { + portable_media_connector_with_page(page(page_id, "Coverage"), blocks) +} + +fn portable_media_connector_with_page( + fixture_page: PageDto, + blocks: Vec, +) -> NotionConnector { + let page_id = fixture_page.id.clone(); let api = FixtureNotionApi { - pages: BTreeMap::from([(page_id.to_string(), page(page_id, "Coverage"))]), + pages: BTreeMap::from([(page_id.clone(), fixture_page)]), children: BTreeMap::from([( - (page_id.to_string(), None), + (page_id.clone(), None), PaginatedListDto { results: blocks, next_cursor: None, @@ -3622,6 +3968,15 @@ fn portable_fetch_request(page_id: &str) -> PortableFetchRequest { } } +fn portable_render_request(page_id: &str, native: NativeEntity) -> PortableRenderRequest { + PortableRenderRequest { + source_connection_id: SourceConnectionId::new("source-notion"), + logical_path: LogicalPath::new(format!("{page_id}/page.md")).expect("portable path"), + native, + format_version: 1, + } +} + #[derive(Debug)] struct FixtureNotionApi { pages: BTreeMap, diff --git a/docs/notion-connector.md b/docs/notion-connector.md index 0c92350b..5a54a1ef 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -204,6 +204,11 @@ uses that local path, and each binary supports only `read` and Signed query parameters, URL fragments, and Notion expiry timestamps are removed before native serialization. Failed or uncaptured payloads have no remote URL in native or rendered Markdown and make the result incomplete. +The same recursive sanitization covers opaque Notion JSON fields, including +formula and rollup results and raw AI/custom block payloads. A non-secret marker +binds each such redaction to an exact incomplete outcome; portable render +recomputes that outcome ledger and rejects missing, duplicate, spurious, or +mismatched entries before producing an artifact. External media stays unsupported and incomplete in this policy. Production capture accepts HTTPS on the default port only, rejects user information and IP literals, and permits exactly these origins: @@ -217,8 +222,10 @@ times, with every destination revalidated. Responses must use absent or `identity` content encoding. The pilot uses a five-second connection timeout, a 30-second overall request deadline, reads at most 64 KiB per streaming read, and enforces 128 assets per page, 20 MiB per asset, and 100 MiB total captured -bytes. Missing, expired, duplicate, oversized, denied, or failed media cannot -silently fall back to a provider URL. +bytes. Asset shape, identity, and count are preflighted before downloading. An +over-limit page is fully sanitized and explicitly incomplete without issuing a +media request or publishing binary projections. Missing, expired, duplicate, +oversized, denied, or failed media cannot silently fall back to a provider URL. ## Path Projection From b4a1b0139297d85b3cf520c507d015fb55a7fb7e Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Wed, 22 Jul 2026 01:23:36 -0700 Subject: [PATCH 16/18] test(notion): pin portable media native json --- crates/locality-notion/src/dto.rs | 40 ++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/locality-notion/src/dto.rs b/crates/locality-notion/src/dto.rs index b00ea092..1f9b11f3 100644 --- a/crates/locality-notion/src/dto.rs +++ b/crates/locality-notion/src/dto.rs @@ -86,7 +86,10 @@ mod portable_media_base64 { #[cfg(test)] mod portable_media_tests { - use super::NotionPortableCapturedMediaV1; + use super::{ + NotionPageBundle, NotionPortableCapturedMediaV1, NotionPortableIncompleteMediaV1, + NotionPortablePageBundleV1, PageDto, + }; use crate::media::PORTABLE_MEDIA_MAX_ASSET_BYTES; #[test] @@ -132,6 +135,41 @@ mod portable_media_tests { let error = serde_json::to_vec(&asset).expect_err("oversized media encode"); assert!(error.to_string().contains("decoded asset limit")); } + + #[test] + fn portable_media_native_json_is_byte_exact() { + let bundle = NotionPortablePageBundleV1 { + format_version: 1, + page: NotionPageBundle { + page: PageDto { + id: "page-1".to_string(), + parent: None, + created_time: None, + last_edited_time: Some("2026-07-22T01:00:00.000Z".to_string()), + archived: false, + in_trash: false, + properties: Default::default(), + }, + blocks: Vec::new(), + }, + captured_media: vec![NotionPortableCapturedMediaV1 { + block_id: "block-1".to_string(), + kind: "image".to_string(), + media_type: "image/png".to_string(), + bytes: vec![1, 2, 3], + }], + incomplete_media: vec![NotionPortableIncompleteMediaV1 { + block_id: "page-property-file-1".to_string(), + kind: "file_property".to_string(), + code: "unsupported_page_property_media".to_string(), + }], + }; + + assert_eq!( + serde_json::to_vec(&bundle).expect("portable media native JSON"), + br#"{"format_version":1,"page":{"page":{"id":"page-1","parent":null,"created_time":null,"last_edited_time":"2026-07-22T01:00:00.000Z","archived":false,"in_trash":false,"properties":{}},"blocks":[]},"captured_media":[{"block_id":"block-1","kind":"image","media_type":"image/png","bytes":"AQID"}],"incomplete_media":[{"block_id":"page-property-file-1","kind":"file_property","code":"unsupported_page_property_media"}]}"# + ); + } } /// Native database container plus the authoritative schemas for its data sources. From 5254a24820f5641a5b31b51cf122bf9a931c87cf Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Wed, 22 Jul 2026 01:32:15 -0700 Subject: [PATCH 17/18] Bound portable media validation --- crates/locality-notion/src/portable.rs | 148 +++++++--- crates/locality-notion/tests/fetch_render.rs | 294 ++++++++++++++++++- docs/notion-connector.md | 10 +- 3 files changed, 409 insertions(+), 43 deletions(-) diff --git a/crates/locality-notion/src/portable.rs b/crates/locality-notion/src/portable.rs index ee0eb21a..703916dd 100644 --- a/crates/locality-notion/src/portable.rs +++ b/crates/locality-notion/src/portable.rs @@ -351,6 +351,13 @@ impl<'a> PortableMediaCaptureState<'a> { if let Some(external) = payload.external.as_mut() { external.url.clear(); } + if self.limit_exceeded { + if let Some(hosted) = payload.file.as_mut() { + hosted.url.clear(); + hosted.expiry_time = None; + } + return Ok(()); + } let Some(hosted) = payload.file.as_mut() else { self.record_incomplete( block_id, @@ -374,10 +381,6 @@ impl<'a> PortableMediaCaptureState<'a> { self.record_incomplete(block_id, kind, "unavailable_hosted_media"); return Ok(()); } - if self.limit_exceeded { - self.record_incomplete(block_id, kind, "unavailable_hosted_media"); - return Ok(()); - } let sanitized_url = match sanitize_portable_hosted_media_url(&original_url) { Ok(url) => url, Err(_) => { @@ -444,11 +447,13 @@ impl<'a> PortableMediaCaptureState<'a> { hosted.url.clear(); hosted.expiry_time = None; } - self.record_incomplete( - &format!("page-property-file-{property_asset_index}"), - "file_property", - "unsupported_page_property_media", - ); + if !self.limit_exceeded { + self.record_incomplete( + &format!("page-property-file-{property_asset_index}"), + "file_property", + "unsupported_page_property_media", + ); + } } for (field, value) in [ ("formula", property.formula.as_mut()), @@ -590,7 +595,7 @@ fn sanitize_arbitrary_json_media_secrets_in_context( match value { serde_json::Value::String(string) => { if sanitize_portable_hosted_media_url(string).is_ok() - || looks_like_media_secret_value(string) + || looks_like_exact_credential_value(string) || (inherited_media_context && media_url_has_query_or_fragment(string)) { string.clear(); @@ -687,14 +692,28 @@ fn is_media_metadata_key(key: &str) -> bool { normalized == "expirytime" || normalized == "query" || normalized == "fragment" } -fn looks_like_media_secret_value(value: &str) -> bool { +fn looks_like_exact_credential_value(value: &str) -> bool { let lower = value.to_ascii_lowercase(); - lower.contains("x-amz-") + lower.contains("x-amz-signature=") + || lower.contains("x-amz-credential=") + || lower.contains("x-amz-security-token=") || lower.contains("signature=") || lower.contains("token=") || lower.contains("secret=") - || lower.contains("authorization") - || lower.contains("bearer ") + || lower.contains("authorization=") + || lower.contains("authorization: bearer ") + || looks_like_bearer_credential(value) +} + +fn looks_like_bearer_credential(value: &str) -> bool { + let Some((scheme, credential)) = value.trim().split_once(' ') else { + return false; + }; + scheme.eq_ignore_ascii_case("bearer") + && credential.len() >= 16 + && credential.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | '~') + }) } fn media_url_has_query_or_fragment(value: &str) -> bool { @@ -831,11 +850,67 @@ pub(crate) fn render(request: &PortableRenderRequest) -> LocalityResult { + expected: &'a [u8], + offset: usize, + matches: bool, +} + +impl<'a> ExactJsonWriter<'a> { + fn new(expected: &'a [u8]) -> Self { + Self { + expected, + offset: 0, + matches: true, + } + } + + fn is_exact(&self) -> bool { + self.matches && self.offset == self.expected.len() + } +} + +impl std::io::Write for ExactJsonWriter<'_> { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + let Some(end) = self.offset.checked_add(bytes.len()) else { + self.matches = false; + self.offset = usize::MAX; + return Ok(bytes.len()); + }; + if end > self.expected.len() || self.expected[self.offset..end] != *bytes { + self.matches = false; + } + self.offset = end; + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn validate_exact_portable_media_native( + bundle: &NotionPortablePageBundleV1, + raw: &[u8], +) -> LocalityResult<()> { + let mut writer = ExactJsonWriter::new(raw); + serde_json::to_writer(&mut writer, bundle).map_err(|_| { + LocalityError::Io("Notion portable media canonical validation failed".to_string()) + })?; + if !writer.is_exact() { + return Err(LocalityError::InvalidState( + "Notion portable media native payload is not canonical".to_string(), + )); + } + Ok(()) +} + fn render_portable_media_page( request: &PortableRenderRequest, ) -> LocalityResult { let bundle = serde_json::from_slice::(&request.native.raw) .map_err(|_| LocalityError::Io("Notion portable media native decode failed".to_string()))?; + validate_exact_portable_media_native(&bundle, &request.native.raw)?; if bundle.format_version != PORTABLE_MEDIA_NATIVE_FORMAT_VERSION { return Err(LocalityError::InvalidState( "Notion portable media native format version is unsupported".to_string(), @@ -999,6 +1074,13 @@ fn validate_portable_media_bundle(bundle: &NotionPortablePageBundleV1) -> Locali &mut media_blocks, &mut expected_incomplete, )?; + let asset_count = portable_media_asset_count(&bundle.page)?; + let limit_exceeded = asset_count > PORTABLE_MEDIA_MAX_ASSETS; + if limit_exceeded && !bundle.captured_media.is_empty() { + return Err(LocalityError::InvalidState( + "Notion portable over-limit native payload contains captured media".to_string(), + )); + } for (block_id, (kind, payload)) in &media_blocks { if payload .external @@ -1045,13 +1127,15 @@ fn validate_portable_media_bundle(bundle: &NotionPortablePageBundleV1) -> Locali "Notion portable incomplete media retained a remote URL".to_string(), )); } - let code = match (payload.external.is_some(), payload.file.is_some()) { - (true, true) => "ambiguous_file_source", - (true, false) => "external_media", - (false, true) => "unavailable_hosted_media", - (false, false) => "missing_file", - }; - insert_expected_incomplete(&mut expected_incomplete, block_id, kind, code)?; + if !limit_exceeded { + let code = match (payload.external.is_some(), payload.file.is_some()) { + (true, true) => "ambiguous_file_source", + (true, false) => "external_media", + (false, true) => "unavailable_hosted_media", + (false, false) => "missing_file", + }; + insert_expected_incomplete(&mut expected_incomplete, block_id, kind, code)?; + } } } for media in &bundle.captured_media { @@ -1079,12 +1163,14 @@ fn validate_portable_media_bundle(bundle: &NotionPortablePageBundleV1) -> Locali "Notion portable page property media is not sanitized".to_string(), )); } - insert_expected_incomplete( - &mut expected_incomplete, - &format!("page-property-file-{property_asset_index}"), - "file_property", - "unsupported_page_property_media", - )?; + if !limit_exceeded { + insert_expected_incomplete( + &mut expected_incomplete, + &format!("page-property-file-{property_asset_index}"), + "file_property", + "unsupported_page_property_media", + )?; + } } for (field, value) in [ ("formula", property.formula.as_ref()), @@ -1103,13 +1189,7 @@ fn validate_portable_media_bundle(bundle: &NotionPortablePageBundleV1) -> Locali } } - let asset_count = portable_media_asset_count(&bundle.page)?; - if asset_count > PORTABLE_MEDIA_MAX_ASSETS { - if !bundle.captured_media.is_empty() { - return Err(LocalityError::InvalidState( - "Notion portable over-limit native payload contains captured media".to_string(), - )); - } + if limit_exceeded { insert_expected_incomplete( &mut expected_incomplete, PORTABLE_MEDIA_LIMIT_OUTCOME_ID, diff --git a/crates/locality-notion/tests/fetch_render.rs b/crates/locality-notion/tests/fetch_render.rs index 1dfa9803..18baedf3 100644 --- a/crates/locality-notion/tests/fetch_render.rs +++ b/crates/locality-notion/tests/fetch_render.rs @@ -3455,6 +3455,57 @@ fn portable_media_sanitizes_nested_arbitrary_json_and_preserves_ordinary_values( } } +#[test] +fn portable_media_preserves_ordinary_authentication_words_exactly() { + let page_id = "ordinary-auth-words"; + let formula = json!({ + "type": "string", + "string": "Authorization approved", + "note": "unrelated token words remain ordinary prose" + }); + let rollup = json!({ + "type": "array", + "array": ["bearer bonds", "token budget approved"] + }); + let mut fixture_page = page(page_id, "Ordinary Words"); + fixture_page.properties.insert( + "formula".to_string(), + PagePropertyDto { + kind: "formula".to_string(), + formula: Some(formula.clone()), + ..Default::default() + }, + ); + fixture_page.properties.insert( + "rollup".to_string(), + PagePropertyDto { + kind: "rollup".to_string(), + rollup: Some(rollup.clone()), + ..Default::default() + }, + ); + let connector = portable_media_connector_with_page(fixture_page, Vec::new()) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + + let fetched = connector + .fetch_portable(portable_fetch_request(page_id)) + .expect("ordinary prose fetch"); + assert!(fetched.completeness.is_complete()); + let native: NotionPortablePageBundleV1 = + serde_json::from_slice(&fetched.native.raw).expect("portable native"); + assert_eq!( + native.page.page.properties["formula"].formula, + Some(formula) + ); + assert_eq!(native.page.page.properties["rollup"].rollup, Some(rollup)); + assert!(native.incomplete_media.is_empty()); + + let rendered = connector + .render_portable(&portable_render_request(page_id, fetched.native)) + .expect("ordinary prose render"); + assert!(rendered.completeness.is_complete()); +} + #[test] fn portable_media_rejects_extra_typed_payloads_without_echoing_credentials() { let first_url = "https://secure.notion-static.com/image.png?X-Amz-Signature=first-secret"; @@ -3600,6 +3651,66 @@ fn portable_media_render_binds_every_incomplete_outcome_exactly() { assert!(!format!("{error:?}").contains("secret")); } +#[test] +fn portable_media_render_rejects_unknown_fields_without_echoing_secrets() { + let page_id = "unknown-fields-page"; + let connector = portable_media_connector( + page_id, + vec![file_block( + "image-unknown", + "image", + "https://example.com/image.png", + "Image", + )], + ) + .with_portable_media_capture(PortableMediaCapturePolicy::HostedPilot); + let fetched = connector + .fetch_portable(portable_fetch_request(page_id)) + .expect("base portable native"); + let raw = String::from_utf8(fetched.native.raw).expect("UTF-8 JSON"); + + let top_secret = concat!( + "https://secure.notion-static.com/top.png?", + "X-Amz-Signature=top-secret&token=top-token" + ); + let top = format!(r#"{{"unknown_top":"{top_secret}",{}"#, &raw[1..]); + let deep_secret = concat!( + "https://secure.notion-static.com/deep.png?", + "X-Amz-Signature=deep-secret&token=deep-token" + ); + let deep = raw.replacen( + r#"},"video":null"#, + &format!( + r#","unknown_nested":{{"url":"{deep_secret}","token":"deep-token"}}}},"video":null"# + ), + 1, + ); + assert_ne!(deep, raw); + + for (tampered, secrets) in [ + (top, ["top-secret", "top-token"]), + (deep, ["deep-secret", "deep-token"]), + ] { + let error = connector + .render_portable(&portable_render_request( + page_id, + NativeEntity { + remote_id: RemoteId::new(page_id), + kind: "notion_page_portable_media_v1".to_string(), + raw: tampered.into_bytes(), + }, + )) + .expect_err("unknown field must fail canonical validation"); + assert_eq!( + error.to_string(), + "invalid state: Notion portable media native payload is not canonical" + ); + for secret in secrets { + assert!(!format!("{error:?}").contains(secret)); + } + } +} + #[test] fn portable_media_duplicate_and_count_limits_fail_closed() { let duplicate = hosted_file_block( @@ -3658,12 +3769,66 @@ fn portable_media_duplicate_and_count_limits_fail_closed() { let native: NotionPortablePageBundleV1 = serde_json::from_slice(&fetched.native.raw).expect("over-limit native"); assert!(native.captured_media.is_empty()); - assert_eq!(native.incomplete_media.len(), 130); - assert!(native.incomplete_media.iter().any(|outcome| { - outcome.block_id == "__locality_portable_media_limit_v1" - && outcome.kind == "page" - && outcome.code == "asset_limit_exceeded" - })); + assert_eq!( + native.incomplete_media, + vec![NotionPortableIncompleteMediaV1 { + block_id: "__locality_portable_media_limit_v1".to_string(), + kind: "page".to_string(), + code: "asset_limit_exceeded".to_string(), + }] + ); + for extra in [ + NotionPortableIncompleteMediaV1 { + block_id: "media-0".to_string(), + kind: "image".to_string(), + code: "unavailable_hosted_media".to_string(), + }, + NotionPortableIncompleteMediaV1 { + block_id: "spurious".to_string(), + kind: "page".to_string(), + code: "asset_limit_exceeded".to_string(), + }, + ] { + let mut tampered = native.clone(); + tampered.incomplete_media.push(extra); + let error = count_connector + .render_portable(&portable_render_request( + "count-page", + NativeEntity { + remote_id: RemoteId::new("count-page"), + kind: "notion_page_portable_media_v1".to_string(), + raw: serde_json::to_vec(&tampered).expect("tampered outcome native"), + }, + )) + .expect_err("over-limit per-asset or spurious outcome must fail"); + assert_eq!( + error.to_string(), + "invalid state: Notion portable media native payload has invalid incomplete outcomes" + ); + } + let mut captured = native.clone(); + captured + .captured_media + .push(locality_notion::dto::NotionPortableCapturedMediaV1 { + block_id: "media-0".to_string(), + kind: "image".to_string(), + media_type: "image/png".to_string(), + bytes: vec![1], + }); + assert_eq!( + count_connector + .render_portable(&portable_render_request( + "count-page", + NativeEntity { + remote_id: RemoteId::new("count-page"), + kind: "notion_page_portable_media_v1".to_string(), + raw: serde_json::to_vec(&captured).expect("tampered capture native"), + }, + )) + .expect_err("over-limit capture must fail") + .to_string(), + "invalid state: Notion portable over-limit native payload contains captured media" + ); let rendered = count_connector .render_portable(&portable_render_request("count-page", fetched.native)) .expect("render over-limit incomplete page"); @@ -3676,6 +3841,123 @@ fn portable_media_duplicate_and_count_limits_fail_closed() { assert!(!format!("{rendered:?}").contains("X-Amz")); } +#[test] +fn portable_media_large_over_limit_page_has_bounded_outcomes() { + const PROPERTY_ASSETS: usize = 4_096; + let page_id = "large-over-limit-page"; + let property_url = concat!( + "https://secure.notion-static.com/property.pdf?", + "X-Amz-Signature=property-secret" + ); + let block_url = concat!( + "https://secure.notion-static.com/image.png?", + "X-Amz-Signature=block-secret" + ); + let mut fixture_page = page(page_id, "Large Over Limit"); + fixture_page.properties.insert( + "attachments".to_string(), + PagePropertyDto { + kind: "files".to_string(), + files: (0..PROPERTY_ASSETS) + .map(|index| FilePropertyDto { + name: Some(format!("file-{index}.pdf")), + kind: "file".to_string(), + external: None, + file: Some(HostedFileDto { + url: property_url.to_string(), + expiry_time: Some("2099-01-01T00:00:00.000Z".to_string()), + }), + }) + .collect(), + ..Default::default() + }, + ); + fixture_page.properties.insert( + "rollup".to_string(), + PagePropertyDto { + kind: "rollup".to_string(), + rollup: Some(json!({ + "type": "files", + "file": { + "url": property_url, + "expiry_time": "2099-01-01T00:00:00.000Z" + } + })), + ..Default::default() + }, + ); + let calls = Arc::new(Mutex::new(Vec::new())); + let connector = portable_media_connector_with_page( + fixture_page, + vec![hosted_file_block( + "large-image", + "image", + block_url, + Some("2099-01-01T00:00:00.000Z"), + )], + ) + .with_portable_media_capture_fetcher( + PortableMediaCapturePolicy::HostedPilot, + Arc::new(FixturePortableMediaFetcher { + outcomes: BTreeMap::new(), + calls: Arc::clone(&calls), + }), + ); + + let fetched = connector + .fetch_portable(portable_fetch_request(page_id)) + .expect("large over-limit fetch"); + assert!(calls.lock().expect("media calls").is_empty()); + let native: NotionPortablePageBundleV1 = + serde_json::from_slice(&fetched.native.raw).expect("large native"); + assert!(native.captured_media.is_empty()); + assert_eq!(native.incomplete_media.len(), 2); + assert!(native.incomplete_media.iter().any(|outcome| { + outcome.block_id == "__locality_portable_media_limit_v1" + && outcome.kind == "page" + && outcome.code == "asset_limit_exceeded" + })); + assert!(native.incomplete_media.iter().any(|outcome| { + outcome.kind == "arbitrary_json" && outcome.code == "sanitized_embedded_media_secret" + })); + assert!( + native.page.page.properties["attachments"] + .files + .iter() + .all(|file| file + .file + .as_ref() + .is_some_and(|hosted| { hosted.url.is_empty() && hosted.expiry_time.is_none() })) + ); + let hosted = native.page.blocks[0] + .block + .image + .as_ref() + .and_then(|payload| payload.file.as_ref()) + .expect("hosted block"); + assert!(hosted.url.is_empty()); + assert!(hosted.expiry_time.is_none()); + let raw = String::from_utf8_lossy(&fetched.native.raw); + for forbidden in [ + "secure.notion-static.com", + "X-Amz", + "property-secret", + "block-secret", + "2099-01-01", + ] { + assert!(!raw.contains(forbidden)); + } + + let rendered = connector + .render_portable(&portable_render_request(page_id, fetched.native)) + .expect("large over-limit render"); + assert_eq!(rendered.projections.len(), 1); + assert_eq!( + rendered.projections[0].file_kind, + ProjectionFileKind::Markdown + ); +} + #[test] fn enumerate_suffixes_every_colliding_sibling_name() { let root_page_id = RemoteId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); diff --git a/docs/notion-connector.md b/docs/notion-connector.md index 5a54a1ef..009a4ae9 100644 --- a/docs/notion-connector.md +++ b/docs/notion-connector.md @@ -208,7 +208,9 @@ The same recursive sanitization covers opaque Notion JSON fields, including formula and rollup results and raw AI/custom block payloads. A non-secret marker binds each such redaction to an exact incomplete outcome; portable render recomputes that outcome ledger and rejects missing, duplicate, spurious, or -mismatched entries before producing an artifact. +mismatched entries before producing an artifact. Durable v1 native JSON is +canonical: render streams a byte-for-byte reserialization comparison and +rejects unknown fields, alternate field order, or noncanonical whitespace. External media stays unsupported and incomplete in this policy. Production capture accepts HTTPS on the default port only, rejects user information and IP literals, and permits exactly these origins: @@ -224,8 +226,10 @@ times, with every destination revalidated. Responses must use absent or and enforces 128 assets per page, 20 MiB per asset, and 100 MiB total captured bytes. Asset shape, identity, and count are preflighted before downloading. An over-limit page is fully sanitized and explicitly incomplete without issuing a -media request or publishing binary projections. Missing, expired, duplicate, -oversized, denied, or failed media cannot silently fall back to a provider URL. +media request or publishing binary projections. Its asset bookkeeping stays +bounded at one page-level `asset_limit_exceeded` outcome, plus any independent +opaque-JSON redaction outcomes. Missing, expired, duplicate, oversized, denied, +or failed media cannot silently fall back to a provider URL. ## Path Projection From dccc26aef526fc5ba6f3e50d7a2ae6154514305f Mon Sep 17 00:00:00 2001 From: misrasaurabh1 Date: Wed, 22 Jul 2026 03:37:58 -0700 Subject: [PATCH 18/18] Add sandbox export encoding control --- crates/loc-cli/src/commands.rs | 120 +++++++++++++++++++++-- crates/loc-cli/src/sandbox.rs | 101 +++++++++++++++++-- crates/loc-cli/tests/sandbox.rs | 166 +++++++++++++++++++++++++++++++- 3 files changed, 368 insertions(+), 19 deletions(-) diff --git a/crates/loc-cli/src/commands.rs b/crates/loc-cli/src/commands.rs index 6faa486d..77fbb2ad 100644 --- a/crates/loc-cli/src/commands.rs +++ b/crates/loc-cli/src/commands.rs @@ -6,7 +6,7 @@ use std::sync::mpsc::{self, Sender}; use std::thread::{self, JoinHandle}; use std::time::Duration; -use clap::{Args, CommandFactory, Parser, Subcommand}; +use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; use locality_connector::ConnectorUndoApplier; use locality_connector::oauth_broker::OAuthBrokerStart; use locality_core::LocalityError; @@ -94,7 +94,8 @@ use crate::push::{ }; use crate::restore::{RestoreError, RestoreOptions, RestoreReport, run_restore}; use crate::sandbox::{ - SandboxInitOptions, SandboxInitReport, resolve_bootstrap_token, run_sandbox_init, + SandboxContentEncodingPreference, SandboxInitOptions, SandboxInitReport, + resolve_bootstrap_token, run_sandbox_init_with_encoding, }; use crate::search::{ SearchError, SearchOptions, SearchReport, SearchResult, is_notion_url_host, notion_id_from_url, @@ -245,10 +246,41 @@ struct SandboxInitArgs { help = "Absent destination path for the read-only replica" )] root: String, + #[arg( + long, + value_enum, + value_name = "ENCODING", + help = "Require zstd or identity export encoding" + )] + encoding: Option, #[arg(long, help = "Read the one-time bootstrap token from standard input")] bootstrap_token_stdin: bool, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +enum SandboxEncodingArg { + Zstd, + Identity, +} + +impl SandboxEncodingArg { + const fn as_str(self) -> &'static str { + match self { + Self::Zstd => "zstd", + Self::Identity => "identity", + } + } +} + +impl From for SandboxContentEncodingPreference { + fn from(value: SandboxEncodingArg) -> Self { + match value { + SandboxEncodingArg::Zstd => Self::Zstd, + SandboxEncodingArg::Identity => Self::Identity, + } + } +} + #[derive(Debug, Subcommand)] enum ConnectCommand { #[command(about = "Connect a Notion workspace")] @@ -1186,6 +1218,9 @@ fn legacy_args_for_command(command: &LocalityCommand) -> Vec { args.push("init".to_string()); push_flag_value(&mut args, "--api-url", &options.api_url); push_flag_value(&mut args, "--root", &options.root); + if let Some(encoding) = options.encoding { + push_flag_value(&mut args, "--encoding", encoding.as_str()); + } push_flag( &mut args, "--bootstrap-token-stdin", @@ -1342,6 +1377,9 @@ fn legacy_args_for_command(command: &LocalityCommand) -> Vec { } fn sandbox_init(options: SandboxInitArgs, json: bool) -> i32 { + let content_encoding = options + .encoding + .map_or(SandboxContentEncodingPreference::Automatic, Into::into); let token = { let mut stdin = io::stdin().lock(); match resolve_bootstrap_token( @@ -1353,12 +1391,13 @@ fn sandbox_init(options: SandboxInitArgs, json: bool) -> i32 { Err(error) => return sandbox_init_command_error(json, error), } }; - match run_sandbox_init( + match run_sandbox_init_with_encoding( SandboxInitOptions { api_url: options.api_url, root: PathBuf::from(options.root), }, token, + content_encoding, ) { Ok(report) => { if json { @@ -8310,12 +8349,12 @@ mod tests { use super::resolve_mount_target; use super::{ Cli, ConnectReport, DaemonUnavailableReason, EXIT_SUCCESS, EXIT_VALIDATION, - FileProviderCommandReport, PushConfirmationPromptError, VirtualProjectionRegistration, - absolute_command_path, auto_registration_for_mounted_projection, - default_mount_id_for_source, diff_report_exit_code, exact_located_entity_record, - file_provider_list_lines, google_docs_oauth_broker_config, - guard_linux_fuse_shared_root_unregister, guard_unresolved_linux_fuse_unregister, - guard_unresolved_windows_cloud_files_unregister, + FileProviderCommandReport, LocalityCommand, PushConfirmationPromptError, SandboxCommand, + SandboxEncodingArg, VirtualProjectionRegistration, absolute_command_path, + auto_registration_for_mounted_projection, default_mount_id_for_source, + diff_report_exit_code, exact_located_entity_record, file_provider_list_lines, + google_docs_oauth_broker_config, guard_linux_fuse_shared_root_unregister, + guard_unresolved_linux_fuse_unregister, guard_unresolved_windows_cloud_files_unregister, guard_windows_cloud_files_shared_root_unregister, legacy_args_for_command, locate_result_from_report, mount_usage, mounted_projection_preflight_error, notion_authorize_url, notion_oauth_broker_config, print_push_confirmation_preview, @@ -8519,6 +8558,7 @@ mod tests { "Usage: loc sandbox init", "--api-url ", "--root ", + "--encoding ", "--bootstrap-token-stdin", "--json", ], @@ -8804,6 +8844,8 @@ mod tests { "https://api.locality.test", "--root", "/mnt/locality", + "--encoding", + "zstd", "--bootstrap-token-stdin", ]); assert_eq!( @@ -8815,6 +8857,8 @@ mod tests { "https://api.locality.test", "--root", "/mnt/locality", + "--encoding", + "zstd", "--bootstrap-token-stdin" ] ); @@ -9034,6 +9078,64 @@ mod tests { } } + #[test] + fn sandbox_encoding_parser_is_typed_optional_and_exact() { + for (value, expected) in [ + ("zstd", SandboxEncodingArg::Zstd), + ("identity", SandboxEncodingArg::Identity), + ] { + let cli = parse_cli([ + "sandbox", + "init", + "--api-url", + "https://api.locality.test", + "--root", + "/mnt/locality", + "--encoding", + value, + ]); + let Some(LocalityCommand::Sandbox { + command: SandboxCommand::Init(options), + }) = cli.command + else { + panic!("sandbox init command expected"); + }; + assert_eq!(options.encoding, Some(expected)); + } + + let automatic = parse_cli([ + "sandbox", + "init", + "--api-url", + "https://api.locality.test", + "--root", + "/mnt/locality", + ]); + let Some(LocalityCommand::Sandbox { + command: SandboxCommand::Init(options), + }) = automatic.command + else { + panic!("sandbox init command expected"); + }; + assert_eq!(options.encoding, None); + + let error = Cli::try_parse_from(argv([ + "sandbox", + "init", + "--api-url", + "https://api.locality.test", + "--root", + "/mnt/locality", + "--encoding", + "gzip", + ])) + .expect_err("unsupported encoding rejected by parser"); + assert_eq!(error.kind(), ErrorKind::InvalidValue); + let message = error.to_string(); + assert!(message.contains("invalid value 'gzip'")); + assert!(message.contains("[possible values: zstd, identity]")); + } + #[test] fn log_report_writer_prints_metadata_and_readable_diff() { let report = LogReport { diff --git a/crates/loc-cli/src/sandbox.rs b/crates/loc-cli/src/sandbox.rs index 354bd3a1..ab290bca 100644 --- a/crates/loc-cli/src/sandbox.rs +++ b/crates/loc-cli/src/sandbox.rs @@ -68,6 +68,38 @@ pub struct SandboxInitOptions { pub root: PathBuf, } +/// Controls HTTP content negotiation for a sandbox export. +/// +/// [`Self::Automatic`] preserves the original preference for Zstd with an +/// identity fallback. The forced variants are intended for acceptance and +/// interoperability testing and fail closed if the server selects a different +/// encoding. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SandboxContentEncodingPreference { + #[default] + Automatic, + Identity, + Zstd, +} + +impl SandboxContentEncodingPreference { + fn accept_encoding(self) -> &'static str { + match self { + Self::Automatic => "zstd, identity", + Self::Identity => "identity", + Self::Zstd => "zstd", + } + } + + fn required_encoding(self) -> Option { + match self { + Self::Automatic => None, + Self::Identity => Some(ReplicaArchiveEncoding::Identity), + Self::Zstd => Some(ReplicaArchiveEncoding::Zstd), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct SandboxInitReport { pub ok: bool, @@ -302,6 +334,29 @@ pub fn resolve_bootstrap_token( pub fn run_sandbox_init( options: SandboxInitOptions, bootstrap_token: SandboxBootstrapToken, +) -> Result { + run_sandbox_init_with_encoding( + options, + bootstrap_token, + SandboxContentEncodingPreference::Automatic, + ) +} + +/// Initializes a sandbox with an explicit export content-negotiation policy. +/// +/// The existing [`run_sandbox_init`] entry point remains automatic for source +/// compatibility. A forced preference is checked against the sealed offer +/// before the export request and against the response before any body bytes are +/// materialized. +/// +/// # Errors +/// +/// Returns [`SandboxInitError`] when validation, protocol exchange, content +/// negotiation, or atomic materialization fails. +pub fn run_sandbox_init_with_encoding( + options: SandboxInitOptions, + bootstrap_token: SandboxBootstrapToken, + content_encoding: SandboxContentEncodingPreference, ) -> Result { let root = absolute_destination(&options.root)?; validate_destination(&root)?; @@ -311,8 +366,9 @@ pub fn run_sandbox_init( validate_capability(&capability)?; let status = client.session_status(&capability)?; let (offer, expected_receipt) = validate_status(&capability, &status)?; + validate_encoding_preference(offer, content_encoding)?; let limits = limits_for_offer(offer)?; - let (encoding, response) = client.open_export(&capability, offer)?; + let (encoding, response) = client.open_export(&capability, offer, content_encoding)?; let archive = ReplicaArchive::new(encoding, response); let summary = materialize_replica_archive_with_expected_receipt(archive, &root, limits, expected_receipt) @@ -495,6 +551,25 @@ fn limits_for_offer( }) } +fn validate_encoding_preference( + offer: &TarExportOffer, + preference: SandboxContentEncodingPreference, +) -> Result<(), SandboxInitError> { + let Some(required) = preference.required_encoding() else { + return Ok(()); + }; + if offer + .supported_content_encodings + .contains(&protocol_encoding(required)) + { + Ok(()) + } else { + Err(SandboxInitError::UnsupportedExportEncoding( + encoding_name(required).to_string(), + )) + } +} + fn report( root: &Path, capability: &SessionCapability, @@ -632,12 +707,13 @@ impl SandboxHttpClient { &self, capability: &SessionCapability, offer: &TarExportOffer, + preference: SandboxContentEncodingPreference, ) -> Result<(ReplicaArchiveEncoding, Response), SandboxInitError> { let response = self .client .get(self.export_url(capability.session_id.as_str())) .header(ACCEPT, TAR_MEDIA_TYPE) - .header(ACCEPT_ENCODING, "zstd, identity") + .header(ACCEPT_ENCODING, preference.accept_encoding()) .bearer_auth(&capability.opaque_capability) .send() .map_err(|error| SandboxInitError::Http { @@ -647,15 +723,21 @@ impl SandboxHttpClient { ensure_success(&response, "session export")?; require_media_type(response.headers(), "session export", TAR_MEDIA_TYPE)?; let encoding = response_encoding(response.headers())?; - let offered = match encoding { - ReplicaArchiveEncoding::Identity => TarContentEncoding::Identity, - ReplicaArchiveEncoding::Zstd => TarContentEncoding::Zstd, - }; + let offered = protocol_encoding(encoding); if !offer.supported_content_encodings.contains(&offered) { return Err(SandboxInitError::UnsupportedExportEncoding( encoding_name(encoding).to_string(), )); } + if let Some(required) = preference.required_encoding() + && encoding != required + { + return Err(SandboxInitError::UnsupportedExportEncoding(format!( + "{} (requested {})", + encoding_name(encoding), + encoding_name(required) + ))); + } Ok((encoding, response)) } @@ -797,6 +879,13 @@ fn encoding_name(encoding: ReplicaArchiveEncoding) -> &'static str { } } +fn protocol_encoding(encoding: ReplicaArchiveEncoding) -> TarContentEncoding { + match encoding { + ReplicaArchiveEncoding::Identity => TarContentEncoding::Identity, + ReplicaArchiveEncoding::Zstd => TarContentEncoding::Zstd, + } +} + #[cfg(test)] mod tests { use std::collections::BTreeMap; diff --git a/crates/loc-cli/tests/sandbox.rs b/crates/loc-cli/tests/sandbox.rs index ba2d9ae7..0ee39eef 100644 --- a/crates/loc-cli/tests/sandbox.rs +++ b/crates/loc-cli/tests/sandbox.rs @@ -10,8 +10,8 @@ use std::thread::{self, JoinHandle}; use std::time::Duration; use loc_cli::sandbox::{ - SandboxBootstrapToken, SandboxInitError, SandboxInitOptions, resolve_bootstrap_token, - run_sandbox_init, + SandboxBootstrapToken, SandboxContentEncodingPreference, SandboxInitError, SandboxInitOptions, + resolve_bootstrap_token, run_sandbox_init, run_sandbox_init_with_encoding, }; use locality_core::portable::SessionId; use locality_protocol::{ @@ -281,6 +281,161 @@ fn zstd_bootstrap_streams_into_the_shared_materializer() { ); } +#[test] +fn forced_content_encodings_send_exact_headers_and_match_reports() { + let tar = tar_file(b"forced.txt", b"forced\n"); + let zstd = zstd::stream::encode_all(tar.as_slice(), 1).expect("compress tar"); + let cases = [ + ( + "forced-identity", + SandboxContentEncodingPreference::Identity, + "identity", + tar.clone(), + ), + ( + "forced-zstd", + SandboxContentEncodingPreference::Zstd, + "zstd", + zstd, + ), + ]; + + for (label, preference, expected_encoding, body) in cases { + let directory = TestDirectory::new(label); + let capability = capability(); + let status = ready_status( + capability.session_id.clone(), + COMPONENT_VERSIONS, + &tar, + BTreeSet::from([TarContentEncoding::Identity, TarContentEncoding::Zstd]), + ); + let server = MockServer::start(vec![ + ResponseFixture::json(&capability), + ResponseFixture::json(&status), + ResponseFixture::export(expected_encoding, body), + ]); + + let report = run_sandbox_init_with_encoding( + SandboxInitOptions { + api_url: server.api_url.clone(), + root: directory.root(), + }, + SandboxBootstrapToken::new("bootstrap-secret").expect("token"), + preference, + ) + .expect("bootstrap forced export encoding"); + + assert_eq!(report.content_encoding, expected_encoding, "case {label}"); + assert_eq!( + fs::read(directory.root().join("forced.txt")).expect("read replica"), + b"forced\n", + "case {label}" + ); + let _ = server.request(); + let _ = server.request(); + let export = server.request(); + assert_eq!( + export.headers.get("accept-encoding").unwrap(), + expected_encoding, + "case {label}" + ); + } +} + +#[test] +fn forced_content_encoding_fails_closed_on_offer_or_response_mismatch() { + let tar = tar_file(b"never-published.txt", b"body"); + let offer_capability = capability(); + let identity_only = ready_status( + offer_capability.session_id.clone(), + COMPONENT_VERSIONS, + &tar, + BTreeSet::from([TarContentEncoding::Identity]), + ); + let server = MockServer::start(vec![ + ResponseFixture::json(&offer_capability), + ResponseFixture::json(&identity_only), + ]); + let directory = TestDirectory::new("forced-zstd-not-offered"); + + let error = run_sandbox_init_with_encoding( + SandboxInitOptions { + api_url: server.api_url.clone(), + root: directory.root(), + }, + SandboxBootstrapToken::new("bootstrap-secret").expect("token"), + SandboxContentEncodingPreference::Zstd, + ) + .expect_err("unoffered forced encoding must fail before export"); + + assert_eq!(error.code(), "backend_protocol_invalid"); + assert_eq!( + error.to_string(), + "unsupported sandbox export encoding `zstd`" + ); + assert!(!directory.root().exists()); + let _ = server.request(); + let _ = server.request(); + server.assert_no_request(); + + for (label, preference, response_encoding, requested) in [ + ( + "forced-identity-got-zstd", + SandboxContentEncodingPreference::Identity, + "zstd", + "identity", + ), + ( + "forced-zstd-got-identity", + SandboxContentEncodingPreference::Zstd, + "identity", + "zstd", + ), + ] { + let directory = TestDirectory::new(label); + let capability = capability(); + let status = ready_status( + capability.session_id.clone(), + COMPONENT_VERSIONS, + &tar, + BTreeSet::from([TarContentEncoding::Identity, TarContentEncoding::Zstd]), + ); + let server = MockServer::start(vec![ + ResponseFixture::json(&capability), + ResponseFixture::json(&status), + ResponseFixture::export(response_encoding, tar.clone()), + ]); + + let error = run_sandbox_init_with_encoding( + SandboxInitOptions { + api_url: server.api_url.clone(), + root: directory.root(), + }, + SandboxBootstrapToken::new("bootstrap-secret").expect("token"), + preference, + ) + .expect_err("forced response mismatch must fail before materialization"); + + assert_eq!(error.code(), "backend_protocol_invalid", "case {label}"); + assert_eq!( + error.to_string(), + format!( + "unsupported sandbox export encoding `{response_encoding} (requested {requested})`" + ), + "case {label}" + ); + assert!(!directory.root().exists(), "case {label}"); + let _ = server.request(); + let _ = server.request(); + let export = server.request(); + assert_eq!( + export.headers.get("accept-encoding").unwrap(), + requested, + "case {label}" + ); + } +} + #[test] fn export_receipt_mismatches_roll_back_without_leaking_tokens() { let tar = tar_file(b"never-published.txt", b"body"); @@ -694,7 +849,7 @@ fn token_sources_are_exclusive_trim_only_line_endings_and_redact_debug() { } #[test] -fn cli_environment_token_never_appears_in_output() { +fn cli_forced_identity_reports_encoding_without_leaking_environment_token() { let directory = TestDirectory::new("cli-redaction"); let tar = tar_file(b"visible.txt", b"visible\n"); let capability = capability(); @@ -719,6 +874,8 @@ fn cli_environment_token_never_appears_in_output() { &server.api_url, "--root", &root, + "--encoding", + "identity", "--json", ]) .env("LOCALITY_BOOTSTRAP_TOKEN", "cli-bootstrap-secret") @@ -750,7 +907,8 @@ fn cli_environment_token_never_appears_in_output() { r#"{"bootstrap_token":"cli-bootstrap-secret"}"# ); let _ = server.request(); - let _ = server.request(); + let export = server.request(); + assert_eq!(export.headers.get("accept-encoding").unwrap(), "identity"); } trait StatusFixtureExt {