【テクニカル・上級編】【ローカルプロファイル容量調査】Shell.Application を活用した特権不要の AppData 内大容量キャッシュ特定・通知 – VBScript (Visual Basic Scripting Edition)解析バイブル

スポンサーリンク

1. プロローグ:特権なきディスク圧迫という現実と、FSOの限界

エンタープライズの現場において、クライアントPCのストレージ逼迫は突如として発生する。特に近年、Microsoft Teams、Slack、各種ブラウザといったElectronベースのアプリケーションや、Web技術を内包したモダンな業務ツールが一般化し、各ユーザーの `%USERPROFILE%\AppData` 以下にはギガバイト単位のキャッシュやログが日々、音もなく積み重なっている。

社内システム管理者やインフラエンジニアがこの問題に対処しようとする際、最大の障壁となるのが「実行権限の壁」である。現代のセキュアなインフラ設計において、一般ユーザーにローカル管理者権限(Administrator)は付与されない。したがって、管理者権限を要求するWMIクエリや、特権昇格が必要なPowerShellスクリプトによる全スキャンは、日常的な自動運用(ログオンスクリプトやスタートアップ、タスクスケジューラでの配布)において極めて採用しづらい。

一般ユーザー権限で動作し、かつ軽量に動作するスクリプトとして、伝統的な `Scripting.FileSystemObject` (FSO) を用いた再帰走査が真っ先に検討される。しかし、FSOには以下の2つの致命的な脆弱性が存在する。

1. ジャンクション(リパースポイント)による無限ループとエラーの発生
Windows Vista以降、`AppData` フォルダ内には後方互換性を維持するために多くのジャンクション(例:`AppData\Local\Application Data` -> `AppData\Local` への自己参照リンク)が配置されている。FSOで単純な再帰走査を行うと、この循環参照を検知できず、同一ディレクトリを無限に掘り続け、最終的にスタック領域を食い潰して異常終了(スタックオーバーフロー)するか、パス長の上限(MAX_PATH: 260文字)を超えて「パスが見つかりません」のエラーで墜ちる。
2. 不適切なアクセス権エラーによる走査の中断
`AppData` 内には、システムや特定のプロセスのみにアクセスが制限された不可視のフォルダ(例:`System Volume Information` の一部や暗号化フォルダ)が存在する。FSOでこれらにアクセスしようとすると、「書き込み禁止」や「パスへのアクセスが拒否されました」といった実行時エラーが発生し、エラーハンドリングを怠ればその時点で走査処理全体が停止してしまう。

我々に必要なのは、「一般ユーザー権限で動作し」「OSのジャンクションを安全にスルーし」「エラーで処理を止めず」「数万〜数十万のファイルを走査してもメモリを食いつぶさない」極限まで洗練された軽量スクリプトである。

これを実現するために、我々はWindowsエクスプローラーのコアエンジンである `Shell.Application` COMオブジェクトと、FSOの「属性判定」を組み合わせたハイブリッド・アーキテクチャを採用する。

2. アーキテクチャの選択:なぜ `Shell.Application` なのか

Windowsエクスプローラーは、ジャンクションやアクセス権のないフォルダを破綻することなく、適切にハンドリングしてフォルダ容量を表示している。この挙動をVBScriptから最も低コストで借用できるのが `Shell.Application` オブジェクトである。

Shell.Application の優位性

`Shell.Application` の `NameSpace` メソッドから取得できる `Folder` オブジェクト(正確には `Folder3` インターフェース)および `FolderItem` オブジェクトは、ファイルシステムを直接操作する低レベルAPIのラッパーではなく、シェル名前空間(Shell Namespace)を介してオブジェクトを抽象化している。

