From 1d5a1472eb2c23476fc59f187d853862457de712 Mon Sep 17 00:00:00 2001 From: Wonjun Choi <99017069+wonjun-lab@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:18:11 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(nfd2nfc):=20=EC=85=B8=20NFC=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=ED=99=94=20=EC=9D=B8=EC=9E=90=C2=B7=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=C2=B7=EA=B1=B0=EC=A7=93=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=20?= =?UTF-8?q?=EA=B7=BC=EB=B3=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 안정성/스트레스 테스트에서 발견한 인자 처리 결함을 디스크 실제 저장명 기준으로 해소. - disk_real_path(): 최상위 인자를 부모 readdir로 디스크 실제 엔트리명으로 교체. zsh glob/탭완성이 인자를 NFC로 정규화해도(nfd2nfc *.hwp) 디스크 NFD를 정상 변환. 역방향(NFC 디스크 + NFD 인자)의 거짓 '1개 변경' 카운트도 해소. - 같은 항목을 NFD/NFC 다른 철자로 중복 지정 시 dedup 실패(카운트 부풀림·이중 rename) 수정. - collect 'no warnings recursion': 아주 깊은 트리의 Deep recursion 경고 억제. - opendir 실패·빈/슬래시뿐 인자도 missing 집계 → 종료 코드 1 반영. - --force/--skip 동시 지정 경고(--skip 죽은 변수 해소), --help에 '-' 파일명 -- 안내. --- nfd2nfc | 62 +++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/nfd2nfc b/nfd2nfc index cb1eb6f..5469648 100755 --- a/nfd2nfc +++ b/nfd2nfc @@ -62,6 +62,7 @@ usage(1) unless @ARGV; # quiet는 verbose보다 우선(둘 다 주면 조용히). --force가 --skip(기본)을 이긴다. $verbose = 0 if $quiet; +warn "경고: --force와 --skip을 함께 지정 — --force가 우선합니다.\n" if $force && $skip; # 사용자가 선택한 최상위 항목(옵션 제거 후 남은 인자) — 완료 후 Finder에 보여줄 대상 my @inputs = @ARGV; @@ -84,26 +85,66 @@ nfd2nfc — macOS 한글 파일명 NFD→NFC 변환기 -v, --verbose 변경 내역 출력 -V, --version 버전 출력 -h, --help 이 도움말 + + 팁: '-'로 시작하는 파일명은 -- 뒤에 두세요 (예: nfd2nfc -- -파일.txt) TXT exit $code; } # 1) 대상 경로 수집 (바이트 문자열 그대로 다룸 = 파일시스템 안전) my @targets; -my %seen; # 같은 경로 중복 수집 방지(카운트 부풀림·이중 rename 방지) -my $missing = 0; # 존재하지 않는 최상위 입력 개수(종료 코드에 반영) +my %seen; # 같은 디스크 항목 중복 수집 방지(카운트 부풀림·이중 rename 방지) +my $missing = 0; # 존재하지 않는/읽을 수 없는 최상위 입력 개수(종료 코드에 반영) + +# 셸(zsh 등)은 glob·탭완성에서 인자를 NFC로 정규화해 넘기기도 한다. 그러면 디스크가 NFD인데도 +# 인자 basename이 NFC라 "이미 정규화됨"으로 오인해 변환을 건너뛴다(반대로 NFC 디스크에 NFD 인자를 +# 주면 실제 변화 없이 카운트만 오른다). 이를 막기 위해 최상위 인자를 '디스크에 실제로 저장된 +# 엔트리명'으로 바꾼다 — 부모 디렉토리를 readdir해 정규화-비구분으로 매칭되는 실제 이름을 찾는다. +sub disk_real_path { + my ($path) = @_; + my $i = rindex($path, '/'); + my $base = $i < 0 ? $path : substr($path, $i + 1); + return $path if $base eq '' || $base eq '.' || $base eq '..'; + my $dir = $i < 0 ? '.' : (substr($path, 0, $i) eq '' ? '/' : substr($path, 0, $i)); + my $u = eval { my $c = $base; decode_utf8($c, Encode::FB_CROAK) }; + return $path unless defined $u; # 비-UTF8 인자는 그대로 둔다 + my $want = NFC($u); + opendir(my $dh, $dir) or return $path; + while (defined(my $e = readdir $dh)) { + next if $e eq '.' || $e eq '..'; + my $eu = eval { my $c = $e; decode_utf8($c, Encode::FB_CROAK) }; + next unless defined $eu; + if (NFC($eu) eq $want) { # 정규화-비구분 매칭 = 같은 디스크 항목 + closedir $dh; + my $prefix = $i < 0 ? '' : substr($path, 0, $i + 1); + return $prefix . $e; # 디스크에 저장된 실제 바이트(NFD일 수도, NFC일 수도) + } + } + closedir $dh; + return $path; # 매칭 없음(존재 안 함 등) → 원본 +} + sub collect { + no warnings 'recursion'; # 아주 깊은 트리에서 'Deep recursion' 경고 억제 my ($path, $toplevel) = @_; - $path =~ s{/+$}{}; # 끝 슬래시 제거 - return if $path eq ''; - # 사용자가 직접 지정한 최상위 경로가 없으면 알린다(오타 등 조용한 무시 방지). - # APFS는 정규화 비구분이라 NFD로 준 경로도 -e가 잡지만, 정말 없는 경로는 걸러진다. - if ($toplevel && !-e $path && !-l $path) { - warn "경고: 경로가 존재하지 않음: $path\n"; - $missing++; + my $orig = $path; + $path =~ s{/+$}{}; # 끝 슬래시 제거 + if ($path eq '') { + # 빈 인자나 슬래시뿐인 인자('', '/', '//')는 변환 대상이 아니다(조용한 무시 방지). + if ($toplevel) { warn "경고: 처리할 수 없는 경로: '$orig'\n"; $missing++; } return; } - return if $seen{$path}++; # 이미 수집한 경로는 건너뜀 + if ($toplevel) { + # 사용자가 직접 지정한 최상위 경로가 없으면 알린다(오타 등 조용한 무시 방지). + # APFS는 정규화 비구분이라 NFD로 준 경로도 -e가 잡지만, 정말 없는 경로는 걸러진다. + if (!-e $path && !-l $path) { + warn "경고: 경로가 존재하지 않음: $path\n"; + $missing++; + return; + } + $path = disk_real_path($path); # 셸 정규화와 무관하게 디스크 실제 저장명을 사용 + } + return if $seen{$path}++; # 이미 수집한 경로는 건너뜀 push @targets, $path; if ($recurse && -d $path && !-l $path) { if (opendir(my $dh, $path)) { @@ -115,6 +156,7 @@ sub collect { } } else { warn "열 수 없음: $path ($!)\n"; + $missing++; # 읽기 실패도 부분 실패로 exit code에 반영 } } } From 53d5ed3836b5f301e6970146d0526b298b8e34a9 Mon Sep 17 00:00:00 2001 From: Wonjun Choi <99017069+wonjun-lab@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:18:11 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test:=20=EC=85=B8=20NFC=20=EC=9D=B8?= =?UTF-8?q?=EC=9E=90=C2=B7=EA=B1=B0=EC=A7=93=20=EC=B9=B4=EC=9A=B4=ED=8A=B8?= =?UTF-8?q?=C2=B7=EC=A4=91=EB=B3=B5=20dedup=20=ED=9A=8C=EA=B7=80=20?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit disk_real_path 회귀 방지: NFC 인자→디스크 NFD 변환, NFC 디스크+NFD 인자 0개, NFD/NFC 철자 중복 dedup. 통과 18/18. --- test.sh | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test.sh b/test.sh index fba2ede..881b0e6 100755 --- a/test.sh +++ b/test.sh @@ -177,6 +177,32 @@ make_fixture "$TMP/t12" ferr=$(/usr/bin/perl "$NFD2NFC" --force "$TMP/t12" 2>&1 >/dev/null) if [ "$(count_nfd "$TMP/t12")" -eq 0 ] && [ -z "$ferr" ]; then ok "--force: 정상 변환 + 경고 없음"; else ng "--force 이상(err=[$ferr])"; fi +# [13] 셸(zsh glob/탭완성)이 NFC로 정규화한 인자로도 디스크 NFD를 변환 (disk_real_path). +# 셸이 인자를 NFC로 넘기면 인자 basename은 NFC지만 디스크 엔트리는 NFD다. +D13="$TMP/t13"; mkdir -p "$D13" +/usr/bin/perl -e 'use utf8;use Unicode::Normalize qw(NFD);use Encode qw(encode_utf8); + open(my$f,">",$ARGV[0]."/".encode_utf8(NFD("계약서.pdf")));close$f;' "$D13" +nfc_arg="$D13/$(/usr/bin/perl -e 'use utf8;use Unicode::Normalize qw(NFC);use Encode qw(encode_utf8);print encode_utf8(NFC("계약서.pdf"))')" +/usr/bin/perl "$NFD2NFC" "$nfc_arg" >/dev/null 2>&1 +if [ "$(count_nfd "$D13")" -eq 0 ]; then ok "NFC 인자(셸 정규화)로도 디스크 NFD 변환"; else ng "disk_real_path 실패(NFC 인자 변환 누락)"; fi + +# [14] NFC로 저장된 디스크 파일에 NFD 철자 인자 → 실제 변화 없으므로 '0개 변경'(거짓 카운트 방지) +D14="$TMP/t14"; mkdir -p "$D14" +printf x > "$D14/$(/usr/bin/perl -e 'use utf8;use Unicode::Normalize qw(NFC);use Encode qw(encode_utf8);print encode_utf8(NFC("문서.txt"))')" +nfd_arg="$D14/$(/usr/bin/perl -e 'use utf8;use Unicode::Normalize qw(NFD);use Encode qw(encode_utf8);print encode_utf8(NFD("문서.txt"))')" +out14=$(/usr/bin/perl "$NFD2NFC" "$nfd_arg" 2>&1) +if echo "$out14" | grep -q "0개 변경"; then ok "NFC 디스크 + NFD 인자 → 거짓 카운트 없음"; else ng "거짓 카운트: $out14"; fi + +# [15] 같은 디스크 항목을 NFD/NFC 다른 철자로 중복 지정 → 카운트 부풀림·이중 처리 없음 +D15="$TMP/t15"; mkdir -p "$D15" +/usr/bin/perl -e 'use utf8;use Unicode::Normalize qw(NFD);use Encode qw(encode_utf8); + my$d=$ARGV[0];my$s="$d/".encode_utf8(NFD("폴더"));mkdir $s; + open(my$f,">","$s/".encode_utf8(NFD("파일.txt")));close$f;' "$D15" +nfc_dir="$D15/$(/usr/bin/perl -e 'use utf8;use Unicode::Normalize qw(NFC);use Encode qw(encode_utf8);print encode_utf8(NFC("폴더"))')" +base15=$(/usr/bin/perl "$NFD2NFC" -n "$D15" 2>&1 | tail -1) +dup15=$(/usr/bin/perl "$NFD2NFC" -n "$D15" "$nfc_dir" 2>&1 | tail -1) +if [ "$base15" = "$dup15" ]; then ok "NFD/NFC 철자 중복 입력 dedup(카운트 안 부풀림)"; else ng "dedup 실패: base=[$base15] dup=[$dup15]"; fi + # [버전] --version 출력 형식 ver_out=$(/usr/bin/perl "$NFD2NFC" --version 2>&1) if echo "$ver_out" | grep -Eq '^nfd2nfc [0-9]+\.[0-9]+\.[0-9]+$'; then ok "--version 출력 형식"; else ng "--version 형식 이상: $ver_out"; fi From 0c61c9b6293475f5ed00540a81b9b63a25963687 Mon Sep 17 00:00:00 2001 From: Wonjun Choi <99017069+wonjun-lab@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:18:11 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EC=95=88=EC=A0=95=EC=84=B1=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=98=EC=98=81(=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20+=20CHANGELOG)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README 안전 섹션에 권한 없는 항목 건너뜀 안내, CHANGELOG에 스트레스 후속 수정 기록. --- CHANGELOG.md | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de3a7fe..3bdbd52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ ## [Unreleased] +### Fixed (안정성/스트레스 테스트 후속) +- **셸 glob/탭완성이 인자를 NFC로 정규화하면 변환이 누락되던 문제** — macOS 기본 셸 zsh는 `nfd2nfc *.hwp`에서 파일명을 NFC로 넘겨, 디스크가 NFD인데도 "이미 정규화됨"으로 스킵했다. 최상위 인자를 부모 readdir로 **디스크 실제 저장명**으로 바꿔(`disk_real_path`) 셸 정규화와 무관하게 동작. +- 같은 디스크 항목을 NFD/NFC **다른 철자로 중복 지정**하면 `%seen` 키가 raw 바이트라 dedup이 실패해 카운트 부풀림·이중 rename이 일어나던 문제(디스크 실제명 기준으로 해소). +- NFC로 저장된 파일에 NFD 인자를 주면 실제 변화 없이 "1개 변경"으로 세던 거짓 카운트. +- 아주 깊은 트리에서 Perl `Deep recursion` 경고가 stderr로 새던 문제(`no warnings 'recursion'`). +- `opendir` 실패(권한 없는 하위)·빈/슬래시뿐인 인자도 부분 실패로 집계해 종료 코드 1에 반영. +- `--force`와 `--skip`을 함께 지정하면 우선순위를 경고로 안내(`--skip` 죽은 변수 해소). +- `--help`에 `-`로 시작하는 파일명은 `--` 뒤에 두라는 팁 추가. + ### Added - `-q`/`--quiet` — 요약 출력을 생략하는 조용한 모드(경고·에러는 stderr 유지). cron·스크립트용. - `-f`/`--force` — 이름 충돌 시 덮어쓰기(기본은 안전하게 건너뜀). `--skip`은 기본값의 명시적 별칭. diff --git a/README.md b/README.md index 799bb44..2fcd3d6 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ nfd2nfc -v 보고서.pdf 자료.xlsx # 여러 파일 + 변경 내역 출 - 여러 번 실행해도 안전합니다(idempotent). 바꿀 게 없으면 아무 일도 일어나지 않습니다. - 깊은 폴더부터 처리해, 폴더 이름을 바꿔도 하위 경로가 어긋나지 않습니다. - 심볼릭 링크를 따라 들어가지 않습니다. +- 권한이 없어 못 읽거나 못 바꾸는 항목은 건너뛰고 알립니다(종료 코드 1). - **Dropbox·iCloud 등 동기화 폴더에서도 안전** — 이름만 바꾸므로(메타데이터만 변경), 클라우드 전용(아직 안 받은) 파일을 통째로 내려받지 않습니다. > 💡 **왜 단순 비교가 아니라 inode를 보냐면** — macOS 파일시스템(APFS·HFS+)은 정규형을 구분하지 않아, NFD 파일을 NFC 이름으로 조회해도 “이미 있다”고 나옵니다. 그래서 nfd2nfc는 단순 존재 검사 대신 inode를 비교해, 정말로 다른 파일이 그 이름을 차지한 경우에만 건너뜁니다.