Skip to content

Commit 8a61e66

Browse files
committed
Bind TierStore pagination tokens to their index context
The local index store supplies an opaque pagination token, but returning that token directly would allow callers to reuse it with another logical namespace or a different index database. In this commit we wrap the index token in a versioned TierStore token containing the logical namespace identity and persistent index database ID and we validate this context before passing the opaque token back to the index store, rejecting any malformed, unsupported, or mismatched tokens.
1 parent 0ea16fa commit 8a61e66

1 file changed

Lines changed: 174 additions & 8 deletions

File tree

src/io/tier_store.rs

Lines changed: 174 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
1212
use std::sync::{Arc, Mutex};
1313

1414
use bitcoin::hashes::{sha256, Hash, HashEngine};
15+
use bitcoin::hex::{DisplayHex, FromHex};
1516
use lightning::util::persist::{
1617
KVStore, PageToken, PaginatedKVStore, PaginatedListResponse, NETWORK_GRAPH_PERSISTENCE_KEY,
1718
NETWORK_GRAPH_PERSISTENCE_PRIMARY_NAMESPACE, SCORER_PERSISTENCE_KEY,
@@ -27,6 +28,7 @@ use crate::logger::{LdkLogger, Logger};
2728
use crate::types::{DynStore, DynStoreWrapper};
2829

2930
const INDEX_DATABASE_ID_LEN: usize = 16;
31+
const PAGE_TOKEN_FORMAT_VERSION: u8 = 1;
3032
const INDEX_ENTRIES_PRIMARY_NAMESPACE: &str = "_tier_store_entries";
3133
const INDEX_JOURNAL_PRIMARY_NAMESPACE: &str = "_tier_store_journal";
3234
const INDEX_METADATA_PRIMARY_NAMESPACE: &str = "_tier_store_metadata";
@@ -98,9 +100,65 @@ impl JournalEntry {
98100
}
99101
}
100102

103+
struct TierStorePageToken {
104+
format_version: u8,
105+
index_database_id: Vec<u8>,
106+
namespace_id: String,
107+
index_page_token: String,
108+
}
109+
110+
impl_writeable_tlv_based!(TierStorePageToken, {
111+
(0, format_version, required),
112+
(2, index_database_id, required),
113+
(4, namespace_id, required),
114+
(6, index_page_token, required),
115+
});
116+
117+
impl TierStorePageToken {
118+
/// Wraps an index-store page token with the context required to validate its later use.
119+
fn encode(
120+
index_database_id: &[u8; INDEX_DATABASE_ID_LEN], namespace_id: String,
121+
index_page_token: PageToken,
122+
) -> PageToken {
123+
let token = Self {
124+
format_version: PAGE_TOKEN_FORMAT_VERSION,
125+
index_database_id: index_database_id.to_vec(),
126+
namespace_id,
127+
index_page_token: index_page_token.to_string(),
128+
};
129+
PageToken::new(Writeable::encode(&token).to_lower_hex_string())
130+
}
131+
132+
/// Decodes a TierStore token and rejects tokens issued for another index or namespace.
133+
fn decode(
134+
token: PageToken, expected_database_id: &[u8; INDEX_DATABASE_ID_LEN],
135+
expected_namespace_id: &str,
136+
) -> io::Result<PageToken> {
137+
let encoded = Vec::from_hex(token.as_str()).map_err(|_| {
138+
io::Error::new(io::ErrorKind::InvalidInput, "Invalid TierStore page token")
139+
})?;
140+
let mut reader = &*encoded;
141+
let token: Self = Readable::read(&mut reader).map_err(|_| {
142+
io::Error::new(io::ErrorKind::InvalidInput, "Invalid TierStore page token")
143+
})?;
144+
if !reader.is_empty()
145+
|| token.format_version != PAGE_TOKEN_FORMAT_VERSION
146+
|| token.index_database_id != expected_database_id
147+
|| token.namespace_id != expected_namespace_id
148+
{
149+
return Err(io::Error::new(
150+
io::ErrorKind::InvalidInput,
151+
"TierStore page token does not belong to this index namespace",
152+
));
153+
}
154+
Ok(PageToken::new(token.index_page_token))
155+
}
156+
}
157+
101158
pub(crate) struct TierStoreIndex {
102159
// Holding the store keeps its exclusive SQLite lock for the lifetime of the tier store.
103160
store: Arc<DynStore>,
161+
database_id: [u8; INDEX_DATABASE_ID_LEN],
104162
}
105163

