Win32 API でファイルのハードリンクカウントを調べる - WinSxS フォルダの真実

先月,Windowsのディスク領域の使い方に関する記事が本家英語のEngineering Windows 7 Blogに掲載された.この記事は非常に興味深いもので,私自身,多くの勘違いと誤解を解くことができた (ついでに,WinHEC の PPTX 資料が公開されているのに気付くこともできた).ずっと翻訳を待っていたのだが,昨日翻訳版が掲載されたので紹介しておきたい.

この投稿はディスク領域と Windows 7 によって「消費される」ディスク領域についてです。ディスク領域は誰も節約したいと思っているものですが、一般的に費用対効果が大きいものでもありました。けれども、容量が回転式のドライブよりずっと小さいソリッド ステート ドライブ (SSD) の出現により、最近、状況が変わってきました。伝統的に、Windows を含むほとんどのソフトウェアは、60GB (あるいは 1,500GB) のディスクで、特定の (正当な理由の) 必要性のために 100MB を消費するのをためらうことはしませんでした。しかし、16GB の SSD を搭載したようなマシンでは、Windows が使用するディスク領域を、セットアップ時と PC 「時代」 の両方において、慎重に検討しています。WinHEC でも SSD に関する特定のセッションを提供したので、興味があればご覧ください。この投稿の著者は、コア OS 開発チームのプログラム マネージャーである Michael Beck です。 --Steven

どの部分を抜き出しても単なる reblog になってしまうので逐一引用はしないが,基本的に以下の点に興味がある方には強くお勧めする記事である.

  • Windows のディスクフットプリントの内訳について,実際の統計に基づいた情報が知りたい方
  • Windows Vista での (一見巨大に見える) WinSxS に不満を持っている方
    • そもそもなぜこのような設計になっているのか?
  • Windows 7 でのディスクフットプリント削減がどのように行われるか興味がある方

この記事に限らず,Engineering Windows 7 Blog の記事は 1 カ所だけ抜き出してしまうのが勿体ないものが多い.実際のデータを引用しつつ,ストーリーのある話として書かれているので,中途半端に抜き出すよりは一人でも多くの方が原文を読むように紹介した方が良いような気がしている.といいつつ,ハードリンクに関して少しだけ書いておきたいので,いつもの長文引用モードで以下にお届けする.
そもそもハードリンクって何? という方は,以下の資料等を参照されたい.

Win32 API でファイルのリンクカウントを調べる

Windows Vista の WinSxS フォルダに含まれるファイル群は,ハードリンクを利用して同一ファイルを二重に保持してしまわないように工夫されている.これは恥ずかしながら私も記事を読むまで気づかなかった.

(Vista からの) 新しいディレクトリである Windows SxS ディレクトリ (%System Root%\winsxs) について、大変多くの質問をいただいています。新しくインストールされたシステムのプロパティが、ファイル数が3,000 以上で 3.5 GB 以上消費していると表示するのを見て、多くの人々はものすごいディスクの消費だと思われることでしょう。時間が経つにつれて、この数字はさらに大きくなります。なんと! 以下は Steven の自宅の PC からの例です。

オペレーティング システムの「モジュラー化」は Windows Vista での工学的な目標でした。それ以前の古い Windows でのインストール、サービス、および信頼性に関する問題への解決策だったのです。Windows SxS ディレクトリは、すべてのシステム コンポーネントの「インストールとサービスの状態」を示すものです。しかし、組み込みツール (DIR コマンドやエクスプローラ) で計測して表示される数字ほど、実際にはディスク領域を消費しているわけではありません。ただし、実際のディレクトリにおけるディスク消費量を分かりにくくしているという事実は、もっともなご指摘です!

実際のところ、WinSxS ディレクトリ内のほとんどのファイルは、システム上の物理的なファイルへの「ハード リンク」です。つまり、ファイルは実際にはディレクトリ内には存在しないのです。たとえば、WinSxS の中には advapi32.dll という 700 KB ほどのサイズのファイルがあるかと思いますが、実際には Windows\System32 内にあるファイルへのハード リンクがここに表示されているのです。つまり、Windows エクスプローラでそれぞれのディレクトリを見ると、そのファイルは 2 回 (もしくはそれ以上) カウントされていることになります。

