Skip to content

Commit 5c5ce50

Browse files
authored
[#253] fix(index): PageStore::open에서 슈퍼블록 version 검증
* [#253] fix(index): PageStore::open에서 슈퍼블록 version 검증 open()이 magic은 검사하면서 version은 검사하지 않았습니다. VERSION 상수는 create() 쓰기 경로에서만 쓰이고 읽기 쪽에는 대응하는 검사가 없었습니다. magic이 막아주는 것은 '아예 다른 파일'뿐입니다. 실제로 마주치게 될 상황은 '같은 종류지만 다른 버전인 파일'이고, 그쪽이 그대로 통과했습니다: open(version=999) -> OK <- 조용히 통과 open(bad magic) -> ERR invalid magic bytes page_size가 슈퍼블록에서 그대로 읽혀 모든 오프셋 계산에 쓰이기 때문에 이것이 조용한 파일 손상으로 이어집니다. page_size=4096으로 쓰인 파일을 8192이라고 적힌 슈퍼블록으로 열면: read_page(0) -> ERR failed to fill whole buffer write_page(1) -> Ok <- 성공해버림 page 1의 올바른 오프셋 = 4160 실제로 쓴 오프셋 = 8256 읽기는 에러로 드러나지만 쓰기는 성공해서, 이후 읽기가 엉뚱한 바이트를 페이지로 해석하게 됩니다. 테스트 3건을 함께 둡니다. - open_rejects_an_unsupported_format_version: 거부 확인 - open_still_accepts_a_store_written_by_this_version: guard rail. 거부만 늘리는 것은 수정이 아니므로, 이 빌드가 쓴 파일이 내용까지 왕복하는지 확인합니다. - a_foreign_version_would_write_pages_at_the_wrong_offset: 이 검사가 왜 필요한지를 고정 — page_size를 믿기 전에 걸러야 한다는 점 bite-test로 확인했습니다. 검사를 제거하면 거부 테스트 2건이 FAILED로 뒤집히고, guard rail은 양쪽 구현에서 모두 통과합니다. 파일 포맷 변경이 아니라 이미 기록 중인 필드를 읽기만 하므로 기존 파일과의 호환성 문제는 없습니다. cargo test 675 passed / 0 failed (master 669 + 6). clippy --all-targets 경고 73건으로 master와 동일. * [#253] fix: 버전 검사를 지원 범위로 완화 (#241 머지 반영) #241이 머지되면서 VERSION이 2로 올라가고, v1 파일을 free_list_head=None 으로 읽는 마이그레이션 경로가 들어왔습니다. 이 PR의 검사는 '정확히 현재 버전'을 요구하고 있어서, 리베이스하니 그 하위호환을 막았습니다: opens_a_version_1_superblock_with_an_empty_free_list ... FAILED index file version 1 is not supported (expected 2) 두 변경이 반대 방향으로 당기는 지점이라, 검사를 '지원 범위'로 바꿉니다. 거부해야 하는 것은 미래 버전입니다. 레이아웃을 모르는데 page_size를 그대로 읽어 모든 오프셋에 쓰기 때문입니다. 반대로 read_superblock이 읽을 수 있는 과거 버전은 통과시켜야 합니다. MIN_SUPPORTED_VERSION을 도입하고 open_accepts_an_older_but_still_supported_version 으로 v1 허용이 의도된 것임을 고정했습니다. 두 변경이 서로를 지우지 않도록 테스트로 남겨둡니다. bite-test: 검사를 제거하면 거부 테스트 2건만 FAILED로 뒤집히고, 호환성 테스트 3건(v1 읽기, 구버전 허용, 현재 버전 왕복)은 통과합니다. cargo test 735 passed / 0 failed. clippy --all-targets 73건으로 master와 동일.
1 parent 958c1a1 commit 5c5ce50

1 file changed

Lines changed: 137 additions & 0 deletions

File tree

src/engine/index/page_store.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ const MAGIC: [u8; 4] = *b"RIDX";
3232
/// decode with `free_list_head = None` (see `read_superblock`), so existing
3333
/// index files keep working and simply start with an empty free-list.
3434
const VERSION: u16 = 2;
35+
/// Oldest on-disk version this build can still read. Anything below it has a
36+
/// layout `read_superblock` no longer knows how to decode.
37+
const MIN_SUPPORTED_VERSION: u16 = 1;
3538
/// Fixed size of the superblock region at the start of the file. Must be
3639
/// large enough to hold the bincode-encoded `Superblock` below; checked by a
3740
/// test.
@@ -113,6 +116,23 @@ impl PageStore {
113116
if sb.magic != MAGIC {
114117
return Err(ExecuteError::wrap("index file has invalid magic bytes"));
115118
}
119+
// The magic only rules out files that are not index files at all. A
120+
// file written by a different format version is the case actually
121+
// worth guarding: page_size is read straight back out of the
122+
// superblock and drives every offset, so accepting a foreign version
123+
// means writing pages at the wrong place in a file that is otherwise
124+
// valid.
125+
// Older versions this build still understands are read through the
126+
// migration path in `read_superblock`, so the check is "within the
127+
// supported range", not "exactly current". What has to be refused is a
128+
// version from the future: its layout is unknown, and `page_size` is
129+
// read straight back out of the superblock to drive every offset.
130+
if sb.version > VERSION || sb.version < MIN_SUPPORTED_VERSION {
131+
return Err(ExecuteError::wrap(format!(
132+
"index file version {} is not supported (this build reads {}..={})",
133+
sb.version, MIN_SUPPORTED_VERSION, VERSION
134+
)));
135+
}
116136

117137
Ok(PageStore {
118138
file: store.file,
@@ -488,4 +508,121 @@ mod tests {
488508
}
489509
assert_eq!(ids, vec![0, 1, 2]);
490510
}
511+
512+
/// Rewrite the superblock of an existing store, keeping everything the
513+
/// caller cannot see (page contents) intact.
514+
fn rewrite_superblock(path: &std::path::Path, mutate: impl FnOnce(&mut Superblock)) {
515+
let mut raw = std::fs::read(path).unwrap();
516+
let mut sb: Superblock = bincode::deserialize(&raw[..SUPERBLOCK_SIZE]).unwrap();
517+
mutate(&mut sb);
518+
let mut encoded = bincode::serialize(&sb).unwrap();
519+
encoded.resize(SUPERBLOCK_SIZE, 0);
520+
raw[..SUPERBLOCK_SIZE].copy_from_slice(&encoded);
521+
std::fs::write(path, &raw).unwrap();
522+
}
523+
524+
#[tokio::test]
525+
async fn open_rejects_an_unsupported_format_version() {
526+
let path = temp_path("foreign_version.idx");
527+
let store = PageStore::create(&path, page::INDEX_PAGE_SIZE).await.unwrap();
528+
drop(store);
529+
530+
rewrite_superblock(&path, |sb| sb.version = VERSION + 1);
531+
532+
let error = match PageStore::open(&path).await {
533+
Ok(_) => panic!("a store written by a different format version was accepted"),
534+
Err(error) => error.to_string(),
535+
};
536+
assert!(
537+
error.contains("version"),
538+
"the error should name the version, got: {}",
539+
error
540+
);
541+
}
542+
543+
/// The version check is a supported *range*, not an exact match: #232 made
544+
/// v1 files readable through a migration path, so refusing anything that is
545+
/// not the current version would break every index written before it.
546+
/// Pinning that here because the two changes pull in opposite directions.
547+
#[tokio::test]
548+
async fn open_accepts_an_older_but_still_supported_version() {
549+
let path = temp_path("older_supported_version.idx");
550+
let store = PageStore::create(&path, page::INDEX_PAGE_SIZE).await.unwrap();
551+
drop(store);
552+
553+
rewrite_superblock(&path, |sb| sb.version = MIN_SUPPORTED_VERSION);
554+
555+
let reopened = PageStore::open(&path)
556+
.await
557+
.expect("a version this build still reads must open");
558+
assert_eq!(reopened.page_size(), page::INDEX_PAGE_SIZE);
559+
}
560+
561+
/// The guard rail for the check above: rejecting more is only a fix if
562+
/// everything legitimate still opens. A store this build wrote must round
563+
/// trip, contents included.
564+
#[tokio::test]
565+
async fn open_still_accepts_a_store_written_by_this_version() {
566+
let path = temp_path("same_version.idx");
567+
let store = PageStore::create(&path, page::INDEX_PAGE_SIZE).await.unwrap();
568+
let id = store.allocate_page().await.unwrap();
569+
store
570+
.write_page(
571+
id,
572+
&Page::Leaf(LeafPage {
573+
entries: vec![LeafEntry {
574+
key: "k".to_string(),
575+
row_path: "/r/1".to_string(),
576+
}],
577+
next_leaf: None,
578+
overflow: None,
579+
}),
580+
)
581+
.await
582+
.unwrap();
583+
drop(store);
584+
585+
let reopened = PageStore::open(&path).await.unwrap();
586+
assert_eq!(reopened.page_size(), page::INDEX_PAGE_SIZE);
587+
match reopened.read_page(id).await.unwrap() {
588+
Page::Leaf(leaf) => assert_eq!(leaf.entries[0].row_path, "/r/1"),
589+
other => panic!("expected the leaf page back, got {:?}", other),
590+
}
591+
}
592+
593+
/// Why the version matters rather than being cosmetic: `page_size` comes
594+
/// straight out of the superblock and drives every offset. Without the
595+
/// check, a foreign version whose page size differs writes pages at the
596+
/// wrong offsets into an otherwise valid file.
597+
#[tokio::test]
598+
async fn a_foreign_version_would_write_pages_at_the_wrong_offset() {
599+
let path = temp_path("wrong_offset.idx");
600+
let store = PageStore::create(&path, page::INDEX_PAGE_SIZE).await.unwrap();
601+
let id = store.allocate_page().await.unwrap();
602+
store
603+
.write_page(
604+
id,
605+
&Page::Leaf(LeafPage {
606+
entries: vec![LeafEntry {
607+
key: "k".to_string(),
608+
row_path: "/r/1".to_string(),
609+
}],
610+
next_leaf: None,
611+
overflow: None,
612+
}),
613+
)
614+
.await
615+
.unwrap();
616+
drop(store);
617+
618+
rewrite_superblock(&path, |sb| {
619+
sb.version = VERSION + 1;
620+
sb.page_size = (page::INDEX_PAGE_SIZE * 2) as u32;
621+
});
622+
623+
assert!(
624+
PageStore::open(&path).await.is_err(),
625+
"the version check has to catch this before the page size is trusted"
626+
);
627+
}
491628
}

0 commit comments

Comments
 (0)