The Man Who Fell From The Wrong Side Of The Sky:最新 5 日分

2019/5/27(Mon)

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

前回はNew-TemporaryFileコマンドレットを紹介し、これで一時ディレクトリに作業用ファイルは作れるけど作業用ディレクトリは掘れないやんけ!ってお話。

無いならNew-TemporaryDirectoryを作ればいいじゃない、でもどうやってセキュリティを担保すればいい?

@ 予測不能な名前でディレクトリを作成する

なんか難しそうだなーと思うかもしれないけど実はそう大したもんじゃない。

予測可能な名前というのは

  • 連番
    workdir_0001
    workdir_0002
    ...
    
  • 現在時刻
    workdir_2019-05-24-00:00:00:000
    workdir_2019-05-25-23:59:59:999
    …
    

みたいな攻撃者が容易に次に作成されるファイルの名前を、金曜日の海軍の夕食メニューは?レベルで的中させられるような法則性を避けろって程度の話。

なので前回の失敗例でとりあげたSystem.IO.Path::GetRandomFileNameだけども、この条件であれば満たしてはいるのだ。

$workdir = [System.IO.Path]::GetRandomFileName()
Write-Host $workdir

これを実行すると

23zgemoa.mqf

と乱数から生成した8.3形式のファイル名(英数小文字)を返すので、可能性は36^11通りとなる。

あるいはInstallShieldなんかのインストーラーのようにGUIDを使ってもいい、.Net Frameworkの GUID構造体を使えばかんたん。

$workdir =  [Guid]::NewGuid().ToString("B")
Write-Host $workdir

これを実行すると

{efd60a90-6ba7-489d-af7b-39c755cb7f87}

と128ビットのうちバリアントとバージョンのためビットを除いた122ビットを乱数で埋め、さらに16進を[0-9a-f]の文字に置換えた文字列を出力するので、可能性は2^122通りになる。

あとは重篤なパラノイアを患っていると乱数が本当に乱数かどうか心配になって夜も眠れなくなるけどさすがにそこまでは面倒みきれん、ハードウェア乱数生成器でも買ってください。

なお前回ちらっと触れたmktemp(3)というタッパーウェアにあった古い関数は「XXXXX」で表される6桁のテンプレート部分を

  • プロセスID
  • アルファベット小文字から1文字

で埋めるだけなんですな。

#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>

int
main(void)
{
	char buf[BUFSIZ];
	snprintf(buf, sizeof(buf), "/tmp/%s.XXXXXX", getprogname());
	printf("current pid: %d\n", getpid());
	printf("mktemp(3) generated: %s\n", mktemp(buf));
}

これを実行すると

$ ./unko
current pid: 11451
mktemp(3) generated: /tmp/unko.11451a

という結果になる、プロセスIDなんぞ余裕でバレるので実質26通りの組合わせしか無いわけで総当り攻撃も余裕。

ちなみに改良されたmkstemp(3)とmkdtemp(3)は乱数を元に62^6通り、さらにテンプレートのXXXXXXを6桁よりも増やして更に可能性を増やすこともできる。

ただ注意が必要なのは、一部のlibcにおけるmkstemp(3)とmkdtemp(3)の実装は、mktemp(3)とまったく同じ生成規則な実質26通りのままで、総当り攻撃が有効なものもある(N6以前とかね)。 とはいえファイル名が予測可能だったとしてもその他の条件で攻撃は防げてるはずなので、ただちに危険というわけではないから無視してもいい。

@ 適切なアクセス権限でディレクトリを作成する

これPOSIX:2008でmktemp(3)が仕様から削除に至った脆弱性としては予測可能性よりこっちの方が重篤なのだ、こいつは名前を生成するだけの関数なので

char path[PATH_MAX];
snprintf(path, sizeof(buf), "/tmp/%s.XXXXXX", getprogname());
mktemp(path)
mkdir(path, 0777)

のようにプログラマの無知によって不適切なアクセス権限でファイルやディレクトリを作成することをどうやっても防げないのも理由の一つ。 mkstemp(3)やmkdtemp(3)は内部で

  • ファイルなら0600
  • ディレクトリなら0700

で作成したものを返すので、たとえ予測可能な名前であっても権限無ければ攻撃できないのだ。