これにより、以下のような恩恵を享受できる。

  • 属性の自動解決: システムが隠し属性やシステム属性として扱う特殊フォルダを、シェルの標準フィルタリングルールに従って安全に列挙できる。
  • 物理パスに依存しない走査: 特殊フォルダ定数(ShellSpecialFolderConstants)を利用することで、OSの言語環境(日本語の「ユーザー」フォルダと英語の「Users」フォルダの差異など)や、ユーザープロファイルがデフォルトの `C:\Users` から変更されている環境であっても、確実に正しいパス(`ssfLOCALAPPDATA` = 28)を解決できる。

COMオブジェクトのライフサイクルとメモリマネジメント

VBScriptは、参照カウント方式(Reference Counting)のガベージコレクションを採用している。
スクリプト言語だからといってメモリ管理をOSに丸投げすると、数万個のファイル・フォルダを再帰的に走査する過程で、内部的に生成されたCOMラッパーオブジェクト(`FolderItem` や `Folder`)がメモリ(ヒープ領域およびCOMのBSTRバッファ)に滞留し、プロセス(`wscript.exe` / `cscript.exe`)のコミットサイズが数百MBに膨れ上がる。これはリソースの限られたVDI(仮想デスクトップ)環境などでは致命傷となる。

このリークを防ぐため、再帰処理のループ内では、不要になったオブジェクトに対して明示的に `Set obj = Nothing` を実行し、参照カウントを即座に `0` に落としてデストラクタを呼び出す設計が必須となる。

3. 極限の設計:ジャンクション回避とメモリリークの完全防御

本アーキテクチャの核心は、「FSOによるリパースポイント(ジャンクション)の検知」「Shell.Application による安全なプロパティ取得」の相乗効果にある。

ジャンクション(属性値 1024)のフィルタリング

Windowsのファイルシステム上、フォルダがジャンクション(Reparse Point)であるか否かは、Win32 APIの `FILE_ATTRIBUTE_REPARSE_POINT` (0x400 = 1024) 属性で定義されている。
FSOの `Folder` オブジェクトが持つ `Attributes` プロパティを参照し、このビットフラグが立っている場合は一切の探索を行わずにスキップする。これにより、循環参照によるスタックオーバーフローを100%防止する。

‘ ジャンクション判定のロジック
If (objSubFolder.Attributes And 1024) <> 0 Then
‘ このフォルダはジャンクション(リパースポイント)であるため、走査をスキップ
End If

再帰スタックの最適化

VBScriptの再帰呼び出し(Recursive Call)は、ローカル変数をスタック(Stack)に積む。深い階層(Deep Directory Trees)を探索する際、スタック領域の枯渇を防ぐため、再帰関数内でのローカル変数の数は最小限に抑え、処理が終わるたびに確実にスコープから外す、あるいは `Nothing` でクリアする必要がある。

4. 実装:プロファイル容量調査スクリプト

以下に、実戦投入可能な完成されたVBScriptコードを示す。このスクリプトは、ユーザーの `AppData\Local` および `AppData\Roaming` 内を高速にスキャンし、指定した閾値(例: 100MB)を超えるフォルダを特定して、結果をデスクトップ上にHTMLレポートおよびテキストログとして出力する。一般ユーザー権限で完全に動作する。

Option Explicit

‘ ==============================================================================
‘ システム設定値・定数定義
‘ ==============================================================================
Const THRESHOLD_MB = 100 ‘ 検出対象とする最小フォルダサイズ (MB)
Const LOG_FILE_NAME = “AppData_Usage_Report.txt”
Const HTML_FILE_NAME = “AppData_Usage_Report.html”

‘ ShellSpecialFolderConstants
Const ssfLOCALAPPDATA = 28 ‘ %USERPROFILE%\AppData\Local
Const ssfAPPDATA = 26 ‘ %USERPROFILE%\AppData\Roaming

‘ ==============================================================================
‘ グローバルオブジェクト初期化
‘ ==============================================================================
Dim FSO, ShellApp, WshShell
Set FSO = CreateObject(“Scripting.FileSystemObject”)
Set ShellApp = CreateObject(“Shell.Application”)
Set WshShell = CreateObject(“WScript.Shell”)

