The Man Who Fell From The Wrong Side Of The Sky:2019年6月2日分

2019/6/2(Sun)

[Windows] PowerShellで生活するために - New-TemporaryFileコマンドレット編(その6)

前回の続き。

@ コードをリファクタリングしてコマンドレット化する

古いコードを書き直すのはいい事なのだが、リファクタリングだああああと叫びながら古い家にガソリン撒いて焼き払った後にバイク小屋ひとつ建てられない事案が多過ぎるのだよな、そもそもファクタリング=債権買取だからリファクタリングってのは再び借金抱えるそういう意味なんじゃねえの(適当)。

ということで前回書いたコードをコマンドレットに書き直す、そもそもコマンドレットのletってなんやねん、Javaのアプレットとかサーブレットでも思ったのだが、いま調べたらカツレツ(小さく切り分けた)と同じで「小さい」を意味する接尾語らしい、ほんとどうでもいいですね…これから嫌な奴に出くわしたら器レットとか度量レット、チンコレットとでも呼ぶことにしよう(ぉ

@ コマンドレットを実装するには

詳しい事は ドキュメント参照なんだがいくつかかいつまんで。

コマンドレットとなるクラスは必ず

のいずれかを継承する必要がある 、ドキュメントには読むと後者を継承することでMSH Runtimeとやらにアクセスきるとあるけど、MSHが何かの説明が何もないのでさっぱりである。おそらくMSHとはMonad(PowerShellの開発コード) ShellであってPowerShell自身を指してるっぽい、ドキュメントがまともに更新できてないってやつだ。

ドキュメント役に立たんときはオープンソースなんだしコードを読めなのだが、どうやらコマンドレットの実装内部からRunspaceと呼ばれている分散処理や非同期処理、ジョブ管理そしてワークフローの機能が触れるのが後者の模様、ただしその代償として直接クラスをインスタンス化できなくなる。

そもそも今回の場合、必要な情報は環境変数や関数経由でとれるものだし、操作する対象もファイルシステムでそもそもトランザクション保護無いから *1分割操作にならんようアトミックに腐心してるわけなので、Cmdletを継承すれば充分ちうことですわ。

にもかかわらず機能使わんのにPSCmdlet継承してるコードが多いのねー貧乏性だと「継承するだけならタダなんだしなんか高機能そうなPSCmdletを継承しとけ」となるとこだけど、逆にインスタンス化できない事でユニットテスト書く時に困るやつ。贅肉あった方が飢えには強いといいつつ飢饉ならどっちみち水無くて死ぬので、成人病リスクで考えたら痩せろってやつですね(ぉ

また継承しただけではダメでコマンドレット名をCmdletアトリビュートで指定する必要もある、動詞ではじめることが推奨されかつ VerbsCommonクラスに定義されてるものを使うのが基本、使える動詞の一覧はGet-Verbコマンドレットで取得できる。

Get-Verb
Verb        Group
----        -----
Add         Common
...
New         Common
...

ジャパニーズSIerが「Regist-*」コマンドレット作ろうとしてもそんな動詞存在しないので追加しないとダメなヤツだ!

他にもいろいろパラメーターはあるけど説明は省略、あとは ドキュメント読んで。

それにしても設計的にCmdletってinterfaceであるべきだと思うんだけど、一番根っこがabstract classってバッドデザインのテンプレやのう。 オーバーライドすべき関数にあらかじめデフォルト実装がご用意されてるので、逆にどれをオーバーライドすべきなのかドキュメント読まないと判らないヤツなんやな。

そんで基本的にオーバーライドするか考える必要のあるメソッドは以下の3つ(他にもあるけどまず必要ない)。

  • BeginProcessing() … 初期処理、一度だけ呼ばれる
  • ProcessRecord() … 処理、パイプで渡されたオブジェクトのレコード数だけ呼ばれる
  • EndProcessing() … 終了処理、一度だけ呼ばれる

パイプからデータ読んで逐次処理するならProcessRecord()をオーバーライドだけど、今回はパイプ無関係なのでEndProcessing()だけオーバーライドする。

戻り値の型はOutputTypeアトリビュートで指定、実際にどうやって返すかというとreturnステートメントではなく、Cmdlet::WriteOutput(Object)という関数を使ってパイプに書き込んでやる必要がある。

@ コード完成

ちゅーことで、とりあえず雑なコードだけどこれで一旦は完成という事で。

using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Management.Automation;
using Microsoft.Win32.SafeHandles;

namespace unko
{
	public static class NewTemporaryObject
	{
		internal const int RETRY_MAX = 100;
		internal enum Errors : int
		{
			ERROR_FILE_EXISTS    = 80,
			ERROR_ALREADY_EXISTS = 183
		}
		internal enum DesiredAccess : uint
		{
			GENERIC_ALL     = 0x10000000,
			GENERIC_EXECUTE = 0x20000000,
			GENERIC_WRITE   = 0x40000000,
			GENERIC_READ    = 0x80000000
		}
		internal enum ShareMode : uint
		{
			FILE_SHARE_READ   = 0x00000001,
			FILE_SHARE_WRITE  = 0x00000002,
			FILE_SHARE_DELETE = 0x00000004
		}
		internal enum CreationDisposition : uint
		{
			CREATE_NEW        = 1,
			CREATE_ALWAYS     = 2,
			OPEN_EXISTING     = 3,
			OPEN_ALWAYS       = 4,
			TRUNCATE_EXISTING = 5
		}
		internal enum FlagsAndAttributes : uint
		{
			FILE_ATTRIBUTE_READONLY            = 0x00000001,
			FILE_ATTRIBUTE_HIDDEN              = 0x00000002,
			FILE_ATTRIBUTE_SYSTEM              = 0x00000004,
			FILE_ATTRIBUTE_DIRECTORY           = 0x00000010,
			FILE_ATTRIBUTE_ARCHIVE             = 0x00000020,
			FILE_ATTRIBUTE_DEVICE              = 0x00000040,
			FILE_ATTRIBUTE_NORMAL              = 0x00000080,
			FILE_ATTRIBUTE_TEMPORARY           = 0x00000100,
			FILE_ATTRIBUTE_SPARSE_FILE         = 0x00000200,
			FILE_ATTRIBUTE_REPARSE_POINT       = 0x00000400,
			FILE_ATTRIBUTE_COMPRESSED          = 0x00000800,
			FILE_ATTRIBUTE_OFFLINE             = 0x00001000,
			FILE_ATTRIBUTE_NOT_CONTENT_INDEXED = 0x00002000,
			FILE_ATTRIBUTE_ENCRYPTED           = 0x00004000,
			FILE_ATTRIBUTE_VIRTUAL             = 0x00010000,
			FILE_ATTRIBUTE_VALID_FLAGS         = 0x00007fb7,
			FILE_ATTRIBUTE_VALID_SET_FLAGS     = 0x000031a7
		};
		[StructLayout(LayoutKind.Sequential)]
		internal struct SECURITY_ATTRIBUTES
		{
			internal uint nLength;
			internal IntPtr lpSecurityDescriptor;
			internal bool bInheritHandle;
		}
		[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto, BestFitMapping = false)]
		internal static extern SafeFileHandle CreateFile(
			String lpFileName,
			DesiredAccess dwDesiredAccess,
			ShareMode dwShareMode,
			ref SECURITY_ATTRIBUTES lpSecurityAttributess,
			CreationDisposition dwCreationDisposition,
			FlagsAndAttributes dwFlagsAndAttributes,
			SafeFileHandle hTemplateFile
		);
		[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto, BestFitMapping = false)]
		internal static extern bool CreateDirectory(
			String lpPathName,
			ref SECURITY_ATTRIBUTES lpSecurityAttributes
		);
		private static readonly SafeFileHandle HANDLE_NULL = new SafeFileHandle(IntPtr.Zero, false);
		private static readonly FileSecurity fs = new FileSecurity();
		private static readonly DirectorySecurity ds = new DirectorySecurity();
		static NewTemporaryObject()
		{
			fs.AddAccessRule(new FileSystemAccessRule(
				WindowsIdentity.GetCurrent().Name,
				FileSystemRights.FullControl,
				InheritanceFlags.None,
				PropagationFlags.NoPropagateInherit,
				AccessControlType.Allow
			));
			ds.AddAccessRule(new FileSystemAccessRule(
				WindowsIdentity.GetCurrent().Name,
				FileSystemRights.FullControl,
				InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
				PropagationFlags.NoPropagateInherit,
				AccessControlType.Allow
			));
		}
		public static FileInfo NewSafeTemporaryFile()
		{
			return NewSafeTemporaryFile(Path.GetTempPath());
		}
		public static FileInfo NewSafeTemporaryFile(String tmpdir)
		{
			SECURITY_ATTRIBUTES sa = new SECURITY_ATTRIBUTES();
			sa.nLength = (uint)Marshal.SizeOf(sa);
			sa.bInheritHandle = false;
			byte[] sdb = fs.GetSecurityDescriptorBinaryForm();
			int sdn = sdb.Length;
			sa.lpSecurityDescriptor = Marshal.AllocHGlobal(sdn);
			try {
				Marshal.Copy(sdb, 0, sa.lpSecurityDescriptor, sdn);
				for (int i = 0; i < RETRY_MAX; ++i) {
					String path = Path.Combine(tmpdir, Path.GetRandomFileName());
					using (SafeFileHandle handle = CreateFile(path,
						DesiredAccess.GENERIC_READ|DesiredAccess.GENERIC_WRITE,
						0,
						ref sa,
						CreationDisposition.CREATE_NEW,
						FlagsAndAttributes.FILE_ATTRIBUTE_NORMAL|
						FlagsAndAttributes.FILE_ATTRIBUTE_NOT_CONTENT_INDEXED,
						HANDLE_NULL
					)) {
						if (!handle.IsInvalid)
							return new FileInfo(path);
						if (Marshal.GetLastWin32Error() != (int)Errors.ERROR_FILE_EXISTS)
							break;
					}
				}
			} finally {
				Marshal.FreeHGlobal(sa.lpSecurityDescriptor);
			}
			throw new IOException();
		}
		public static DirectoryInfo NewSafeTemporaryDirectory()
		{
			return NewSafeTemporaryDirectory(Path.GetTempPath());
		}
		public static DirectoryInfo NewSafeTemporaryDirectory(String tmpdir)
		{
			SECURITY_ATTRIBUTES sa = new SECURITY_ATTRIBUTES();
			sa.nLength = (uint)Marshal.SizeOf(sa);
			sa.bInheritHandle = false;
			byte[] sdb = ds.GetSecurityDescriptorBinaryForm();
			int sdn = sdb.Length;
			sa.lpSecurityDescriptor = Marshal.AllocHGlobal(sdn);
			try {
				Marshal.Copy(sdb, 0, sa.lpSecurityDescriptor, sdn);
				for (int i = 0; i < RETRY_MAX; ++i) {
					String path = Path.Combine(tmpdir, Guid.NewGuid().ToString("B"));
					if (CreateDirectory(path, ref sa))
						return new DirectoryInfo(path);
					if (Marshal.GetLastWin32Error() != (int)Errors.ERROR_ALREADY_EXISTS)
						break;
				}
			} finally {
				Marshal.FreeHGlobal(sa.lpSecurityDescriptor);
			}
			throw new IOException();
		}
	}
	[Cmdlet(VerbsCommon.New, "SafeTemporaryFile", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Low)]
	[OutputType(typeof(System.IO.FileInfo))]
	public class NewSafeTemporaryFileCommand : Cmdlet
	{
		[Parameter(Position = 0)]
		[ValidateNotNullOrEmpty]
		public String LiteralPath { get; set; } = Path.GetTempPath();
		protected override void EndProcessing()
		{
			String tmpdir = LiteralPath;
			if (ShouldProcess(tmpdir)) {
				try {
					WriteObject(NewTemporaryObject.NewSafeTemporaryFile(tmpdir));
				} catch (IOException e) {
					ThrowTerminatingError(new ErrorRecord(
						e, "NewSafeTemporaryFileWriteError",
						ErrorCategory.WriteError, tmpdir
					));
				}
			}
		}
	}
	[Cmdlet(VerbsCommon.New, "SafeTemporaryDirectory", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Low)]
	[OutputType(typeof(System.IO.DirectoryInfo))]
	public class NewSafeTemporaryDirectoryCommand : Cmdlet
	{
		[Parameter(Position = 0)]
		[ValidateNotNullOrEmpty]
		public String LiteralPath { get; set; } = Path.GetTempPath();
		protected override void EndProcessing()
		{
			String tmpdir = LiteralPath;
			if (ShouldProcess(tmpdir)) {
				try {
					WriteObject(NewTemporaryObject.NewSafeTemporaryDirectory(tmpdir));
				} catch (IOException e) {
					ThrowTerminatingError(new ErrorRecord(
						e, "NewSafeTemporaryDirectoryWriteError",
						ErrorCategory.WriteError, tmpdir
					));
				}
			}
		}
	}
}

