Edited at

.NETでディレクトリを消すのがこんなに面倒なわけがない


目的

PowerShellでファイルシステムのディレクトリを削除する方法について、よくある例と、それを行うとうまくいかない、場合によっては破壊的な動作をする例を説明します。

なお、最終的に.NETの問題にぶつかるので、PowerShellからC#使えばいいじゃない教の人もハマります。


よくある例

以下のようなコマンドでファイルシステムにテストデータを作成します。

New-Item -Path ./testdir  -Type Directory -Force

New-Item -Path ./testdir/test -Type Directory -Force
New-Item -Path ./testdir/test -Name 1.txt -Type File -Value 'Test1'
New-Item -Path ./testdir/test -Name 2.txt -Type File -Value 'Test2'
New-Item -Path ./testdir/test/sub1 -Type Directory -Force
New-Item -Path ./testdir/test/sub1 -Name s1.txt -Type File -Value 'Sub-Test1'

以下のようなディレクトリが作成されます。

TESTDIR

└─test
│ 1.txt
│ 2.txt

└─sub1
s1.txt

この時にtestフォルダを削除するにはどうすればいいでしょうか?

よくある説明では下記のコマンドを入力すると行えるとあります。

Remove-Item -LiteralPath ./testdir/test -Recurse -Force


破壊的に動作する例

先の例は、多くの場合、正しく動きますが、特定の条件下でユーザが意図しないような破壊的動作をします。

まずファイルシステムの場合、シンボリックリンクやジャンクションという種類が存在しています。

Windowsのシンボリックリンクとジャンクションとハードリンクの違い

https://www.atmarkit.co.jp/ait/articles/1306/07/news111.html

これらの機能は簡単にいうとディレクトリへのリンクを作成することができる機能になっています。

これらを実際に作成するには下記のスクリプトを管理者権限で実行してください。

New-Item -Path ./testdir  -Type Directory -Force

New-Item -Path ./testdir/src1 -Type Directory -Force
New-Item -Path ./testdir/src1 -Name src1.txt -Type File -Value 'src1'

New-Item -Path ./testdir/src2 -Type Directory -Force
New-Item -Path ./testdir/src2 -Name src2.txt -Type File -Value 'src2'

# シンボリックリンクの作成には管理者権限が必要
New-Item -Path ./testdir/test -Type Directory -Force
New-Item -Path ./testdir/test -Name 1.txt -Type File -Value 'Test1'
New-Item -Value './testdir/src1' -Path './testdir/test/symbolic_src1' -ItemType SymbolicLink
New-Item -Value './testdir/src2' -Path './testdir/test/junction_src2' -ItemType Junction

# PowerShellのNew-Itemでサポートしていないバージョンの場合
#cmd /c "mklink /d ./testdir/test/symbolic_src1 c:\share\testdir\src1"
#cmd /c "mklink /j ./testdir/test/junction_src2 ./testdir/src2"

以下のようなディレクトリになります。

C:\DEV\PS\FILE\TESTDIR

├─src1
│ src1.txt

├─src2
│ src2.txt

└─test
│ 1.txt

├─junction_src2
│ src2.txt

└─symbolic_src1
src1.txt

ジャンクションやシンボリックリンクを用いることでディレクトリへのリンクが作成されていることが確認できます。

ではsrc1フォルダとsrc2フォルダを残してtestフォルダのみを削除します。

Remove-Item -LiteralPath ./testdir/test -Recurse -Force

この結果はPowerShellのバージョンによって異なります。


PowerShell 2.0の場合

C:\SHARE\TESTDIR

├─src1
└─src2

testディレクトリは削除されましたが、リンク先のファイルが全て削除されてしまいます。

これがユーザーの意図しない破壊的結果になります。

この挙動は以下で修正されています。

https://github.com/PowerShell/PowerShell/issues/621


Window10+PowerShell 5.1の場合