106164
impl TierStoreIndex {
@@ -112,14 +170,22 @@ impl TierStoreIndex {
112170
Some(KV_TABLE_NAME.to_string()),
113171
)?;
114172
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(store));
115-
Self::read_or_create_database_id(store.as_ref()).await?;
116-
Ok(Self { store })
173+
let database_id = Self::read_or_create_database_id(store.as_ref()).await?;
174+
Ok(Self { store, database_id })
117175
}
118176

119177
/// Constructs an index over the supplied store for tests that do not need SQLite persistence.
120178
#[cfg(test)]
121179
fn from_store(store: Arc<DynStore>) -> Self {
122-
Self { store }
180+
Self { store, database_id: [1; INDEX_DATABASE_ID_LEN] }
181+
}
182+
183+
/// Constructs a test index with a specified database identity.
184+
#[cfg(test)]
185+
fn from_store_with_database_id(
186+
store: Arc<DynStore>, database_id: [u8; INDEX_DATABASE_ID_LEN],
187+
) -> Self {
188+
Self { store, database_id }
123189
}
124190

125191
/// Derives the internal secondary namespace for a logical namespace pair.
@@ -262,17 +328,25 @@ impl TierStoreIndex {
262328
.await
263329
}
264330

265-
/// Lists logical keys in the index store's creation order.
331+
/// Lists logical keys in the index store's creation order using a namespace-bound token.
266332
async fn list_paginated(
267333
&self, primary_namespace: &str, secondary_namespace: &str, page_token: Option<PageToken>,
268334
) -> io::Result<PaginatedListResponse> {
269-
PaginatedKVStore::list_paginated(
335+
let namespace_id = Self::namespace_id(primary_namespace, secondary_namespace);
336+
let index_page_token = page_token
337+
.map(|token| TierStorePageToken::decode(token, &self.database_id, &namespace_id))
338+
.transpose()?;
339+
let response = PaginatedKVStore::list_paginated(
270340
self.store.as_ref(),
271341
INDEX_ENTRIES_PRIMARY_NAMESPACE,
272-
&Self::namespace_id(primary_namespace, secondary_namespace),
273-
page_token,
342+
&namespace_id,
343+
index_page_token,
274344
)
275-
.await
345+
.await?;
346+
let next_page_token = response
347+
.next_page_token
348+
.map(|token| TierStorePageToken::encode(&self.database_id, namespace_id, token));
349+
Ok(PaginatedListResponse { keys: response.keys, next_page_token })
276350
}
277351

278352
/// Persists a pending operation before its value-store effects begin.
@@ -1350,6 +1424,57 @@ mod tests {
13501424
}
13511425
}
13521426