@ Import-ModuleでDLL(アセンブリ)を読み込んだ場合

このコードをcsc.exeなりVisual StudioなりでビルドしDLL(アセンブリ)を作成したら、あとはImport-Moduleコマンドレット読み込むだけで使えるようになる。

Import-Module 'unko.dll' -Verbose
VERBOSE: Importing cmdlet 'New-SafeTemporaryFile'.
VERBOSE: Importing cmdlet 'New-SafeTemporaryDirectory'.

これでようやく安全な一時ファイルとディレクトリを作成するためのオレオレコマンドレット、New-SafeTemporaryFileとNew-SafeTemporaryDirectoryが使えるようになったよ…

New-SafeTemporaryFile | Get-Acl
New-SafeTemporaryDirectory | Get-Acl

    Directory: C:\Users\tnozaki\AppData\Local\Temp

Path                                   Owner         Access                          
----                                   -----         ------                          
c2ddxpwq.i5b                           X200S\tnozaki X200S\tnozaki Allow  FullControl
{5793d2b7-02ce-47b6-b21c-a38e5e3d7cc0} X200S\tnozaki X200S\tnozaki Allow  FullControl

元のNew-TemporaryFileにはない機能として-LiteralPathオプションで作成先の一時ディレクトリを変更可能とした。これはファイルのアトミックな更新をしたい時に一時ファイル作って書き込みが終わってからリネームするなんてよくやる基本テクだけど、一時ファイルが別ファイルシステム上に存在する場合はリネームは失敗するかコピー扱いなので期待通りの動作にならない、そんな時に有用なオプションのはず。

