Windows における不正なパス名を変換する

Windows における不正なパス名を変換する関数を少し真面目に書いてみます.現在の実装項目を簡単に列挙すると以下の通りです.

  • 「/*"<>|」の 6文字(Path.GetInvalidPathChars() メソッドで返される文字に「*」記号を加えたものに相当?)は無条件で置換しています.すなわち,ワイルドカードを許可していません.
  • 「:」記号は,ドライブ指定と見なされる部分のみ保持し,それ以外は置換します.
  • 「?」記号は,拡張機能の不活性化指定と見なされる部分のみ保持し,それ以外は置換します.
  • 「\」記号は,ホスト名の指定と見なされる部分の重複のみ保持し,それ以外は重複を取り除きます.
  • 末尾の「.」記号や半角スペースは,拡張機能の不活性化が指定されていない場合,取り除きます.

関数部分のコードは以下の通り.

public string NormalizePath(string src, char replaced) {
    char[] invalids = { '/', '*', '"', '<', '>', '|' };

    bool inactivated = false;
    var buffer = new System.Text.StringBuilder();
    for (int i = 0; i < src.Length; ++i) {
        char current = src[i];
        foreach (char c in invalids) {
            if (current == c) current = replaced;
        }

        // ドライブ指定としての : 記号かどうかを判定する.
        // 拡張機能の不活性化が指定されていない場合は,C:\hoge,指定されている場合は \\?\C:\hoge
        // の位置でのみ : 記号が出現する事を許す.
        if (current == ':') {
            int first = inactivated ? 5 : 1;
            if (i == first && char.IsLetter(src[i - 1]) && i + 1 < src.Length && src[i + 1] == '\\') {
                // ドライブ指定なので : 記号を保持.
            }
            else current = replaced;
        }

        // 拡張機能の不活性化指定である \\?\ のみ許す.
        if (current == '?') {
            if (i + 1 < src.Length && i == 2 && src[i - 1] == '\\' && src[i - 2] == '\\' && src[i + 1] == '\\') {
                inactivated = true;
            }
            else current = replaced;
        }

        // ホスト名指定 (最初の \\server\hoge) 以外の \ 記号の重複は取り除く.
        if (current == '\\') {
            if (i > 1 && src[i - 1] == '\\') continue;
        }

        buffer.Append(current);
    }

    // 末尾の . 記号や半角スペースは取り除く.
    // ただし,拡張機能の不活性化が指定されている場合は保持する.
    if (!inactivated) {
        int n = 0;
        for (int i = buffer.Length - 1; i >= 0; --i) {
            if (buffer[i] == '.' || buffer[i] == ' ') ++n;
            else break;
        }
        if (n > 0) buffer.Remove(buffer.Length - n, n);
    }

    return buffer.ToString();
}

テストコードは以下の通り.

using System;
using System.IO;
using NUnit.Framework;

namespace Cubic {
    [TestFixture]
    class InvalidPathNameTest {
        [Test]
        public void TestNormalizePath() {
            var test1 = @"C:\foo\bar\bas.txt";
            Assert.AreEqual(@"C:\foo\bar\bas.txt", NormalizePath(test1, '_'));

            var test2 = @"\\server\foo\bar\bas.txt";
            Assert.AreEqual(@"\\server\foo\bar\bas.txt", NormalizePath(test2, '_'));

            var test3 = @"C:\foo\bar\bas/*?<>|.txt";
            Assert.AreEqual(@"C:\foo\bar\bas______.txt", NormalizePath(test3, '_'));

            var test4 = @"C:\foo\bar\bas:.txt";
            Assert.AreEqual(@"C:\foo\bar\bas_.txt", NormalizePath(test4, '_'));

            var test5 = @"C:\foo\bar\bas:\hoge.txt";
            Assert.AreEqual(@"C:\foo\bar\bas_\hoge.txt", NormalizePath(test5, '_'));

            var test6 = @"C:\foo\bar\bas.txt.";
            Assert.AreEqual(@"C:\foo\bar\bas.txt", NormalizePath(test6, '_'));
            
            var test7 = @"c:\foo\\bar\bas.txt";
            Assert.AreEqual(@"c:\foo\bar\bas.txt", NormalizePath(test7, '_'));

            var test8 = @"\\?\c:\foo\bar\bas.txt";
            Assert.AreEqual(@"\\?\c:\foo\bar\bas.txt", NormalizePath(test8, '_'));

            var test9 = @"C:\foo\bar\bas.txt . ... ";
            Assert.AreEqual(@"C:\foo\bar\bas.txt", NormalizePath(test9, '_'));

            var test10 = @"\\?\C:\foo\bar\bas.txt . ... ";
            Assert.AreEqual(@"\\?\C:\foo\bar\bas.txt . ... ", NormalizePath(test10, '_'));
        }
    }
}

問題点としては,「何かが足りない」場合にうまくいかない事があります.例えば,拡張機能の不活性化指定がなされた場合には直後にドライブ指定が来るはずなのですが(拡張機能の不活性化周りはよく分かっていないので自信なし),\\?\hoge\fuga のような形も通ってしまいます.また,よく例に挙げられますが,AUX, CON, NUL, PRN, COM1〜COM9, LPT1〜LPT9 辺りの対策もできていません.