WinSxS は単に存在するだけでディスク領域をいくらか消費し、またその中にはたくさんのメタデータ ファイル、フォルダ、マニフェスト、カタログが存在するというのは事実ですが、そのサイズは表示される数字よりずっと小さいです。実際のサイズには幅がありますが、一般的なシステムでは 400MB 程度です。この数字は小さくないかもしれませんが、その結果生まれるサービスの安定性を考えると、妥当なトレードオフだと思います。

では、なぜシェルはハード リンクをこのように表示するのでしょう? ハード リンクはシステム全体の重複したファイルのディスク専有面積を最適化する働きをします。アプリケーションの開発者も、アプリケーションのディスク消費を最適化するのにこの機能を使用することができます。アプリケーションが期待するパスがファイル システムの物理的なファイルとして見えることは、実際のファイルの適切な読み込みをサポートするのに非常に重要です。このケースでは、シェルはファイルについてその情報を表示するアプリケーションに過ぎません。この混乱とディスク専有面積削減の欲求の結果、多くの人々はディスク領域を節約するためにこのディレクトリの削除を試みました。

WinSxS ディレクトリを削除しても大丈夫とするブログや「アンダーグランドの」ツールがありました。インストール後、システムから削除してもシステムはちゃんと起動し動作するみたいだ、というのも確かに事実です。しかし、これまで説明したように、安全なサービスを提供する能力やすべての OS コンポーネント、システムのオプション コンポーネントをアップデートまたは設定する能力を削除することになるので、これはとても悪いやり方です。Windows Vista は、物理ドライブ上の最初にインストールされた場所にある WinSxS ディレクトリのみサポートします。上記で説明したことからもわかるように、WinSxS ディレクトリを削除したりシステムから場所を移動したりすることによるリスクは、それによって得られるものをはるかに上回ります。

実際私も,Intel SSD 導入後に,WinSxS 削除とまではいかないもののせめて圧縮ぐらいはできないものかと試みていた.その証拠がつぶやきとして残っている.当時はハードリンクを使っていることにまるで気づけなかった.
では,Win32 API を利用して,あるファイルのリンクカウントを調べるにはどうしたらよいのだろうか? この手順について簡単にまとめてみる.

1. ファイルハンドルを取得する

まず,リンクカウントを調べたいファイルを開いてそのファイルハンドルを取得する必要がある.これには CreateFile API を使用する.ファイルの中身を読み書きするのではなく,単にファイルのメタデータを取得するだけにハンドルを用いるのであれば,dwDesiredAccess パラメータに 0 を渡すのがよい.こうすることで,本来不要な権限が不足しているためにファイルオープンに失敗するケースを回避できる.
ただし,.NET の場合は注意が必要だ..NET の Framework Base Class Library (BCL) は,dwDesiredAccess パラメータに 0 を渡すシナリオを考慮していない.以下のコードは,パラメータが無効であるとして例外が発生する.

