ダウンロードしたファイルのブロックを解除する PowerShell スクリプトをささっと作る

# 0. はじめに

いつ頃からだろうか（Windows XP の頃？）インターネットからダウンロードしたファイルを実行しようとするとセキュリティ上の注意喚起が行われるようになった。まあ，インターネットからダウンロードした実行ファイルにはリスクがあるので当然だと思うが，近年，会社の共用ファイルサーバーが SharePoint に移行したため，Excel や PowerPoint などのマイクロソフトオフィスのファイルを開こうとしても注意喚起が行われるのだ。
※まあ VBA マクロとかあるから当然と言えば当然だけどね。

```plantuml
@startuml
<style>
root {
  margin: 0;
  padding: 0;
}
caption {
  fontStyle: normal;
  fontSize: 16
}
</style>
title <img:https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3490655/fb88a014-3315-285b-63c6-a1716211b4e5.png>
caption 図１　Excel の起動画面
@enduml
```

で，ダウンロードしたファイルを一つずつ右クリックして「許可する」にチェックを入れなくてはならない。はっきり言って面倒である。

```plantuml
@startuml
<style>
root {
  margin: 0;
  padding: 0;
}
caption {
  fontStyle: normal;
  fontSize: 16
}
</style>
title <img:https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3490655/7ec9f246-9ca1-6b7e-df79-0c7362b766d2.png>
caption 図２　ダウンロードしたファイルのプロパティ画面
@enduml
```
# 1. 代替データストリームの確認方法

インターネットからダウンロードしたファイルって，どんな感じでマーキングされているのだろうか？長年疑問に思っていたが，最近になって Windows の NTFS というファイルシステムの代替データストリーム（Alternate Data Stream）という技術を用いて実現していることが分かった。詳しくは参考文献を参照されたい。

コマンドプロンプトからは `dir /r` で確認できる。

```
C:\Download>dir /r
 ドライブ C のボリューム ラベルは Windows です
 ボリューム シリアル番号は ****-**** です

 C:\Download のディレクトリ

2024/08/18  18:51    <DIR>          .
2024/08/18  18:51    <DIR>          ..
2024/08/18  18:50            20,073 棋士名一覧20230926.xlsx
                              1,239 棋士名一覧20230926.xlsx:Zone.Identifier:$DATA
               1 個のファイル              20,073 バイト
               2 個のディレクトリ  ***,***,***,*** バイトの空き領域
```

どこからダウンロードしたのかについては `more` コマンドで分かる。ただし，リダイレクトしないといけないのが辛いところ。

```
C:\Download>more < 棋士名一覧20230926.xlsx:Zone.Identifier
[ZoneTransfer]
ZoneId=3
HostUrl=https://*** ～ 中略 ～ ***
```

ちなみにイントラネットからダウンロードしたら `ZoneId=2` になる場合もあるようだ。また `HostUrl` は単に `about:internet` とのみ記載される場合もあるようだ。

で，コマンドプロンプトからこの代替データストリームを削除する標準的な方法が用意されていないっていうのが問題だ。

# 2. Sysinternals 社のツールを使う

