using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Threading; using Compression.BSA; using ICSharpCode.SharpZipLib.Zip; using Newtonsoft.Json; using Wabbajack.Common; using Directory = Alphaleonis.Win32.Filesystem.Directory; using File = Alphaleonis.Win32.Filesystem.File; using FileInfo = Alphaleonis.Win32.Filesystem.FileInfo; using Path = Alphaleonis.Win32.Filesystem.Path; namespace VFS { public class VirtualFileSystem { internal static string _stagedRoot; public static VirtualFileSystem VFS; private bool _disableDiskCache; private Dictionary _files = new Dictionary(); private volatile bool _isSyncing; static VirtualFileSystem() { VFS = new VirtualFileSystem(); var entry = Assembly.GetEntryAssembly(); if (entry != null && !string.IsNullOrEmpty(entry.Location)) { RootFolder = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location); _stagedRoot = Path.Combine(RootFolder, "vfs_staged_files"); } } public static void Reconfigure(string root) { RootFolder = root; _stagedRoot = Path.Combine(RootFolder, "vfs_staged_files"); } public static void Clean() { if (Directory.Exists(_stagedRoot)) { Directory.EnumerateDirectories(_stagedRoot) .PMap(f => DeleteDirectory(f)); DeleteDirectory(_stagedRoot); } Directory.CreateDirectory(_stagedRoot); } public VirtualFileSystem() { LoadFromDisk(); } public static string RootFolder { get; private set; } public Dictionary> HashIndex { get; private set; } public VirtualFile this[string path] => Lookup(path); public static void DeleteDirectory(string path) { var info = new ProcessStartInfo { FileName = "cmd.exe", Arguments = $"/c del /f /q /s \"{path}\" && rmdir /q /s \"{path}\" ", RedirectStandardError = true, RedirectStandardInput = true, RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true }; var p = new Process { StartInfo = info }; p.Start(); ChildProcessTracker.AddProcess(p); try { p.PriorityClass = ProcessPriorityClass.BelowNormal; } catch (Exception) { } while (!p.HasExited) { var line = p.StandardOutput.ReadLine(); if (line == null) break; Utils.Status(line); } p.WaitForExit(); } public void Reset() { LoadFromDisk(); } private void LoadFromDisk() { try { HashIndex = new Dictionary>(); Utils.Log("Loading VFS Cache"); if (!File.Exists("vfs_cache.bin")) return; _files = new Dictionary(); try { using (var fs = File.OpenRead("vfs_cache.bin")) using (var br = new BinaryReader(fs)) { while (true) { var fr = VirtualFile.Read(br); _files.Add(fr.FullPath, fr); } } } catch (EndOfStreamException ex) { } CleanDB(); } catch (Exception ex) { Utils.Log($"Purging cache due to {ex}"); File.Delete("vfs_cache.bson"); _files.Clear(); } } public void SyncToDisk() { if (!_disableDiskCache) { Utils.Status("Syncing VFS Cache"); lock (this) { try { _isSyncing = true; if (File.Exists("vfs_cache.bin_new")) File.Delete("vfs_cache.bin_new"); using (var fs = File.OpenWrite("vfs_cache.bin_new")) using (var bw = new BinaryWriter(fs)) { Utils.Log($"Syncing VFS to Disk: {_files.Count} entries"); foreach (var f in _files.Values) f.Write(bw); } if (File.Exists("vfs_cache.bin")) File.Delete("vfs_cache.bin"); File.Move("vfs_cache.bin_new", "vfs_cache.bin"); } finally { _isSyncing = false; } } } } public IList FilesInArchive(VirtualFile f) { var path = f.FullPath + "|"; return _files.Values .Where(v => v.FullPath.StartsWith(path)) .ToList(); } public void Purge(VirtualFile f) { var path = f.FullPath + "|"; lock (this) { _files.Values .Where(v => v.FullPath.StartsWith(path) || v.FullPath == f.FullPath) .ToList() .Do(r => { _files.Remove(r.FullPath); }); } } public void Add(VirtualFile f) { lock (this) { if (_files.ContainsKey(f.FullPath)) Purge(f); _files.Add(f.FullPath, f); } } public VirtualFile Lookup(string f) { lock (this) { if (_files.TryGetValue(f, out var found)) return found; return null; } } /// /// Remove any orphaned files in the DB. /// private void CleanDB() { Utils.Log("Cleaning VFS cache"); lock (this) { _files.Values .Where(f => { if (f.IsConcrete) return !File.Exists(f.StagedPath); if (f.Hash == null) return true; while (f.ParentPath != null) { if (Lookup(f.ParentPath) == null) return true; if (f.Hash == null) return true; f = Lookup(f.ParentPath); } return false; }) .ToList() .Do(f => { _files.Remove(f.FullPath); }); SyncToDisk(); } } public void BackfillMissing() { lock (this) { _files.Values .Select(f => f.ParentPath) .Where(s => s != null) .Where(s => !_files.ContainsKey(s)) .ToHashSet() .Do(s => { AddKnown(new VirtualFile {Paths = s.Split('|')}); }); } } /// /// Add a known file to the index, bit of a hack as we won't assume that all the fields for the archive are filled in. /// you will need to manually update the SHA hash when you are done adding files, by calling `RefreshIndexes` /// /// public void AddKnown(VirtualFile virtualFile) { lock (this) { // We don't know enough about these files to be able to store them in the disk cache _disableDiskCache = true; _files[virtualFile.FullPath] = virtualFile; } } /// /// Adds the root path to the filesystem. This may take quite some time as every file in the folder will be hashed, /// and every archive examined. /// /// public void AddRoot(string path) { if (!Directory.Exists(path)) return; IndexPath(path); RefreshIndexes(); } public void RefreshIndexes() { Utils.Log("Building Hash Index"); lock (this) { HashIndex = _files.Values .GroupBy(f => f.Hash) .Where(f => f.Key != null) .ToDictionary(f => f.Key, f => (IEnumerable) f); } } private void IndexPath(string path) { var file_list = Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories).ToList(); Utils.Log($"Updating the cache for {file_list.Count} files"); file_list.PMap(f => UpdateFile(f)); SyncToDisk(); } private void UpdateFile(string f) { TOP: var lv = Lookup(f); if (lv == null) { Utils.Status($"Analyzing {f}"); lv = new VirtualFile { Paths = new[] {f} }; lv.Analyze(); Add(lv); if (lv.IsArchive) { UpdateArchive(lv); // Upsert after extraction incase extraction fails lv.FinishedIndexing = true; } } if (lv.IsOutdated) { Purge(lv); goto TOP; } } private void UpdateArchive(VirtualFile f) { if (!f.IsStaged) throw new InvalidDataException("Can't analyze an unstaged file"); var tmp_dir = Path.Combine(_stagedRoot, Guid.NewGuid().ToString()); Utils.Status($"Extracting Archive {Path.GetFileName(f.StagedPath)}"); FileExtractor.ExtractAll(f.StagedPath, tmp_dir); Utils.Status($"Updating Archive {Path.GetFileName(f.StagedPath)}"); var entries = Directory.EnumerateFiles(tmp_dir, "*", SearchOption.AllDirectories) .Select(path => path.RelativeTo(tmp_dir)); var new_files = entries.Select(e => { var new_path = new string[f.Paths.Length + 1]; f.Paths.CopyTo(new_path, 0); new_path[f.Paths.Length] = e; var nf = new VirtualFile { Paths = new_path }; nf._stagedPath = Path.Combine(tmp_dir, e); Add(nf); return nf; }).ToList(); // Analyze them new_files.PMap(file => { Utils.Status($"Analyzing {Path.GetFileName(file.StagedPath)}"); file.Analyze(); }); // Recurse into any archives in this archive new_files.Where(file => file.IsArchive).Do(file => UpdateArchive(file)); f.FinishedIndexing = true; if (!_isSyncing) SyncToDisk(); Utils.Status("Cleaning Directory"); DeleteDirectory(tmp_dir); } public Action Stage(IEnumerable files) { var grouped = files.SelectMany(f => f.FilesInPath) .Distinct() .Where(f => f.ParentArchive != null) .GroupBy(f => f.ParentArchive) .OrderBy(f => f.Key == null ? 0 : f.Key.Paths.Length) .ToList(); var Paths = new List(); foreach (var group in grouped) { var tmp_path = Path.Combine(_stagedRoot, Guid.NewGuid().ToString()); FileExtractor.ExtractAll(group.Key.StagedPath, tmp_path); Paths.Add(tmp_path); foreach (var file in group) file._stagedPath = Path.Combine(tmp_path, file.Paths[group.Key.Paths.Length]); } return () => { Paths.Do(p => { if (Directory.Exists(p)) DeleteDirectory(p); }); }; } public StagingGroup StageWith(IEnumerable files) { var grp = new StagingGroup(files); grp.Stage(); return grp; } internal List GetArchiveEntryNames(VirtualFile file) { if (!file.IsStaged) throw new InvalidDataException("File is not staged"); if (file.Extension == ".bsa") using (var ar = new BSAReader(file.StagedPath)) { return ar.Files.Select(f => f.Path).ToList(); } if (file.Extension == ".zip") using (var s = new ZipFile(File.OpenRead(file.StagedPath))) { s.IsStreamOwner = true; s.UseZip64 = UseZip64.On; if (s.OfType().FirstOrDefault(e => !e.CanDecompress) == null) return s.OfType() .Where(f => f.IsFile) .Select(f => f.Name.Replace('/', '\\')) .ToList(); } /* using (var e = new ArchiveFile(file.StagedPath)) { return e.Entries .Where(f => !f.IsFolder) .Select(f => f.FileName).ToList(); }*/ return null; } /// /// Given a path that starts with a HASH, return the Virtual file referenced /// /// /// public VirtualFile FileForArchiveHashPath(string[] archiveHashPath) { if (archiveHashPath.Length == 1) return HashIndex[archiveHashPath[0]].First(); var archive = HashIndex[archiveHashPath[0]].Where(a => a.IsArchive).OrderByDescending(a => a.LastModified) .First(); var fullPath = archive.FullPath + "|" + string.Join("|", archiveHashPath.Skip(1)); return Lookup(fullPath); } public IDictionary> GroupedByArchive() { return _files.Values .Where(f => f.TopLevelArchive != null) .GroupBy(f => f.TopLevelArchive) .ToDictionary(f => f.Key, f => (IEnumerable) f); } } public class StagingGroup : List, IDisposable { public StagingGroup(IEnumerable files) : base(files) { } public void Dispose() { this.Do(f => f.Unstage()); } internal void Stage() { VirtualFileSystem.VFS.Stage(this); } } [JsonObject(MemberSerialization = MemberSerialization.OptIn)] public class VirtualFile { private string _fullPath; private bool? _isArchive; private string _parentPath; public string[] _paths; internal string _stagedPath; [JsonProperty] public string[] Paths { get => _paths; set { for (var idx = 0; idx < value.Length; idx += 1) value[idx] = string.Intern(value[idx]); _paths = value; } } [JsonProperty] public string Hash { get; set; } [JsonProperty] public long Size { get; set; } [JsonProperty] public ulong LastModified { get; set; } [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public bool? FinishedIndexing { get; set; } public string FullPath { get { if (_fullPath != null) return _fullPath; _fullPath = string.Join("|", Paths); return _fullPath; } } public string Extension => Path.GetExtension(Paths.Last()); /// /// If this file is in an archive, return the Archive File, otherwise return null. /// public VirtualFile TopLevelArchive { get { if (Paths.Length == 0) return null; return VirtualFileSystem.VFS[Paths[0]]; } } public VirtualFile ParentArchive { get { if (ParentPath == null) return null; return VirtualFileSystem.VFS.Lookup(ParentPath); } } public bool IsArchive { get { if (_isArchive == null) _isArchive = FileExtractor.CanExtract(Extension); return (bool) _isArchive; } } public bool IsStaged { get { if (IsConcrete) return true; return _stagedPath != null; } } public string StagedPath { get { if (!IsStaged) throw new InvalidDataException("File is not staged"); if (IsConcrete) return Paths[0]; return _stagedPath; } set { if (IsStaged && value != null) throw new InvalidDataException("Can't change the path of a already staged file"); _stagedPath = value; } } /// /// Returns true if this file always exists on-disk, and doesn't need to be staged. /// public bool IsConcrete => Paths.Length == 1; public bool IsOutdated { get { if (IsStaged) { var fi = new FileInfo(StagedPath); if (fi.LastWriteTime.ToMilliseconds() != LastModified || fi.Length != Size) return true; if (IsArchive) if (!FinishedIndexing ?? true) return true; } return false; } } public string ParentPath { get { if (_parentPath == null && !IsConcrete) _parentPath = string.Join("|", Paths.Take(Paths.Length - 1)); return _parentPath; } } public IEnumerable FileInArchive => VirtualFileSystem.VFS.FilesInArchive(this); public IEnumerable FilesInPath { get { return Enumerable.Range(1, Paths.Length) .Select(i => Paths.Take(i)) .Select(path => VirtualFileSystem.VFS.Lookup(string.Join("|", path))); } } public FileStream OpenRead() { if (!IsStaged) throw new InvalidDataException("File is not staged, cannot open"); return File.OpenRead(_stagedPath); } /// /// Calculate the file's SHA, size and last modified /// internal void Analyze() { if (!IsStaged) throw new InvalidDataException("Cannot analyze an unstaged file"); var fio = new FileInfo(StagedPath); Size = fio.Length; Hash = StagedPath.FileSHA256(); LastModified = fio.LastWriteTime.ToMilliseconds(); } /// /// Delete the temoporary file associated with this file /// internal void Unstage() { if (IsStaged && !IsConcrete) { File.Delete(_stagedPath); _stagedPath = null; } } internal string GenerateStagedName() { if (_stagedPath != null) return _stagedPath; _stagedPath = Path.Combine(VirtualFileSystem._stagedRoot, Guid.NewGuid() + Path.GetExtension(Paths.Last())); return _stagedPath; } public string[] MakeRelativePaths() { var path_copy = (string[]) Paths.Clone(); path_copy[0] = VirtualFileSystem.VFS.Lookup(Paths[0]).Hash; return path_copy; } public void Write(BinaryWriter bw) { bw.Write(FullPath); bw.Write(Hash ?? ""); bw.Write(Size); bw.Write(LastModified); bw.Write(FinishedIndexing ?? false); } public static VirtualFile Read(BinaryReader rdr) { var vf = new VirtualFile(); var full_path = rdr.ReadString(); vf.Paths = full_path.Split('|'); for (var x = 0; x < vf.Paths.Length; x++) vf.Paths[x] = string.Intern(vf.Paths[x]); vf._fullPath = full_path; vf.Hash = rdr.ReadString(); if (vf.Hash == "") vf.Hash = null; vf.Size = rdr.ReadInt64(); vf.LastModified = rdr.ReadUInt64(); vf.FinishedIndexing = rdr.ReadBoolean(); if (vf.FinishedIndexing == false) vf.FinishedIndexing = null; return vf; } } }