Skip to content

Instantly share code, notes, and snippets.

@ynkdir
Last active January 3, 2018 17:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ynkdir/93cea5aa514ac706b5a91b6283a71b9a to your computer and use it in GitHub Desktop.
Save ynkdir/93cea5aa514ac706b5a91b6283a71b9a to your computer and use it in GitHub Desktop.
Windows FileHistory Verifier
// public domain
//
// Windows10 のバックアップ(ファイル履歴)は半角全角違いの同名ファイルを扱えない
// が、バックアップがどこで失敗してるのかは教えてくれないので、バックアップでき
// ていないファイルを見つけるスクリプト。
//
// using https://github.com/Microsoft/ManagedEsent
// csc fhverify.cs /r:Esent.Interop.dll
//
// NOBACKUP: 最新のファイルが(まだ)バックアップされていない(データベースにエントリがない)
// DB DIRECTORY NAME COLLISION: ディレクトリパスがデータベースの名前と衝突している
// DB FILE NAME COLLISION: ファイル名がデータベースの名前と衝突している
// FS DIRECTORY NAME COLLISION: ディレクトリパスがファイルシステムの他のディレクトリと衝突している
// FS FILE NAME COLLISION: ファイル名がファイルシステムの他のファイルと衝突している
//
// Catalog1.edb (or Catalog2.edb) データベース 抜粋
// namespace テーブル
// parentId (=>string.id ディレクトリパス)
// childId (=>string.id ファイル名)
// fileModified (ファイルの更新時間)
// tCreated (=>backupset.id バックアップが最初に作られたバックアップセット)
// tVisible (=>backupset.id ファイルがどのバックアップセットまで存在していたか)
// ...
// string テーブル
// id
// string (ディレクトリのパスまたはファイル名)
// C:\Path\To\Dir
// ?UP\Path\To\Dir (?UPは$UserProfile namespace.parentIdが参照しているのはこっち)
// file1.txt
// パス文字列はstringテーブルに保存されidが振られる。
// ほかのテーブルからはstring.idで参照する。
//
// string.string は
// CompareOptions: IgnoreCase, IgnoreKanaType, IgnoreWidth
// なので、大文字小文字、平仮名片仮名、半角全角の違いが無視される。
//
// stringテーブルに最初にパスが追加されるときは文字種に関係なくバックアップは成功する。
//
// 文字種違いの同名ファイルが追加されるとき
// string.Compare(string.string, new_string, IgnoreCase|IgnoreKanaType|IgnoreWidth) == 0
// かつ
// string.Compare(string.string, new_string, IgnoreCase) != 0
// という条件で、バックアップは失敗する。
// 例: "abc.txt" と "Abc.txt" => 成功
// "abc.txt" と "abc.txt" => 失敗
// "abc.txt" と "Abc.txt" => 成功
// "あいう.txt" と "アイウ.txt" => 失敗
//
// これは、ファイルがそれぞれ違うディレクトリに保存されていても同じく失敗する。
//
// また、これらのファイルを削除してもデータベースには名前が残るため、バック
// アップは失敗しつづける。
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using System.Threading;
using System.Xml;
using Microsoft.Isam.Esent.Interop;
namespace FhVerify {
class FhVerify {
static void Main() {
var app = new FhVerify();
app.Run();
}
string path_config;
string path_catalog;
string path_userprofile;
XmlDocument config;
ISet<string> folders;
ISet<string> exclude;
ISet<string> visited;
Instance instance;
Session session;
Table table_namespace;
Table table_string;
IDictionary<string,JET_COLUMNID> colid_namespace;
IDictionary<string,JET_COLUMNID> colid_string;
IDictionary<string,IList<string>> collision;
void Run() {
path_userprofile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
path_config = SelectLatestFile(
Path.Combine(Environment.GetEnvironmentVariable("LOCALAPPDATA"), "Microsoft\\Windows\\FileHistory\\Configuration\\Config1.xml"),
Path.Combine(Environment.GetEnvironmentVariable("LOCALAPPDATA"), "Microsoft\\Windows\\FileHistory\\Configuration\\Config2.xml"));
config = new XmlDocument();
config.Load(path_config);
path_catalog = SelectLatestFile(
config.GetElementsByTagName("LocalCatalogPath1")[0].InnerText,
config.GetElementsByTagName("LocalCatalogPath2")[0].InnerText);
folders = new SortedSet<string>(new FhCompI());
foreach (XmlElement e in config.GetElementsByTagName("Folder")) {
folders.Add(e.InnerText);
}
foreach (XmlElement e in config.GetElementsByTagName("UserFolder")) {
folders.Add(e.InnerText);
}
exclude = new SortedSet<string>(new FhCompI());
foreach (XmlElement e in config.GetElementsByTagName("FolderExclude")) {
exclude.Add(e.InnerText);
}
visited = new SortedSet<string>(new FhCompI());
collision = new SortedDictionary<string, IList<string>>(new FhCompIKW());
using (instance = new Instance("fhverify")) {
instance.Parameters.Recovery = false;
instance.Init();
using (session = new Session(instance)) {
JET_DBID dbid;
Api.JetAttachDatabase(session, path_catalog, AttachDatabaseGrbit.ReadOnly);
Api.JetOpenDatabase(session, path_catalog, null, out dbid, OpenDatabaseGrbit.ReadOnly);
using (table_namespace = new Table(session, dbid, "namespace", OpenTableGrbit.ReadOnly)) {
colid_namespace = Api.GetColumnDictionary(session, table_namespace);
using (table_string = new Table(session, dbid, "string", OpenTableGrbit.ReadOnly)) {
colid_string = Api.GetColumnDictionary(session, table_string);
foreach (var d in folders) {
Walk(d, (string f) => {
if (Directory.Exists(f)) {
if (!collision.ContainsKey(f)) {
collision[f] = new List<string>();
}
collision[f].Add(f);
return true;
}
var path = f;
if (path.StartsWith(path_userprofile + "\\", StringComparison.CurrentCultureIgnoreCase)) {
path = "?UP" + path.Substring(path_userprofile.Length);
}
var parent = Path.GetDirectoryName(path);
var child = Path.GetFileName(path);
var t = File.GetLastWriteTimeUtc(f);
t.AddTicks(-(t.Ticks % TimeSpan.TicksPerSecond)); // clear ticks counter
if (!collision.ContainsKey(child)) {
collision[child] = new List<string>();
}
collision[child].Add(f);
if (FindCurrentBackup(parent, child, t.ToFileTimeUtc())) {
// there is latest backup
} else if (FindString(parent) != null && string.Compare(parent, GetString(), Thread.CurrentThread.CurrentCulture, CompareOptions.IgnoreCase) != 0) {
Console.WriteLine(string.Format("DB DIRECTORY NAME COLLISION: {0}", f));
} else if (FindString(child) != null && string.Compare(child, GetString(), Thread.CurrentThread.CurrentCulture, CompareOptions.IgnoreCase) != 0) {
Console.WriteLine(string.Format(" DB FILE NAME COLLISION: {0}", f));
} else {
Console.WriteLine(string.Format(" NOBACKUP: {0}", f));
}
return true;
});
}
foreach (var k in collision.Keys) {
if (CheckCollision(k)) {
foreach (var f in collision[k]) {
if (Directory.Exists(f)) {
Console.WriteLine(string.Format("FS DIRECTORY NAME COLLISION: {0}", f));
} else {
Console.WriteLine(string.Format(" FS FILE NAME COLLISION: {0}", f));
}
}
}
}
}
}
}
}
}
bool CheckCollision(string key) {
var list = collision[key];
var s = Path.GetFileName(list[0]);
for (int i = 1; i < list.Count; ++i) {
if (string.Compare(s, Path.GetFileName(list[i]), Thread.CurrentThread.CurrentCulture, CompareOptions.IgnoreCase) != 0) {
return true;
}
}
return false;
}
// TODO: 隠しファイルはバックアップされないっぽいけど除外するかどうか
bool Walk(string root, Func<string, bool> fun) {
if (exclude.Contains(root)) {
return true;
}
if (!visited.Add(root)) {
return true;
}
foreach (var f in Directory.GetFiles(root)) {
if (!fun(f)) {
return false;
}
}
foreach (var d in Directory.GetDirectories(root)) {
DirectoryInfo di = new DirectoryInfo(d);
if ((di.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) {
// リパース ポイントは探索不可
continue;
}
if (!fun(d)) {
return false;
}
if (!Walk(d, fun)) {
return false;
}
}
return true;
}
string SelectLatestFile(string file1, string file2) {
if (File.GetLastWriteTime(file1) < File.GetLastWriteTime(file2)) {
return file2;
}
return file1;
}
bool FindCurrentBackup(string parent, string child, long modified) {
var parentId = FindString(parent);
if (parentId == null) {
return false;
}
var childId = FindString(child);
if (childId == null) {
return false;
}
Api.JetSetCurrentIndex(session, table_namespace, "filePathIndex");
Api.MakeKey(session, table_namespace, (int)parentId, MakeKeyGrbit.NewKey);
Api.MakeKey(session, table_namespace, (int)childId, MakeKeyGrbit.None);
if (!Api.TrySeek(session, table_namespace, SeekGrbit.SeekGE)) {
return false;
}
Api.MakeKey(session, table_namespace, (int)parentId, MakeKeyGrbit.NewKey);
Api.MakeKey(session, table_namespace, (int)childId, MakeKeyGrbit.FullColumnEndLimit);
if (!Api.TrySetIndexRange(session, table_namespace, SetIndexRangeGrbit.RangeInclusive | SetIndexRangeGrbit.RangeUpperLimit)) {
return false;
}
do {
if (Api.RetrieveColumnAsInt64(session, table_namespace, colid_namespace["fileModified"]) == modified &&
Api.RetrieveColumnAsInt32(session, table_namespace, colid_namespace["tVisible"]) == 2147483647) {
return true;
}
} while (Api.TryMoveNext(session, table_namespace));
return false;
}
int? FindString(string s) {
Api.JetSetCurrentIndex(session, table_string, "stringIndex");
Api.MakeKey(session, table_string, s, Encoding.Unicode, MakeKeyGrbit.NewKey);
if (!Api.TrySeek(session, table_string, SeekGrbit.SeekEQ)) {
return null;
}
return Api.RetrieveColumnAsInt32(session, table_string, colid_string["id"]);
}
string GetString() {
var s = Api.RetrieveColumnAsString(session, table_string, colid_string["string"], Encoding.Unicode);
// NUL terminated?
if (s.EndsWith("\x00")) {
s = s.Substring(0, s.Length - 1);
}
return s;
}
}
class FhCompI : IComparer<string>
{
public int Compare(string a, string b)
{
return string.Compare(a, b, Thread.CurrentThread.CurrentCulture, CompareOptions.IgnoreCase);
}
}
class FhCompIKW : IComparer<string>
{
public int Compare(string a, string b)
{
return string.Compare(a, b, Thread.CurrentThread.CurrentCulture, CompareOptions.IgnoreCase | CompareOptions.IgnoreKanaType | CompareOptions.IgnoreWidth);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment