From b1f6662d26bafd56ad1528efdf5f8ffbea5c8a5c Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 22:53:02 +0900 Subject: [PATCH 1/3] test: expose missing output helper recovery guidance --- crates/hwp-cli/src/commands/output.rs | 65 +++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/hwp-cli/src/commands/output.rs b/crates/hwp-cli/src/commands/output.rs index acf4e281..50e11159 100644 --- a/crates/hwp-cli/src/commands/output.rs +++ b/crates/hwp-cli/src/commands/output.rs @@ -2718,6 +2718,71 @@ mod tests { dir } + fn assert_output_helper_recovery(error: &anyhow::Error, phase: &str, private_root: &Path) { + let private_root = private_root.to_string_lossy().into_owned(); + for message in [ + error.to_string(), + format!("{error:#}"), + format!("{error:?}"), + ] { + assert!(message.contains(phase), "missing helper phase: {message}"); + for remedy in ["권한", "여유 공간", "다시 실행"] { + assert!( + message.contains(remedy), + "missing {remedy} remedy: {message}" + ); + } + assert!( + !message.contains("PRIVATE_471") && !message.contains(private_root.as_str()), + "helper errors must not expose output/staging paths: {message}" + ); + } + } + + #[test] + fn output_helper_sync_missing_stage_has_private_recovery_message() { + let dir = test_dir("helper-missing-stage"); + let destination = dir.join("PRIVATE_471-output.hwpx"); + fs::write(&destination, b"original").unwrap(); + let error = { + let staged = StagedOutput::new(&destination, None).unwrap(); + staged.sync_file().unwrap_err() + }; + assert_eq!(fs::read(&destination).unwrap(), b"original"); + assert_no_debris(&dir); + fs::remove_dir_all(&dir).unwrap(); + assert_output_helper_recovery(&error, "임시 출력 동기화 단계", &dir); + } + + #[test] + fn output_helper_sync_non_regular_stage_has_private_recovery_message() { + let dir = test_dir("helper-non-regular-stage"); + let destination = dir.join("PRIVATE_471-output.hwpx"); + fs::write(&destination, b"original").unwrap(); + let error = { + let staged = StagedOutput::new(&destination, None).unwrap(); + fs::create_dir(staged.path()).unwrap(); + staged.sync_file().unwrap_err() + }; + assert_eq!(fs::read(&destination).unwrap(), b"original"); + assert_no_debris(&dir); + fs::remove_dir_all(&dir).unwrap(); + assert_output_helper_recovery(&error, "임시 출력 동기화 단계", &dir); + } + + #[cfg(windows)] + #[test] + fn output_helper_identity_probe_has_private_recovery_message() { + let dir = test_dir("helper-identity-probe"); + let path = dir.join("PRIVATE_471-identity.hwpx"); + fs::write(&path, b"identity").unwrap(); + let metadata = fs::metadata(&path).unwrap(); + fs::remove_file(&path).unwrap(); + let error = FileIdentity::from_metadata(&metadata, &path).unwrap_err(); + fs::remove_dir_all(&dir).unwrap(); + assert_output_helper_recovery(&error, "파일 식별자 확인 단계", &dir); + } + fn assert_no_debris(dir: &Path) { let leftovers: Vec<_> = fs::read_dir(dir) .unwrap() From d4589079e1f18941ff97774d36c6ec03a84934f9 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 23:00:28 +0900 Subject: [PATCH 2/3] fix: redact output helper failures and add recovery guidance --- CHANGELOG.md | 5 +++ crates/hwp-cli/src/commands/output.rs | 56 ++++++++++++++------------- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15b7029c..ed0cde7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ The workspace `Cargo.toml` `[workspace.package] version` is the single source fo **Fixed** +- Shared stage-sync and file-identity helper failures now name their phase and advise checking + output-directory permissions and free space before rerunning. Fixed Korean messages omit + staging/destination paths and discard source error chains consistently for all helper callers, + including binding commands ([#471](https://github.com/STAIxBWLB/hwp-cli/issues/471)). + - HWP5 picture synthesis resolves unlisted `ItemRef` values with the model's case-sensitive exact name, path-suffix and filename-stem rules. Partial names such as `image1` no longer select `image10.png`; unresolved references report `BinaryAssetRemoved` instead of embedding diff --git a/crates/hwp-cli/src/commands/output.rs b/crates/hwp-cli/src/commands/output.rs index 50e11159..5ee2929a 100644 --- a/crates/hwp-cli/src/commands/output.rs +++ b/crates/hwp-cli/src/commands/output.rs @@ -707,6 +707,14 @@ struct StagedOutput { preserve_workspace: bool, } +/// Fixed helper diagnostics shared by all callers, including binding commands. +/// Static phase/cause text carries no paths, and call sites discard source error chains. +fn output_helper_error(phase: &'static str, cause: &'static str) -> anyhow::Error { + anyhow::anyhow!( + "{phase} 단계에서 {cause}. 출력 디렉터리의 권한과 여유 공간을 확인한 뒤 다시 실행하세요." + ) +} + impl StagedOutput { fn new(destination: &Path, input: Option<&Path>) -> anyhow::Result { let snapshot = inspect_destination(destination)?; @@ -764,25 +772,24 @@ impl StagedOutput { } fn sync_file(&self) -> anyhow::Result<()> { - let metadata = fs::symlink_metadata(&self.staged).with_context(|| { - format!( - "임시 출력 파일을 확인할 수 없습니다: {}", - self.staged.display() - ) + let metadata = fs::symlink_metadata(&self.staged).map_err(|_| { + output_helper_error("임시 출력 동기화", "파일 정보를 확인할 수 없습니다") })?; if metadata.file_type().is_symlink() || !metadata.file_type().is_file() { - anyhow::bail!( - "writer가 일반 파일이 아닌 임시 출력을 만들었습니다: {}", - self.staged.display() - ); + return Err(output_helper_error( + "임시 출력 동기화", + "일반 파일이 아닌 임시 출력이 확인됐습니다", + )); } fs::OpenOptions::new() .read(true) .write(true) .open(&self.staged) - .with_context(|| format!("임시 출력 파일을 열 수 없습니다: {}", self.staged.display()))? + .map_err(|_| output_helper_error("임시 출력 동기화", "파일을 열 수 없습니다"))? .sync_all() - .with_context(|| format!("임시 출력 동기화 실패: {}", self.staged.display())) + .map_err(|_| { + output_helper_error("임시 출력 동기화", "파일 내용을 동기화할 수 없습니다") + }) } // On Windows, the cfg(windows) block below is last, so an early return appears as a tail return. @@ -1963,27 +1970,22 @@ impl FileIdentity { ) }; if handle == INVALID_HANDLE_VALUE { - return Err(std::io::Error::last_os_error()).with_context(|| { - format!( - "Windows 파일 식별자용 핸들을 열 수 없습니다: {}", - path.display() - ) - }); + return Err(output_helper_error( + "파일 식별자 확인", + "Windows 파일 식별자용 핸들을 열 수 없습니다", + )); } let mut information = BY_HANDLE_FILE_INFORMATION::default(); let loaded = unsafe { GetFileInformationByHandle(handle, &mut information) }; - let error = (loaded == 0).then(std::io::Error::last_os_error); unsafe { CloseHandle(handle); } - if let Some(error) = error { - return Err(error).with_context(|| { - format!( - "Windows 파일 식별자를 확인할 수 없습니다: {}", - path.display() - ) - }); + if loaded == 0 { + return Err(output_helper_error( + "파일 식별자 확인", + "Windows 파일 식별자를 확인할 수 없습니다", + )); } Ok(Self { volume: information.dwVolumeSerialNumber, @@ -2013,7 +2015,9 @@ struct FileIdentity { impl FileIdentity { fn from_metadata(_metadata: &fs::Metadata, path: &Path) -> anyhow::Result { Ok(Self { - canonical_path: path.canonicalize()?, + canonical_path: path.canonicalize().map_err(|_| { + output_helper_error("파일 식별자 확인", "정규 파일 경로를 확인할 수 없습니다") + })?, }) } From 23eeee9ca7bb7407aec8d94e761b501e2708a22d Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 10 Nov 2026 23:02:59 +0900 Subject: [PATCH 3/3] test: enforce source-free output helper refusals --- CHANGELOG.md | 6 +++--- crates/hwp-cli/src/commands/output.rs | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed0cde7a..0f2c1551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,9 @@ The workspace `Cargo.toml` `[workspace.package] version` is the single source fo **Fixed** - Shared stage-sync and file-identity helper failures now name their phase and advise checking - output-directory permissions and free space before rerunning. Fixed Korean messages omit - staging/destination paths and discard source error chains consistently for all helper callers, - including binding commands ([#471](https://github.com/STAIxBWLB/hwp-cli/issues/471)). + output-directory permissions and free space before rerunning. The helper error values use + fixed Korean messages without staging/destination paths or underlying I/O error chains, + including in binding commands ([#471](https://github.com/STAIxBWLB/hwp-cli/issues/471)). - HWP5 picture synthesis resolves unlisted `ItemRef` values with the model's case-sensitive exact name, path-suffix and filename-stem rules. Partial names such as `image1` no longer diff --git a/crates/hwp-cli/src/commands/output.rs b/crates/hwp-cli/src/commands/output.rs index 5ee2929a..946d85b4 100644 --- a/crates/hwp-cli/src/commands/output.rs +++ b/crates/hwp-cli/src/commands/output.rs @@ -2741,6 +2741,12 @@ mod tests { "helper errors must not expose output/staging paths: {message}" ); } + // The helper contract discards original I/O sources, including future path-bearing detail. + assert_eq!( + error.chain().count(), + 1, + "helper refusals must discard their original I/O error chains" + ); } #[test]