/ src / Ryujinx.HLE / FileSystem / VirtualFileSystem.cs
VirtualFileSystem.cs
  1  using LibHac;
  2  using LibHac.Common;
  3  using LibHac.Common.Keys;
  4  using LibHac.Fs;
  5  using LibHac.Fs.Fsa;
  6  using LibHac.Fs.Shim;
  7  using LibHac.FsSrv;
  8  using LibHac.FsSystem;
  9  using LibHac.Ncm;
 10  using LibHac.Sdmmc;
 11  using LibHac.Spl;
 12  using LibHac.Tools.Es;
 13  using LibHac.Tools.Fs;
 14  using LibHac.Tools.FsSystem;
 15  using Ryujinx.Common.Configuration;
 16  using Ryujinx.Common.Logging;
 17  using Ryujinx.HLE.HOS;
 18  using System;
 19  using System.Buffers.Text;
 20  using System.Collections.Concurrent;
 21  using System.Collections.Generic;
 22  using System.IO;
 23  using System.Runtime.CompilerServices;
 24  using Path = System.IO.Path;
 25  
 26  namespace Ryujinx.HLE.FileSystem
 27  {
 28      public class VirtualFileSystem : IDisposable
 29      {
 30          public static readonly string SafeNandPath = Path.Combine(AppDataManager.DefaultNandDir, "safe");
 31          public static readonly string SystemNandPath = Path.Combine(AppDataManager.DefaultNandDir, "system");
 32          public static readonly string UserNandPath = Path.Combine(AppDataManager.DefaultNandDir, "user");
 33  
 34          public KeySet KeySet { get; private set; }
 35          public EmulatedGameCard GameCard { get; private set; }
 36          public SdmmcApi SdCard { get; private set; }
 37          public ModLoader ModLoader { get; private set; }
 38  
 39          private readonly ConcurrentDictionary<ulong, Stream> _romFsByPid;
 40  
 41          private static bool _isInitialized = false;
 42  
 43          public static VirtualFileSystem CreateInstance()
 44          {
 45              if (_isInitialized)
 46              {
 47                  throw new InvalidOperationException("VirtualFileSystem can only be instantiated once!");
 48              }
 49  
 50              _isInitialized = true;
 51  
 52              return new VirtualFileSystem();
 53          }
 54  
 55          private VirtualFileSystem()
 56          {
 57              ReloadKeySet();
 58              ModLoader = new ModLoader(); // Should only be created once
 59              _romFsByPid = new ConcurrentDictionary<ulong, Stream>();
 60          }
 61  
 62          public void LoadRomFs(ulong pid, string fileName)
 63          {
 64              var romfsStream = new FileStream(fileName, FileMode.Open, FileAccess.Read);
 65  
 66              _romFsByPid.AddOrUpdate(pid, romfsStream, (pid, oldStream) =>
 67              {
 68                  oldStream.Close();
 69  
 70                  return romfsStream;
 71              });
 72          }
 73  
 74          public void SetRomFs(ulong pid, Stream romfsStream)
 75          {
 76              _romFsByPid.AddOrUpdate(pid, romfsStream, (pid, oldStream) =>
 77              {
 78                  oldStream.Close();
 79  
 80                  return romfsStream;
 81              });
 82          }
 83  
 84          public Stream GetRomFs(ulong pid)
 85          {
 86              return _romFsByPid[pid];
 87          }
 88  
 89          public static string GetFullPath(string basePath, string fileName)
 90          {
 91              if (fileName.StartsWith("//"))
 92              {
 93                  fileName = fileName[2..];
 94              }
 95              else if (fileName.StartsWith('/'))
 96              {
 97                  fileName = fileName[1..];
 98              }
 99              else
100              {
101                  return null;
102              }
103  
104              string fullPath = Path.GetFullPath(Path.Combine(basePath, fileName));
105  
106              if (!fullPath.StartsWith(AppDataManager.BaseDirPath))
107              {
108                  return null;
109              }
110  
111              return fullPath;
112          }
113  
114          internal static string GetSdCardPath() => MakeFullPath(AppDataManager.DefaultSdcardDir);
115          public static string GetNandPath() => MakeFullPath(AppDataManager.DefaultNandDir);
116  
117          public static string SwitchPathToSystemPath(string switchPath)
118          {
119              string[] parts = switchPath.Split(":");
120  
121              if (parts.Length != 2)
122              {
123                  return null;
124              }
125  
126              return GetFullPath(MakeFullPath(parts[0]), parts[1]);
127          }
128  
129          public static string SystemPathToSwitchPath(string systemPath)
130          {
131              string baseSystemPath = AppDataManager.BaseDirPath + Path.DirectorySeparatorChar;
132  
133              if (systemPath.StartsWith(baseSystemPath))
134              {
135                  string rawPath = systemPath.Replace(baseSystemPath, "");
136                  int firstSeparatorOffset = rawPath.IndexOf(Path.DirectorySeparatorChar);
137  
138                  if (firstSeparatorOffset == -1)
139                  {
140                      return $"{rawPath}:/";
141                  }
142  
143                  var basePath = rawPath.AsSpan(0, firstSeparatorOffset);
144                  var fileName = rawPath.AsSpan(firstSeparatorOffset + 1);
145  
146                  return $"{basePath}:/{fileName}";
147              }
148  
149              return null;
150          }
151  
152          private static string MakeFullPath(string path, bool isDirectory = true)
153          {
154              // Handles Common Switch Content Paths
155              switch (path)
156              {
157                  case ContentPath.SdCard:
158                      path = AppDataManager.DefaultSdcardDir;
159                      break;
160                  case ContentPath.User:
161                      path = UserNandPath;
162                      break;
163                  case ContentPath.System:
164                      path = SystemNandPath;
165                      break;
166                  case ContentPath.SdCardContent:
167                      path = Path.Combine(AppDataManager.DefaultSdcardDir, "Nintendo", "Contents");
168                      break;
169                  case ContentPath.UserContent:
170                      path = Path.Combine(UserNandPath, "Contents");
171                      break;
172                  case ContentPath.SystemContent:
173                      path = Path.Combine(SystemNandPath, "Contents");
174                      break;
175              }
176  
177              string fullPath = Path.Combine(AppDataManager.BaseDirPath, path);
178  
179              if (isDirectory && !Directory.Exists(fullPath))
180              {
181                  Directory.CreateDirectory(fullPath);
182              }
183  
184              return fullPath;
185          }
186  
187          public void InitializeFsServer(LibHac.Horizon horizon, out HorizonClient fsServerClient)
188          {
189              LocalFileSystem serverBaseFs = new(useUnixTimeStamps: true);
190              Result result = serverBaseFs.Initialize(AppDataManager.BaseDirPath, LocalFileSystem.PathMode.DefaultCaseSensitivity, ensurePathExists: true);
191              if (result.IsFailure())
192              {
193                  throw new HorizonResultException(result, "Error creating LocalFileSystem.");
194              }
195  
196              fsServerClient = horizon.CreatePrivilegedHorizonClient();
197              var fsServer = new FileSystemServer(fsServerClient);
198  
199              RandomDataGenerator randomGenerator = Random.Shared.NextBytes;
200  
201              DefaultFsServerObjects fsServerObjects = DefaultFsServerObjects.GetDefaultEmulatedCreators(serverBaseFs, KeySet, fsServer, randomGenerator);
202  
203              // Use our own encrypted fs creator that doesn't actually do any encryption
204              fsServerObjects.FsCreators.EncryptedFileSystemCreator = new EncryptedFileSystemCreator();
205  
206              GameCard = fsServerObjects.GameCard;
207              SdCard = fsServerObjects.Sdmmc;
208  
209              SdCard.SetSdCardInserted(true);
210  
211              var fsServerConfig = new FileSystemServerConfig
212              {
213                  ExternalKeySet = KeySet.ExternalKeySet,
214                  FsCreators = fsServerObjects.FsCreators,
215                  StorageDeviceManagerFactory = fsServerObjects.StorageDeviceManagerFactory,
216                  RandomGenerator = randomGenerator,
217              };
218  
219              FileSystemServerInitializer.InitializeWithConfig(fsServerClient, fsServer, fsServerConfig);
220          }
221  
222          public void ReloadKeySet()
223          {
224              KeySet ??= KeySet.CreateDefaultKeySet();
225  
226              string keyFile = null;
227              string titleKeyFile = null;
228              string consoleKeyFile = null;
229  
230              if (AppDataManager.Mode == AppDataManager.LaunchMode.UserProfile)
231              {
232                  LoadSetAtPath(AppDataManager.KeysDirPathUser);
233              }
234  
235              LoadSetAtPath(AppDataManager.KeysDirPath);
236  
237              void LoadSetAtPath(string basePath)
238              {
239                  string localKeyFile = Path.Combine(basePath, "prod.keys");
240                  string localTitleKeyFile = Path.Combine(basePath, "title.keys");
241                  string localConsoleKeyFile = Path.Combine(basePath, "console.keys");
242  
243                  if (File.Exists(localKeyFile))
244                  {
245                      keyFile = localKeyFile;
246                  }
247  
248                  if (File.Exists(localTitleKeyFile))
249                  {
250                      titleKeyFile = localTitleKeyFile;
251                  }
252  
253                  if (File.Exists(localConsoleKeyFile))
254                  {
255                      consoleKeyFile = localConsoleKeyFile;
256                  }
257              }
258  
259              ExternalKeyReader.ReadKeyFile(KeySet, keyFile, titleKeyFile, consoleKeyFile, null);
260          }
261  
262          public void ImportTickets(IFileSystem fs)
263          {
264              foreach (DirectoryEntryEx ticketEntry in fs.EnumerateEntries("/", "*.tik"))
265              {
266                  using var ticketFile = new UniqueRef<IFile>();
267  
268                  Result result = fs.OpenFile(ref ticketFile.Ref, ticketEntry.FullPath.ToU8Span(), OpenMode.Read);
269  
270                  if (result.IsSuccess())
271                  {
272                      // When reading a file from a Sha256PartitionFileSystem, you can't start a read in the middle
273                      // of the hashed portion (usually the first 0x200 bytes) of the file and end the read after
274                      // the end of the hashed portion, so we read the ticket file using a single read.
275                      byte[] ticketData = new byte[0x2C0];
276                      result = ticketFile.Get.Read(out long bytesRead, 0, ticketData);
277  
278                      if (result.IsFailure() || bytesRead != ticketData.Length)
279                          continue;
280  
281                      Ticket ticket = new(new MemoryStream(ticketData));
282                      var titleKey = ticket.GetTitleKey(KeySet);
283  
284                      if (titleKey != null)
285                      {
286                          KeySet.ExternalKeySet.Add(new RightsId(ticket.RightsId), new AccessKey(titleKey));
287                      }
288                  }
289              }
290          }
291  
292          // Save data created before we supported extra data in directory save data will not work properly if
293          // given empty extra data. Luckily some of that extra data can be created using the data from the
294          // save data indexer, which should be enough to check access permissions for user saves.
295          // Every single save data's extra data will be checked and fixed if needed each time the emulator is opened.
296          // Consider removing this at some point in the future when we don't need to worry about old saves.
297          public static Result FixExtraData(HorizonClient hos)
298          {
299              Result rc = GetSystemSaveList(hos, out List<ulong> systemSaveIds);
300              if (rc.IsFailure())
301              {
302                  return rc;
303              }
304  
305              rc = FixUnindexedSystemSaves(hos, systemSaveIds);
306              if (rc.IsFailure())
307              {
308                  return rc;
309              }
310  
311              rc = FixExtraDataInSpaceId(hos, SaveDataSpaceId.System);
312              if (rc.IsFailure())
313              {
314                  return rc;
315              }
316  
317              rc = FixExtraDataInSpaceId(hos, SaveDataSpaceId.User);
318              if (rc.IsFailure())
319              {
320                  return rc;
321              }
322  
323              return Result.Success;
324          }
325  
326          private static Result FixExtraDataInSpaceId(HorizonClient hos, SaveDataSpaceId spaceId)
327          {
328              Span<SaveDataInfo> info = stackalloc SaveDataInfo[8];
329  
330              using var iterator = new UniqueRef<SaveDataIterator>();
331  
332              Result rc = hos.Fs.OpenSaveDataIterator(ref iterator.Ref, spaceId);
333              if (rc.IsFailure())
334              {
335                  return rc;
336              }
337  
338              while (true)
339              {
340                  rc = iterator.Get.ReadSaveDataInfo(out long count, info);
341                  if (rc.IsFailure())
342                  {
343                      return rc;
344                  }
345  
346                  if (count == 0)
347                  {
348                      return Result.Success;
349                  }
350  
351                  for (int i = 0; i < count; i++)
352                  {
353                      rc = FixExtraData(out bool wasFixNeeded, hos, in info[i]);
354  
355                      if (ResultFs.TargetNotFound.Includes(rc))
356                      {
357                          // If the save wasn't found, try to create the directory for its save data ID
358                          rc = CreateSaveDataDirectory(hos, in info[i]);
359  
360                          if (rc.IsFailure())
361                          {
362                              Logger.Warning?.Print(LogClass.Application, $"Error {rc.ToStringWithName()} when creating save data 0x{info[i].SaveDataId:x} in the {spaceId} save data space");
363  
364                              // Don't bother fixing the extra data if we couldn't create the directory
365                              continue;
366                          }
367  
368                          Logger.Info?.Print(LogClass.Application, $"Recreated directory for save data 0x{info[i].SaveDataId:x} in the {spaceId} save data space");
369  
370                          // Try to fix the extra data in the new directory
371                          rc = FixExtraData(out wasFixNeeded, hos, in info[i]);
372                      }
373  
374                      if (rc.IsFailure())
375                      {
376                          Logger.Warning?.Print(LogClass.Application, $"Error {rc.ToStringWithName()} when fixing extra data for save data 0x{info[i].SaveDataId:x} in the {spaceId} save data space");
377                      }
378                      else if (wasFixNeeded)
379                      {
380                          Logger.Info?.Print(LogClass.Application, $"Fixed extra data for save data 0x{info[i].SaveDataId:x} in the {spaceId} save data space");
381                      }
382                  }
383              }
384          }
385  
386          private static Result CreateSaveDataDirectory(HorizonClient hos, in SaveDataInfo info)
387          {
388              if (info.SpaceId != SaveDataSpaceId.User && info.SpaceId != SaveDataSpaceId.System)
389              {
390                  return Result.Success;
391              }
392  
393              const string MountName = "SaveDir";
394              var mountNameU8 = MountName.ToU8Span();
395  
396              BisPartitionId partitionId = info.SpaceId switch
397              {
398                  SaveDataSpaceId.System => BisPartitionId.System,
399                  SaveDataSpaceId.User => BisPartitionId.User,
400                  _ => throw new ArgumentOutOfRangeException(nameof(info), info.SpaceId, null),
401              };
402  
403              Result rc = hos.Fs.MountBis(mountNameU8, partitionId);
404              if (rc.IsFailure())
405              {
406                  return rc;
407              }
408  
409              try
410              {
411                  var path = $"{MountName}:/save/{info.SaveDataId:x16}".ToU8Span();
412  
413                  rc = hos.Fs.GetEntryType(out _, path);
414  
415                  if (ResultFs.PathNotFound.Includes(rc))
416                  {
417                      rc = hos.Fs.CreateDirectory(path);
418                  }
419  
420                  return rc;
421              }
422              finally
423              {
424                  hos.Fs.Unmount(mountNameU8);
425              }
426          }
427  
428          // Gets a list of all the save data files or directories in the system partition.
429          private static Result GetSystemSaveList(HorizonClient hos, out List<ulong> list)
430          {
431              list = null;
432  
433              var mountName = "system".ToU8Span();
434              DirectoryHandle handle = default;
435              List<ulong> localList = new();
436  
437              try
438              {
439                  Result rc = hos.Fs.MountBis(mountName, BisPartitionId.System);
440                  if (rc.IsFailure())
441                  {
442                      return rc;
443                  }
444  
445                  rc = hos.Fs.OpenDirectory(out handle, "system:/save".ToU8Span(), OpenDirectoryMode.All);
446                  if (rc.IsFailure())
447                  {
448                      return rc;
449                  }
450  
451                  DirectoryEntry entry = new();
452  
453                  while (true)
454                  {
455                      rc = hos.Fs.ReadDirectory(out long readCount, SpanHelpers.AsSpan(ref entry), handle);
456                      if (rc.IsFailure())
457                      {
458                          return rc;
459                      }
460  
461                      if (readCount == 0)
462                      {
463                          break;
464                      }
465  
466                      if (Utf8Parser.TryParse(entry.Name, out ulong saveDataId, out int bytesRead, 'x') && bytesRead == 16 && (long)saveDataId < 0)
467                      {
468                          localList.Add(saveDataId);
469                      }
470                  }
471  
472                  list = localList;
473  
474                  return Result.Success;
475              }
476              finally
477              {
478                  if (handle.IsValid)
479                  {
480                      hos.Fs.CloseDirectory(handle);
481                  }
482  
483                  if (hos.Fs.IsMounted(mountName))
484                  {
485                      hos.Fs.Unmount(mountName);
486                  }
487              }
488          }
489  
490          // Adds system save data that isn't in the save data indexer to the indexer and creates extra data for it.
491          // Only save data IDs added to SystemExtraDataFixInfo will be fixed.
492          private static Result FixUnindexedSystemSaves(HorizonClient hos, List<ulong> existingSaveIds)
493          {
494              foreach (var fixInfo in _systemExtraDataFixInfo)
495              {
496                  if (!existingSaveIds.Contains(fixInfo.StaticSaveDataId))
497                  {
498                      continue;
499                  }
500  
501                  Result rc = FixSystemExtraData(out bool wasFixNeeded, hos, in fixInfo);
502  
503                  if (rc.IsFailure())
504                  {
505                      Logger.Warning?.Print(LogClass.Application,
506                          $"Error {rc.ToStringWithName()} when fixing extra data for system save data 0x{fixInfo.StaticSaveDataId:x}");
507                  }
508                  else if (wasFixNeeded)
509                  {
510                      Logger.Info?.Print(LogClass.Application,
511                          $"Tried to rebuild extra data for system save data 0x{fixInfo.StaticSaveDataId:x}");
512                  }
513              }
514  
515              return Result.Success;
516          }
517  
518          private static Result FixSystemExtraData(out bool wasFixNeeded, HorizonClient hos, in ExtraDataFixInfo info)
519          {
520              wasFixNeeded = true;
521  
522              Result rc = hos.Fs.Impl.ReadSaveDataFileSystemExtraData(out SaveDataExtraData extraData, info.StaticSaveDataId);
523              if (!rc.IsSuccess())
524              {
525                  if (!ResultFs.TargetNotFound.Includes(rc))
526                  {
527                      return rc;
528                  }
529  
530                  // We'll reach this point only if the save data directory exists but it's not in the save data indexer.
531                  // Creating the save will add it to the indexer while leaving its existing contents intact.
532                  return hos.Fs.CreateSystemSaveData(info.StaticSaveDataId, UserId.InvalidId, info.OwnerId, info.DataSize,
533                      info.JournalSize, info.Flags);
534              }
535  
536              if (extraData.Attribute.StaticSaveDataId != 0 && extraData.OwnerId != 0)
537              {
538                  wasFixNeeded = false;
539                  return Result.Success;
540              }
541  
542              extraData = new SaveDataExtraData
543              {
544                  Attribute = { StaticSaveDataId = info.StaticSaveDataId },
545                  OwnerId = info.OwnerId,
546                  Flags = info.Flags,
547                  DataSize = info.DataSize,
548                  JournalSize = info.JournalSize,
549              };
550  
551              // Make a mask for writing the entire extra data
552              Unsafe.SkipInit(out SaveDataExtraData extraDataMask);
553              SpanHelpers.AsByteSpan(ref extraDataMask).Fill(0xFF);
554  
555              return hos.Fs.Impl.WriteSaveDataFileSystemExtraData(SaveDataSpaceId.System, info.StaticSaveDataId,
556                  in extraData, in extraDataMask);
557          }
558  
559          private static Result FixExtraData(out bool wasFixNeeded, HorizonClient hos, in SaveDataInfo info)
560          {
561              wasFixNeeded = true;
562  
563              Result rc = hos.Fs.Impl.ReadSaveDataFileSystemExtraData(out SaveDataExtraData extraData, info.SpaceId, info.SaveDataId);
564              if (rc.IsFailure())
565              {
566                  return rc;
567              }
568  
569              // The extra data should have program ID or static save data ID set if it's valid.
570              // We only try to fix the extra data if the info from the save data indexer has a program ID or static save data ID.
571              bool canFixByProgramId = extraData.Attribute.ProgramId == ProgramId.InvalidId &&
572                                         info.ProgramId != ProgramId.InvalidId;
573  
574              bool canFixBySaveDataId = extraData.Attribute.StaticSaveDataId == 0 && info.StaticSaveDataId != 0;
575  
576              bool hasEmptyOwnerId = extraData.OwnerId == 0 && info.Type != SaveDataType.System;
577  
578              if (!canFixByProgramId && !canFixBySaveDataId && !hasEmptyOwnerId)
579              {
580                  wasFixNeeded = false;
581                  return Result.Success;
582              }
583  
584              // The save data attribute struct can be completely created from the save data info.
585              extraData.Attribute.ProgramId = info.ProgramId;
586              extraData.Attribute.UserId = info.UserId;
587              extraData.Attribute.StaticSaveDataId = info.StaticSaveDataId;
588              extraData.Attribute.Type = info.Type;
589              extraData.Attribute.Rank = info.Rank;
590              extraData.Attribute.Index = info.Index;
591  
592              // The rest of the extra data can't be created from the save data info.
593              // On user saves the owner ID will almost certainly be the same as the program ID.
594              if (info.Type != SaveDataType.System)
595              {
596                  extraData.OwnerId = info.ProgramId.Value;
597              }
598              else
599              {
600                  // Try to match the system save with one of the known saves
601                  foreach (ExtraDataFixInfo fixInfo in _systemExtraDataFixInfo)
602                  {
603                      if (extraData.Attribute.StaticSaveDataId == fixInfo.StaticSaveDataId)
604                      {
605                          extraData.OwnerId = fixInfo.OwnerId;
606                          extraData.Flags = fixInfo.Flags;
607                          extraData.DataSize = fixInfo.DataSize;
608                          extraData.JournalSize = fixInfo.JournalSize;
609  
610                          break;
611                      }
612                  }
613              }
614  
615              // Make a mask for writing the entire extra data
616              Unsafe.SkipInit(out SaveDataExtraData extraDataMask);
617              SpanHelpers.AsByteSpan(ref extraDataMask).Fill(0xFF);
618  
619              return hos.Fs.Impl.WriteSaveDataFileSystemExtraData(info.SpaceId, info.SaveDataId, in extraData, in extraDataMask);
620          }
621  
622          struct ExtraDataFixInfo
623          {
624              public ulong StaticSaveDataId;
625              public ulong OwnerId;
626              public SaveDataFlags Flags;
627              public long DataSize;
628              public long JournalSize;
629          }
630  
631          private static readonly ExtraDataFixInfo[] _systemExtraDataFixInfo =
632          {
633              new ExtraDataFixInfo()
634              {
635                  StaticSaveDataId = 0x8000000000000030,
636                  OwnerId = 0x010000000000001F,
637                  Flags = SaveDataFlags.KeepAfterResettingSystemSaveDataWithoutUserSaveData,
638                  DataSize = 0x10000,
639                  JournalSize = 0x10000,
640              },
641              new ExtraDataFixInfo()
642              {
643                  StaticSaveDataId = 0x8000000000001040,
644                  OwnerId = 0x0100000000001009,
645                  Flags = SaveDataFlags.None,
646                  DataSize = 0xC000,
647                  JournalSize = 0xC000,
648              },
649          };
650  
651          public void Dispose()
652          {
653              GC.SuppressFinalize(this);
654              Dispose(true);
655          }
656  
657          protected virtual void Dispose(bool disposing)
658          {
659              if (disposing)
660              {
661                  foreach (var stream in _romFsByPid.Values)
662                  {
663                      stream.Close();
664                  }
665  
666                  _romFsByPid.Clear();
667              }
668          }
669      }
670  }