【PowerShell 5以前】Expand-Archiveの文字コードをレジストリの変更で指定する方法

### 注意
この記事の解決方法はあまり実用性がなく、<INS>PowerShell 7以降(.=NET環境)では使えない</INS>ため、実務で使うことをオススメしません。
以下の内容について解説がありますので、興味を持った方は是非読んでください。
<dl>
<dt>Expand-Archiveの仕組み</dt>
<dd>・処理の概要と下記のクラス・定数との関係性が分かります。</dd>
<dt>System.IO.Compression.ZipArchiveの処理</dt>
<dd>・レジストリとの関係が分かります（.NET Famework限定）。</dd>
<dd>・ZIP内のファイル名を読み込むタイミングが分かります。</dd>
<dd>・.NET Frameworkと.NETの差異が分かります（主に文字コード）。</dd>
</dl>

# 概要
PowerShellには、`Expand-Archive`というzipを解凍するコマンドがあります。これには**文字コードを指定できない**欠点があり、**ファイル名の文字化け**を起こす問題があると言われています。そのため、解決策として以下の方法が利用されます。
* 7-Zipなどの外部ツールを使う
* `[System.IO.Compression]::ZipArchive`などのクラスを使う

ところが、実は他にも解決策があります。
PowerShell 5以前（=.NET Framework環境）では、<INS>システムのコードページに依存する</INS>性質があります。そのため、レジストリを変更することで間接的に圧縮時・解凍時の文字コードを指定することができます。

この記事では、[スクリプト例](#スクリプト例)、[Expand-Archiveの仕組み](#expand-archiveの仕組み)、[まとめ](#まとめ)を紹介します。

# スクリプト例
`Expand-Archive`は、レジストリの`HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Nls\CodePage`の`ACP`から影響を受けます。これを弄ることで解凍時の文字コードを指定します。

```powershell
# 解凍したいZIPのパス
$ZipPath = 'C:/a/a.zip'
# 解凍先
$ExtractPath = 'C:/a'
# 解凍時に使う文字コード
# 例えば、これを'1252'に設定した状態で解凍すると日本語のファイルが文字化けする。
$CharCode = '932'

# レジストリ変更前に、今のコードページを保存する
$before = ([System.Text.Encoding]::Default).WindowsCodePage

# 管理者権限でPowerShellを起動し、レジストリを変更する
Start-Process PowerShell -Verb RunAs -ArgumentList "-command ""Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Nls\CodePage' -Name 'ACP' -Value '$CharCode'"

# Zipを展開する
powershell -command "Expand-Archive $ZipPath $ExtractPath"

# 管理者権限でPowerShellを起動し、レジストリを元に戻す
Start-Process powershell -Verb RunAs -ArgumentList "-command ""Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Nls\CodePage'  -Name 'ACP' -Value '$before'"
```

## 実行例
この例では、「7-ZIP」で圧縮したzipファイルに対して、CP1252に変更した状態でExpand-Archiveを実行します。

**7-ZIPで圧縮**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3986818/0c802951-dc0d-4a70-102d-a01c43baef92.png)

**圧縮したファイル(a.zip)**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3986818/12574297-98bc-20b4-e22a-ecf47a643e2f.png)

**中身**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3986818/8069e2cf-b038-43c0-9d2c-37232d6aec94.png)

**解凍(スクリプトを実行)**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3986818/fde6c9f9-7c71-d0ec-120b-b5099f013830.png)

**レジストリの状態**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3986818/276d9e79-2082-c7c2-915f-bab1fd0febb9.png)


**解凍した結果**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3986818/74e3f26e-1150-34ad-a4ce-750eb630911f.png)

この結果から、`Expand-Archive`がシステムのコードページに依存することが分かります。

# `Expand-Archive`の仕組み
`Expand-Archive`の処理を大雑把に書くと以下のようになっています。内部で`System.IO.Compression.ZipArchive`を利用しています。さらに、そのクラスが`System.IO.Compression.ZipArchiveEntry`を利用しており、これの処理がレジストリから影響を受けます。

```plantuml
title Expand-Archiveの処理の一部
start
:System.IO.Compression.ZipArchiveのインスタンス作成
（文字コード指定なし）;
:ZipArchiveを使って、ZIP内のファイル名一覧を取得;

group ファイル名一覧取得処理
:ZipArchiveEntryを使って、ファイルの情報を解析;
end group
stop
```