FileInfo fileInfo = .....
using (var fs = fileInfo.Open(FileMode.Open, (FileAccess)0) )
{
    var handle = fs.SafeFileHandle;
    ........

場合によっては,CreateFile API を P/Invoke した方が良いだろう.

2. GetFileInformationByHandle API を呼び出す

ファイルハンドルが得られたら,GetFileInformationByHandle API でファイルの詳細情報を取得できる.

BOOL GetFileInformationByHandle(
  HANDLE hFile,                                  // ファイルのハンドル
  LPBY_HANDLE_FILE_INFORMATION lpFileInformation // バッファ
);
typedef struct _BY_HANDLE_FILE_INFORMATION {
    DWORD dwFileAttributes;
    FILETIME ftCreationTime;
    FILETIME ftLastAccessTime;
    FILETIME ftLastWriteTime;
    DWORD dwVolumeSerialNumber;
    DWORD nFileSizeHigh;
    DWORD nFileSizeLow;
    DWORD nNumberOfLinks;
    DWORD nFileIndexHigh;
    DWORD nFileIndexLow;
} BY_HANDLE_FILE_INFORMATION, *PBY_HANDLE_FILE_INFORMATION, *LPBY_HANDLE_FILE_INFORMATION;

この,nNumberOfLinks フィールドに求めるリンク数が格納されている.

3. ファイルの同一性の判定

あるふたつのパスが異なるファイルが,たまたま内容が同じなだけでファイルの実体としては異なっているのか,それともハードリンクで結びついた単一の実体なのかを判定したいとする.これには,ファイル ID を比較するという方法がある.
ファイル ID は,BY_HANDLE_FILE_INFORMATION 構造体の最後のフィールドである nFileIndexHigh および nFileIndexLow によって構成される 64-bit 値である.

The identifier that is stored in the nFileIndexHigh and nFileIndexLow members is called the file ID. Support for file IDs is file system-specific. File IDs are not guaranteed to be unique over time, because file systems are free to reuse them. In some cases, the file ID for a file can change over time.

In the FAT file system, the file ID is generated from the first cluster of the containing directory and the byte offset within the directory of the entry for the file. Some defragmentation products change this byte offset. (Windows in-box defragmentation does not.) Thus, a FAT file ID can change over time. Renaming a file in the FAT file system can also change the file ID, but only if the new file name is longer than the old one.

In the NTFS file system, a file keeps the same file ID until it is deleted. You can replace one file with another file without changing the file ID by using the ReplaceFile function. However, the file ID of the replacement file, not the replaced file, is retained as the file ID of the resulting file.

サンプルコード

以下に,C# 3.0 で書かれたサンプルコードを示す.このコードは,WinSxS フォルダ以下のファイルのうち,開くことができてリンク数が 2 以上の全てのファイルについて,そのリンク数と File ID を表示する.

using System;
using System.Linq;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
using System.IO;
using System.ComponentModel;
using FILETIME = System.Runtime.InteropServices.ComTypes.FILETIME;

public static class FSUtil
{
    internal static DateTime ToDateTime(this FILETIME filetime)
    {
        return DateTime.FromFileTime(ToLongUnchecked(filetime.dwHighDateTime, filetime.dwLowDateTime));
    }
    internal static FILETIME ToFILETIME(this DateTime datetime)
    {
        var value = datetime.ToFileTime();
        return new FILETIME() { dwHighDateTime = unchecked((int)(value >> 32)), dwLowDateTime = unchecked((int)value) };
    }
    internal static long ToLongUnchecked(int high, int low)
    {
        return unchecked((long)((((ulong)high) << 32) | (uint)low));
    }
    internal static void ToIntUnchecked(long value, out int high, out int low)
    {
        high = unchecked((int)((value >> 32)));
        low = unchecked((int)(value));
    }
    internal static ulong ToULongUnchecked(uint high, uint low)
    {
        return ((((ulong)high) << 32) | low);
    }
    internal static void ToUIntUnchecked(ulong value, out uint high, out uint low)
    {
        high = unchecked(((uint)(value >> 32)));
        low = unchecked((uint)(value));
    }
    [DllImport("Kernel32.dll", SetLastError = true, ExactSpelling = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool GetFileInformationByHandle(
        SafeFileHandle file,
        out BY_HANDLE_FILE_INFORMATION fileInformation);

    /// <remarks>
    /// desiredAccess, shareMode, createDisposition, flagsAndAttributes
    /// に関しては,System.IO 名前空間の列挙体を流用している
    /// これらの列挙体には、
    /// API でサポートされている全てのフラグが含まれていないことに注意
    /// </remarks>
    [DllImport("Kernel32.dll", SetLastError = true, ExactSpelling = true)]
    private static extern SafeFileHandle CreateFileW(
       [In, MarshalAs(UnmanagedType.LPWStr)] string fileName,
       FileAccess desiredAccess,
       FileShare shareMode,
       IntPtr securityAttributes,
       FileMode createDisposition,
       FileOptions flagsAndAttributes,
       IntPtr templateFile);

    internal static SafeFileHandle OpenQuery(this FileInfo fileinfo)
    {
        var error = default(FileLoadException);
        return OpenQuery(fileinfo, out error);
    }
    internal static SafeFileHandle OpenQuery(this FileInfo fileinfo, out FileLoadException error)
    {
        var handle = CreateFileW(fileinfo.FullName,
                                  (FileAccess)0,
                                  FileShare.Delete | FileShare.ReadWrite,
                                  IntPtr.Zero,
                                  FileMode.Open,
                                  FileOptions.None,
                                  IntPtr.Zero);
        var eno = Marshal.GetLastWin32Error();
        if (handle == null || handle.IsInvalid)
        {
            error = new FileLoadException("CreateFileW failed", new Win32Exception(eno));
            return null;
        }
        error = null;
        return handle;
    }
    internal static BY_HANDLE_FILE_INFORMATION? GetFileInformation(this FileInfo fileInfo)
    {
        var e = default(Exception);
        return GetFileInformation(fileInfo, out e);
    }

    internal static BY_HANDLE_FILE_INFORMATION?
        GetFileInformation(this FileInfo fileInfo, out FileLoadException error)
    {
        var info = default(BY_HANDLE_FILE_INFORMATION);
        error = null;
        try
        {
            using (var handle = fileInfo.OpenQuery(out error))
            {
                return (handle != null && !handle.IsInvalid
                                       && GetFileInformationByHandle(handle, out info))
                        ? info : default(BY_HANDLE_FILE_INFORMATION?);
            }
        }
        catch (FileLoadException e)
        {
            error = e;
            return default(BY_HANDLE_FILE_INFORMATION?);
        }
    }

    internal static BY_HANDLE_FILE_INFORMATION?
        GetFileInformation<TError>(this FileInfo fileInfo, out TError error)
        where TError : Exception
    {
        var info = default(BY_HANDLE_FILE_INFORMATION);
        error = null;
        try
        {
            using (var handle = fileInfo.OpenQuery())
            {
                return (handle != null && !handle.IsInvalid
                                       && GetFileInformationByHandle(handle, out info))
                        ? info : default(BY_HANDLE_FILE_INFORMATION?);
            }
        }
        catch (TError e)
        {
            error = e;
            return default(BY_HANDLE_FILE_INFORMATION?);
        }
    }
}

struct BY_HANDLE_FILE_INFORMATION
{
    /// <remarks>
    /// 厳密には、System.IO.FileAttributes のパラメータに
    /// 含まれない値が入り込む可能性がある
    /// </remarks>
    public FileAttributes FileAttributes;
    private FILETIME _CreationTime;
    private FILETIME _LastAccessTime;
    private FILETIME _LastWriteTime;

    public uint VolumeSerialNumber;
    private uint FileSizeHigh;
    private uint FileSizeLow;
    public uint NumberOfLinks;
    private uint FileIndexHigh;
    private uint FileIndexLow;

    public DateTime CreationTime
    {
        get { return _CreationTime.ToDateTime(); }
        set { _CreationTime = value.ToFILETIME(); }
    }
    public DateTime LastAccessTime
    {
        get { return _LastAccessTime.ToDateTime(); }
        set { _LastAccessTime = value.ToFILETIME(); }
    }
    public DateTime LastWriteTime
    {
        get { return _LastWriteTime.ToDateTime(); }
        set { _LastWriteTime = value.ToFILETIME(); }
    }
    public ulong FileSize
    {
        get { return FSUtil.ToULongUnchecked(FileSizeHigh, FileSizeLow); }
        set { FSUtil.ToUIntUnchecked(value, out FileSizeHigh, out FileSizeLow); }
    }
    public ulong FileIndex
    {
        get { return FSUtil.ToULongUnchecked(FileIndexHigh, FileIndexLow); }
        set { FSUtil.ToUIntUnchecked(value, out FileIndexHigh, out FileIndexLow); }
    }
}

static class Program
{
    static void Main(string[] args)
    {
        Console.BufferHeight = 9999;

        var dir = new DirectoryInfo(
            new DirectoryInfo(Environment.SystemDirectory).Parent.FullName + @"\winsxs");

        var results = dir.GetFiles("*.*", SearchOption.AllDirectories)
                         .Select(fileInfo =>
                             new
                             {
                                 FileInfo = fileInfo,
                                 HandleInfo = fileInfo.GetFileInformation(),
                             })
                         .Where(info => info.HandleInfo.HasValue)
                         .Select(info =>
                             new
                             {
                                 info.FileInfo,
                                 HandleInfo = info.HandleInfo.Value,
                             });

        foreach (var item in results.Where(info => info.HandleInfo.NumberOfLinks > 1))
        {
            Console.WriteLine("{0}\n\tLink Count: {1} / FileID: {2}",
                item.FileInfo.Name,
                item.HandleInfo.NumberOfLinks,
                item.HandleInfo.FileIndex);
        }
    }
}