Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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
select `image10.png`; unresolved references report `BinaryAssetRemoved` instead of embedding
Expand Down
127 changes: 101 additions & 26 deletions crates/hwp-cli/src/commands/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
let snapshot = inspect_destination(destination)?;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2013,7 +2015,9 @@ struct FileIdentity {
impl FileIdentity {
fn from_metadata(_metadata: &fs::Metadata, path: &Path) -> anyhow::Result<Self> {
Ok(Self {
canonical_path: path.canonicalize()?,
canonical_path: path.canonicalize().map_err(|_| {
output_helper_error("파일 식별자 확인", "정규 파일 경로를 확인할 수 없습니다")
})?,
})
}

Expand Down Expand Up @@ -2718,6 +2722,77 @@ 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}"
);
}
// 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]
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()
Expand Down
Loading