Skip to content

Commit b8a3c55

Browse files
authored
Merge branch 'main' into expose-primitives
2 parents f3c69b6 + 730615c commit b8a3c55

4 files changed

Lines changed: 106 additions & 37 deletions

File tree

src/pg/backup/mod.rs

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ use anyhow::{Context, Result, anyhow};
99
use chrono::{DateTime, Utc};
1010
use serde::{Deserialize, Deserializer, Serialize, Serializer};
1111

12+
use crate::pg::parse_hex;
13+
1214
pub mod copy;
1315
pub mod delete;
1416
pub mod delta;
@@ -107,20 +109,17 @@ pub fn format_backup_name(timeline: u32, start_lsn: u64, seg_size: u64) -> Strin
107109
/// the prefix, is too short, or contains non-hex digits.
108110
pub fn parse_timeline_from_backup_name(name: &str) -> Option<u32> {
109111
let rest = name.strip_prefix(BACKUP_NAME_PREFIX)?;
110-
if rest.len() < 8 {
111-
return None;
112-
}
113-
u32::from_str_radix(&rest[..8], 16).ok()
112+
Some(parse_hex(rest.as_bytes().get(..8)?, 8)? as u32)
114113
}
115114

116-
/// Parse `0/1A2B3C4D` (postgres pg_lsn text form) into u64
115+
/// Parse `0/1A2B3C4D` (postgres pg_lsn text form) into u64.
116+
/// Strict per pg_lsn_in_internal: 1..=8 hex digits per component, no sign
117117
pub fn parse_pg_lsn(s: &str) -> Result<u64> {
118118
let s = s.trim();
119119
let (hi, lo) = s
120120
.split_once('/')
121+
.and_then(|(hi, lo)| parse_hex(hi.as_bytes(), 8).zip(parse_hex(lo.as_bytes(), 8)))
121122
.ok_or_else(|| anyhow!("bad LSN format: {s}"))?;
122-
let hi = u64::from_str_radix(hi, 16).with_context(|| format!("bad LSN hi: {hi}"))?;
123-
let lo = u64::from_str_radix(lo, 16).with_context(|| format!("bad LSN lo: {lo}"))?;
124123
Ok((hi << 32) | lo)
125124
}
126125