Dim DesktopPath, LogPath, HTMLPath
DesktopPath = WshShell.SpecialFolders(“Desktop”)
LogPath = FSO.BuildPath(DesktopPath, LOG_FILE_NAME)
HTMLPath = FSO.BuildPath(DesktopPath, HTML_FILE_NAME)

Dim LogStream, HTMLStream
Set LogStream = FSO.CreateTextFile(LogPath, True, True) ‘ Unicode(UTF-16)で作成
Set HTMLStream = FSO.CreateTextFile(HTMLPath, True, True)

‘ ==============================================================================
‘ メイン処理開始
‘ ==============================================================================
Call WriteHeaders()

‘ 対象パスの取得 (Shell.Application から物理パスを安全に解決)
Dim LocalAppDataPath, RoamingAppDataPath
LocalAppDataPath = ShellApp.NameSpace(ssfLOCALAPPDATA).Self.Path
RoamingAppDataPath = ShellApp.NameSpace(ssfAPPDATA).Self.Path

WScript.Echo “スキャンを開始します…” & vbCrLf & _
“対象1: ” & LocalAppDataPath & vbCrLf & _
“対象2: ” & RoamingAppDataPath

‘ 走査実行
Call ScanTargetDirectory(LocalAppDataPath)
Call ScanTargetDirectory(RoamingAppDataPath)

Call WriteFooters()

‘ 後処理(明示的解放)
LogStream.Close
HTMLStream.Close
Set LogStream = Nothing
Set HTMLStream = Nothing
Set FSO = Nothing
Set ShellApp = Nothing
Set WshShell = Nothing

WScript.Echo “スキャンが完了しました。” & vbCrLf & _
“デスクトップにレポートを出力しました。” & vbCrLf & _
“・” & LOG_FILE_NAME & vbCrLf & _
“・” & HTML_FILE_NAME

‘ ==============================================================================
‘ サブルーチン・関数群
‘ ==============================================================================

Sub ScanTargetDirectory(ByVal targetPath)
On Error Resume Next
If Not FSO.FolderExists(targetPath) Then Exit Sub

Dim rootFolder
Set rootFolder = FSO.GetFolder(targetPath)
If Err.Number <> 0 Then
Call WriteLog(“ERROR”, “アクセス不可: ” & targetPath, 0)
Err.Clear
Exit Sub
End If

Dim subFolder
For Each subFolder In rootFolder.SubFolders
‘ ジャンクション(リパースポイント)の完全回避
If (subFolder.Attributes And 1024) = 0 Then
Dim folderSize
folderSize = GetFolderSizeSafe(subFolder.Path)

Dim sizeMB
sizeMB = Round(folderSize / 1024 / 1024, 2)

‘ 閾値判定
If sizeMB >= THRESHOLD_MB Then
Call WriteLog(“ALERT”, subFolder.Path, sizeMB)
Else
Call WriteLog(“INFO”, subFolder.Path, sizeMB)
End If
Else
Call WriteLog(“SKIP”, “[Junction] ” & subFolder.Path, 0)
End If
Next

Set rootFolder = Nothing
End Sub

‘ ——————————————————————————
‘ 安全なフォルダ容量取得(Shell.Application によるエミュレーション)
‘ ——————————————————————————
Function GetFolderSizeSafe(ByVal folderPath)
On Error Resume Next
GetFolderSizeSafe = 0

Dim shellFolder, folderItem
Set shellFolder = ShellApp.NameSpace(folderPath)

If Not shellFolder Is Nothing Then
Set folderItem = shellFolder.Self
If Not folderItem Is Nothing Then
‘ FolderItem.Size はファイルの場合のみ有効なため、
‘ フォルダの場合は配下のアイテムを安全に集計する
GetFolderSizeSafe = CalculateFolderSizeRecursive(shellFolder)
End If
End If

‘ COMオブジェクトの解放漏れを防ぐ
Set folderItem = Nothing
Set shellFolder = Nothing
Err.Clear
End Function