Windows10+PowerShell 5.1の場合以下のようになります。

Remove-Item : 要求で指定したタグと再解析ポイントにあるタグが一致しません。

発生場所 :1 文字:1
+ Remove-Item -LiteralPath ./testdir/test -Recurse -Force
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [Remove-Item], Win32Exception
+ FullyQualifiedErrorId : System.ComponentModel.Win32Exception,Microsoft.PowerShell.Commands.RemoveItemCommand

フォルダの内容:
C:\DEV\PS\FILE\TESTDIR
├─src1
src1.txt

├─src2
src2.txt

└─test
1.txt

└─symbolic_src1
src1.txt

ジャンクションは正しく削除されましたが、シンボリックリンクは残ってしまいます。

シンボリックリンクが消えない事象は、以下のコメントと同様の事象と思われます。

https://github.com/PowerShell/PowerShell/issues/621#issuecomment-515729308


プランBの削除方法

よく上記の話が出た際にプランBとして紹介される削除方法は以下になります。

(Get-Item -LiteralPath "./testdir/test").Delete($True)

[System.IO.Directory]::Delete("c:\dev\ps\file\testdir\test", $True)

これは2つの問題を引き起こします。

1つ目は読み取り専用のファイルが混在していた場合に削除できません。

2つ目はジャンクションを含むサブディレクトリを削除した場合、エラーになります。

先に挙げたジャンクションを含むディレクトリを削除した場合、以下のようになります。

PS C:\dev\ps\file> (gi "./testdir/test").Delete($true)

"1" 個の引数を指定して "Delete" を呼び出し中に例外が発生しました: "パラメーターが間違っています。
"
発生場所 行:1 文字:1
+ (Get-Item -LiteralPath "./testdir/test").Delete($True)
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : IOException

PS C:\dev\ps\file> [System.IO.Directory]::Delete("c:\dev\ps\file\testdir\test", $True)

"2" 個の引数を指定して "Delete" を呼び出し中に例外が発生しました: "パラメーターが間違っています。
"
発生場所 行:1 文字:1
+ [System.IO.Directory]::Delete("c:\dev\ps\file\testdir\test", $True)
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (:) [], MethodInvocationException
+ FullyQualifiedErrorId : IOException

このときのフォルダ構成は以下のようになっており、ジャンクションを含むディレクトリ以外は削除されています。

C:\DEV\PS\FILE\TESTDIR

├─src1
│ src1.txt

├─src2
│ src2.txt

└─test

このため、もう一度、再実行すると正常に削除されます。

この事象は、PowerShell固有の問題でなく、C#で実装した場合も発生します。

例えば、C#で以下のような実装で削除した場合も同様の事象になります。

System.IO.Directory.Delete(@"c:\dev\ps\file\testdir\test", true);

{"パス 'junction_src2' へのアクセスが拒否されました。"} System.UnauthorizedAccessException

VisualStudio2019の下記にて検証

・.NET Framework 4.7.2 + Win10

・.NET Core 2.1 + Win10.

※TODO:Linuxで動かした場合はそのうち。

なぜこの事象が発生するかの理由は、わかりませんでしたが、おそらく、以下と類似の問題だと思います。

Cannot delete directory with Directory.Delete(path, true)

https://stackoverflow.com/questions/329355/cannot-delete-directory-with-directory-deletepath-true

ジャンクションポイントが削除されきっていない状態で親フォルダを削除しようとしてエラーになっているのではないかと思いますが、正直わかりません。


どうやって消すべきか


PowerShellや.NETにはたよらない。

昔ながらのcmd.exeにやらせる。


PowerShellの例