140                 if (doopen) {
141                         if ((*doopen =
142                             open(path, O_CREAT | O_EXCL | O_RDWR, 0600)) >= 0)
...
146                 } else if (domkdir) {
147                         if (mkdir(path, 0700) >= 0)
...

ではタッパーウェアではなくWindows、それもPowerShellの場合はどうやって適切なアクセス権限でディレクトリを作成すればいいのか。

ディレクトリを作成するコマンドレットは

New-Item -Path 親ディレクトリ -Name ディレクトリ名 -ItemType Directory

とNew-Itemコマンドレットを使用する(mkdirもコマンドでなくこいつのaliasになる)のだけど、こいつのソース Microsoft.PowerShell.Commands.FileSystemProviderを確認すると、実際に実行されるのは.Net FrameworkのSystem.IO.Directory::CreateDirectoryとなっている。

  11 using System.IO;
...
2196         protected override void NewItem(
2197             string path,
2198             string type,
2199             object value)
2200         {
...
2224             itemType = GetItemType(type);
2225
2226             if (itemType == ItemType.Directory)
2227             {
2228                 CreateDirectory(path, true);
2229             }
...
2697         private void CreateDirectory(string path, bool streamOutput)
2698         {
...
2738                     var result = Directory.CreateDirectory(Path.Combine(parentPath, childName));

そんで System.IO.Directoryクラスのドキュメントを確認するとCreateDirectoryには

  • CreateDirectory(String) … Creates all directories and subdirectories in the specified path unless they already exist.
  • CreateDirectory(String, DirectorySecurity) … Creates all the directories in the specified path, unless the already exist, applying the specified Windows security.

という2種類の狂い咲きオーバーロードがある、それぞれの違いは

  • 前者は作成するディレクトリに対してデフォルトのアクセス権を設定する
  • 後者は明示的にアクセス権を設定する

という違いがあるのだけど、New-Item -ItemType Directoryは前者しか呼んでいないので明示的にアクセス権を設定することができないのだ、なんてこったい。

ただこれもまた直ちに影響というわけではない、Windows NT系の場合一時ディレクトリは各ユーザー毎にご用意されるいわゆる「per user tmp」というやつで、環境変数TMP(あるいはTEMP)には

  • %USERPROFILE%\Local Settings\Temp … XP以前
  • %USERPROFILE%\App\Local\Temp … Vista以降

以下が指定されている、そしてこれとは別にSYSTEMユーザーなどが使う環境変数TMP(あるいはTEMP)には

  • %SYSTEMROOT%\Temp

以下が指定されている。

前者の一時ディレクトリを含む%USERPROFILE%(例えばC:\Users\ユーザー名)以下はデフォルトでは

  • SYSTEMユーザ
  • Administratorsグループ
  • 当該ユーザ

以外にはアクセス権限が無いので、CreateDirectory(String)を使って親ディレクトリのアクセス権限を引継いだまま作成してれば、いちいちSet-Acl呼ばなくても攻撃者からは参照できないので安全だと主張もできなくはない。

ただしSYSTEMユーザなどが使う%SYSTEMROOT%\Temp以下は上記に加えて

  • Creator Ownerユーザ(ファイル作成者)
  • Usersグループ

にもファイルやディレクトリ作成と読み書きが許可されていて(若干の制限はある)、セキュリティは緩めなのよね。

なので%SYSTEMROOT%\Tempを使うユーザー権限で動作するスクリプトを書く場合、ファイル名の予測不可能性が破られた時の事を考えてきっちりアクセス権を設定しておかないとアウトなのだ。

それに環境変数なんていくらでも汚染できるので、TMPあるいはTEMPがWindows 9x系までの頃の流儀であるC:\Tempに書き換えられてたり、SSDの寿命を延ばすためD:\Tempとか自分で掘ってそっち使うようにレジストリ含めて変更してる人もいるしな…そういう人アクセス権限とその継承を正しく設定してるとは思えん。

世の中のPowerShell使いたちはどうしてるのか、我々はその謎を解き明かすべくStackOverflowやQiitaといった未開人の住まうジャングルの奥地へと向かった、そこで目にした光景は

$workdir = [System.IO.Path]::GetTempPath() + [System.IO.Path]::GetRandomFileName()
New-Item -Path $workdir -ItemType Directory
$alc = Get-Acl -Path $workdir
$alc.SetAccessRuleProtection($true, $false)
$alc.Access | ForEach-Object {
	$alc.RemoveAccessRule($_)
}
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule (
	[System.Security.Principal.WindowsIdentity]::GetCurrent().Name,
	[System.Security.AccessControl.FileSystemRights]::FullControl,
	([System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit),
	[System.Security.AccessControl.PropagationFlags]::NoPropagateInherit,
	[System.Security.AccessControl.AccessControlType]::Allow
)
$alc.AddAccessRule($rule)
# New-Item -> Set-Acl is not Atomic ops, there's TOCTTOU race condition security problem.
Set-Acl -Path $workdir -AclObject $acl
(Get-Acl -Path $workdir).Access
...
Remove-Item -Force -Path $workdir

とディレクトリを作成した後に、Set-Aclコマンドレット(あるいはicalcsコマンド)でアクセス権限を設定し直すというコード例であった。

賢明なプログラマならお気づきだろうけど、New-ItemとSet-Aclに操作が分割されているのでアトミックではない、よってこのわずかな時間を利用してシンボリックリンク攻撃などを成功させてしまう可能性があるのだよね。 まぁアクセス制限を緩める方向性(例えばファイル共有用に誰でも読み書きできるようにするとか)ならええけどさあ…

なのでアトミックに作業用ディレクトリを掘るのであれば内部的にCreateFile(String)を使ってるNew-Itemは禁止、オーバーロードのCreateFile(String, DirectorySecurity)の方を呼ぶことでディレクトリ作成とアクセス権設定を同時にやらんとアカン。

$workdir = [System.IO.Path]::GetTempPath() + [System.IO.Path]::GetRandomFileName()
$acl = New-Object System.Security.AccessControl.DirectorySecurity
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule (
	[System.Security.Principal.WindowsIdentity]::GetCurrent().Name,
	[System.Security.AccessControl.FileSystemRights]::FullControl,
	([System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit),
	[System.Security.AccessControl.PropagationFlags]::NoPropagateInherit,
	[System.Security.AccessControl.AccessControlType]::Allow
)
$acl.AddAccessRule($rule)
# Atomic ops, there's no TOCTTOU race condition.
[System.IO.Directory]::CreateDirectory($workdir, $acl)
(Get-Acl -Path $workdir).Access
...
Remove-Item -Force -Path $workdir

うーんこの、WindowsのACL複雑すぎんよー…というかCreateDirectory(String, DirectorySecurity)がアトミック操作なのか心配になってきたゾ。

@ 次回

残りの「競合状態の回避」をどうやって実装するか、そしてNew-TemporaryFileに対してNew-TemporaryDirectoryとでも名付ければいいのか、コマンドレットっぽく使える関数を作るところまで書けたらいいですね…(かなり飽きた)。

2019/5/26(Sun)

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

ちょいと前回のサンプルにNew-TemporaryFileコマンドレットが登場したので、Import-Csv/Export-Csvの話より先に書いておく。

@ UNIXのmktemp(1)とは何か

毎回WindowsカテゴリなのにタッパーウェアことUNIXの話をしている気がするけどまあええわ、タッパーウェアにおいて一時用ディレクトリである/tmpや/var/tmpは誰でも読み書きが可能な「激アツスリーセブン!ジャンバリ大開放!!」パーミッションとなってる。

$ ls -ld /tmp
drwxrwxrwt+ 1 tnozaki None 0 May 25 23:47 /tmp

$ ls -ld /var/tmp
drwxrwxrwt+ 1 tnozaki None 0 Apr 14 10:26 /var/tmp

今時Cygwinなのでオーナーとグループが変だが気にするな、ここに作業用のファイルやディレクトリを作成する場合、情報漏洩やシンボリックリンク攻撃などのセキュリティ事故を避けるよう注意してコーディングする必要がある。

まぁ詳しい話はここはタッパーウェア通販サイトでもパチスロ情報誌でもないので セキュアコーディングガイドでも読んどけ、 最近のOSは「per user tmp」すなわちユーザー毎に一時ディレクトリが用意されているのでセキュリティについては昔よりマシにはなってるけど、機密性がわずかにマシになっただけでプログラミングには変わらず注意が必要なことに変わりはない。

シェルからならたいていの環境にはmktemp(1)コマンドが用意されているので、これを使って作業用ファイル・ディレクトリを作成すればおk。

作業用ファイルなら

$ f=`mktemp -p /tmp unko.XXXXXX`
$ ls -l $f
-rw------- 1 tnozaki None 0 May 25 23:46 /tmp/unko.CrhtWx
$ rm $f

作業用ディレクトリであれば

$ d=`mktemp -d -p /tmp unko.XXXXXX`
$ ls -dl $d
drwx------+ 1 tnozaki None 0 May 25 23:47 /tmp/unko.ygEwOO
$ rmdir $d

どちらのケースでも

  • XXXXXXの部分が乱数を元に生成された英数字62種に置換えられてるので、62^6通りのファイル名が生成 → 予測不可能なファイル名
  • パーミッションもファイルなら0600、ディレクトリなら0700で他のユーザーからは読み書きできない → 適切なアクセスコントロール
  • 作成したファイル・ディレクトリは既に存在したものを上書きしてないことが保証される → 競合状態の回避

というさっきのセキュアコーディングガイドで触れられている原則が、このコマンドを使うだけで保証できる訳。

ちなみにmktemp(1)の内部ではC APIである

  • mkstemp(3) … 作業用ファイル作成
  • mkdtemp(3) … 作業用ディレクトリ作成

が呼ばれているはずだ、以下はオレオレN6のコードより。

134                 if (dflag) {
135                         if (mkdtemp(name) == NULL) {
...
144                 } else {
145                         fd = mkstemp(name);
...
156                 }

なおコマンドと同名のmktemp(3)は設計ミスで危険な関数なので決して使ってはならない、POSIX:2008でめでたく抹殺されました。

ちなみにSolarisとかAIXなんかの商用UNIXしか経験の無いシェルスクリプトコーダーには、わりと最近までmktemp(1)が存在しなかった関係上「プログラム名 + プロセスID + 日付時刻」みたいなザルなコード書く輩が多いという印象がある。

$ f=`date +"/tmp/$0-$$-%Y-%m-%d_%H:%M:%S"`
$ touch $f
$ ls -l $f
-rw-r--r-- 1 tnozaki None 0 5月  26 00:55 /tmp/unko.sh-1362-2019-05-26_00:55:57
$ rm $f

こーゆーのみかけたら見次第殺。

だからshebang含めて3行以上のシェルスクリプトは書きたくない読みたくないのだよ心臓が止まりそうになる、なぜこれじゃダメなのかはもうお判りですね?

@ PowerShellにおけるmktemp(1)の代替品

これはPowerShellも同様の無関心さだったようで、mktemp(1)に該当するコマンドレットであるNew-TemporaryFileは5.0になってようやく実装された始末。

$tmpfile = New-TemporaryFile

それ以前の環境であれば、.Net FrameworkのSystem.IO.Path::GetTempFileNameを使うしかない。

$tmpfile = [System.IO.Path]::GetTempFileName()

ちなみにNew-TemporaryFileの実装( Microsoft.PowerShell.Commands.NewTemporaryFileCommand)もGetTempFileNameを呼出してるだけなのだ。

 16     public class NewTemporaryFileCommand : Cmdlet
 17     {
...
 29                     filePath = Path.GetTempFileName();
...

そして.Net Frameworkの System.IO.Pathのコードを読むと

166        public static string GetTempFileName()
167        {
...
176            uint result = Interop.Kernel32.GetTempFileNameW(
177                ref tempPathBuilder.GetPinnableReference(), "tmp", 0, ref builder.GetPinnableReference());

と、Windows APIのFileAPI.hにある GetTempFileNameWを呼んでるだけなので、詳しい挙動はそっちを参照ってことですな。

しかし困ったことに作業用ファイルの作成はこいつら使えばいいんだけど、作業用ディレクトリを作成する方法については未だにPowerShellにも.Net FrameworkにもWindows APIにもご用意されていないのだ、ファッキン。

@ よくあるまちがい

ちなみに作業用ディレクトリを掘れといわれてワンワンワン、セキュリティに理解の浅いプログラマー未満のやらかしがちな失敗は

  • System.IO.Path::GetRandomFileNameを使ってランダムな名前で作業用ディレクトリを掘ればいい
    $tmpdir = [System.IO.Path]::GetRandomFileName()
    New-Item -ItemType Directory $tmpdir
    
  • 作業用ファイルを作成した後に削除し再び同名で作業用ディレクトリを掘ればいい
    $tmpdir = New-TemporaryFile
    Remove-Item $tmpdir
    New-Item -ItemType Directory $tmpdir
    

みたいなコードを書いてしまいがちなんだけど、これどちらもNew-Itemするまでのわずかな時間に「TOCTTOU(Time Of Check To Time Of Use)」と呼ばれる競合状態が発生する可能性があるのでアウトなのだ、TOCTTOUについては 過去回で説明を書いてるので今回は省略。

@ 本当にそのコードって安全?

んで話脱線するけども、前回Import-Csvがパイプからファイル読めないから作業用ファイルを経由するというサンプル書いたけど

[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$tmpfile = New-TemporaryFile
Invoke-WebRequest -Method Get -Uri 'https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv' -OutFile $tmpfile
Import-Csv -Encoding OEM $tmpfile | Where-Object { ([DateTime]$_.{国民の祝日・休日月日}).Year -eq 2019 } | ForEach-Object {
	Write-Host $_.{国民の祝日・休日月日}
}
Remove-Item -Force $tmpfile

これだってパラノイアこじらせると怪しいコードに見えてくるのだ、Invoke-WebRequestの-OutFileの挙動がリダイレクトの「>」つまり上書き相当なら問題ないけれど、これが「削除 → 新規作成」という内部動作しとったらやはりTOCTTOUが発生する可能性がある。

なので安心したいので念の為に仕様を確認しておこう、PowerShellのコア部分はMITライセンス下のオープンソースなのでさっさとソースを読むことにする。

  • Microsoft.PowerShell.Commands.InvokeWebRequestCommand
    16     public class InvokeWebRequestCommand : WebRequestPSCmdlet
    17     {
    ...
    32         internal override void ProcessResponse(HttpResponseMessage response)
    33         {
    ...
    52             if (ShouldSaveToOutFile)
    53             {
    54                 StreamHelper.SaveStreamToFile(responseStream, QualifiedOutFile, this);
    55             }
    
  • Microsoft.PowerShell.Commands.WebRequestPSCmdlet
     87     public abstract partial class WebRequestPSCmdlet : PSCmdlet
     88     {
    ...
    369         [Parameter]
    370         public virtual string OutFile { get; set; }
    ...
    
    683         internal bool ShouldSaveToOutFile
    684         {
    685             get { return (!string.IsNullOrEmpty(OutFile)); }
    686         }
    ...
    
  • Microsoft.PowerShell.Commands.StreamHelper
    261     internal static class StreamHelper
    262     {
    ...
    326         internal static void SaveStreamToFile(Stream stream, string filePath, PSCmdlet cmdlet)
    327         {
    ...
    338                 using (FileStream output = File.Create(filePath))
    339                 {
    340                     WriteToStream(stream, output, cmdlet);
    341                 }
    ...
    

このあたりのコードをざっと読むと

  • Invoke-WebRequestコマンドレットの実体はInvokeWebRequestCommandクラスである
  • InvokeWebRequestCommandクラスはWebRequestPSCmdltクラスを継承してる
  • Invoke-WebRequestの-OutFileオプションに指定された引数はWebRequestPSCmdltクラスのOutFileプロパティに格納されている
  • OutFileプロパティが空でない場合、StreamHelperクラスのSaveStreamToFileメソッドが呼ばれる
  • SaveStreamToFileメソッドの中ではSystem.IO.File::Createメソッドが呼ばれる
  • Createメソッドの仕様としてはすでにファイルが存在する場合は上書きモードになる

ということなのでTOCTTOU問題は回避できるので一安心ですな。

だいぶ脱線した、これもストリームに流れるのがテキストでなくオブジェクトなので迂闊にリダイレクトが使えないPowerShellのクソデザインがそもそも悪手なのだ、テキストなら「>」か「>>」使えれば一目瞭然なんだよな、だから 最初に門倉元投手の言葉を借りて「PowerShellだけはやめとけよ」といいたくなるのもお分かりいただけるだろうか。

@ 次回

作業用ディレクトリを作成する方法なんだけどこれはもう自分で実装するより他にないのだ、セキュリティのためには少なくとも

  • 予測不可能なファイル名
  • 適切なパーミッションを設定する
  • 既に存在するディレクトリとは絶対に被ってはならない

は必須になるのだけれど、これをどう実現したものか説明しようと思う。

2019/5/25(Sat)

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

前回はImport-Csvというのは awk(1)というより、CSVファイルを読込んでオブジェクト(=PSCustomObject)のストリームとして流す特殊な cat(1)だと書いた。

なお特殊ではないcat(1)に最も近いコマンドレットには Get-Contentというのがある。

Get-Content -Path 'unko.csv' -Encoding OEM | ForEach-Object { Write-Host $_ }

こちらはファイルをやはりオブジェクト、つまりString *1に変換してパイプに流す。

細かいことをいえばcat(1)が「con CAT inate(結合)」であるのに対し「Get Content(中身をとりだす)」という思想の違いがあるけど、そこは目を瞑っておこう。 そもそもcat(1)でファイル結合ができた時代なんてのは遠い過去の歴史上の話だ、CSVみたいな古色蒼然としたファイルフォーマットですらヘッダ行が存在するなら結合時に読み飛ばさんとおかしなことになる。

なによりテキストファイルの文字コードがUS-ASCIIしかない時代ならまだしも、現代では数えきれないほどの文字コードが存在し異なる文字コードのファイルをcat(1)したら即文字化けだ。 Unicodeですら複数CESが存在してあまつさえBOMなんてシロモノがある時点で論外なんやで。

ということでファイルフォーマットの数だけcat(1)が増えるのは致し方ない、三毛とか鯖虎とかね…

@ UNIX哲学の負の側面

そんでcat(1)というと思い出すのが Useless Use Of Cat、つまり「無駄にネコさまの手をわずらわせる」と呼ばれる性能問題なんですわ。

簡単な例としては

$ cat unko1.txt unko2.txt | awk -F',' '{ print $NF }'

というやつ、awk(1)では

$ awk -F',' '{ print $NF }' unko1.txt unko2.txt

と引数でファイルを指定できるので、ここでのネコさま登場させるのはパーフェクトに無駄骨でありプロセス生成とプロセス間通信のコストだけ性能は確実に劣化するわけ。

そんでこちらもまったく意味の無いファイルの最後5行を表示するのにネコの尻尾踏んづけるコード

$ cat unko.txt | tail -n 5

尻尾ひとふりするだけで終わる仕事なんよな。

$ tail -n 5 unko.txt

他にも

$ cat unko.txt | sort | uniq

なんて書かずに

$ sort -u unko.txt

とsort(1)だけで書けるよとかね。

この問題はcat(1)にとどまらないので、はつみみですというネコは米Yahoo!でスケーラビリティーに関する仕事に携わってた、NなんとかBSDの開発者による「 Useless Use of *」というプレゼンでも読んでみてどうぞ。

そんでよくUNIX哲学と関数型言語は似ているなんていわれるけど、それはすなわち同じ欠点を持ってるってことなのだ。Useless Use Of Higher-Order Functionとでも呼ぶんすかねこれ。

これはPowerShellも同じで

Get-Content unko.txt | Select-Object -First 5 | ForEach-Object {
	Write-Host $_
}

なんてコードを書かずに

  • -TotalCount … 実質head(1)コマンド
  • -Tail … 実質tail(1)コマンド

とSelect-Objectの-First/-Lastオプションに相当するfilterが実装されてるのでそちらを使った方が性能的に有利なはず。

Get-Content -TotalCount 5 | ForEach-Object {
	Write-Host $_
}

同じ理屈はそのまんまImport-Csvにも当てはまるんだけど、こっちには読込む行数を指定するオプションが存在しないのよね、あくまでパイプで渡した先のfilter側で件数を絞らざるをえない。

Import-Csv 'unko.csv' -Header "Column1", "Column2" | Select-Object -First 5 | ForEach-Object {
	Write-Host $_.Column1
}

まぁパイプで繋いでるのだから

  • Select-Objectがパイプラインから5レコード読んだら
  • SIGPIPEだかバグパイプ的なものがピーヒャララと飛んで
  • それを受取ったImport-Csvは読込処理をそこで終了

するだろうからCSVファイル全体を読み込むわけでもなし、そもそもコマンドレットはプロセスではないからUNIXシェルスクリプトよかコストは格段に低いとは思うけど。

ただ 過去回でさらっと触れたforeach vs ForEach-Object対決をみる限り、ほんとうに無視できるほどのコストなんですかね?って疑ってしまうのですな。

ちなみに性能測定にはMeasure-Commandというコマンドレットがあるけど、困ったことにImport-CsvはCSVファイルをパイプラインから読み込むという動作ができんので

Measure-Command {
	Get-Content 'unko.txt' | Import-Csv | Select-Object -First 5
}

Measure-Command {
	Get-Content -TotalCount 5 'unko.txt' | Import-Csv
}

の差を測定するみたいな性能テストができないのよね、PowerShellはオープンソースなので ソース落としてきて読み込む行数を指定するオプションを自分で実装してどれくらい改善するか調べりゃいいんだが、あまり読みたいコードじゃねぇんだよなぁ…

@ パイプを流れるストリームがテキストではなくオブジェクトであることの弊害

そしてここでもうひとつImport-Csvの、ひいてはPowerShellそのもののバッドデザインが明らかになりましたね、そうImport-CsvはパイプラインからCSVファイルを読み込めないんですわ。

例えばcat(1)はパイプからもデータを読み込める、だから下のような完全に意味の無い多頭飼いネコの数珠繋ぎができる、これにはムカデ人間のヨーゼフ・ハイター博士もニッコリ。

$ cat - | cat | cat | cat | cat | cat | cat | cat | cat | cat | cat

まぁこの節操の無さこそがUseless Use Of *問題を生むんだけどね!

しかしPowerShellにおいてGet-ContentやImport-Csvのようなコマンドレットは、ファイルをオブジェクトに変換してパイプにストリームとして流す起点にはなれるんだけど、自身がパイプからストリームを読み込むができないんですわ。

これすげー地味に利便性悪く、インターネットから例えば「非国民の休日.csv」なんてものをダウンロードしてきて処理するのに

[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
(Invoke-WebRequest -Method Get -Uri 'https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv').RawContent `
    | Import-Csv -Encoding OEM |  Where-Object { ([DateTime]$_.{国民の祝日・休日月日}).Year -eq 2019 } | ForEach-Object {
	Write-Host $_.{国民の祝日・休日月日}
}

みたいに書けないのよね、いちいち一時ファイルとして保存して

[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$tmpfile = New-TemporaryFile
Invoke-WebRequest -Method Get -Uri 'https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv' -OutFile $tmpfile
Import-Csv -Encoding OEM $tmpfile | Where-Object { ([DateTime]$_.{国民の祝日・休日月日}).Year -eq 2019 } | ForEach-Object {
	Write-Host $_.{国民の祝日・休日月日}
}
Remove-Item -Force $tmpfile

と余計な処理が増えるのがいろいろ制約でてきてつらい。

UNIX哲学においてはパイプを流れるのはテキストという汎用インタフェースなのだけど、PowerShellにおいてはオブジェクトなのでImport-CsvもCSVファイルを読込んでせっせとオブジェクトを作ってパイプに流すけど 便所に紙以外のもの流すと詰まるよなーという話、Microsoftのオフィスのトイレはベル研より配管が太いのだろう、まぁベル研は音響カプラで300ボーという便所だったしな…

そういえばGoogle翻訳は「トイレ スッポン」を「Toilet Suppon」と訳すので、こっちもさぞや詰まらない立派な便所があるんだろう。 ちなみに正解は「Toilet Plunger」、でも「Toilet Softshell Turtle」と訳されて動物愛護団体と深刻な文化摩擦を引き起こされりよかマシか…。 なお「すっぽん」だとなぜか「Happy」と訳されるので、Googleオフィスはみな裸族であり川とか流れててそこで用を足してると推察される。

@ 次回

もうちょっとだけImport-Csvへの文句は続く、そして今度はCSVをファイルに出力するExport-Csvの話までいけたらいいね。

*1:あるいは-AsByteStreamオプションでByte、6.0以降より

2019/5/24(Fri)

[Windows] Dismコマンドで永続的パッケージを無理矢理アンインストールする

今家族マシン用に確保してあるWindows 8のDSPライセンスだけど、8をインストールしてパッチ適用してストア経由で8.1にアプグレしてからまたパッチ適用というアホみたいな手順は踏みたくない。 しかしWindows 8.1はサービスパックみたいに単体でWindows 8のインストールイメージに統合できる形式で提供されてないのでアレ。

なのでせっかくMicrosoft公式が

を提供してるので、こいつ使ってクリーンインストールすることにする。 なんせWindows 8のシリアル番号はそのままWindows 8.1のインストールに有効だから利用条件は満たしてるので以下略

ちなみにISOの中身はRTM版ではなくUpdate 3なんだけどそれでも150近いパッチの適用が必要で、Security Rollup適用すれば70程度で済むWindows 7よりひどいことになっている、うーんこの。

そしてしれっと悪名高いKB2976978いわゆるテレメトリパッチが混入しているのがクソ、ワイはパラノイアなので

dism /Image:mount /Remove-Package /PackageName:Package_for_KB2976978~31bf3856ad364e35~amd64~~6.3.2.1

を実行してアンインストールを試みたんだけど

1 / 1 を処理しています - Package_for_KB2976978: 永続的パッケージはアンインストールできません。
 エラー: 0x800f0825

エラー: 0x800f0825

DISM が失敗しました。操作は実行されませんでした。

というエラーが出てアンインストールできねえやんけクソが。

これはなぜかというとシステム回復時にUpdate3の更新ロールアップまでリセットされないように

dism /Image:mount /Cleanup-Image /StartComponentCleanup /ResetBase

を実行してそれらのパッチ(テレメトリまで含めて)に永続化をマークしてるからなのよね、詳しいことは これ読め

ところがこの永続化フラグってのは実にいいかげんなものなようで、もういちどAdd-Packageし直したら外れることが判った。

dism /Image:mount /Add-Package /PackageName:Package_for_KB2976978~31bf3856ad364e35~amd64~~6.3.2.1
dism /Image:mount /Remove-Package /PackageName:Package_for_KB2976978~31bf3856ad364e35~amd64~~6.3.2.1

ポイントはPackagePathではなくPackageNameにインストール済のパッケージ名を指定してAdd-Packageを実行するのがミソ。 もちろんこのKB2976978がベースシステムの置き換えで無い単純な増分でしかないから可能な技で、Cleanup-ImageでWinSxSの下にある古いバージョンは消されちゃった永続的パッケージは削除できないとは思うが。

そんでPowerShellのAdd-WindowsPackageコマンドレットは-PackageNameオプションが無いけれどそれなら-PackagePathを使えばいいじゃないと、最新のKB2976978(今はv24)のパッチをダウンロードしてきて

Add-WindowsPackage -Path .\mount -PackagePath .\windows8.1-kb2976978-v24-x64_edb8f26452e645838dc6797fa23374fb24cfd2df.msu
Get-WindowsPackage -Path .\mount | Where-Object { $_.PackageName -match 'KB2976978' } | ForEach-Object {
	Remove-WindowsPackage -Path .\mount -PackageName $_.PackageName
}

とすることでこっちでも削除できたゾ。

2019/5/20(Mon)

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

前回はForEach-Objectコマンドレットの正体はawk(1)であると看破したけど(おい)、PowerShellはMicrosoftで生まれました、ベル研でもCSRGでもMITの発明品じゃありません、Jeffrey Snoverフェローのオリジナルです、しばし遅れをとりましたが今や巻き返しの時です。

しかし実際のところ世間一般でawk(1)の代替はImport-Csvコマンドレットの方だと思われているかもね、ただawk(1)ってやつは区切り文字でテキストを分割しての処理に適してるとはいうけど、CSVってやつはもうちょっと複雑なデータ形式でクオート文字の中に区切り文字や改行コードまで含めることが可能だから、本当はawk(1)向きではないのだ。

ワイはそもそもshebang含めて3行超えそうならPerlで書いてしまうおじさんなので *1、Text::CSV_XSあたりCPANから拾ってささっと書いとったのだが、世の中にはGNU awkの拡張(FPATとか)を駆使して頑張る人もいるらしい。

いっぽうImport-Csvはこのようなクオート中の区切り文字や改行コードも、どうぞ回してみてください…いい音でしょう?余裕の処理だ、馬力が違いますよ。

とりあえず書き方のサンプルはこんな感じ。

Import-Csv -Delimiter '`t' -Encoding OEM -Path '1.tsv', '2.tsv', ... | ForEach-Object { Write-Host $_.カラム名 }

うん、Import-CsvそのものはCSV形式のファイルをオブジェクトに変換してパイプラインに流すだけだからやっぱりForEach-Objectとセットでawk(1)やね、Import-Csvは特殊なcat(1)相当でしかないっすわ。

そんでこのプロパティ名って何を元に決めてるのかというと、1行目がヘッダ行として扱われそこでの値が使われるのですよな。

"アクセスURL","参照元URL","アクセス日時","ホスト名","User-Agent"
"/","","2019/05/01 00:01:16 JST","host1-113-0-203.example.com","NCSA_Mosaic/2.0"
...

なんてCSVファイルであれば

Import-Csv -Path 'access.log' | ForEach-Object { Write-Host $_.ホスト名 }

と書けば特定の列、このコード例なら「ホスト名」にアクセスできるわけ、またの名を源氏名。

これがもしヘッダ行の無いCSVなら(そっちの方が多いよね…)

Import-Csv -Path 'access.log' -Header "アクセスURL","参照元","アクセス日時","ホスト名","User-Agent" | ForEach-Object { Write-Host $_.ホスト名 }

のようにいちいち-Headerオプションで明示的に指定する必要があるのがちょっとめんどくせえなという感じ。

そんでこれは文法の話に脱線しちゃうけど、カラム名に「-」や「$」などの特殊文字やスペース等が含まれる場合

$_.{User-Agent}
$_.'User-Agent'
$_."User-Agent"

とクオートしてやらんと演算子や変数と間違われて予期しない結果になるのは注意。

なおSelect-Objectの-Propertyオプションの引数ではクオートしなくてよいとか

$_ | Select-Object -Property User-Agent

なーんか一貫性が無いよな…

はいお次、最後の列だけ取り出すにもawkなら組込変数NFを使えばいいのだけれどもPowerShellではちょいと厄介だ。 ヘッダを明示的に指定しているパターンであれば、配列の最後の要素は添字に-1を指定することで取り出せるので

$header = "アクセスURL","参照元","アクセス日時","ホスト名","ユーザーエージェント"
Import-Csv -Path 'access.log' -Header $header | ForEach-Object { Write-Host $_.($header[-1]) }

とすることで何とかなるのだけど、CSVファイル中のヘッダ行を使う場合には

Import-Csv -Path 'access.log' | ForEach-Object { $_.($_.PSObject.Properties.Name[-1]) }

とだいぶ薄汚れたコードを書かないとあかんっぽい、なんやこのクソ言語…

@ 次回

まだまだImpotent-Csvへの文句は続くよ…

*1:なおPowerShellと同様に記憶喪失かと思うくらい覚えられないのだよね>Perl、何年書いてるんだっけ俺…毎回同じような事を調べてる気がする…