## 詳細解説
ユーザが`Expand-Archive`を実行すると、ZIPファイルを展開するために[文字コードを指定せずにZipArchiveクラスのインスタンスを生成します](https://github.com/PowerShell/Microsoft.PowerShell.Archive/blob/SteveL-MSFT-patch-1/Microsoft.PowerShell.Archive/Microsoft.PowerShell.Archive.psm1#L940)。そのコンストラクタは、ZIPファイルの読み込みを行います（この時点ではファイルストリームからZIPのバイトデータを取得するだけで、ZIP内にあるファイル名の解析等は行いません）。

`Expand-Archive`は`ZipArchive`のインスタンスを作った後、[ZIP内のファイル名一覧を取得します](https://github.com/PowerShell/Microsoft.PowerShell.Archive/blob/SteveL-MSFT-patch-1/Microsoft.PowerShell.Archive/Microsoft.PowerShell.Archive.psm1#L972)。ファイル名の取得のために[`ZipArchive.Entries`プロパティ](https://github.com/dotnet/runtime/blob/main/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs#L236-L248)へアクセスしますが、そのアクセスでファイルの解析処理が行われます（[EnsureCentralDirectoryReadメソッド](https://github.com/dotnet/runtime/blob/main/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs#L484-L491)、[ReadCentralDirectoryメソッド](https://github.com/dotnet/runtime/blob/main/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs#L493-L584)）。

ファイル名といった細かい部分の解析処理は`ZipArchiveEntry`クラスのコンストラクタで行い、`ZipArchive`に設定された文字コードを利用します。文字コードが設定されていない場合、ある条件に基づいて文字コードを設定します。なお、その条件は.NET Frameworkと.NETで異なります。

### .NET Frameworkの場合
**情報元**：`C:\Windows\Microsoft.NET\Framework\v4.0.30319\System.IO.Compression.dll`をILSpyでデコンパイルしたコード

|条件|利用する文字コード|
|-|-|
|ファイル名にUnicode文字が含まれる場合|`System.Text.Encoding.UTF-8`|
|そうでない場合|`System.Text.Encoding.GetEncoding(0)`|

※`System.Text.Encoding.GetEncoding(0)`は、`System.Text.Encoding.Default`と同じ値になります。`Encoding.Default`はWindows APIの`GetACP()`の戻り値が設定されており、その関数がレジストリを読み取ります（[https://referencesource.microsoft.com/#mscorlib/system/text/encoding.cs,1402](https://referencesource.microsoft.com/#mscorlib/system/text/encoding.cs,1402)）。


### .NETの場合
**情報元**：https://github.com/dotnet/runtime/blob/main/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs#L369-L379

|条件|利用する文字コード|
|-|-|
|ファイル名にUnicode文字が含まれる場合|`System.Text.Encoding.UTF-8`|
|そうでない場合|`System.Text.Encoding.UTF-8`|


`ZipArchive`の処理内容が文章だけだと分かりづらいので、アクティビティ図で表記します。
**Entriesプロパティにアクセスしたときのアクティビティ図**
```plantuml
title ZipArchive関連の処理
start
:ZipArchive.Entriesプロパティにアクセス;
group ZipArchive
group Entriesプロパティ
:ZIP内のディレクトリの読み込み
(EnsureCentralDirectoryReadメソッドを実行);
end group
group EnsureCentralDirectoryReadメソッド
if (ディレクトリを今までに読み込んだか) then (読み込んでいない)
:ZIP内のディレクトリの読み込み
(ReadCentralDirectoryメソッドを実行);

group ReadCentralDirectoryメソッド
:Zipファイルのバイトデータを一部読み込み;
:ZipArchiveEntryのインスタンスを生成
（上述のバイトデータを渡す）;

group ZipArchiveEntryコンストラクタ
:ファイル名部分のバイトデータをデコード
(DecodeEntryStringメソッドを実行);
end group
end group

:ディレクトリを読み込み済みに設定する
(_readEntries=true);
endif
end group
stop
```

**文字コード設定のアクティビティ図**
```plantuml
title 文字コードの設定条件
group DecodeEntryStringメソッド
start
if (ZipArchiveに文字コードが設定されているか) then (設定されている)
:その文字コードに基づきファイル名を取得;
else (設定されていない)
switch (実行環境)
case (.NET)
:UTF-8に基づきファイル名を取得;
case (.NET Framework)
if (ファイル名にUnicode文字が含まれるか) then (含まれる)
:UTF-8に基づきファイル名を取得;
else (含まれない)
:システムのコードページに基づきファイル名を取得
(System.Text.Encoding.GetEncoding(0));
endif
endswitch
endif
stop
end group
```

# まとめ
情報量が多くごちゃごちゃになってしまったので、まとめを2種類書いて締めます。

### 1. 直し方
PowerShell 5の`Expand-Archive`は、`HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Nls\CodePage`の`ACP`から影響を受けるので、それを変更すれば間接的に文字コードを指定できる。

### 2. 文字コードがレジストリから影響を受ける原因
|項目|説明|
|-|-|
|コマンド側の原因|`Expand-Archive`が`ZipArchive`を使うときに文字コードを指定しないから|
|ライブラリ側の原因|.NET Frameworkの`ZipArchiveEntry`は、文字コードが指定されていないと`Encoding.GetEncoding(0)`を利用するから。<br>（.NETでは、文字コード指定がない場合は常にUTF-8を常に使うので影響を受けない）|


# 参考文献
https://github.com/PowerShell/Microsoft.PowerShell.Archive/blob/SteveL-MSFT-patch-1/Microsoft.PowerShell.Archive/Microsoft.PowerShell.Archive.psm1

https://github.com/dotnet/runtime/blob/main/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs

https://github.com/dotnet/runtime/blob/main/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs

https://referencesource.microsoft.com/#mscorlib/system/text/encoding.cs