cmd /c "rmdir /s/q `"c:\dev\ps\file\testdir\test`""



C#の実装例

Process.Start("cmd.exe", "/c " + @"rmdir /s/q c:\dev\ps\file\testdir\test");



2回やってしまう

PowerShellの場合、以下のようにSystem.IO.DirectoryInfo.Deleteで消せなかった残りの項目をRemove-Itemを強制+再帰的に呼び出してしまう方法もあります。

try { (Get-Item -LiteralPath "./testdir/test").Delete($True) } catch { Remove-Item ./testdir/test -Recurse -Force  }


再帰呼び出しをしてうまいことやる

FileAttributes.ReparsePointを判定してシンボリックリンクやジャンクションの場合は再帰を行わず、該当のディレクトリだけを削除します。

それ以外の場合は読み取り属性を外してファイルを削除後、各ディレクトリについて再起呼び出しをします。

※よくあるサンプルだとFileAttributes.ReparsePointの判定をしていないので、リンク先の属性を変更してしまっているサンプルがあります。

        // using System.IO;

// Delete
public static void DeleteDirectoryNest(string target_dir)
{
DirectoryInfo di = new DirectoryInfo(target_dir);
if ((di.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
{
Directory.Delete(target_dir, false);
return;
}

string[] files = Directory.GetFiles(target_dir);
string[] dirs = Directory.GetDirectories(target_dir);

foreach (string file in files)
{
File.SetAttributes(file, FileAttributes.Normal);
File.Delete(file);
}

foreach (string dir in dirs)
{
DeleteDirectoryNest(dir);
}

Directory.Delete(target_dir, false);
}


SHFileOperationを利用する

SHFileOperationを利用してエクスプローラーから削除したものと同じ動作を行う方法もあります。

この場合、削除中の進捗プログレスなどが表示されます。

ファイルロック中等でエラーなどが表示された場合に以下のダイアログが表示されて処理が止まるのでバッチ処理には向いていません

        // TODO :using System.Runtime.InteropServices;

private const int FO_DELETE = 0x0003;
private const int FOF_ALLOWUNDO = 0x0040; // Preserve undo information, if possible.
private const int FOF_NOCONFIRMATION = 0x0010; // Show no confirmation dialog box to the user

// Struct which contains information that the SHFileOperation function uses to perform file operations.
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
public struct SHFILEOPSTRUCT
{
public IntPtr hwnd;
[MarshalAs(UnmanagedType.U4)]
public int wFunc;
public string pFrom;
public string pTo;
public short fFlags;
[MarshalAs(UnmanagedType.Bool)]
public bool fAnyOperationsAborted;
public IntPtr hNameMappings;
public string lpszProgressTitle;
}

[DllImport("shell32.dll", CharSet = CharSet.Auto)]
static extern int SHFileOperation(ref SHFILEOPSTRUCT FileOp);

public static void DeleteFileOrFolder(string path)
{
SHFILEOPSTRUCT fileop = new SHFILEOPSTRUCT();
fileop.wFunc = FO_DELETE;
fileop.pFrom = path + '\0' + '\0';
//fileop.fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION;
fileop.fFlags = FOF_NOCONFIRMATION;
SHFileOperation(ref fileop);
}

これと同様のことはMicrosoft.VisualBasicを参照後以下のコードでも実現可能です。

            Microsoft.VisualBasic.FileIO.FileSystem.DeleteDirectory(

@"c:\dev\ps\file\testdir\test",
Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs,
Microsoft.VisualBasic.FileIO.RecycleOption.DeletePermanently);


まとめ

シンボリックリンクやジャンクションを含むディレクトリの削除について記載しました。

C#のディレクトリ削除やPowerShellのディレクトリ削除でグーグル検索した場合の上位に出てくるものは、この辺りを考慮していないので注意してください。


参考:

http://m-hiyama.hatenablog.com/entry/20150914/1442195413

https://kristofmattei.be/2012/12/15/powershell-remove-item-and-symbolic-links/

https://stackoverflow.com/questions/329355/cannot-delete-directory-with-directory-deletepath-true

https://www.fluxbytes.com/csharp/delete-files-or-folders-to-recycle-bin-in-c/