1427+
#[test]
1428+
fn page_token_roundtrips_and_validates_context() {
1429+
let database_id = [2; INDEX_DATABASE_ID_LEN];
1430+
let namespace_id = TierStoreIndex::namespace_id("primary", "secondary");
1431+
let token = TierStorePageToken::encode(
1432+
&database_id,
1433+
namespace_id.clone(),
1434+
PageToken::new("opaque:index-token".to_string()),
1435+
);
1436+
1437+
let decoded =
1438+
TierStorePageToken::decode(token.clone(), &database_id, &namespace_id).unwrap();
1439+
assert_eq!(decoded.as_str(), "opaque:index-token");
1440+
assert_eq!(
1441+
TierStorePageToken::decode(token.clone(), &[3; INDEX_DATABASE_ID_LEN], &namespace_id)
1442+
.unwrap_err()
1443+
.kind(),
1444+
io::ErrorKind::InvalidInput
1445+
);
1446+
assert_eq!(
1447+
TierStorePageToken::decode(token, &database_id, "another-namespace")
1448+
.unwrap_err()
1449+
.kind(),
1450+
io::ErrorKind::InvalidInput
1451+
);
1452+
assert_eq!(
1453+
TierStorePageToken::decode(
1454+
PageToken::new("not-a-tier-store-token".to_string()),
1455+
&database_id,
1456+
&namespace_id,
1457+
)
1458+
.unwrap_err()
1459+
.kind(),
1460+
io::ErrorKind::InvalidInput
1461+
);
1462+
let unsupported_version = TierStorePageToken {
1463+
format_version: PAGE_TOKEN_FORMAT_VERSION + 1,
1464+
index_database_id: database_id.to_vec(),
1465+
namespace_id: namespace_id.clone(),
1466+
index_page_token: "opaque:index-token".to_string(),
1467+
};
1468+
let unsupported_version =
1469+
PageToken::new(Writeable::encode(&unsupported_version).to_lower_hex_string());
1470+
assert_eq!(
1471+
TierStorePageToken::decode(unsupported_version, &database_id, &namespace_id)
1472+
.unwrap_err()
1473+
.kind(),
1474+
io::ErrorKind::InvalidInput
1475+
);
1476+
}
1477+
13531478
#[tokio::test]
13541479
async fn index_store_is_internal_persistent_sqlite_store() {
13551480
let base_dir = random_storage_path();
@@ -1497,6 +1622,47 @@ mod tests {
14971622
assert_eq!(KVStore::list(&tier, "namespace", "").await.unwrap().len(), 55);
14981623
}
14991624

1625+
#[tokio::test]
1626+
async fn paginated_listing_rejects_tokens_from_another_namespace_or_index() {
1627+
let base_dir = random_storage_path();
1628+
let log_path = base_dir.join("tier_store_test.log").to_string_lossy().into_owned();
1629+
let logger = Arc::new(Logger::new_fs_writer(log_path, Level::Trace).unwrap());
1630+
let _cleanup = CleanupDir(base_dir);
1631+
1632+
let primary_store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
1633+
let mut tier = setup_tier_store(Arc::clone(&primary_store), Arc::clone(&logger));
1634+
let index_store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
1635+
tier.set_index_store(TierStoreIndex::from_store_with_database_id(
1636+
index_store,
1637+
[1; INDEX_DATABASE_ID_LEN],
1638+
));
1639+
for i in 0..51 {
1640+
tier.write("namespace", "", &format!("key-{i:02}"), vec![1]).await.unwrap();
1641+
}
1642+
let token = PaginatedKVStore::list_paginated(&tier, "namespace", "", None)
1643+
.await
1644+
.unwrap()
1645+
.next_page_token
1646+
.unwrap();
1647+
1648+
let namespace_error =
1649+
PaginatedKVStore::list_paginated(&tier, "other-namespace", "", Some(token.clone()))
1650+
.await
1651+
.unwrap_err();
1652+
assert_eq!(namespace_error.kind(), io::ErrorKind::InvalidInput);
1653+
1654+
let replacement_index_store: Arc<DynStore> =
1655+
Arc::new(DynStoreWrapper(InMemoryStore::new()));
1656+
tier.set_index_store(TierStoreIndex::from_store_with_database_id(
1657+
replacement_index_store,
1658+
[2; INDEX_DATABASE_ID_LEN],
1659+
));
1660+
let index_error = PaginatedKVStore::list_paginated(&tier, "namespace", "", Some(token))
1661+
.await
1662+
.unwrap_err();
1663+
assert_eq!(index_error.kind(), io::ErrorKind::InvalidInput);
1664+
}
1665+
15001666
#[tokio::test]
15011667
async fn pending_creates_roll_forward_to_primary_and_backup_in_journal_order() {
15021668
let base_dir = random_storage_path();

0 commit comments

Comments
 (0)