‘ ——————————————————————————
‘ Shell.Application を用いたメモリセーフな再帰サイズ集計
‘ ——————————————————————————
Function CalculateFolderSizeRecursive(ByVal objFolder)
On Error Resume Next
Dim totalSize
totalSize = 0

If objFolder Is Nothing Then
CalculateFolderSizeRecursive = 0
Exit Function
End If

Dim folderItems, item
Set folderItems = objFolder.Items()

‘ 隠しファイルも含めて走査するためのフィルタ(シェル標準)
folderItems.Filter 73920, “” ‘ SHCONTF_FOLDERS Or SHCONTF_NONFOLDERS Or SHCONTF_INCLUDEHIDDEN

For Each item In folderItems
If item.IsFolder Then
‘ ジャンクション判定のためにFSOオブジェクトを一時的に経由する
Dim subPath, fsoSub
subPath = item.Path
Set fsoSub = FSO.GetFolder(subPath)

If Err.Number = 0 And Not fsoSub Is Nothing Then
‘ ジャンクションでなければ再帰
If (fsoSub.Attributes And 1024) = 0 Then
Dim subShellFolder
Set subShellFolder = ShellApp.NameSpace(subPath)
totalSize = totalSize + CalculateFolderSizeRecursive(subShellFolder)
Set subShellFolder = Nothing
End If
End If
Set fsoSub = Nothing
Err.Clear
Else
totalSize = totalSize + item.Size
End If
Next

Set folderItems = Nothing
CalculateFolderSizeRecursive = totalSize
Err.Clear
End Function

‘ ——————————————————————————
‘ ログおよびHTMLレポートの出力制御
‘ ——————————————————————————
Sub WriteHeaders()
‘ テキストヘッダー
LogStream.WriteLine “==============================================================================”
LogStream.WriteLine ” AppData Local/Roaming 大容量キャッシュスキャンレポート”
LogStream.WriteLine ” 実行日時: ” & Now()
LogStream.WriteLine ” 検出閾値: ” & THRESHOLD_MB & ” MB 以上”
LogStream.WriteLine “==============================================================================”
LogStream.WriteLine “STATUS” & vbTab & “SIZE (MB)” & vbTab & “FOLDER_PATH”

‘ HTMLヘッダー(レスポンシブ・モダンデザイン)
HTMLStream.WriteLine “”
HTMLStream.WriteLine “”
HTMLStream.WriteLine “”
HTMLStream.WriteLine “
HTMLStream.WriteLine “AppData Storage Analyzer
HTMLStream.WriteLine “


HTMLStream.WriteLine “”
HTMLStream.WriteLine “”
HTMLStream.WriteLine “

AppData Storage Analyzer


HTMLStream.WriteLine “

実行日時: ” & Now() & ” | 検出閾値: ” & THRESHOLD_MB & ” MB 以上


HTMLStream.WriteLine “


HTMLStream.WriteLine “


HTMLStream.WriteLine “


End Sub

Sub WriteLog(ByVal status, ByVal path, ByVal sizeMB)
‘ テキスト出力
If status = “ALERT” Or status = “SKIP” Or status = “ERROR” Then
LogStream.WriteLine status & vbTab & sizeMB & vbTab & path
End If

‘ HTML出力 (ALERTのみ、あるいはSKIPも視覚的に分ける)
Dim rowClass, sizeText
sizeText = FormatNumber(sizeMB, 2) & ” MB”

Select Case status
Case “ALERT”
rowClass = ” class=’alert'”
Case “SKIP”
rowClass = ” class=’skip'”
sizeText = “-”
Case “ERROR”
rowClass = ” class=’alert'”
sizeText = “ERROR”
Case Else
Exit Sub ‘ 閾値以下のINFOはHTMLレポートの肥大化を防ぐため除外
End Select

HTMLStream.WriteLine “


End Sub

