Skip to content

Commit d624869

Browse files
presempathy-awbfarhan-syah
authored andcommitted
fix(fsck): reject ambiguous CLI arguments
1 parent 3bdc2be commit d624869

3 files changed

Lines changed: 240 additions & 20 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,22 @@ pagedb's answer: **one substrate, two surfaces.** Engines that want their own fo
5555
└────────────────────────────────────────────────────────────┘
5656
```
5757

58+
## Integrity inspection
59+
60+
`pagedb-fsck` opens an existing store in frozen read-only mode. Add `--deep` to
61+
run a full authenticated structural walk:
62+
63+
```bash
64+
cargo run --bin pagedb-fsck -- <path> --deep \
65+
--realm 00000000000000000000000000000000 <64-hex-character-kek>
66+
```
67+
68+
The KEK may instead come from `PAGEDB_KEK` and defaults to all zeros. The realm
69+
defaults to all ones; nodedb-lite stores use the all-zero realm shown above.
70+
Argument parsing rejects duplicate flags, unknown options, missing realm
71+
values, and multiple positional KEKs. Inspection disables commit-history
72+
retention and does not rewrite authoritative `main.db` or segment bytes.
73+
5874
## Benchmarks
5975

6076
Measured on native NVMe, AES-NI host, single thread, via `fluxbench`. Reproduce the

src/bin/pagedb-fsck.rs

Lines changed: 83 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! file, verifying AEAD tags, structural invariants, orphan pages, and
55
//! catalog–disk consistency.
66
7+
#[cfg(not(target_arch = "wasm32"))]
78
use std::process::ExitCode;
89

910
#[cfg(not(target_arch = "wasm32"))]
@@ -13,37 +14,99 @@ use pagedb::vfs::tokio_backend::TokioVfs;
1314
#[cfg(not(target_arch = "wasm32"))]
1415
use pagedb::{Db, RealmId, run_deep_walk};
1516

17+
#[cfg(not(target_arch = "wasm32"))]
18+
struct CliArgs {
19+
path: String,
20+
deep: bool,
21+
realm_hex: Option<String>,
22+
kek_hex: Option<String>,
23+
}
24+
1625
#[cfg(target_arch = "wasm32")]
1726
fn main() {
1827
// pagedb-fsck is a native-only tool; it is not functional on wasm32.
1928
}
2029

2130
#[cfg(not(target_arch = "wasm32"))]
22-
#[tokio::main(flavor = "current_thread")]
23-
async fn main() -> ExitCode {
24-
let args: Vec<String> = std::env::args().collect();
25-
if args.len() < 2 {
26-
eprintln!("usage: pagedb-fsck <path> [--deep] [--realm <hex16>] [<hex-kek>]");
27-
eprintln!("(KEK may also be set via PAGEDB_KEK env var; defaults to zeros.");
28-
eprintln!(" --realm defaults to all-ones; nodedb-lite stores use all-zeros.)");
29-
return ExitCode::from(2);
31+
fn usage() {
32+
eprintln!("usage: pagedb-fsck <path> [--deep] [--realm <hex16>] [<hex-kek>]");
33+
eprintln!("(KEK may also be set via PAGEDB_KEK; defaults to zeros.");
34+
eprintln!(" --realm defaults to all-ones; nodedb-lite stores use all-zeros.)");
35+
}
36+
37+
#[cfg(not(target_arch = "wasm32"))]
38+
fn parse_args(args: &[String]) -> Result<CliArgs, String> {
39+
let Some(path) = args.get(1) else {
40+
return Err("database path is required".to_string());
41+
};
42+
if path.starts_with("--") {
43+
return Err(format!("unknown option {path}"));
3044
}
31-
let path = &args[1];
3245

33-
// Parse optional flags and positional KEK.
3446
let mut deep = false;
35-
let mut kek_hex: Option<String> = None;
36-
let mut realm_hex: Option<String> = None;
37-
let mut it = args.iter().skip(2);
38-
while let Some(arg) = it.next() {
39-
if arg == "--deep" {
40-
deep = true;
41-
} else if arg == "--realm" {
42-
realm_hex = it.next().cloned();
43-
} else if kek_hex.is_none() {
44-
kek_hex = Some(arg.clone());
47+
let mut realm_hex = None;
48+
let mut kek_hex = None;
49+
let mut index = 2;
50+
while index < args.len() {
51+
match args[index].as_str() {
52+
"--deep" => {
53+
if deep {
54+
return Err("duplicate --deep".to_string());
55+
}
56+
deep = true;
57+
index += 1;
58+
}
59+
"--realm" => {
60+
if realm_hex.is_some() {
61+
return Err("duplicate --realm".to_string());
62+
}
63+
let value = args
64+
.get(index + 1)
65+
.filter(|value| !value.starts_with("--"))
66+
.ok_or_else(|| "--realm requires a 32-character hex value".to_string())?;
67+
realm_hex = Some(value.clone());
68+
index += 2;
69+
}
70+
option if option.starts_with("--") => {
71+
return Err(format!("unknown option {option}"));
72+
}
73+
value => {
74+
if kek_hex.is_some() {
75+
return Err("multiple KEK values supplied".to_string());
76+
}
77+
kek_hex = Some(value.to_string());
78+
index += 1;
79+
}
4580
}
4681
}
82+
83+
Ok(CliArgs {
84+
path: path.clone(),
85+
deep,
86+
realm_hex,
87+
kek_hex,
88+
})
89+
}
90+
91+
#[cfg(not(target_arch = "wasm32"))]
92+
#[tokio::main(flavor = "current_thread")]
93+
async fn main() -> ExitCode {
94+
let args: Vec<String> = std::env::args().collect();
95+
let parsed = match parse_args(&args) {
96+
Ok(parsed) => parsed,
97+
Err(error) => {
98+
eprintln!("pagedb-fsck: {error}");
99+
usage();
100+
return ExitCode::from(2);
101+
}
102+
};
103+
let CliArgs {
104+
path,
105+
deep,
106+
realm_hex,
107+
mut kek_hex,
108+
} = parsed;
109+
47110
if kek_hex.is_none() {
48111
kek_hex = std::env::var("PAGEDB_KEK").ok();
49112
}

tests/fsck_cli.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
#![cfg(not(target_arch = "wasm32"))]
2+
3+
use std::{collections::BTreeMap, path::Path, process::Command};
4+
5+
use pagedb::options::{OpenOptions, RetainPolicy};
6+
use pagedb::segment::types::SegmentPageKind;
7+
use pagedb::vfs::tokio_backend::TokioVfs;
8+
use pagedb::{Db, RealmId, SegmentKind};
9+
10+
const KEK: [u8; 32] = [0xA5; 32];
11+
const KEK_HEX: &str = "a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5";
12+
const REALM: RealmId = RealmId::new([0; 16]);
13+
const REALM_HEX: &str = "00000000000000000000000000000000";
14+
const PAGE: usize = 4096;
15+
16+
fn fsck() -> Command {
17+
let mut command = Command::new(env!("CARGO_BIN_EXE_pagedb-fsck"));
18+
command.env_remove("PAGEDB_KEK");
19+
command
20+
}
21+
22+
fn collect_files(root: &Path, current: &Path, files: &mut BTreeMap<String, Vec<u8>>) {
23+
if !current.exists() {
24+
return;
25+
}
26+
for entry in std::fs::read_dir(current).unwrap() {
27+
let entry = entry.unwrap();
28+
let path = entry.path();
29+
if path.is_dir() {
30+
collect_files(root, &path, files);
31+
} else {
32+
let relative = path
33+
.strip_prefix(root)
34+
.unwrap()
35+
.to_string_lossy()
36+
.into_owned();
37+
files.insert(relative, std::fs::read(path).unwrap());
38+
}
39+
}
40+
}
41+
42+
fn authoritative_bytes(root: &Path) -> BTreeMap<String, Vec<u8>> {
43+
let mut files = BTreeMap::new();
44+
files.insert(
45+
"main.db".to_string(),
46+
std::fs::read(root.join("main.db")).unwrap(),
47+
);
48+
collect_files(root, &root.join("seg"), &mut files);
49+
files
50+
}
51+
52+
#[tokio::test(flavor = "current_thread")]
53+
async fn fsck_accepts_explicit_realm_and_preserves_authoritative_bytes() {
54+
let dir = tempfile::tempdir().unwrap();
55+
{
56+
let options = OpenOptions::default().with_commit_history_retain(RetainPolicy::Disabled);
57+
let db = Db::open(TokioVfs::new(dir.path()), KEK, PAGE, REALM, options)
58+
.await
59+
.unwrap();
60+
let mut segment = db
61+
.create_segment(REALM, SegmentKind::Unspecified)
62+
.await
63+
.unwrap();
64+
segment
65+
.append_page(SegmentPageKind::Data, b"fsck-segment")
66+
.await
67+
.unwrap();
68+
let segment_meta = segment.seal().await.unwrap();
69+
let mut write = db.begin_write().await.unwrap();
70+
write.put(b"fsck-key", b"fsck-value").await.unwrap();
71+
write
72+
.link_segment("fsck-segment", &segment_meta)
73+
.await
74+
.unwrap();
75+
write.commit().await.unwrap();
76+
}
77+
78+
let before = authoritative_bytes(dir.path());
79+
let output = fsck()
80+
.arg(dir.path())
81+
.args(["--deep", "--realm", REALM_HEX, KEK_HEX])
82+
.output()
83+
.unwrap();
84+
let after = authoritative_bytes(dir.path());
85+
86+
assert!(
87+
output.status.success(),
88+
"fsck failed: stdout={} stderr={}",
89+
String::from_utf8_lossy(&output.stdout),
90+
String::from_utf8_lossy(&output.stderr)
91+
);
92+
assert_eq!(
93+
after, before,
94+
"fsck changed authoritative main.db or segment bytes"
95+
);
96+
assert!(
97+
String::from_utf8_lossy(&output.stdout).contains("result: CLEAN"),
98+
"deep fsck did not emit a clean report: {}",
99+
String::from_utf8_lossy(&output.stdout)
100+
);
101+
}
102+
103+
#[test]
104+
fn fsck_rejects_ambiguous_or_unknown_arguments() {
105+
let dir = tempfile::tempdir().unwrap();
106+
let cases: &[(&[&str], &str)] = &[
107+
(&["--deep", "--deep"], "duplicate --deep"),
108+
(
109+
&["--realm", REALM_HEX, "--realm", REALM_HEX],
110+
"duplicate --realm",
111+
),
112+
(&[KEK_HEX, KEK_HEX], "multiple KEK"),
113+
(&["--unknown"], "unknown option"),
114+
(&["--realm"], "--realm requires"),
115+
(&["--realm", "--deep"], "--realm requires"),
116+
];
117+
118+
for (args, expected) in cases {
119+
let output = fsck().arg(dir.path()).args(*args).output().unwrap();
120+
assert_eq!(
121+
output.status.code(),
122+
Some(2),
123+
"args {args:?}: stdout={} stderr={}",
124+
String::from_utf8_lossy(&output.stdout),
125+
String::from_utf8_lossy(&output.stderr)
126+
);
127+
assert!(
128+
String::from_utf8_lossy(&output.stderr).contains(expected),
129+
"args {args:?}: expected stderr containing {expected:?}, got {}",
130+
String::from_utf8_lossy(&output.stderr)
131+
);
132+
}
133+
134+
let output = fsck().arg("--deep").output().unwrap();
135+
assert_eq!(output.status.code(), Some(2));
136+
assert!(
137+
String::from_utf8_lossy(&output.stderr).contains("unknown option --deep"),
138+
"option used as path was not rejected: {}",
139+
String::from_utf8_lossy(&output.stderr)
140+
);
141+
}

0 commit comments

Comments
 (0)