ふつうの人は [Sysinternals 社の Streams](https://learn.microsoft.com/ja-jp/sysinternals/downloads/streams) というツールを使ったほうがいい。ちなみに Sysinternals 社はマイクロソフト社の完全子会社とのこと。Streams はコマンドラインから実行するコンソールアプリケーションだが，初回起動時に下記のようなライセンス条項の確認画面が表示される。この条項に異論が無ければ（および社内ルールに違反しなければ）このツールを使うのが良いだろう。

```plantuml
@startuml
<style>
root {
  margin: 0;
  padding: 0;
}
caption {
  fontStyle: normal;
  fontSize: 16
}
</style>
title <img:https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3490655/5c26bd88-546f-f281-dc35-5b4c954f3519.png>
caption 図３　ライセンス条項の確認画面
@enduml
```

# 3. お品書き（仕様案）

ええと，ライセンス条項の英文を読むのが面倒だったので，自分で作ることにした。

- コマンドプロンプトから実行するコンソールアプリケーションであること。
- Sysinternals 社の Streams と同様に複数ファイル（ワイルドカード可）を指定でき，サブディレクトリ以下も検索できること。
- 代替データストリームが存在するファイルを一覧できること。
- Sysinternals 社の Streams と同様に代替データストリームを削除できること。
- Sysinternals 社の Streams と異なり，代替データストリームの内容を表示できること。
  これは，どこからダウンロードしたのか忘れた際に役に立つ機能だ。

# 4. 実装について

### 4.1 言語選定
この種のアプリケーションを開発するのにあたり一番悩ましいのは開発言語の選定である。

C 言語というか，Win32 API には `FindFirstStreamW` や `FindNextStreamW` など代替データストリームを検索・列挙する関数が提供されている。また，代替データストリームの削除はふつうに `DeleteFile` で可能だ。ただし，サブディレクトリ以下のファイル検索が面倒である。

一方，C# になるとファイル検索は非常に容易にできる。ただし，代替データストリームを直接取り扱うことはできないので P/Invoke 経由で Win32 API を呼び出す必要があり，とても面倒である。

PowerShell はファイル検索も簡単であり，代替データストリームを直接取り扱う機能もあることから PowerShell スクリプトで作成することにした。

### 4.2 代替データストリームの削除方法

代替データストリームの `Zone.Identifier` のみを削除するのであれば，`Unblock-File` コマンドで良い。しかし。代替データストリームとしては他に `SmartScreen` というものがあることを確認しており，それらも削除するためには `Remove-Item` コマンドを使う必要がある。

### 4.3 実装コード

実装コードを以下に示す。

```powershell:AdsUtil.ps1
#-------------------------------------------------------------------------------
# 代替データストリームユーティリティ
#
# 2024-08-18 作成
# 2024-08-19 ディレクトリ名に角括弧 [...] が付く場合に対応
#            Special Thanks To: https://note.com/yasu_yukimaru/n/n638b8b4f3f68
# 2024-08-20 ファイルにアクセス権限がない場合の例外処理を追加
#-------------------------------------------------------------------------------
# グローバル変数
#-------------------------------------------------------------------------------
$Command   = 'LIST';	# コマンド：'LIST', 'VIEW', 'DELETE'
$Pattern   = @()		# ファイル検索パターン
$Recursive = $false		# 再帰検索フラグ
$Count     = 0			# 代替データストリームの数
#-------------------------------------------------------------------------------
# ヘルプメッセージ
#-------------------------------------------------------------------------------
function Show-Help() {
	Write-Host '代替データストリーム（複数可）を表示または削除します。'
	Write-Host ''
	Write-Host 'ADSUTIL(.PS1) [/S] [/V] [/D] [ドライブ:][パス][ファイル名]'
	Write-Host ''
	Write-Host '/S　サブディレクトリを検索します。'
	Write-Host '/V　代替データストリームの内容を表示します。'
	Write-Host '/D　代替データストリームを削除します。'
	Exit -1
}
#-------------------------------------------------------------------------------
# コマンド実行
#-------------------------------------------------------------------------------
function Exec-Command( $filename ) {
	try {
		$items = Get-Item -LiteralPath $filename -Stream *
	} catch {
		Write-Host "ファイル $filename の代替データストリーム取得に失敗しました!!"
		Return
	}
	foreach( $item in $items ) {
		if( $item.Stream -eq ':$DATA' ) {
			continue
		}
		Write-Host ($filename + ':' + $item.Stream)
		if( $Command -eq "LIST" ) {
			# 何もしない
		} elseif( $Command -eq 'VIEW' ) {
			Get-Content -LiteralPath $filename -Stream $item.Stream
			Write-Host ''
		} elseif( $Command -eq 'DELETE' ) {
			Remove-Item -LiteralPath $filename -Stream $item.Stream
		}
		$SCRIPT:Count++
	}
}
#-------------------------------------------------------------------------------
# ファイル検索
#-------------------------------------------------------------------------------
function Search-File( $pat ) {
	$path = Split-Path -Path $pat -Parent
	$base = Split-Path -Path $pat -Leaf
	if( $path -eq '' ) {
		$path = '.'
	} elseif( !(Test-Path -LiteralPath $path) ) {
		Write-Host "ディレクトリ $path は存在しません!!"
		Exit -1
	}
	$path = Resolve-Path -LiteralPath $path
	if( $Recursive ) {
		$files = Get-ChildItem -LiteralPath $path -Filter $base -Recurse
	} else {
		$files = Get-ChildItem -LiteralPath $path -Filter $base
	}
	foreach( $file in $files ) {
		Exec-Command $file.FullName
	}
}
#-------------------------------------------------------------------------------
# オプション解析
#-------------------------------------------------------------------------------
foreach( $arg in $ARGS ) {
	if( $arg -like '[-/]S' ) {
		$Recursive = $true
	} elseif( $arg -like '[-/]V' ) {
		$Command = 'VIEW'
	} elseif( $arg -like '[-/]D' ) {
		$Command = 'DELETE'
	} elseif( $arg -like '[-/][H\?]' ) {
		Show-Help
	} elseif( $arg -like '[-/]*' ) {
		Write-Host "不正なオプション $arg を指定しました!!"
		Exit -1
	} else {
		$Pattern += $arg
	}
}
#-------------------------------------------------------------------------------
# ファイルの指定が無い場合，カレントディレクトリの全てのファイルを対象とする
#-------------------------------------------------------------------------------
if( $Pattern.length -eq 0 ) {
	$Pattern += '*'
}
#-------------------------------------------------------------------------------
# ファイル検索
#-------------------------------------------------------------------------------
foreach( $pat in $Pattern ) {
	Search-File $pat
}
#-------------------------------------------------------------------------------
# 結果出力
#-------------------------------------------------------------------------------
if( $Command -eq 'DELETE' ) {
	Write-Host "$Count 個の代替データストリームを削除しました。"
} else {
	Write-Host "$Count 個の代替データストリームがあります。"
}
Exit 0
```

# 5. 実行例

オプション `/?` または `/H` を付けるとヘルプメッセージが表示される。

```:ヘルプメッセージ
C:\Download>adsutil /?
代替データストリーム（複数可）を表示または削除します。

ADSUTIL(.PS1) [/S] [/V] [/D] [ドライブ:][パス][ファイル名]

/S　サブディレクトリを検索します。
/V　代替データストリームの内容を表示します。
/D　代替データストリームを削除します。
```

引数なしで実行するとカレントディレクトリの全てのファイルを対象とし，代替データストリームが存在するファイルの一覧を表示する。

```:代替データストリームの一覧表示
C:\Download>adsutil
C:\Download\棋士名一覧20230926.xlsx:Zone.Identifier
1 個の代替データストリームがあります。
```

オプション `/V` を付けると代替データストリームの内容を表示する。

```:代替データストリームの内容を表示
C:\Download>adsutil /v
C:\Download\棋士名一覧20230926.xlsx:Zone.Identifier
[ZoneTransfer]
ZoneId=3
HostUrl=https://*** ～ 中略 ～ ***

1 個の代替データストリームがあります。
```

オプション `/D` を付けると代替データストリームを削除する。

```:代替データストリームを削除
C:\Download>adsutil /d
C:\Download\棋士名一覧20230926.xlsx:Zone.Identifier
1 個の代替データストリームを削除しました。

C:\Download>adsutil
0 個の代替データストリームがあります。
```

# 6. 参考文献

- [Windowsでインターネットからダウンロードしたファイルのブロックについて - Qiita](https://qiita.com/SAITO_Keita/items/4ce171bce91b6daa5cbf)
- [代替データストリーム（ADS）について色々調べてみた - Qiita](https://qiita.com/minr/items/c2393f532b2df35f7a9d)
- [代替ストリーム(ADS)についてのメモ - Qiita](https://qiita.com/tanakah/items/9a83c006e7cd92f0e947)
- [ADSについて調べてみた - Qiita](https://qiita.com/yuk1n3ko/items/5a89e2b2deeb09940804)
- [代替データストリームを利用した１日１回起動スクリプト - Qiita](https://qiita.com/Takeru/items/5f08322c730cfc3c5e56)
- [F#からNTFSの代替データストリームを利用する方法 - Qiita](https://qiita.com/Midoliy/items/1e671e1e08dddfee3a9a)
- [インターネットからダウンロードしたファイルはZone.Identifierでセキュリティ管理をする - ASCII](https://ascii.jp/elem/000/001/550/1550399/)
- [dirやPowerShellでNTFSの代替データストリーム情報を表示する - itmedia](https://atmarkit.itmedia.co.jp/ait/articles/1407/11/news111.html)
- [streamsコマンドでNTFSの代替データストリーム情報を表示／削除する - itmedia](https://atmarkit.itmedia.co.jp/ait/articles/1001/14/news112.html)
- [代替データストリームの取得はNtQueryInformationFile一択 - のふ処](https://nofu.jp/wiki/blog/2021/2021-06-17)
- [名前に角括弧を含むファイルをGet-ChildItemやCopy-Itemで取り扱う方法 Windows PowerShell - note](https://note.com/yasu_yukimaru/n/n638b8b4f3f68)
- [Streams v1.6 - microsoft](https://learn.microsoft.com/ja-jp/sysinternals/downloads/streams)