Sub WriteFooters()
‘ HTMLフッター
HTMLStream.WriteLine “


HTMLStream.WriteLine “

ステータス サイズ (MB) フォルダパス
” & status & “ ” & sizeText & “ ” & path & “


HTMLStream.WriteLine “”
HTMLStream.WriteLine “”
End Sub

5. ディープダイブ:技術的特異点と解説

このスクリプトは、単なる自動化ツールを超え、OSの内部挙動を考慮した複数の設計パターンを内包している。その技術的特異点を解説する。

1. シングルスレッドCOMモデル(STA)における「非同期」の排除

VBScriptが動作するWSH(Windows Script Host)環境は、STA(Single-Threaded Apartment)モデルで動作する。
`Shell.Application` の一部のメソッド(例:`Folder.CopyHere` など)は非同期で実行され、スクリプトの実行を追い越してしまう問題があるが、本スクリプトで使用している `FolderItems.Filter` および `item.Size` のプロパティアクセスは完全に同期処理として実行される。
これにより、バックグラウンドでのファイルロックやスレッドの衝突を回避し、逐次処理としての安全性を担保している。

2. `Filter` メソッドによる高速化と隠しファイルの捕捉

`folderItems.Filter` に渡しているマジックナンバー `73920` は、以下のシェルフラグの論理和(OR)である。

  • `SHCONTF_FOLDERS` (32) : フォルダを含める
  • `SHCONTF_NONFOLDERS` (64) : ファイルを含める
  • `SHCONTF_INCLUDEHIDDEN` (73728) : 隠しファイル・システムファイルを含める

通常のエクスプローラーの設定(「隠しファイルを表示しない」など)に依存せず、スクリプトの実行コンテキストにおいて、すべてのキャッシュ(隠し属性が付与されていることが多い)を漏れなく捕捉するために、この明示的なフィルタリングが不可欠となる。

3. FSOと Shell.Application の相補関係

なぜ `Shell.Application` 単体で完結させず、ジャンクション判定に FSO(`Scripting.FileSystemObject`)を混ぜているのか。
ここにアーキテクトとしての最大のこだわりがある。

`Shell.Application` の `FolderItem` オブジェクトには、そのアイテムがジャンクションであるかを「直接かつ軽量に」判定するプロパティが欠けている(`IsLink` はショートカット `.lnk` ファイルにのみ反応する)。
一方、FSOはジャンクションを「Attributes = 1024」のフォルダとして正確に識別できるが、そのまま再帰的に配下を掘ろうとするとエラーを起こす。

したがって、「FSOでジャンクションでないことを確認し、安全が担保されたパスのみを Shell.Application に渡し、シェルAPIの堅牢性をもってサイズを集計する」という、相互の強みを活かしたハイブリッド設計が、レガシーWindows環境から最新のWindows 11までを確実に、ノンストップで走査するための最適解となる。

6. エピローグ:レガシーを「掌握」するということ

「VBScriptは過去の遺物である」という言説は、モダンな開発環境にのみ身を置く者の浅薄な見解に過ぎない。
数万台規模のシンクライアント、あるいは金融、製造業、官公庁の閉域網において、追加コンポーネントのインストールを一切許されず、PowerShellの実行ポリシー(Execution Policy)すらガチガチに縛られた環境において、「ダブルクリックするだけで、一般ユーザー権限のままOSの深部を安全に走査できる」 VBScriptの優位性は、今なお揺るいでいない。

レガシーとされる技術を真に「掌握」するとは、その言語の限界(エラーハンドリングの貧弱さ、シングルスレッドの制約、COMのメモリリーク問題)を完全に理解した上で、Win32およびシェル名前空間の仕様を逆手に取り、堅牢無比なシステムを仕立て上げることにある。

本稿で示したコードと設計思想が、あなたのシステムの安定稼働と、ブラックボックス化したクライアントPCの「可視化」に寄与することを願ってやまない。

タイトルとURLをコピーしました