@@ -139,13 +138,9 @@ pub fn format_pg_lsn(lsn: u64) -> impl std::fmt::Display {
139138

140139
/// Match `base_<24hex>` and optional `_D_<24hex>` delta and `_<8hex>` LSN
141140
pub fn looks_like_backup_name(s: &str) -> bool {
142-
let Some(rest) = s.strip_prefix(BACKUP_NAME_PREFIX) else {
143-
return false;
144-
};
145-
if rest.len() < 24 {
146-
return false;
147-
}
148-
rest[..24].chars().all(|c| c.is_ascii_hexdigit())
141+
s.strip_prefix(BACKUP_NAME_PREFIX)
142+
.and_then(|rest| rest.as_bytes().get(..24))
143+
.is_some_and(|w| w.iter().all(u8::is_ascii_hexdigit))
149144
}
150145

151146
/// Strip wal-g sentinel suffix to recover backup name
@@ -566,6 +561,42 @@ mod tests {
566561
assert_eq!(parse_pg_lsn("FF/FF").unwrap(), (0xFF_u64 << 32) | 0xFF);
567562
}
568563

564+
#[test]
565+
fn rejects_malformed_lsn() {
566+
for s in [
567+
"",
568+
"1",
569+
"/",
570+
"1/",
571+
"/1",
572+
"0x1/0",
573+
"+1/0",
574+
"1/+0",
575+
"-1/0",
576+
"000000001/0", // 9 digits, pg caps components at 8
577+
"0/000000001",
578+
"1FFFFFFFF/0", // hi overflow must not shift into oblivion
579+
"0/1FFFFFFFF", // lo overflow must not bleed into hi
580+
"1 /0",
581+
"0/ 1",
582+
"g/0",
583+
"1/2/3",
584+
] {
585+
assert!(parse_pg_lsn(s).is_err(), "{s:?} should be rejected");
586+
}
587+
}
588+
589+
#[test]
590+
fn backup_name_parsers_reject_sign_and_multibyte() {
591+
assert_eq!(parse_timeline_from_backup_name("base_+0000001rest"), None);
592+
// char straddling the 8- and 24-byte windows: must not panic
593+
assert_eq!(parse_timeline_from_backup_name("base_0000000é0"), None);
594+
assert!(!looks_like_backup_name(&format!(
595+
"base_{}é",
596+
"0".repeat(23)
597+
)));
598+
}
599+
569600
#[test]
570601
fn formats_lsn_uppercase() {
571602
assert_eq!(format_pg_lsn(0x0300_0000).to_string(), "0/3000000");

src/pg/mod.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,35 @@ pub mod walparser;
66

77
pub const WAL_FOLDER: &str = "wal_005";
88
pub const BASEBACKUP_FOLDER: &str = "basebackups_005";
9+
10+
/// Fold 1..=max_digits ascii hex bytes into u64; rejects sign, prefix,
11+
/// whitespace. Callers slice with `.get(..n)` to stay panic-free on
12+
/// arbitrary byte-length input
13+
pub(crate) fn parse_hex(b: &[u8], max_digits: usize) -> Option<u64> {
14+
debug_assert!(max_digits <= 16);
15+
if b.is_empty() || b.len() > max_digits {
16+
return None;
17+
}
18+
b.iter().try_fold(0u64, |acc, &c| {
19+
Some((acc << 4) | (c as char).to_digit(16)? as u64)
20+
})
21+
}
22+
23+
#[cfg(test)]
24+
mod tests {
25+
use super::*;
26+
27+
#[test]
28+
fn parse_hex_strict() {
29+
assert_eq!(parse_hex(b"0", 8), Some(0));
30+
assert_eq!(parse_hex(b"DeadBeef", 8), Some(0xDEAD_BEEF));
31+
assert_eq!(parse_hex(b"FFFFFFFFFFFFFFFF", 16), Some(u64::MAX));
32+
assert_eq!(parse_hex(b"", 8), None);
33+
assert_eq!(parse_hex(b"123456789", 8), None);
34+
assert_eq!(parse_hex(b"+1", 8), None);
35+
assert_eq!(parse_hex(b"-1", 8), None);
36+
assert_eq!(parse_hex(b" 1", 8), None);
37+
assert_eq!(parse_hex(b"0x1", 8), None);
38+
assert_eq!(parse_hex("é1".as_bytes(), 8), None);
39+
}
40+
}

src/pg/wal/segment.rs

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -73,19 +73,19 @@ pub enum SegmentError {
7373

7474
impl SegmentName {
7575
pub fn parse(s: &str) -> Result<Self, SegmentError> {
76-
if s.len() != SEGMENT_NAME_LEN {
77-
return Err(SegmentError::BadLength(s.len()));
76+
let b = s.as_bytes();
77+
if b.len() != SEGMENT_NAME_LEN {
78+
return Err(SegmentError::BadLength(b.len()));
7879
}
79-
let timeline =
80-
u32::from_str_radix(&s[0..8], 16).map_err(|_| SegmentError::NonHex(s.into()))?;
81-
let log_id =
82-
u32::from_str_radix(&s[8..16], 16).map_err(|_| SegmentError::NonHex(s.into()))?;
83-
let seg_no =
84-
u32::from_str_radix(&s[16..24], 16).map_err(|_| SegmentError::NonHex(s.into()))?;
80+
let field = |i: usize| {
81+
crate::pg::parse_hex(&b[i..i + 8], 8)
82+
.map(|v| v as u32)
83+
.ok_or_else(|| SegmentError::NonHex(s.into()))
84+
};
8585
Ok(SegmentName {
86-
timeline,
87-
log_id,
88-
seg_no,
86+
timeline: field(0)?,
87+
log_id: field(8)?,
88+
seg_no: field(16)?,
8989
})
9090
}
9191

@@ -162,6 +162,17 @@ mod tests {
162162
));
163163
}
164164

165+
#[test]
166+
fn rejects_sign_and_multibyte() {
167+
// from_str_radix would have accepted the '+'
168+
assert!(matches!(
169+
SegmentName::parse("+00000010000000000000001").unwrap_err(),
170+
SegmentError::NonHex(_)
171+
));
172+
// 24 bytes with char straddling field boundary: byte slicing must not panic
173+
assert!(SegmentName::parse("0000000é000000000000001").is_err());
174+
}
175+
165176
#[test]
166177
fn start_lsn_computation() {
167178
let s = SegmentName::parse("000000010000000200000003").unwrap();

src/pg/wal_summaries.rs

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ use std::path::{Path, PathBuf};
2828
use roaring::RoaringBitmap;
2929
use thiserror::Error;
3030

31+
use crate::pg::parse_hex;
32+
3133
use crate::pg::backup::delta::PagedFileDeltaMap;
3234
use crate::pg::backup::format_pg_lsn;
3335
use crate::pg::walparser::RelFileNode;
@@ -159,22 +161,15 @@ pub fn list_summary_files(dir: &Path) -> Result<Vec<SummaryFile>, SummaryError>
159161
/// empty; caller fills). Returns `None` if the name doesn't match
160162
pub fn parse_summary_filename(name: &str) -> Option<SummaryFile> {
161163
let stem = name.strip_suffix(".summary")?;
162-
if stem.len() != 40 {
163-
return None;
164-
}
165-
if !stem.bytes().all(|b| b.is_ascii_hexdigit()) {
164+
let b = stem.as_bytes();
165+
if b.len() != 40 {
166166
return None;
167167
}
168-
let timeline = u32::from_str_radix(&stem[0..8], 16).ok()?;
169-
let start_hi = u32::from_str_radix(&stem[8..16], 16).ok()?;
170-
let start_lo = u32::from_str_radix(&stem[16..24], 16).ok()?;
171-
let end_hi = u32::from_str_radix(&stem[24..32], 16).ok()?;
172-
let end_lo = u32::from_str_radix(&stem[32..40], 16).ok()?;
173168
Some(SummaryFile {
174169
path: PathBuf::new(),
175-
timeline,
176-
start_lsn: ((start_hi as u64) << 32) | (start_lo as u64),
177-
end_lsn: ((end_hi as u64) << 32) | (end_lo as u64),
170+
timeline: parse_hex(&b[0..8], 8)? as u32,
171+
start_lsn: parse_hex(&b[8..24], 16)?,
172+
end_lsn: parse_hex(&b[24..40], 16)?,
178173
})
179174
}
180175

0 commit comments

Comments
 (0)