@ Add-TypeでDLL(アセンブリ)を読み込んだ場合

ところが過去回で説明したAdd-Typeコマンドレットを使ってロードした場合が問題なのだ。

Add-Type -Path 'unko.dll'

あるいは-TypeDefinitionをつかってPowerShell内にC#コードを埋め込んで

Add-Type -TypeDefinition @'
	...
'@

このケースだとアセンブリ中のクラスと関数は可視なんだけども

[unko.NewTemporaryObject]::NewSafeTemporaryFile() | Get-Acl
[unko.NewTemporaryObject]::NewSafeTemporaryDirectory() | Get-Acl

    Directory: C:\Users\tnozaki\AppData\Local\Temp

Path                                   Owner         Access                          
----                                   -----         ------                          
zjmqedyr.u25                           X200S\tnozaki X200S\tnozaki Allow  FullControl
{ceb01460-d7c1-4967-a78d-3a97f13fac66} X200S\tnozaki X200S\tnozaki Allow  FullControl

でもコマンドレットは不可視なのよね。

New-SafeTemporaryDirectory

New-SafeTemporaryDirectory : The term 'New-SafeTemporaryDirectory' is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spell
ing of the name, or if a path was included, verify that the path is correct and try again.
At line:3 char:1
+ New-SafeTemporaryDirectory
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : ObjectNotFound: (New-SafeTemporaryDirectory:String) [], CommandNotFoundException
    + FullyQualifiedErrorId : CommandNotFoundException

これはAdd-Typeした後に改めてImport-Moduleすればいいようなのだけど、その場合は-Nameオプションでモジュール名を指定する必要があって、そのためには Module Manifestというファイルを書かなければならないようだ、うへーめんどくせえ何の為のアトリビュートとかnamespaceなんだか…ばっかじゃねぇの。

@ 次回

あーめんどくせ、Module Manifestの書き方を学んでいいかげんこの編は最終回にしたい。

*1:トランザクションNTFSはもう死んだので…