Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 63 additions & 4 deletions src/model/page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,19 +222,22 @@ impl PageAreas {
if page_def.binding == BindingMethod::DuplexSided && is_even_page {
(
page_def.margin_right,
page_def.margin_left + page_def.margin_gutter,
page_def.margin_left.saturating_add(page_def.margin_gutter),
)
} else {
(
page_def.margin_left + page_def.margin_gutter,
page_def.margin_left.saturating_add(page_def.margin_gutter),
page_def.margin_right,
)
};

let mut content_left = effective_left;
let mut content_right = page_width.saturating_sub(effective_right);
// HWP 본문 시작 = margin_header + margin_top (한컴 도움말 기준)
let mut content_top = page_def.margin_header + page_def.margin_top;
// [DoS] 손상 문서는 여백 필드에 u32 극값을 넣을 수 있다 — 합이 u32 를 넘으면
// 오버플로 패닉이 난다. saturating_add 로 막아도 아래 content_bottom<=content_top
// 폴백(5% 기본 여백)이 포화값을 정상 경로로 흡수한다.
let mut content_top = page_def.margin_header.saturating_add(page_def.margin_top);
// HWP 본문 끝 = height - margin_footer - margin_bottom
let mut content_bottom = page_height
.saturating_sub(page_def.margin_footer)
Expand Down Expand Up @@ -268,7 +271,11 @@ impl PageAreas {
left: content_left as i32,
top: content_bottom as i32,
right: content_right as i32,
bottom: (page_height - page_def.margin_footer) as i32,
// [DoS] margin_footer 가 page_height 를 넘는 손상 문서에서 뺄셈이 u32
// 언더플로 패닉을 냈다(퍼징 실측). 위 content_bottom 과 같은 규약으로
// saturating_sub 를 쓴다 — 정상 문서(margin_footer <= page_height)에서는
// 결과가 동일하다.
bottom: page_height.saturating_sub(page_def.margin_footer) as i32,
};

PageAreas {
Expand Down Expand Up @@ -318,6 +325,58 @@ mod tests {
);
}

/// [DoS 하드닝] 손상 문서가 여백 필드에 u32 극값을 넣어도 페이지 영역 계산이
/// 오버플로 패닉을 내지 않는다. batch 경로 퍼징에서 `margin_footer > height`
/// 인 hwp3 손상 파일이 `page.rs` 의 `page_height - margin_footer` 뺄셈을
/// u32 언더플로시켜 `rhwp info` 를 exit 101 로 죽였다(단건 명령엔 catch_unwind
/// 가드가 없다). 뺄셈(footer)·덧셈(header/top, left/gutter) 전 자리를 함께 잠근다.
#[test]
fn test_page_areas_extreme_margins_do_not_overflow() {
// 1) 뺄셈 언더플로: margin_footer 가 height 를 초과.
let underflow = PageDef {
width: 59528,
height: 84188,
margin_footer: u32::MAX,
..Default::default()
};
let areas = PageAreas::from_page_def_for_page(&underflow, 1);
// 패닉 없이 반환되고, footer bottom 은 saturating_sub 로 0 이하로 새지 않는다.
assert!(
areas.footer_area.bottom >= 0,
"footer bottom 은 음수로 새면 안 된다: {:?}",
areas.footer_area
);
assert!(
areas.body_area.bottom > areas.body_area.top
&& areas.body_area.right > areas.body_area.left,
"극값 여백에서도 본문 영역은 양수로 폴백해야 한다: {:?}",
areas.body_area
);

// 2) 덧셈 오버플로: margin_header + margin_top, margin_left + margin_gutter
// 가 각각 u32 를 넘는다. 짝수쪽(짝수 페이지) 경로까지 함께 태운다.
let add_overflow = PageDef {
width: 59528,
height: 84188,
margin_header: u32::MAX,
margin_top: u32::MAX,
margin_left: u32::MAX,
margin_gutter: u32::MAX,
margin_right: u32::MAX,
binding: BindingMethod::DuplexSided,
..Default::default()
};
for page_number in [1, 2] {
let areas = PageAreas::from_page_def_for_page(&add_overflow, page_number);
assert!(
areas.body_area.bottom > areas.body_area.top
&& areas.body_area.right > areas.body_area.left,
"덧셈 오버플로 여백에서도 본문 영역은 양수로 폴백해야 한다 (page={page_number}): {:?}",
areas.body_area
);
}
}

#[test]
fn test_page_def_a4() {
// A4 기본 설정 (210mm x 297mm)
Expand Down
93 changes: 93 additions & 0 deletions tests/batch_axes_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const SAMPLE: &str = "samples/hwp3-sample.hwp";
const SAMPLE_TABLE: &str = "samples/table-001.hwp";
/// 누름틀을 가진 문서.
const SAMPLE_FIELDS: &str = "samples/field-01.hwp";
/// 페이지 정의 헤더가 작은 hwp3 문서 — 헤더 바이트를 결정적으로 뒤집어
/// `margin_footer > height` 손상본을 만드는 데 쓴다(page.rs 오버플로 회귀).
const SAMPLE_PAGEDEF: &str = "samples/hwp3-pagedef-1915.hwp";

fn sample(rel: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join(rel)
Expand Down Expand Up @@ -192,6 +195,96 @@ fn batch_new_axes_preserve_input_order_and_report_partial_failure() {
assert!(records[2].get("error").is_none(), "{:?}", records[2]);
}

/// [DoS 하드닝 회귀] batch 경로 퍼징이 잡은 `src/model/page.rs` 여백 뺄셈 오버플로.
///
/// `margin_footer > page_height` 인 hwp3 손상 문서가 페이지 영역 계산에서
/// `page_height - margin_footer` 를 u32 언더플로시켜 패닉했다. 단건 명령
/// (`rhwp info`)에는 catch_unwind 가드가 없어 그대로 exit 101 로 죽었고, batch 는
/// 행별 catch_unwind 로 격리해 그 행을 "내부 오류(panic)" error 레코드로 바꿨다.
/// `saturating_sub` 수정 후에는 손상 문서도 우아하게 처리되어 그 행이 **성공**
/// 레코드가 된다.
///
/// 이 테스트는 손상 파일이 섞인 배치가 (1) 패닉·행 무단누락 없이 끝까지 돌고
/// (2) 진짜 실패 행을 격리해 부분 실패 exit 1 을 내며 (3) page.rs 오버플로
/// 트리거 행이 더는 실패가 아님을 함께 잠근다.
#[test]
fn batch_isolates_corrupt_page_def_without_panic() {
let tmp = ConvertTmpDir::new("corrupt-pagedef");

// 1) page.rs:271 트리거 — 커밋된 hwp3 샘플의 페이지 정의 헤더 2바이트를
// 결정적으로 뒤집어 margin_footer > height 로 만든다(수정 전 패닉하던 입력).
let mut corrupt = std::fs::read(sample(SAMPLE_PAGEDEF)).expect("pagedef 샘플 읽기");
assert!(
corrupt.len() > 56,
"샘플이 예상보다 작습니다: {}",
corrupt.len()
);
corrupt[51] = 0x5d;
corrupt[56] = 0xd1;
let corrupt_path = tmp.path().join("corrupt_pagedef.hwp");
std::fs::write(&corrupt_path, &corrupt).expect("손상본 쓰기");

// 2) 어떤 파서로도 열리지 않는 순수 쓰레기 — 진짜 실패 행(격리·exit 1 계약용).
let garbage_path = tmp.path().join("garbage.hwp");
std::fs::write(&garbage_path, b"NOT-AN-HWP-FILE\x00\x01\x02rubbish").expect("쓰레기 쓰기");

let valid = sample(SAMPLE);
let (v, c, g) = (
valid.to_str().unwrap(),
corrupt_path.to_str().unwrap(),
garbage_path.to_str().unwrap(),
);
let args = ["batch", "info", "--json"];
// 유효 · 손상(page.rs 트리거) · 쓰레기 · 유효 순서로 섞는다.
let output = run_with_stdin(&args, &format!("{v}\n{c}\n{g}\n{v}\n"));

// 무패닉: 프로세스가 패닉 종료(101)하지 않는다.
assert_ne!(
output.status.code(),
Some(101),
"{}",
describe(&args, &output)
);
// 부분 실패(쓰레기 행 하나) → exit 1 (기존 batch 계약).
assert_eq!(
output.status.code(),
Some(1),
"{}",
describe(&args, &output)
);

let records = ndjson(&args, &output);
// 입력 N = 출력 N — 조용히 사라지는 행이 없다.
assert_eq!(records.len(), 4, "{}", describe(&args, &output));
// 순서 보존: 유효 행.
assert!(
records[0].get("error").is_none(),
"유효 행0: {:?}",
records[0]
);
// 핵심 회귀: page.rs 오버플로 수정 후, 손상 페이지 정의 행은 더는 패닉/실패가
// 아니라 성공 레코드다(수정 전에는 catch_unwind 가 잡은 panic error 였다).
assert!(
records[1].get("error").is_none(),
"손상 페이지 정의 행이 여전히 실패한다(page.rs 오버플로 수정 회귀?): {:?}",
records[1]
);
assert_eq!(records[1]["format"], "hwp3", "{:?}", records[1]);
// 진짜 실패 행은 격리되어 error 레코드로 보고된다.
assert!(
records[2].get("error").is_some(),
"쓰레기 행은 error 레코드여야 한다: {:?}",
records[2]
);
assert_eq!(records[2]["exitClass"], "runtime", "{:?}", records[2]);
// 순서 보존: 마지막 유효 행.
assert!(
records[3].get("error").is_none(),
"유효 행3: {:?}",
records[3]
);
}

#[test]
fn batch_search_without_query_is_usage_error() {
let args = ["batch", "search", "--json"];
Expand Down