diff --git a/pkNX.Containers/Archives/BaseArchive.cs b/pkNX.Containers/Archives/BaseArchive.cs
new file mode 100644
index 00000000..419fa229
--- /dev/null
+++ b/pkNX.Containers/Archives/BaseArchive.cs
@@ -0,0 +1,461 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+
+namespace pkNX.IO.Archives;
+
+public interface IArchive
+{
+ ///
+ /// The ArchiveMode that the archive was initialized with.
+ ///
+ ArchiveOpenMode OpenMode { get; }
+
+ BaseArchiveEntry CreateEntry(string entryName, int compressionLevel = 9);
+}
+
+public class BaseArchive : IDisposable
+{
+ private ArchiveStreamWrapper ArchiveStreamWrapper { get; }
+
+ ///
+ /// The ArchiveMode that the archive was initialized with.
+ ///
+ public ArchiveOpenMode OpenMode { get; }
+
+ private readonly List _entries = new();
+ private readonly ReadOnlyCollection _entriesCollection;
+ private readonly Dictionary _entriesDictionary = new();
+
+ private BaseArchiveEntry? _archiveStreamOwner;
+ private bool _readEntries;
+ private readonly bool _leaveOpen;
+ private long _centralDirectoryStart; //only valid after ReadCentralDirectory
+ private bool _isDisposed;
+ private uint _numberOfThisDisk; //only valid after ReadCentralDirectory
+ private long _expectedNumberOfEntries;
+
+ internal BinaryReader? ArchiveReader { get; }
+
+ internal Stream ArchiveStream => ArchiveStreamWrapper.Stream;
+ internal uint NumberOfThisDisk => _numberOfThisDisk;
+
+ ///
+ /// Initializes a new instance of archive on the given stream in the specified mode, specifying whether to leave the stream open.
+ ///
+ /// The stream is already closed. -or- mode is incompatible with the capabilities of the stream.
+ /// The stream is null.
+ /// mode specified an invalid value.
+ /// The contents of the stream could not be interpreted as a Zip file. -or- mode is Update and an entry is missing from the archive or is corrupt and cannot be read. -or- mode is Update and an entry is too large to fit into memory.
+ /// The input or output stream.
+ /// See the description of the ArchiveMode enum. Read requires the stream to support reading, Create requires the stream to support writing, and Update requires the stream to support reading, writing, and seeking.
+ /// true to leave the stream open upon disposing the archive, otherwise false.
+ public BaseArchive(Stream stream, ArchiveOpenMode openMode = ArchiveOpenMode.Read, bool leaveOpen = false)
+ {
+ ArchiveStreamWrapper = new ArchiveStreamWrapper(stream, openMode, leaveOpen);
+
+ OpenMode = openMode;
+ _entriesCollection = new(_entries);
+ _leaveOpen = leaveOpen;
+
+ switch (openMode)
+ {
+ case ArchiveOpenMode.Create:
+ _readEntries = true;
+ break;
+ case ArchiveOpenMode.Read:
+ ArchiveReader = new BinaryReader(ArchiveStream);
+
+ ReadEndOfCentralDirectory();
+ break;
+ case ArchiveOpenMode.Update:
+ default:
+ ArchiveReader = new BinaryReader(ArchiveStream);
+
+ if (ArchiveStream.Length == 0)
+ {
+ _readEntries = true;
+ }
+ else
+ {
+ ReadEndOfCentralDirectory();
+ EnsureCentralDirectoryRead();
+
+ foreach (BaseArchiveEntry entry in _entries)
+ entry.ThrowIfNotOpenable(false, true);
+ }
+ break;
+ }
+ }
+
+ ///
+ /// The collection of entries that are currently in the archive. This may not accurately represent the actual entries that are present in the underlying file or stream.
+ ///
+ /// The archive does not support reading.
+ /// The archive has already been closed.
+ /// The Zip archive is corrupt and the entries cannot be retrieved.
+ public ReadOnlyCollection Entries
+ {
+ get
+ {
+ if (OpenMode == ArchiveOpenMode.Create)
+ throw new NotSupportedException("Cannot read archive entries when archive is opened in create mode.");
+
+ ThrowIfDisposed();
+
+ EnsureCentralDirectoryRead();
+ return _entriesCollection;
+ }
+ }
+
+ ///
+ /// Creates an empty entry in the Zip archive with the specified entry name. There are no restrictions on the names of entries. The last write time of the entry is set to the current time. If an entry with the specified name already exists in the archive, a second entry will be created that has an identical name.
+ ///
+ /// entryName is a zero-length string.
+ /// entryName is null.
+ /// The archive does not support writing.
+ /// The archive has already been closed.
+ /// A path relative to the root of the archive, indicating the name of the entry to be created.
+ /// The level of the compression (speed/memory vs. compressed size trade-off).
+ /// A wrapper for the newly created file entry in the archive.
+ public BaseArchiveEntry CreateEntry(string entryName, int compressionLevel = 9)
+ {
+ if (string.IsNullOrEmpty(entryName))
+ throw new ArgumentException(SR.CannotBeEmpty, nameof(entryName));
+
+ if (OpenMode == ArchiveOpenMode.Read)
+ throw new NotSupportedException("Cannot create new archive entries when archive is opened in read mode.");
+
+ ThrowIfDisposed();
+
+
+ BaseArchiveEntry entry = new BaseArchiveEntry(this, entryName, compressionLevel);
+
+ AddEntry(entry);
+
+ return entry;
+ }
+
+ ///
+ /// Retrieves a wrapper for the file entry in the archive with the specified name. Names are compared using ordinal comparison. If there are multiple entries in the archive with the specified name, the first one found will be returned.
+ ///
+ /// entryName is a zero-length string.
+ /// entryName is null.
+ /// The archive does not support reading.
+ /// The archive has already been closed.
+ /// The Zip archive is corrupt and the entries cannot be retrieved.
+ /// A path relative to the root of the archive, identifying the desired entry.
+ /// A wrapper for the file entry in the archive. If no entry in the archive exists with the specified name, null will be returned.
+ public BaseArchiveEntry? GetEntry(string entryName)
+ {
+ ArgumentNullException.ThrowIfNull(entryName);
+
+ if (OpenMode == ArchiveOpenMode.Create)
+ throw new NotSupportedException("Cannot read archive entries when archive is opened in create mode.");
+
+ EnsureCentralDirectoryRead();
+ _entriesDictionary.TryGetValue(entryName, out BaseArchiveEntry? result);
+ return result;
+ }
+
+ private void AddEntry(BaseArchiveEntry entry)
+ {
+ _entries.Add(entry);
+ _entriesDictionary.TryAdd(entry.FullName, entry);
+ }
+
+ internal void RemoveEntry(BaseArchiveEntry entry)
+ {
+ _entries.Remove(entry);
+ _entriesDictionary.Remove(entry.FullName);
+ }
+
+
+ [Conditional("DEBUG")]
+ internal void DebugAssertIsStillArchiveStreamOwner(BaseArchiveEntry entry) => Debug.Assert(_archiveStreamOwner == entry);
+
+ internal void AcquireArchiveStream(BaseArchiveEntry entry)
+ {
+ // if a previous entry had held the stream but never wrote anything, we write their local header for them
+ if (_archiveStreamOwner != null)
+ {
+ if (!_archiveStreamOwner.EverOpenedForWrite)
+ {
+ _archiveStreamOwner.WriteAndFinishLocalEntry();
+ }
+ else
+ {
+ throw new IOException(SR.CreateModeCreateEntryWhileOpen);
+ }
+ }
+
+ _archiveStreamOwner = entry;
+ }
+
+ internal void ReleaseArchiveStream(BaseArchiveEntry entry)
+ {
+ Debug.Assert(_archiveStreamOwner == entry);
+
+ _archiveStreamOwner = null;
+ }
+
+ internal void ThrowIfDisposed()
+ {
+ ObjectDisposedException.ThrowIf(_isDisposed, this);
+ }
+
+ private void EnsureCentralDirectoryRead()
+ {
+ if (!_readEntries)
+ {
+ ReadCentralDirectory();
+ _readEntries = true;
+ }
+ }
+
+ private void ReadCentralDirectory()
+ {
+ try
+ {
+ // assume ReadEndOfCentralDirectory has been called and has populated _centralDirectoryStart
+
+ ArchiveStream.Seek(_centralDirectoryStart, SeekOrigin.Begin);
+
+ long numberOfEntries = 0;
+
+ Debug.Assert(ArchiveReader != null);
+ //read the central directory
+ ZipCentralDirectoryFileHeader currentHeader;
+ bool saveExtraFieldsAndComments = OpenMode == ArchiveOpenMode.Update;
+ while (ZipCentralDirectoryFileHeader.TryReadBlock(ArchiveReader,
+ saveExtraFieldsAndComments, out currentHeader))
+ {
+ AddEntry(new BaseArchiveEntry(this, currentHeader));
+ numberOfEntries++;
+ }
+
+ if (numberOfEntries != _expectedNumberOfEntries)
+ throw new InvalidDataException(SR.NumEntriesWrong);
+ }
+ catch (EndOfStreamException ex)
+ {
+ throw new InvalidDataException(SR.Format(SR.CentralDirectoryInvalid, ex));
+ }
+ }
+
+ // This function reads all the EOCD stuff it needs to find the offset to the start of the central directory
+ // This offset gets put in _centralDirectoryStart and the number of this disk gets put in _numberOfThisDisk
+ // Also does some verification that this isn't a split/spanned archive
+ // Also checks that offset to CD isn't out of bounds
+ private void ReadEndOfCentralDirectory()
+ {
+ try
+ {
+ // This seeks backwards almost to the beginning of the EOCD, one byte after where the signature would be
+ // located if the EOCD had the minimum possible size (no file zip comment)
+ ArchiveStream.Seek(-ZipEndOfCentralDirectoryBlock.SizeOfBlockWithoutSignature, SeekOrigin.End);
+
+ // If the EOCD has the minimum possible size (no zip file comment), then exactly the previous 4 bytes will contain the signature
+ // But if the EOCD has max possible size, the signature should be found somewhere in the previous 64K + 4 bytes
+ if (!ZipHelper.SeekBackwardsToSignature(ArchiveStream,
+ ZipEndOfCentralDirectoryBlock.SignatureConstant,
+ ZipEndOfCentralDirectoryBlock.ZipFileCommentMaxLength + ZipEndOfCentralDirectoryBlock.SignatureSize))
+ throw new InvalidDataException(SR.EOCDNotFound);
+
+ long eocdStart = ArchiveStream.Position;
+
+ Debug.Assert(ArchiveReader != null);
+ // read the EOCD
+ ZipEndOfCentralDirectoryBlock eocd;
+ bool eocdProper = ZipEndOfCentralDirectoryBlock.TryReadBlock(ArchiveReader, out eocd);
+ Debug.Assert(eocdProper); // we just found this using the signature finder, so it should be okay
+
+ if (eocd.NumberOfThisDisk != eocd.NumberOfTheDiskWithTheStartOfTheCentralDirectory)
+ throw new InvalidDataException(SR.SplitSpanned);
+
+ _numberOfThisDisk = eocd.NumberOfThisDisk;
+ _centralDirectoryStart = eocd.OffsetOfStartOfCentralDirectoryWithRespectToTheStartingDiskNumber;
+
+ if (eocd.NumberOfEntriesInTheCentralDirectory != eocd.NumberOfEntriesInTheCentralDirectoryOnThisDisk)
+ throw new InvalidDataException(SR.SplitSpanned);
+
+ _expectedNumberOfEntries = eocd.NumberOfEntriesInTheCentralDirectory;
+
+ _archiveComment = eocd.ArchiveComment;
+
+ TryReadZip64EndOfCentralDirectory(eocd, eocdStart);
+
+ if (_centralDirectoryStart > ArchiveStream.Length)
+ {
+ throw new InvalidDataException(SR.FieldTooBigOffsetToCD);
+ }
+ }
+ catch (EndOfStreamException ex)
+ {
+ throw new InvalidDataException(SR.CDCorrupt, ex);
+ }
+ catch (IOException ex)
+ {
+ throw new InvalidDataException(SR.CDCorrupt, ex);
+ }
+ }
+
+ // Tries to find the Zip64 End of Central Directory Locator, then the Zip64 End of Central Directory, assuming the
+ // End of Central Directory block has already been found, as well as the location in the stream where the EOCD starts.
+ private void TryReadZip64EndOfCentralDirectory(ZipEndOfCentralDirectoryBlock eocd, long eocdStart)
+ {
+ // Only bother looking for the Zip64-EOCD stuff if we suspect it is needed because some value is FFFFFFFFF
+ // because these are the only two values we need, we only worry about these
+ // if we don't find the Zip64-EOCD, we just give up and try to use the original values
+ if (eocd.NumberOfThisDisk == ZipHelper.Mask16Bit ||
+ eocd.OffsetOfStartOfCentralDirectoryWithRespectToTheStartingDiskNumber == ZipHelper.Mask32Bit ||
+ eocd.NumberOfEntriesInTheCentralDirectory == ZipHelper.Mask16Bit)
+ {
+ // Read Zip64 End of Central Directory Locator
+
+ // This seeks forwards almost to the beginning of the Zip64-EOCDL, one byte after where the signature would be located
+ ArchiveStream.Seek(eocdStart - Zip64EndOfCentralDirectoryLocator.SizeOfBlockWithoutSignature, SeekOrigin.Begin);
+
+ // Exactly the previous 4 bytes should contain the Zip64-EOCDL signature
+ // if we don't find it, assume it doesn't exist and use data from normal EOCD
+ if (ZipHelper.SeekBackwardsToSignature(ArchiveStream,
+ Zip64EndOfCentralDirectoryLocator.SignatureConstant,
+ Zip64EndOfCentralDirectoryLocator.SignatureSize))
+ {
+ Debug.Assert(ArchiveReader != null);
+
+ // use locator to get to Zip64-EOCD
+ Zip64EndOfCentralDirectoryLocator locator;
+ bool zip64eocdLocatorProper = Zip64EndOfCentralDirectoryLocator.TryReadBlock(ArchiveReader, out locator);
+ Debug.Assert(zip64eocdLocatorProper); // we just found this using the signature finder, so it should be okay
+
+ if (locator.OffsetOfZip64EOCD > long.MaxValue)
+ throw new InvalidDataException(SR.FieldTooBigOffsetToZip64EOCD);
+
+ long zip64EOCDOffset = (long)locator.OffsetOfZip64EOCD;
+
+ ArchiveStream.Seek(zip64EOCDOffset, SeekOrigin.Begin);
+
+ // Read Zip64 End of Central Directory Record
+
+ Zip64EndOfCentralDirectoryRecord record;
+ if (!Zip64EndOfCentralDirectoryRecord.TryReadBlock(ArchiveReader, out record))
+ throw new InvalidDataException(SR.Zip64EOCDNotWhereExpected);
+
+ _numberOfThisDisk = record.NumberOfThisDisk;
+
+ if (record.NumberOfEntriesTotal > long.MaxValue)
+ throw new InvalidDataException(SR.FieldTooBigNumEntries);
+
+ if (record.OffsetOfCentralDirectory > long.MaxValue)
+ throw new InvalidDataException(SR.FieldTooBigOffsetToCD);
+
+ if (record.NumberOfEntriesTotal != record.NumberOfEntriesOnThisDisk)
+ throw new InvalidDataException(SR.SplitSpanned);
+
+ _expectedNumberOfEntries = (long)record.NumberOfEntriesTotal;
+ _centralDirectoryStart = (long)record.OffsetOfCentralDirectory;
+ }
+ }
+ }
+
+ private void WriteFile()
+ {
+ // if we are in create mode, we always set readEntries to true in Init
+ // if we are in update mode, we call EnsureCentralDirectoryRead, which sets readEntries to true
+ Debug.Assert(_readEntries);
+
+ if (OpenMode == ArchiveOpenMode.Update)
+ {
+ List markedForDelete = new List();
+ foreach (BaseArchiveEntry entry in _entries)
+ {
+ if (!entry.LoadLocalHeaderExtraFieldAndCompressedBytesIfNeeded())
+ markedForDelete.Add(entry);
+ }
+ foreach (BaseArchiveEntry entry in markedForDelete)
+ entry.Delete();
+
+ ArchiveStream.Seek(0, SeekOrigin.Begin);
+ ArchiveStream.SetLength(0);
+ }
+
+ foreach (BaseArchiveEntry entry in _entries)
+ {
+ entry.WriteAndFinishLocalEntry();
+ }
+
+ long startOfCentralDirectory = ArchiveStream.Position;
+
+ foreach (BaseArchiveEntry entry in _entries)
+ {
+ entry.WriteCentralDirectoryFileHeader();
+ }
+
+ long sizeOfCentralDirectory = ArchiveStream.Position - startOfCentralDirectory;
+
+ WriteArchiveEpilogue(startOfCentralDirectory, sizeOfCentralDirectory);
+ }
+
+ // writes eocd, and if needed, zip 64 eocd, zip64 eocd locator
+ // should only throw an exception in extremely exceptional cases because it is called from dispose
+ private void WriteArchiveEpilogue(long startOfCentralDirectory, long sizeOfCentralDirectory)
+ {
+ // determine if we need Zip 64
+ if (startOfCentralDirectory >= uint.MaxValue
+ || sizeOfCentralDirectory >= uint.MaxValue
+ || _entries.Count >= ZipHelper.Mask16Bit
+#if DEBUG_FORCE_ZIP64
+ || _forceZip64
+#endif
+ )
+ {
+ // if we need zip 64, write zip 64 eocd and locator
+ long zip64EOCDRecordStart = ArchiveStream.Position;
+ Zip64EndOfCentralDirectoryRecord.WriteBlock(ArchiveStream, _entries.Count, startOfCentralDirectory, sizeOfCentralDirectory);
+ Zip64EndOfCentralDirectoryLocator.WriteBlock(ArchiveStream, zip64EOCDRecordStart);
+ }
+
+ // write normal eocd
+ ZipEndOfCentralDirectoryBlock.WriteBlock(ArchiveStream, _entries.Count, startOfCentralDirectory, sizeOfCentralDirectory, _archiveComment);
+ }
+
+ ///
+ /// Finishes writing the archive and releases all resources used by the archive object, unless the object was constructed with leaveOpen as true. Any streams from opened entries in the archive still open will throw exceptions on subsequent writes, as the underlying streams will have been closed.
+ ///
+ public void Dispose()
+ {
+ try
+ {
+ switch (OpenMode)
+ {
+ case ArchiveOpenMode.Read:
+ break;
+ case ArchiveOpenMode.Create:
+ case ArchiveOpenMode.Update:
+ default:
+ Debug.Assert(OpenMode is ArchiveOpenMode.Update or ArchiveOpenMode.Create);
+ WriteFile();
+ break;
+ }
+ }
+ finally
+ {
+ CloseStreams();
+ _isDisposed = true;
+ }
+ GC.SuppressFinalize(this);
+ }
+
+ private void CloseStreams()
+ {
+ if (!_leaveOpen)
+ ArchiveReader?.Dispose();
+
+ ArchiveStreamWrapper.Dispose();
+ }
+}
diff --git a/pkNX.Containers/Archives/BaseArchiveEntry.cs b/pkNX.Containers/Archives/BaseArchiveEntry.cs
new file mode 100644
index 00000000..17855b87
--- /dev/null
+++ b/pkNX.Containers/Archives/BaseArchiveEntry.cs
@@ -0,0 +1,1309 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics;
+using System.IO;
+using System.IO.Compression;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+using static System.IO.Compression.ZipArchiveEntryConstants;
+
+namespace pkNX.IO.Archives;
+
+// The disposable fields that this class owns get disposed when the ZipArchive it belongs to gets disposed
+public partial class BaseArchiveEntry
+{
+ private BaseArchive _archive;
+ private readonly bool _originallyInArchive;
+ private readonly int _diskNumberStart;
+ private readonly ZipVersionMadeByPlatform _versionMadeByPlatform;
+ private ZipVersionNeededValues _versionMadeBySpecification;
+ internal ZipVersionNeededValues _versionToExtract;
+ private BitFlagValues _generalPurposeBitFlag;
+ private bool _isEncrypted;
+ private CompressionMethodValues _storedCompressionMethod;
+ private DateTimeOffset _lastModified;
+ private long _compressedSize;
+ private long _uncompressedSize;
+ private long _offsetOfLocalHeader;
+ private long? _storedOffsetOfCompressedData;
+ private uint _crc32;
+ // An array of buffers, each a maximum of MaxSingleBufferSize in size
+ private byte[][]? _compressedBytes;
+ private MemoryStream? _storedUncompressedData;
+ private bool _currentlyOpenForWrite;
+ private bool _everOpenedForWrite;
+ private Stream? _outstandingWriteStream;
+ private uint _externalFileAttr;
+ private string _storedEntryName;
+ private byte[] _storedEntryNameBytes;
+ // only apply to update mode
+ private List? _cdUnknownExtraFields;
+ private List? _lhUnknownExtraFields;
+ private byte[] _fileComment;
+ private readonly CompressionLevel? _compressionLevel;
+
+ // Initializes a ZipArchiveEntry instance for an existing archive entry.
+ internal BaseArchiveEntry(BaseArchive archive, ZipCentralDirectoryFileHeader cd)
+ {
+ _archive = archive;
+
+ _originallyInArchive = true;
+
+ _diskNumberStart = cd.DiskNumberStart;
+ _versionMadeByPlatform = (ZipVersionMadeByPlatform)cd.VersionMadeByCompatibility;
+ _versionMadeBySpecification = (ZipVersionNeededValues)cd.VersionMadeBySpecification;
+ _versionToExtract = (ZipVersionNeededValues)cd.VersionNeededToExtract;
+ _generalPurposeBitFlag = (BitFlagValues)cd.GeneralPurposeBitFlag;
+ _isEncrypted = (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0;
+ CompressionMethod = (CompressionMethodValues)cd.CompressionMethod;
+ _lastModified = new DateTimeOffset(ZipHelper.DosTimeToDateTime(cd.LastModified));
+ _compressedSize = cd.CompressedSize;
+ _uncompressedSize = cd.UncompressedSize;
+ _externalFileAttr = cd.ExternalFileAttributes;
+ _offsetOfLocalHeader = cd.RelativeOffsetOfLocalHeader;
+ // we don't know this yet: should be _offsetOfLocalHeader + 30 + _storedEntryNameBytes.Length + extrafieldlength
+ // but entryname/extra length could be different in LH
+ _storedOffsetOfCompressedData = null;
+ _crc32 = cd.Crc32;
+
+ _compressedBytes = null;
+ _storedUncompressedData = null;
+ _currentlyOpenForWrite = false;
+ _everOpenedForWrite = false;
+ _outstandingWriteStream = null;
+
+ _storedEntryNameBytes = cd.Filename;
+ _storedEntryName = (_archive.EntryNameAndCommentEncoding ?? Encoding.UTF8).GetString(_storedEntryNameBytes);
+ DetectEntryNameVersion();
+
+ _lhUnknownExtraFields = null;
+ // the cd should have this as null if we aren't in Update mode
+ _cdUnknownExtraFields = cd.ExtraFields;
+
+ _fileComment = cd.FileComment;
+
+ _compressionLevel = null;
+ }
+
+ // Initializes a ZipArchiveEntry instance for a new archive entry with a specified compression level.
+ internal BaseArchiveEntry(BaseArchive archive, string entryName, CompressionLevel compressionLevel)
+ : this(archive, entryName)
+ {
+ _compressionLevel = compressionLevel;
+ if (_compressionLevel == CompressionLevel.NoCompression)
+ {
+ CompressionMethod = CompressionMethodValues.Stored;
+ }
+ }
+
+ // Initializes a ZipArchiveEntry instance for a new archive entry.
+ internal BaseArchiveEntry(BaseArchive archive, string entryName)
+ {
+ _archive = archive;
+
+ _originallyInArchive = false;
+
+ _diskNumberStart = 0;
+ _versionMadeByPlatform = CurrentZipPlatform;
+ _versionMadeBySpecification = ZipVersionNeededValues.Default;
+ _versionToExtract = ZipVersionNeededValues.Default; // this must happen before following two assignment
+ _generalPurposeBitFlag = 0;
+ CompressionMethod = CompressionMethodValues.Deflate;
+ _lastModified = DateTimeOffset.Now;
+
+ _compressedSize = 0; // we don't know these yet
+ _uncompressedSize = 0;
+ UnixFileMode defaultEntryPermissions = entryName.EndsWith(Path.DirectorySeparatorChar) || entryName.EndsWith(Path.AltDirectorySeparatorChar)
+ ? DefaultDirectoryEntryPermissions
+ : DefaultFileEntryPermissions;
+ _externalFileAttr = (uint)(defaultEntryPermissions) << 16;
+
+ _offsetOfLocalHeader = 0;
+ _storedOffsetOfCompressedData = null;
+ _crc32 = 0;
+
+ _compressedBytes = null;
+ _storedUncompressedData = null;
+ _currentlyOpenForWrite = false;
+ _everOpenedForWrite = false;
+ _outstandingWriteStream = null;
+
+ FullName = entryName;
+
+ _cdUnknownExtraFields = null;
+ _lhUnknownExtraFields = null;
+
+ _fileComment = Array.Empty();
+
+ _compressionLevel = null;
+
+ if (_storedEntryNameBytes.Length > ushort.MaxValue)
+ throw new ArgumentException(SR.EntryNamesTooLong);
+
+ // grab the stream if we're in create mode
+ if (_archive.OpenMode == ArchiveOpenMode.Create)
+ {
+ _archive.AcquireArchiveStream(this);
+ }
+ }
+
+ ///
+ /// The ZipArchive that this entry belongs to. If this entry has been deleted, this will return null.
+ ///
+ public BaseArchive Archive => _archive;
+
+ [CLSCompliant(false)]
+ public uint Crc32 => _crc32;
+
+ ///
+ /// Gets a value that indicates whether the entry is encrypted.
+ ///
+ public bool IsEncrypted => _isEncrypted;
+
+ ///
+ /// The compressed size of the entry. If the archive that the entry belongs to is in Create mode, attempts to get this property will always throw an exception. If the archive that the entry belongs to is in update mode, this property will only be valid if the entry has not been opened.
+ ///
+ /// This property is not available because the entry has been written to or modified.
+ public long CompressedLength
+ {
+ get
+ {
+ if (_everOpenedForWrite)
+ throw new InvalidOperationException(SR.LengthAfterWrite);
+ return _compressedSize;
+ }
+ }
+
+ public int ExternalAttributes
+ {
+ get
+ {
+ return (int)_externalFileAttr;
+ }
+ set
+ {
+ ThrowIfInvalidArchive();
+ _externalFileAttr = (uint)value;
+ }
+ }
+
+ ///
+ /// Gets or sets the optional entry comment.
+ ///
+ ///
+ ///The comment encoding is determined by the entryNameEncoding parameter of the constructor.
+ /// If the comment byte length is larger than , it will be truncated when disposing the archive.
+ ///
+ [AllowNull]
+ public string Comment
+ {
+ get => (_archive.EntryNameAndCommentEncoding ?? Encoding.UTF8).GetString(_fileComment);
+ set
+ {
+ _fileComment = ZipHelper.GetEncodedTruncatedBytesFromString(value, _archive.EntryNameAndCommentEncoding, ushort.MaxValue, out bool isUTF8);
+
+ if (isUTF8)
+ {
+ _generalPurposeBitFlag |= BitFlagValues.UnicodeFileNameAndComment;
+ }
+ }
+ }
+
+ ///
+ /// The relative path of the entry as stored in the Zip archive. Note that Zip archives allow any string to be the path of the entry, including invalid and absolute paths.
+ ///
+ public string FullName
+ {
+ get
+ {
+ return _storedEntryName;
+ }
+
+ [MemberNotNull(nameof(_storedEntryNameBytes))]
+ [MemberNotNull(nameof(_storedEntryName))]
+ private set
+ {
+ ArgumentNullException.ThrowIfNull(value, nameof(FullName));
+
+ _storedEntryNameBytes = ZipHelper.GetEncodedTruncatedBytesFromString(
+ value, _archive.EntryNameAndCommentEncoding, 0 /* No truncation */, out bool isUTF8);
+
+ _storedEntryName = value;
+
+ if (isUTF8)
+ {
+ _generalPurposeBitFlag |= BitFlagValues.UnicodeFileNameAndComment;
+ }
+ else
+ {
+ _generalPurposeBitFlag &= ~BitFlagValues.UnicodeFileNameAndComment;
+ }
+
+ DetectEntryNameVersion();
+ }
+ }
+
+ ///
+ /// The last write time of the entry as stored in the Zip archive. When setting this property, the DateTime will be converted to the
+ /// Zip timestamp format, which supports a resolution of two seconds. If the data in the last write time field is not a valid Zip timestamp,
+ /// an indicator value of 1980 January 1 at midnight will be returned.
+ ///
+ /// An attempt to set this property was made, but the ZipArchive that this entry belongs to was
+ /// opened in read-only mode.
+ /// An attempt was made to set this property to a value that cannot be represented in the
+ /// Zip timestamp format. The earliest date/time that can be represented is 1980 January 1 0:00:00 (midnight), and the last date/time
+ /// that can be represented is 2107 December 31 23:59:58 (one second before midnight).
+ public DateTimeOffset LastWriteTime
+ {
+ get
+ {
+ return _lastModified;
+ }
+ set
+ {
+ ThrowIfInvalidArchive();
+ if (_archive.OpenMode == ArchiveOpenMode.Read)
+ throw new NotSupportedException(SR.ReadOnlyArchive);
+ if (_archive.OpenMode == ArchiveOpenMode.Create && _everOpenedForWrite)
+ throw new IOException(SR.FrozenAfterWrite);
+ if (value.DateTime.Year < ZipHelper.ValidZipDate_YearMin || value.DateTime.Year > ZipHelper.ValidZipDate_YearMax)
+ throw new ArgumentOutOfRangeException(nameof(value), SR.DateTimeOutOfRange);
+
+ _lastModified = value;
+ }
+ }
+
+ ///
+ /// The uncompressed size of the entry. This property is not valid in Create mode, and it is only valid in Update mode if the entry has not been opened.
+ ///
+ /// This property is not available because the entry has been written to or modified.
+ public long Length
+ {
+ get
+ {
+ if (_everOpenedForWrite)
+ throw new InvalidOperationException(SR.LengthAfterWrite);
+ return _uncompressedSize;
+ }
+ }
+
+ ///
+ /// The filename of the entry. This is equivalent to the substring of Fullname that follows the final directory separator character.
+ ///
+ public string Name => ParseFileName(FullName, _versionMadeByPlatform);
+
+ ///
+ /// Deletes the entry from the archive.
+ ///
+ /// The entry is already open for reading or writing.
+ /// The ZipArchive that this entry belongs to was opened in a mode other than ArchiveMode.Update.
+ /// The ZipArchive that this entry belongs to has been disposed.
+ public void Delete()
+ {
+ if (_currentlyOpenForWrite)
+ throw new IOException(SR.DeleteOpenEntry);
+
+ if (_archive.OpenMode != ArchiveOpenMode.Update)
+ throw new NotSupportedException(SR.DeleteOnlyInUpdate);
+
+ _archive.ThrowIfDisposed();
+
+ _archive.RemoveEntry(this);
+ _archive = null!;
+ UnloadStreams();
+ }
+
+ ///
+ /// Opens the entry. If the archive that the entry belongs to was opened in Read mode, the returned stream will be readable, and it may or may not be seekable. If Create mode, the returned stream will be writable and not seekable. If Update mode, the returned stream will be readable, writable, seekable, and support SetLength.
+ ///
+ /// A Stream that represents the contents of the entry.
+ /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. -or- The archive that this entry belongs to was opened in ArchiveMode.Create, and this entry has already been written to once.
+ /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported.
+ /// The ZipArchive that this entry belongs to has been disposed.
+ public Stream Open()
+ {
+ ThrowIfInvalidArchive();
+
+ switch (_archive.OpenMode)
+ {
+ case ArchiveOpenMode.Read:
+ return OpenInReadMode(checkOpenable: true);
+ case ArchiveOpenMode.Create:
+ return OpenInWriteMode();
+ case ArchiveOpenMode.Update:
+ default:
+ Debug.Assert(_archive.OpenMode == ArchiveOpenMode.Update);
+ return OpenInUpdateMode();
+ }
+ }
+
+ ///
+ /// Returns the FullName of the entry.
+ ///
+ /// FullName of the entry
+ public override string ToString()
+ {
+ return FullName;
+ }
+
+ // Only allow opening ZipArchives with large ZipArchiveEntries in update mode when running in a 64-bit process.
+ // This is for compatibility with old behavior that threw an exception for all process bitnesses, because this
+ // will not work in a 32-bit process.
+ private static readonly bool s_allowLargeZipArchiveEntriesInUpdateMode = IntPtr.Size > 4;
+
+ internal bool EverOpenedForWrite => _everOpenedForWrite;
+
+ private long OffsetOfCompressedData
+ {
+ get
+ {
+ if (_storedOffsetOfCompressedData == null)
+ {
+ Debug.Assert(_archive.ArchiveReader != null);
+ _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin);
+ // by calling this, we are using local header _storedEntryNameBytes.Length and extraFieldLength
+ // to find start of data, but still using central directory size information
+ if (!ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveReader))
+ throw new InvalidDataException(SR.LocalFileHeaderCorrupt);
+ _storedOffsetOfCompressedData = _archive.ArchiveStream.Position;
+ }
+ return _storedOffsetOfCompressedData.Value;
+ }
+ }
+
+ private MemoryStream UncompressedData
+ {
+ get
+ {
+ if (_storedUncompressedData == null)
+ {
+ // this means we have never opened it before
+
+ // if _uncompressedSize > int.MaxValue, it's still okay, because MemoryStream will just
+ // grow as data is copied into it
+ _storedUncompressedData = new MemoryStream((int)_uncompressedSize);
+
+ if (_originallyInArchive)
+ {
+ using (Stream decompressor = OpenInReadMode(false))
+ {
+ try
+ {
+ decompressor.CopyTo(_storedUncompressedData);
+ }
+ catch (InvalidDataException)
+ {
+ // this is the case where the archive say the entry is deflate, but deflateStream
+ // throws an InvalidDataException. This property should only be getting accessed in
+ // Update mode, so we want to make sure _storedUncompressedData stays null so
+ // that later when we dispose the archive, this entry loads the compressedBytes, and
+ // copies them straight over
+ _storedUncompressedData.Dispose();
+ _storedUncompressedData = null;
+ _currentlyOpenForWrite = false;
+ _everOpenedForWrite = false;
+ throw;
+ }
+ }
+ }
+
+ // if they start modifying it and the compression method is not "store", we should make sure it will get deflated
+ if (CompressionMethod != CompressionMethodValues.Stored)
+ {
+ CompressionMethod = CompressionMethodValues.Deflate;
+ }
+ }
+
+ return _storedUncompressedData;
+ }
+ }
+
+ private CompressionMethodValues CompressionMethod
+ {
+ get => _storedCompressionMethod;
+ set
+ {
+ if (value == CompressionMethodValues.Deflate)
+ VersionToExtractAtLeast(ZipVersionNeededValues.Deflate);
+ else if (value == CompressionMethodValues.Deflate64)
+ VersionToExtractAtLeast(ZipVersionNeededValues.Deflate64);
+ _storedCompressionMethod = value;
+ }
+ }
+
+ // does almost everything you need to do to forget about this entry
+ // writes the local header/data, gets rid of all the data,
+ // closes all of the streams except for the very outermost one that
+ // the user holds on to and is responsible for closing
+ //
+ // after calling this, and only after calling this can we be guaranteed
+ // that we are reading to write the central directory
+ //
+ // should only throw an exception in extremely exceptional cases because it is called from dispose
+ internal void WriteAndFinishLocalEntry()
+ {
+ CloseStreams();
+ WriteLocalFileHeaderAndDataIfNeeded();
+ UnloadStreams();
+ }
+
+ // should only throw an exception in extremely exceptional cases because it is called from dispose
+ internal void WriteCentralDirectoryFileHeader()
+ {
+ // This part is simple, because we should definitely know the sizes by this time
+ BinaryWriter writer = new BinaryWriter(_archive.ArchiveStream);
+
+ // _entryname only gets set when we read in or call moveTo. MoveTo does a check, and
+ // reading in should not be able to produce an entryname longer than ushort.MaxValue
+ Debug.Assert(_storedEntryNameBytes.Length <= ushort.MaxValue);
+
+ // decide if we need the Zip64 extra field:
+ Zip64ExtraField zip64ExtraField = default;
+ uint compressedSizeTruncated, uncompressedSizeTruncated, offsetOfLocalHeaderTruncated;
+
+ bool zip64Needed = false;
+
+ if (SizesTooLarge()
+#if DEBUG_FORCE_ZIP64
+ || _archive._forceZip64
+#endif
+ )
+ {
+ zip64Needed = true;
+ compressedSizeTruncated = ZipHelper.Mask32Bit;
+ uncompressedSizeTruncated = ZipHelper.Mask32Bit;
+
+ // If we have one of the sizes, the other must go in there as speced for LH, but not necessarily for CH, but we do it anyways
+ zip64ExtraField.CompressedSize = _compressedSize;
+ zip64ExtraField.UncompressedSize = _uncompressedSize;
+ }
+ else
+ {
+ compressedSizeTruncated = (uint)_compressedSize;
+ uncompressedSizeTruncated = (uint)_uncompressedSize;
+ }
+
+
+ if (_offsetOfLocalHeader > uint.MaxValue
+#if DEBUG_FORCE_ZIP64
+ || _archive._forceZip64
+#endif
+ )
+ {
+ zip64Needed = true;
+ offsetOfLocalHeaderTruncated = ZipHelper.Mask32Bit;
+
+ // If we have one of the sizes, the other must go in there as speced for LH, but not necessarily for CH, but we do it anyways
+ zip64ExtraField.LocalHeaderOffset = _offsetOfLocalHeader;
+ }
+ else
+ {
+ offsetOfLocalHeaderTruncated = (uint)_offsetOfLocalHeader;
+ }
+
+ if (zip64Needed)
+ VersionToExtractAtLeast(ZipVersionNeededValues.Zip64);
+
+ // determine if we can fit zip64 extra field and original extra fields all in
+ int bigExtraFieldLength = (zip64Needed ? zip64ExtraField.TotalSize : 0)
+ + (_cdUnknownExtraFields != null ? ZipGenericExtraField.TotalSize(_cdUnknownExtraFields) : 0);
+ ushort extraFieldLength;
+ if (bigExtraFieldLength > ushort.MaxValue)
+ {
+ extraFieldLength = (ushort)(zip64Needed ? zip64ExtraField.TotalSize : 0);
+ _cdUnknownExtraFields = null;
+ }
+ else
+ {
+ extraFieldLength = (ushort)bigExtraFieldLength;
+ }
+
+ writer.Write(ZipCentralDirectoryFileHeader.SignatureConstant); // Central directory file header signature (4 bytes)
+ writer.Write((byte)_versionMadeBySpecification); // Version made by Specification (version) (1 byte)
+ writer.Write((byte)CurrentZipPlatform); // Version made by Compatibility (type) (1 byte)
+ writer.Write((ushort)_versionToExtract); // Minimum version needed to extract (2 bytes)
+ writer.Write((ushort)_generalPurposeBitFlag); // General Purpose bit flag (2 bytes)
+ writer.Write((ushort)CompressionMethod); // The Compression method (2 bytes)
+ writer.Write(ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); // File last modification time and date (4 bytes)
+ writer.Write(_crc32); // CRC-32 (4 bytes)
+ writer.Write(compressedSizeTruncated); // Compressed Size (4 bytes)
+ writer.Write(uncompressedSizeTruncated); // Uncompressed Size (4 bytes)
+ writer.Write((ushort)_storedEntryNameBytes.Length); // File Name Length (2 bytes)
+ writer.Write(extraFieldLength); // Extra Field Length (2 bytes)
+
+ Debug.Assert(_fileComment.Length <= ushort.MaxValue);
+
+ writer.Write((ushort)_fileComment.Length);
+ writer.Write((ushort)0); // disk number start
+ writer.Write((ushort)0); // internal file attributes
+ writer.Write(_externalFileAttr); // external file attributes
+ writer.Write(offsetOfLocalHeaderTruncated); // offset of local header
+
+ writer.Write(_storedEntryNameBytes);
+
+ // write extra fields
+ if (zip64Needed)
+ zip64ExtraField.WriteBlock(_archive.ArchiveStream);
+ if (_cdUnknownExtraFields != null)
+ ZipGenericExtraField.WriteAllBlocks(_cdUnknownExtraFields, _archive.ArchiveStream);
+
+ if (_fileComment.Length > 0)
+ writer.Write(_fileComment);
+ }
+
+ // returns false if fails, will get called on every entry before closing in update mode
+ // can throw InvalidDataException
+ internal bool LoadLocalHeaderExtraFieldAndCompressedBytesIfNeeded()
+ {
+ // we should have made this exact call in _archive.Init through ThrowIfOpenable
+ Debug.Assert(IsOpenable(false, true, out _));
+
+ // load local header's extra fields. it will be null if we couldn't read for some reason
+ if (_originallyInArchive)
+ {
+ _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin);
+
+ Debug.Assert(_archive.ArchiveReader != null);
+ _lhUnknownExtraFields = ZipLocalFileHeader.GetExtraFields(_archive.ArchiveReader);
+ }
+
+ if (!_everOpenedForWrite && _originallyInArchive)
+ {
+ // we know that it is openable at this point
+ int MaxSingleBufferSize = Array.MaxLength;
+
+ _compressedBytes = new byte[(_compressedSize / MaxSingleBufferSize) + 1][];
+ for (int i = 0; i < _compressedBytes.Length - 1; i++)
+ {
+ _compressedBytes[i] = new byte[MaxSingleBufferSize];
+ }
+ _compressedBytes[_compressedBytes.Length - 1] = new byte[_compressedSize % MaxSingleBufferSize];
+
+ _archive.ArchiveStream.Seek(OffsetOfCompressedData, SeekOrigin.Begin);
+
+ for (int i = 0; i < _compressedBytes.Length - 1; i++)
+ {
+ ZipHelper.ReadBytes(_archive.ArchiveStream, _compressedBytes[i], MaxSingleBufferSize);
+ }
+ ZipHelper.ReadBytes(_archive.ArchiveStream, _compressedBytes[_compressedBytes.Length - 1], (int)(_compressedSize % MaxSingleBufferSize));
+ }
+
+ return true;
+ }
+
+ internal void ThrowIfNotOpenable(bool needToUncompress, bool needToLoadIntoMemory)
+ {
+ if (!IsOpenable(needToUncompress, needToLoadIntoMemory, out string? message))
+ throw new InvalidDataException(message);
+ }
+
+ private void DetectEntryNameVersion()
+ {
+ if (ParseFileName(_storedEntryName, _versionMadeByPlatform) == "")
+ {
+ VersionToExtractAtLeast(ZipVersionNeededValues.ExplicitDirectory);
+ }
+ }
+
+ private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose)
+ {
+ // stream stack: backingStream -> DeflateStream -> CheckSumWriteStream
+
+ // By default we compress with deflate, except if compression level is set to NoCompression then stored is used.
+ // Stored is also used for empty files, but we don't actually call through this function for that - we just write the stored value in the header
+ // Deflate64 is not supported on all platforms
+ Debug.Assert(CompressionMethod == CompressionMethodValues.Deflate
+ || CompressionMethod == CompressionMethodValues.Stored);
+
+ bool isIntermediateStream = true;
+ Stream compressorStream;
+ switch (CompressionMethod)
+ {
+ case CompressionMethodValues.Stored:
+ compressorStream = backingStream;
+ isIntermediateStream = false;
+ break;
+ case CompressionMethodValues.Deflate:
+ case CompressionMethodValues.Deflate64:
+ default:
+ compressorStream = new DeflateStream(backingStream, _compressionLevel ?? CompressionLevel.Optimal, leaveBackingStreamOpen);
+ break;
+
+ }
+ bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream;
+ var checkSumStream = new CheckSumAndSizeWriteStream(
+ compressorStream,
+ backingStream,
+ leaveCompressorStreamOpenOnClose,
+ this,
+ onClose,
+ (long initialPosition, long currentPosition, uint checkSum, Stream backing, ZipArchiveEntry thisRef, EventHandler? closeHandler) =>
+ {
+ thisRef._crc32 = checkSum;
+ thisRef._uncompressedSize = currentPosition;
+ thisRef._compressedSize = backing.Position - initialPosition;
+ closeHandler?.Invoke(thisRef, EventArgs.Empty);
+ });
+
+ return checkSumStream;
+ }
+
+ private Stream GetDataDecompressor(Stream compressedStreamToRead)
+ {
+ Stream? uncompressedStream;
+ switch (CompressionMethod)
+ {
+ case CompressionMethodValues.Deflate:
+ uncompressedStream = new DeflateStream(compressedStreamToRead, CompressionMode.Decompress, _uncompressedSize);
+ break;
+ case CompressionMethodValues.Deflate64:
+ uncompressedStream = new DeflateManagedStream(compressedStreamToRead, CompressionMethodValues.Deflate64, _uncompressedSize);
+ break;
+ case CompressionMethodValues.Stored:
+ default:
+ // we can assume that only deflate/deflate64/stored are allowed because we assume that
+ // IsOpenable is checked before this function is called
+ Debug.Assert(CompressionMethod == CompressionMethodValues.Stored);
+
+ uncompressedStream = compressedStreamToRead;
+ break;
+ }
+
+ return uncompressedStream;
+ }
+
+ private Stream OpenInReadMode(bool checkOpenable)
+ {
+ if (checkOpenable)
+ ThrowIfNotOpenable(needToUncompress: true, needToLoadIntoMemory: false);
+
+ Stream compressedStream = new SubReadStream(_archive.ArchiveStream, OffsetOfCompressedData, _compressedSize);
+ return GetDataDecompressor(compressedStream);
+ }
+
+ private Stream OpenInWriteMode()
+ {
+ if (_everOpenedForWrite)
+ throw new IOException(SR.CreateModeWriteOnceAndOneEntryAtATime);
+
+ // we assume that if another entry grabbed the archive stream, that it set this entry's _everOpenedForWrite property to true by calling WriteLocalFileHeaderIfNeeed
+ _archive.DebugAssertIsStillArchiveStreamOwner(this);
+
+ _everOpenedForWrite = true;
+ CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor(_archive.ArchiveStream, true, (object? o, EventArgs e) =>
+ {
+ // release the archive stream
+ var entry = (BaseArchiveEntry)o!;
+ entry._archive.ReleaseArchiveStream(entry);
+ entry._outstandingWriteStream = null;
+ });
+ _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this);
+
+ return new WrappedStream(baseStream: _outstandingWriteStream, closeBaseStream: true);
+ }
+
+ private Stream OpenInUpdateMode()
+ {
+ if (_currentlyOpenForWrite)
+ throw new IOException(SR.UpdateModeOneStream);
+
+ ThrowIfNotOpenable(needToUncompress: true, needToLoadIntoMemory: true);
+
+ _everOpenedForWrite = true;
+ _currentlyOpenForWrite = true;
+ // always put it at the beginning for them
+ UncompressedData.Seek(0, SeekOrigin.Begin);
+ return new WrappedStream(UncompressedData, this, thisRef =>
+ {
+ // once they close, we know uncompressed length, but still not compressed length
+ // so we don't fill in any size information
+ // those fields get figured out when we call GetCompressor as we write it to
+ // the actual archive
+ thisRef!._currentlyOpenForWrite = false;
+ });
+ }
+
+ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out string? message)
+ {
+ message = null;
+
+ if (_originallyInArchive)
+ {
+ if (needToUncompress)
+ {
+ if (CompressionMethod != CompressionMethodValues.Stored &&
+ CompressionMethod != CompressionMethodValues.Deflate &&
+ CompressionMethod != CompressionMethodValues.Deflate64)
+ {
+ switch (CompressionMethod)
+ {
+ case CompressionMethodValues.BZip2:
+ case CompressionMethodValues.LZMA:
+ message = SR.Format(SR.UnsupportedCompressionMethod, CompressionMethod.ToString());
+ break;
+ default:
+ message = SR.UnsupportedCompression;
+ break;
+ }
+ return false;
+ }
+ }
+ if (_diskNumberStart != _archive.NumberOfThisDisk)
+ {
+ message = SR.SplitSpanned;
+ return false;
+ }
+ if (_offsetOfLocalHeader > _archive.ArchiveStream.Length)
+ {
+ message = SR.LocalFileHeaderCorrupt;
+ return false;
+ }
+ Debug.Assert(_archive.ArchiveReader != null);
+ _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin);
+ if (!ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveReader))
+ {
+ message = SR.LocalFileHeaderCorrupt;
+ return false;
+ }
+ // when this property gets called, some duplicated work
+ if (OffsetOfCompressedData + _compressedSize > _archive.ArchiveStream.Length)
+ {
+ message = SR.LocalFileHeaderCorrupt;
+ return false;
+ }
+ // This limitation originally existed because a) it is unreasonable to load > 4GB into memory
+ // but also because the stream reading functions make it hard. This has been updated to handle
+ // this scenario in a 64-bit process using multiple buffers, delivered first as an OOB for
+ // compatibility.
+ if (needToLoadIntoMemory)
+ {
+ if (_compressedSize > int.MaxValue)
+ {
+ if (!s_allowLargeZipArchiveEntriesInUpdateMode)
+ {
+ message = SR.EntryTooLarge;
+ return false;
+ }
+ }
+ }
+ }
+
+ return true;
+ }
+
+ private bool SizesTooLarge() => _compressedSize > uint.MaxValue || _uncompressedSize > uint.MaxValue;
+
+ // return value is true if we allocated an extra field for 64 bit headers, un/compressed size
+ private bool WriteLocalFileHeader(bool isEmptyFile)
+ {
+ BinaryWriter writer = new BinaryWriter(_archive.ArchiveStream);
+
+ // _entryname only gets set when we read in or call moveTo. MoveTo does a check, and
+ // reading in should not be able to produce an entryname longer than ushort.MaxValue
+ Debug.Assert(_storedEntryNameBytes.Length <= ushort.MaxValue);
+
+ // decide if we need the Zip64 extra field:
+ Zip64ExtraField zip64ExtraField = default;
+ bool zip64Used = false;
+ uint compressedSizeTruncated, uncompressedSizeTruncated;
+
+ // if we already know that we have an empty file don't worry about anything, just do a straight shot of the header
+ if (isEmptyFile)
+ {
+ CompressionMethod = CompressionMethodValues.Stored;
+ compressedSizeTruncated = 0;
+ uncompressedSizeTruncated = 0;
+ Debug.Assert(_compressedSize == 0);
+ Debug.Assert(_uncompressedSize == 0);
+ Debug.Assert(_crc32 == 0);
+ }
+ else
+ {
+ // if we have a non-seekable stream, don't worry about sizes at all, and just set the right bit
+ // if we are using the data descriptor, then sizes and crc should be set to 0 in the header
+ if (_archive.OpenMode == ArchiveOpenMode.Create && _archive.ArchiveStream.CanSeek == false)
+ {
+ _generalPurposeBitFlag |= BitFlagValues.DataDescriptor;
+ zip64Used = false;
+ compressedSizeTruncated = 0;
+ uncompressedSizeTruncated = 0;
+ // the crc should not have been set if we are in create mode, but clear it just to be sure
+ Debug.Assert(_crc32 == 0);
+ }
+ else // if we are not in streaming mode, we have to decide if we want to write zip64 headers
+ {
+ // We are in seekable mode so we will not need to write a data descriptor
+ _generalPurposeBitFlag &= ~BitFlagValues.DataDescriptor;
+ if (SizesTooLarge()
+#if DEBUG_FORCE_ZIP64
+ || (_archive._forceZip64 && _archive.Mode == ArchiveMode.Update)
+#endif
+ )
+ {
+ zip64Used = true;
+ compressedSizeTruncated = ZipHelper.Mask32Bit;
+ uncompressedSizeTruncated = ZipHelper.Mask32Bit;
+
+ // prepare Zip64 extra field object. If we have one of the sizes, the other must go in there
+ zip64ExtraField.CompressedSize = _compressedSize;
+ zip64ExtraField.UncompressedSize = _uncompressedSize;
+
+ VersionToExtractAtLeast(ZipVersionNeededValues.Zip64);
+ }
+ else
+ {
+ zip64Used = false;
+ compressedSizeTruncated = (uint)_compressedSize;
+ uncompressedSizeTruncated = (uint)_uncompressedSize;
+ }
+ }
+ }
+
+ // save offset
+ _offsetOfLocalHeader = writer.BaseStream.Position;
+
+ // calculate extra field. if zip64 stuff + original extraField aren't going to fit, dump the original extraField, because this is more important
+ int bigExtraFieldLength = (zip64Used ? zip64ExtraField.TotalSize : 0)
+ + (_lhUnknownExtraFields != null ? ZipGenericExtraField.TotalSize(_lhUnknownExtraFields) : 0);
+ ushort extraFieldLength;
+ if (bigExtraFieldLength > ushort.MaxValue)
+ {
+ extraFieldLength = (ushort)(zip64Used ? zip64ExtraField.TotalSize : 0);
+ _lhUnknownExtraFields = null;
+ }
+ else
+ {
+ extraFieldLength = (ushort)bigExtraFieldLength;
+ }
+
+ // write header
+ writer.Write(ZipLocalFileHeader.SignatureConstant);
+ writer.Write((ushort)_versionToExtract);
+ writer.Write((ushort)_generalPurposeBitFlag);
+ writer.Write((ushort)CompressionMethod);
+ writer.Write(ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); // uint
+ writer.Write(_crc32); // uint
+ writer.Write(compressedSizeTruncated); // uint
+ writer.Write(uncompressedSizeTruncated); // uint
+ writer.Write((ushort)_storedEntryNameBytes.Length);
+ writer.Write(extraFieldLength); // ushort
+
+ writer.Write(_storedEntryNameBytes);
+
+ if (zip64Used)
+ zip64ExtraField.WriteBlock(_archive.ArchiveStream);
+ if (_lhUnknownExtraFields != null)
+ ZipGenericExtraField.WriteAllBlocks(_lhUnknownExtraFields, _archive.ArchiveStream);
+
+ return zip64Used;
+ }
+
+ private void WriteLocalFileHeaderAndDataIfNeeded()
+ {
+ // _storedUncompressedData gets frozen here, and is what gets written to the file
+ if (_storedUncompressedData != null || _compressedBytes != null)
+ {
+ if (_storedUncompressedData != null)
+ {
+ _uncompressedSize = _storedUncompressedData.Length;
+
+ //The compressor fills in CRC and sizes
+ //The DirectToArchiveWriterStream writes headers and such
+ using (Stream entryWriter = new DirectToArchiveWriterStream(
+ GetDataCompressor(_archive.ArchiveStream, true, null),
+ this))
+ {
+ _storedUncompressedData.Seek(0, SeekOrigin.Begin);
+ _storedUncompressedData.CopyTo(entryWriter);
+ _storedUncompressedData.Dispose();
+ _storedUncompressedData = null;
+ }
+ }
+ else
+ {
+ if (_uncompressedSize == 0)
+ {
+ // reset size to ensure proper central directory size header
+ _compressedSize = 0;
+ }
+
+ WriteLocalFileHeader(isEmptyFile: _uncompressedSize == 0);
+
+ // according to ZIP specs, zero-byte files MUST NOT include file data
+ if (_uncompressedSize != 0)
+ {
+ Debug.Assert(_compressedBytes != null);
+ foreach (byte[] compressedBytes in _compressedBytes)
+ {
+ _archive.ArchiveStream.Write(compressedBytes, 0, compressedBytes.Length);
+ }
+ }
+ }
+ }
+ else // there is no data in the file, but if we are in update mode, we still need to write a header
+ {
+ if (_archive.OpenMode == ArchiveOpenMode.Update || !_everOpenedForWrite)
+ {
+ _everOpenedForWrite = true;
+ WriteLocalFileHeader(isEmptyFile: true);
+ }
+ }
+ }
+
+ // Using _offsetOfLocalHeader, seeks back to where CRC and sizes should be in the header,
+ // writes them, then seeks back to where you started
+ // Assumes that the stream is currently at the end of the data
+ private void WriteCrcAndSizesInLocalHeader(bool zip64HeaderUsed)
+ {
+ long finalPosition = _archive.ArchiveStream.Position;
+ BinaryWriter writer = new BinaryWriter(_archive.ArchiveStream);
+
+ bool zip64Needed = SizesTooLarge()
+#if DEBUG_FORCE_ZIP64
+ || _archive._forceZip64
+#endif
+ ;
+
+ bool pretendStreaming = zip64Needed && !zip64HeaderUsed;
+
+ uint compressedSizeTruncated = zip64Needed ? ZipHelper.Mask32Bit : (uint)_compressedSize;
+ uint uncompressedSizeTruncated = zip64Needed ? ZipHelper.Mask32Bit : (uint)_uncompressedSize;
+
+ // first step is, if we need zip64, but didn't allocate it, pretend we did a stream write, because
+ // we can't go back and give ourselves the space that the extra field needs.
+ // we do this by setting the correct property in the bit flag to indicate we have a data descriptor
+ // and setting the version to Zip64 to indicate that descriptor contains 64-bit values
+ if (pretendStreaming)
+ {
+ VersionToExtractAtLeast(ZipVersionNeededValues.Zip64);
+ _generalPurposeBitFlag |= BitFlagValues.DataDescriptor;
+
+ _archive.ArchiveStream.Seek(_offsetOfLocalHeader + ZipLocalFileHeader.OffsetToVersionFromHeaderStart,
+ SeekOrigin.Begin);
+ writer.Write((ushort)_versionToExtract);
+ writer.Write((ushort)_generalPurposeBitFlag);
+ }
+
+ // next step is fill out the 32-bit size values in the normal header. we can't assume that
+ // they are correct. we also write the CRC
+ _archive.ArchiveStream.Seek(_offsetOfLocalHeader + ZipLocalFileHeader.OffsetToCrcFromHeaderStart,
+ SeekOrigin.Begin);
+ if (!pretendStreaming)
+ {
+ writer.Write(_crc32);
+ writer.Write(compressedSizeTruncated);
+ writer.Write(uncompressedSizeTruncated);
+ }
+ else // but if we are pretending to stream, we want to fill in with zeroes
+ {
+ writer.Write((uint)0);
+ writer.Write((uint)0);
+ writer.Write((uint)0);
+ }
+
+ // next step: if we wrote the 64 bit header initially, a different implementation might
+ // try to read it, even if the 32-bit size values aren't masked. thus, we should always put the
+ // correct size information in there. note that order of uncomp/comp is switched, and these are
+ // 64-bit values
+ // also, note that in order for this to be correct, we have to insure that the zip64 extra field
+ // is always the first extra field that is written
+ if (zip64HeaderUsed)
+ {
+ _archive.ArchiveStream.Seek(_offsetOfLocalHeader + ZipLocalFileHeader.SizeOfLocalHeader
+ + _storedEntryNameBytes.Length + Zip64ExtraField.OffsetToFirstField,
+ SeekOrigin.Begin);
+ writer.Write(_uncompressedSize);
+ writer.Write(_compressedSize);
+ }
+
+ // now go to the where we were. assume that this is the end of the data
+ _archive.ArchiveStream.Seek(finalPosition, SeekOrigin.Begin);
+
+ // if we are pretending we did a stream write, we want to write the data descriptor out
+ // the data descriptor can have 32-bit sizes or 64-bit sizes. In this case, we always use
+ // 64-bit sizes
+ if (pretendStreaming)
+ {
+ writer.Write(_crc32);
+ writer.Write(_compressedSize);
+ writer.Write(_uncompressedSize);
+ }
+ }
+
+ private void WriteDataDescriptor()
+ {
+ // We enter here because we cannot seek, so the data descriptor bit should be on
+ Debug.Assert((_generalPurposeBitFlag & BitFlagValues.DataDescriptor) != 0);
+
+ // data descriptor can be 32-bit or 64-bit sizes. 32-bit is more compatible, so use that if possible
+ // signature is optional but recommended by the spec
+
+ BinaryWriter writer = new BinaryWriter(_archive.ArchiveStream);
+
+ writer.Write(ZipLocalFileHeader.DataDescriptorSignature);
+ writer.Write(_crc32);
+ if (SizesTooLarge())
+ {
+ writer.Write(_compressedSize);
+ writer.Write(_uncompressedSize);
+ }
+ else
+ {
+ writer.Write((uint)_compressedSize);
+ writer.Write((uint)_uncompressedSize);
+ }
+ }
+
+ private void UnloadStreams()
+ {
+ _storedUncompressedData?.Dispose();
+ _compressedBytes = null;
+ _outstandingWriteStream = null;
+ }
+
+ private void CloseStreams()
+ {
+ // if the user left the stream open, close the underlying stream for them
+ _outstandingWriteStream?.Dispose();
+ }
+
+ private void VersionToExtractAtLeast(ZipVersionNeededValues value)
+ {
+ if (_versionToExtract < value)
+ {
+ _versionToExtract = value;
+ }
+ if (_versionMadeBySpecification < value)
+ {
+ _versionMadeBySpecification = value;
+ }
+ }
+
+ private void ThrowIfInvalidArchive()
+ {
+ if (_archive == null)
+ throw new InvalidOperationException(SR.DeletedEntry);
+ _archive.ThrowIfDisposed();
+ }
+
+ ///
+ /// Gets the file name of the path based on Windows path separator characters
+ ///
+ private static string GetFileName_Windows(string path)
+ {
+ int i = path.AsSpan().LastIndexOfAny('\\', '/', ':');
+ return i >= 0 ? path[(i + 1)..] : path;
+ }
+
+ ///
+ /// Gets the file name of the path based on Unix path separator characters
+ ///
+ private static string GetFileName_Unix(string path)
+ {
+ int i = path.LastIndexOf('/');
+ return i >= 0 ? path[(i + 1)..] : path;
+ }
+
+ private sealed class DirectToArchiveWriterStream : Stream
+ {
+ private long _position;
+ private readonly CheckSumAndSizeWriteStream _crcSizeStream;
+ private bool _everWritten;
+ private bool _isDisposed;
+ private readonly BaseArchiveEntry _entry;
+ private bool _usedZip64inLH;
+ private bool _canWrite;
+
+ // makes the assumption that somewhere down the line, crcSizeStream is eventually writing directly to the archive
+ // this class calls other functions on ZipArchiveEntry that write directly to the archive
+ public DirectToArchiveWriterStream(CheckSumAndSizeWriteStream crcSizeStream, BaseArchiveEntry entry)
+ {
+ _position = 0;
+ _crcSizeStream = crcSizeStream;
+ _everWritten = false;
+ _isDisposed = false;
+ _entry = entry;
+ _usedZip64inLH = false;
+ _canWrite = true;
+ }
+
+ public override long Length
+ {
+ get
+ {
+ ThrowIfDisposed();
+ throw new NotSupportedException(SR.SeekingNotSupported);
+ }
+ }
+ public override long Position
+ {
+ get
+ {
+ ThrowIfDisposed();
+ return _position;
+ }
+ set
+ {
+ ThrowIfDisposed();
+ throw new NotSupportedException(SR.SeekingNotSupported);
+ }
+ }
+
+ public override bool CanRead => false;
+ public override bool CanSeek => false;
+ public override bool CanWrite => _canWrite;
+
+ private void ThrowIfDisposed()
+ {
+ if (_isDisposed)
+ throw new ObjectDisposedException(GetType().ToString(), SR.HiddenStreamName);
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ ThrowIfDisposed();
+ throw new NotSupportedException(SR.ReadingNotSupported);
+ }
+
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ ThrowIfDisposed();
+ throw new NotSupportedException(SR.SeekingNotSupported);
+ }
+
+ public override void SetLength(long value)
+ {
+ ThrowIfDisposed();
+ throw new NotSupportedException(SR.SetLengthRequiresSeekingAndWriting);
+ }
+
+ // careful: assumes that write is the only way to write to the stream, if writebyte/beginwrite are implemented
+ // they must set _everWritten, etc.
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ ValidateBufferArguments(buffer, offset, count);
+
+ ThrowIfDisposed();
+ Debug.Assert(CanWrite);
+
+ // if we're not actually writing anything, we don't want to trigger the header
+ if (count == 0)
+ return;
+
+ if (!_everWritten)
+ {
+ _everWritten = true;
+ // write local header, we are good to go
+ _usedZip64inLH = _entry.WriteLocalFileHeader(isEmptyFile: false);
+ }
+
+ _crcSizeStream.Write(buffer, offset, count);
+ _position += count;
+ }
+
+ public override void Write(ReadOnlySpan source)
+ {
+ ThrowIfDisposed();
+ Debug.Assert(CanWrite);
+
+ // if we're not actually writing anything, we don't want to trigger the header
+ if (source.Length == 0)
+ return;
+
+ if (!_everWritten)
+ {
+ _everWritten = true;
+ // write local header, we are good to go
+ _usedZip64inLH = _entry.WriteLocalFileHeader(isEmptyFile: false);
+ }
+
+ _crcSizeStream.Write(source);
+ _position += source.Length;
+ }
+
+ public override void WriteByte(byte value) =>
+ Write(new ReadOnlySpan(in value));
+
+ public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ ValidateBufferArguments(buffer, offset, count);
+ return WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken).AsTask();
+ }
+
+ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default)
+ {
+ ThrowIfDisposed();
+ Debug.Assert(CanWrite);
+
+ return !buffer.IsEmpty ?
+ Core(buffer, cancellationToken) :
+ default;
+
+ async ValueTask Core(ReadOnlyMemory buffer, CancellationToken cancellationToken)
+ {
+ if (!_everWritten)
+ {
+ _everWritten = true;
+ // write local header, we are good to go
+ _usedZip64inLH = _entry.WriteLocalFileHeader(isEmptyFile: false);
+ }
+
+ await _crcSizeStream.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
+ _position += buffer.Length;
+ }
+ }
+
+ public override void Flush()
+ {
+ ThrowIfDisposed();
+ Debug.Assert(CanWrite);
+
+ _crcSizeStream.Flush();
+ }
+
+ public override Task FlushAsync(CancellationToken cancellationToken)
+ {
+ ThrowIfDisposed();
+ Debug.Assert(CanWrite);
+
+ return _crcSizeStream.FlushAsync(cancellationToken);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && !_isDisposed)
+ {
+ _crcSizeStream.Dispose(); // now we have size/crc info
+
+ if (!_everWritten)
+ {
+ // write local header, no data, so we use stored
+ _entry.WriteLocalFileHeader(isEmptyFile: true);
+ }
+ else
+ {
+ // go back and finish writing
+ if (_entry._archive.ArchiveStream.CanSeek)
+ // finish writing local header if we have seek capabilities
+ _entry.WriteCrcAndSizesInLocalHeader(_usedZip64inLH);
+ else
+ // write out data descriptor if we don't have seek capabilities
+ _entry.WriteDataDescriptor();
+ }
+ _canWrite = false;
+ _isDisposed = true;
+ }
+
+ base.Dispose(disposing);
+ }
+ }
+
+ [Flags]
+ internal enum BitFlagValues : ushort { IsEncrypted = 0x1, DataDescriptor = 0x8, UnicodeFileNameAndComment = 0x800 }
+
+ internal enum CompressionMethodValues : ushort { Stored = 0x0, Deflate = 0x8, Deflate64 = 0x9, BZip2 = 0xC, LZMA = 0xE }
+}
diff --git a/pkNX.Containers/Misc/GFPack.cs b/pkNX.Containers/Archives/GFPack.cs
similarity index 100%
rename from pkNX.Containers/Misc/GFPack.cs
rename to pkNX.Containers/Archives/GFPack.cs
diff --git a/pkNX.Containers/Archives/IO/ArchiveOpenMode.cs b/pkNX.Containers/Archives/IO/ArchiveOpenMode.cs
new file mode 100644
index 00000000..5d33f9f3
--- /dev/null
+++ b/pkNX.Containers/Archives/IO/ArchiveOpenMode.cs
@@ -0,0 +1,27 @@
+namespace pkNX.IO.Archives;
+
+public enum ArchiveOpenMode
+{
+ ///
+ /// Only reading entries from the archive is permitted.
+ /// If the underlying file or stream is seekable, then files will be read from the archive on-demand as they are requested.
+ /// If the underlying file or stream is not seekable, the entire archive will be held in memory.
+ /// Requires that the underlying file or stream is readable.
+ ///
+ Read,
+ ///
+ /// Only supports the creation of new archives.
+ /// Only writing to newly created entries in the archive is permitted.
+ /// Each entry in the archive can only be opened for writing once.
+ /// If only one entry is written to at a time, data will be written to the underlying stream or file as soon as it is available.
+ /// The underlying stream must be writable, but need not be seekable.
+ ///
+ Create,
+ ///
+ /// Reading and writing from entries in the archive is permitted.
+ /// Requires that the contents of the entire archive be held in memory.
+ /// The underlying file or stream must be readable, writable and seekable.
+ /// No data will be written to the underlying file or stream until the archive is disposed.
+ ///
+ Update
+}
diff --git a/pkNX.Containers/Archives/IO/ArchiveStreamWrapper.cs b/pkNX.Containers/Archives/IO/ArchiveStreamWrapper.cs
new file mode 100644
index 00000000..4622abc8
--- /dev/null
+++ b/pkNX.Containers/Archives/IO/ArchiveStreamWrapper.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+
+namespace pkNX.IO.Archives;
+
+public class ArchiveStreamWrapper : IDisposable
+{
+ ///
+ /// The ArchiveMode that the archive was initialized with.
+ ///
+ public ArchiveOpenMode OpenMode { get; }
+ public Stream Stream { get; }
+
+ public event Action? OnStreamClosing;
+
+ private readonly bool _leaveOpen;
+ private readonly Stream? _backingStream;
+ private bool _isDisposed;
+
+ ///
+ /// Initializes a new instance of archive on the given stream in the specified mode, specifying whether to leave the stream open.
+ ///
+ /// The stream is already closed. -or- mode is incompatible with the capabilities of the stream.
+ /// The stream is null.
+ /// mode specified an invalid value.
+ /// The input or output stream.
+ /// See the description of the ArchiveMode enum. Read requires the stream to support reading, Create requires the stream to support writing, and Update requires the stream to support reading, writing, and seeking.
+ /// true to leave the stream open upon disposing the archive, otherwise false.
+ public ArchiveStreamWrapper(Stream stream, ArchiveOpenMode openMode = ArchiveOpenMode.Read, bool leaveOpen = false)
+ {
+ OpenMode = openMode;
+ _leaveOpen = leaveOpen;
+ Stream = stream;
+
+ ValidateStreamForArchiveOpenMode(Stream);
+
+ if (Stream.CanSeek)
+ return;
+
+ switch (openMode)
+ {
+ case ArchiveOpenMode.Create:
+ Stream = new PositionPreservingWriteOnlyStreamWrapper(Stream);
+ break;
+ case ArchiveOpenMode.Read:
+ try
+ {
+ _backingStream = Stream;
+ Stream = new MemoryStream();
+ _backingStream.CopyTo(Stream);
+ Stream.Seek(0, SeekOrigin.Begin);
+ }
+ catch
+ {
+ if (_backingStream != null)
+ Stream.Dispose();
+
+ throw;
+ }
+
+ break;
+ case ArchiveOpenMode.Update:
+ default:
+ Debug.Assert(openMode == ArchiveOpenMode.Update);
+ break;
+ }
+ }
+
+ /// Validate the given stream for the specified archive open mode
+ /// The stream is already closed. -or- mode is incompatible with the capabilities of the stream.
+ /// mode specified an invalid value.
+ /// The input or output stream.
+ private void ValidateStreamForArchiveOpenMode(Stream stream)
+ {
+ switch (OpenMode)
+ {
+ case ArchiveOpenMode.Create:
+ if (!stream.CanWrite)
+ throw new ArgumentException("The provided stream can not be used to create a new archive", nameof(stream));
+ break;
+ case ArchiveOpenMode.Read:
+ if (!stream.CanRead)
+ throw new ArgumentException("The provided stream can not be used to read an archive", nameof(stream));
+ break;
+ case ArchiveOpenMode.Update:
+ if (!stream.CanRead || !stream.CanWrite || !stream.CanSeek)
+ throw new ArgumentException("The provided stream can not be used to update an archive", nameof(stream));
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(OpenMode));
+ }
+ }
+
+ public void Dispose()
+ {
+ if (!_isDisposed)
+ {
+ try
+ {
+ OnStreamClosing?.Invoke(this);
+ }
+ finally
+ {
+ CloseStreams();
+ _isDisposed = true;
+ }
+ }
+ GC.SuppressFinalize(this);
+ }
+
+ private void CloseStreams()
+ {
+ if (!_leaveOpen)
+ {
+ Stream.Dispose();
+ _backingStream?.Dispose();
+ }
+ else
+ {
+ if (_backingStream != null)
+ {
+ // The original stream was assigned to BackingStream and Stream was a copy created for seeking
+ Stream.Dispose();
+ }
+ }
+ }
+}
diff --git a/pkNX.Containers/Archives/IO/PositionPreservingWriteOnlyStreamWrapper.cs b/pkNX.Containers/Archives/IO/PositionPreservingWriteOnlyStreamWrapper.cs
new file mode 100644
index 00000000..8831f6ee
--- /dev/null
+++ b/pkNX.Containers/Archives/IO/PositionPreservingWriteOnlyStreamWrapper.cs
@@ -0,0 +1,114 @@
+using System;
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace pkNX.IO.Archives;
+
+internal sealed class PositionPreservingWriteOnlyStreamWrapper : Stream
+{
+ private readonly Stream _stream;
+ private long _position;
+
+ ///
+ /// Creates a wrapper for write-only (non-readable, non-seekable) streams that keeps track of and allows it to be read.
+ ///
+ /// The underlying stream, which handles all actual writes.
+ public PositionPreservingWriteOnlyStreamWrapper(Stream stream)
+ {
+ _stream = stream;
+ }
+
+ public override bool CanRead => false;
+ public override bool CanSeek => false;
+ public override bool CanWrite => true;
+
+ public override long Position
+ {
+ get => _position;
+ set => throw new NotSupportedException();
+ }
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ _position += count;
+ _stream.Write(buffer, offset, count);
+ }
+
+ public override void Write(ReadOnlySpan buffer)
+ {
+ _position += buffer.Length;
+ _stream.Write(buffer);
+ }
+
+ public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state)
+ {
+ _position += count;
+ return _stream.BeginWrite(buffer, offset, count, callback, state);
+ }
+
+ public override void EndWrite(IAsyncResult asyncResult) => _stream.EndWrite(asyncResult);
+
+ public override void WriteByte(byte value)
+ {
+ _position += 1;
+ _stream.WriteByte(value);
+ }
+
+ public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ _position += count;
+ return _stream.WriteAsync(buffer, offset, count, cancellationToken);
+ }
+
+ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default)
+ {
+ _position += buffer.Length;
+ return _stream.WriteAsync(buffer, cancellationToken);
+ }
+
+ public override bool CanTimeout => _stream.CanTimeout;
+ public override int ReadTimeout
+ {
+ get => _stream.ReadTimeout;
+ set => _stream.ReadTimeout = value;
+ }
+ public override int WriteTimeout
+ {
+ get => _stream.WriteTimeout;
+ set => _stream.WriteTimeout = value;
+ }
+
+ public override void Flush() => _stream.Flush();
+ public override Task FlushAsync(CancellationToken cancellationToken) => _stream.FlushAsync(cancellationToken);
+
+ public override void Close()
+ {
+ _stream.Close();
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ _stream.Dispose();
+ }
+
+ public override long Length => throw new NotSupportedException();
+
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ throw new NotSupportedException();
+ }
+
+ public override void SetLength(long value)
+ {
+ throw new NotSupportedException();
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ throw new NotSupportedException();
+ }
+}
diff --git a/pkNX.Containers/VFS/FileSystems/Archives/GfpakArchiveFileSystem.cs b/pkNX.Containers/VFS/FileSystems/Archives/GfpakArchiveFileSystem.cs
new file mode 100644
index 00000000..66f9442c
--- /dev/null
+++ b/pkNX.Containers/VFS/FileSystems/Archives/GfpakArchiveFileSystem.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace pkNX.Containers.VFS;
+
+public class GfpakArchiveFileSystem : IFileSystem
+{
+ public GFPack GfpakArchive { get; }
+
+ public bool IsReadOnly => false;
+
+ public static GfpakArchiveFileSystem Open(Stream s)
+ {
+ return new GfpakArchiveFileSystem(new GfpakArchive(s, GfpakArchiveMode.Update, true));
+ }
+
+ public static GfpakArchiveFileSystem Create(Stream s)
+ {
+ return new GfpakArchiveFileSystem(new GfpakArchive(s, GfpakArchiveMode.Create, true));
+ }
+
+ private GfpakArchiveFileSystem(GFPack archive)
+ {
+ GfpakArchive = archive;
+ }
+ public void Dispose()
+ {
+ GfpakArchive.Dispose();
+ }
+
+ protected IEnumerable GetGfpakEntries()
+ {
+ return GfpakArchive.Entries;
+ }
+ protected FileSystemPath ToPath(GfpakArchiveEntry entry)
+ {
+ return FileSystemPath.Parse(FileSystemPath.DirectorySeparator + entry.FullName);
+ }
+ protected string ToEntryPath(FileSystemPath path)
+ {
+ // Remove heading '/' from path.
+ return path.Path.TrimStart(FileSystemPath.DirectorySeparator);
+ }
+
+ protected GfpakArchiveEntry? ToEntry(FileSystemPath path)
+ {
+ return GfpakArchive.GetEntry(ToEntryPath(path));
+ }
+
+ public IEnumerable GetEntityPaths(FileSystemPath path)
+ {
+ return GetGfpakEntries()
+ .Select(ToPath)
+ .Where(path.IsParentOf)
+ .Select(entryPath => entryPath.ParentPath == path
+ ? entryPath
+ : path.AppendDirectory(entryPath.MakeRelativeTo(path).GetDirectorySegments().First()))
+ .Distinct();
+ }
+
+ public IEnumerable GetDirectoryPaths(FileSystemPath path)
+ {
+ if (!path.IsDirectory)
+ throw new ArgumentException("This FileSystemPath is not a directory.", nameof(path));
+
+ return GetGfpakEntries()
+ .Select(ToPath)
+ .Where(p => path.IsParentOf(p) && p.IsDirectory)
+ .Select(entryPath => entryPath.ParentPath == path
+ ? entryPath
+ : path.AppendDirectory(entryPath.MakeRelativeTo(path).GetDirectorySegments().First()))
+ .Distinct();
+ }
+
+ public IEnumerable GetFilePaths(FileSystemPath path)
+ {
+ if (!path.IsDirectory)
+ throw new ArgumentException("The specified path is not a directory.", nameof(path));
+
+ return GetGfpakEntries()
+ .Select(ToPath)
+ .Where(p => path.IsParentOf(p) && p.IsFile)
+ .Select(entryPath => entryPath.ParentPath == path
+ ? entryPath
+ : path.AppendDirectory(entryPath.MakeRelativeTo(path).GetDirectorySegments().First()))
+ .Distinct();
+ }
+
+ public bool Exists(FileSystemPath path)
+ {
+ if (path.IsFile)
+ return ToEntry(path) != null;
+
+ return GetGfpakEntries()
+ .Select(ToPath)
+ .Any(entryPath => entryPath.IsChildOf(path) || entryPath.Equals(path));
+ }
+
+ public Stream CreateFile(FileSystemPath path)
+ {
+ var zae = GfpakArchive.CreateEntry(ToEntryPath(path));
+ return zae.Open();
+ }
+
+ public Stream OpenFile(FileSystemPath path, FileAccess access)
+ {
+ var entry = GfpakArchive.GetEntry(ToEntryPath(path));
+ return entry?.Open() ?? Stream.Null;
+ }
+
+ public void CreateDirectory(FileSystemPath path)
+ {
+ GfpakArchive.CreateEntry(ToEntryPath(path));
+ }
+
+ public void Delete(FileSystemPath path)
+ {
+ var entry = GfpakArchive.GetEntry(ToEntryPath(path));
+ entry?.Delete();
+ }
+}
diff --git a/pkNX.Containers/VFS/FileSystems/Archives/ZipArchiveFileSystem.cs b/pkNX.Containers/VFS/FileSystems/Archives/ZipArchiveFileSystem.cs
index 2f5802ec..ac0f62d3 100644
--- a/pkNX.Containers/VFS/FileSystems/Archives/ZipArchiveFileSystem.cs
+++ b/pkNX.Containers/VFS/FileSystems/Archives/ZipArchiveFileSystem.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -7,7 +8,7 @@ namespace pkNX.Containers.VFS;
public class ZipArchiveFileSystem : IFileSystem
{
- public ZipArchive ZipArchive { get; private set; }
+ public ZipArchive ZipArchive { get; }
public bool IsReadOnly => false;
@@ -48,20 +49,51 @@ protected string ToEntryPath(FileSystemPath path)
{
return ZipArchive.GetEntry(ToEntryPath(path));
}
- public IEnumerable GetEntities(FileSystemPath path)
+
+ public IEnumerable GetEntityPaths(FileSystemPath path)
{
- return GetZipEntries().Select(ToPath).Where(path.IsParentOf)
+ return GetZipEntries()
+ .Select(ToPath)
+ .Where(path.IsParentOf)
.Select(entryPath => entryPath.ParentPath == path
- ? entryPath
- : path.AppendDirectory(entryPath.RemoveParent(path).GetDirectorySegments().First()))
- .Distinct()
- .ToList();
+ ? entryPath
+ : path.AppendDirectory(entryPath.MakeRelativeTo(path).GetDirectorySegments().First()))
+ .Distinct();
+ }
+
+ public IEnumerable GetDirectoryPaths(FileSystemPath path)
+ {
+ if (!path.IsDirectory)
+ throw new ArgumentException("This FileSystemPath is not a directory.", nameof(path));
+
+ return GetZipEntries()
+ .Select(ToPath)
+ .Where(p => path.IsParentOf(p) && p.IsDirectory)
+ .Select(entryPath => entryPath.ParentPath == path
+ ? entryPath
+ : path.AppendDirectory(entryPath.MakeRelativeTo(path).GetDirectorySegments().First()))
+ .Distinct();
+ }
+
+ public IEnumerable GetFilePaths(FileSystemPath path)
+ {
+ if (!path.IsDirectory)
+ throw new ArgumentException("The specified path is not a directory.", nameof(path));
+
+ return GetZipEntries()
+ .Select(ToPath)
+ .Where(p => path.IsParentOf(p) && p.IsFile)
+ .Select(entryPath => entryPath.ParentPath == path
+ ? entryPath
+ : path.AppendDirectory(entryPath.MakeRelativeTo(path).GetDirectorySegments().First()))
+ .Distinct();
}
public bool Exists(FileSystemPath path)
{
if (path.IsFile)
return ToEntry(path) != null;
+
return GetZipEntries()
.Select(ToPath)
.Any(entryPath => entryPath.IsChildOf(path) || entryPath.Equals(path));
diff --git a/pkNX.Containers/VFS/FileSystems/IFileSystem.cs b/pkNX.Containers/VFS/FileSystems/IFileSystem.cs
index fd2ad5c7..33050b3d 100644
--- a/pkNX.Containers/VFS/FileSystems/IFileSystem.cs
+++ b/pkNX.Containers/VFS/FileSystems/IFileSystem.cs
@@ -1,12 +1,16 @@
using System.IO;
using System.Collections.Generic;
using System;
+using System.Linq;
namespace pkNX.Containers.VFS;
public interface IFileSystem : IDisposable
{
- IEnumerable GetEntities(FileSystemPath path);
+ IEnumerable GetEntityPaths(FileSystemPath path);
+ IEnumerable GetDirectoryPaths(FileSystemPath path);
+ IEnumerable GetFilePaths(FileSystemPath path);
+
bool Exists(FileSystemPath path);
Stream CreateFile(FileSystemPath path);
Stream OpenFile(FileSystemPath path, FileAccess access);
@@ -41,4 +45,24 @@ public void WriteAllText(FileSystemPath path, string content)
public static class IFileSystemExtensions
{
public static ReadOnlyFileSystem AsReadOnlyFileSystem(this IFileSystem self) => new(self);
+
+ public static RelativeFileSystem AsRelativeFileSystem(this IFileSystem self, PathTransformation toAbsolutePath, PathTransformation toRelativePath)
+ {
+ return new(self, toAbsolutePath, toRelativePath);
+ }
+
+ public static IEnumerable GetEntities(this IFileSystem self, FileSystemPath path)
+ {
+ return self.GetEntityPaths(path).Select(p => IFileSystemEntity.Create(self, p));
+ }
+
+ public static IEnumerable GetDirectories(this IFileSystem self, FileSystemPath path)
+ {
+ return self.GetDirectoryPaths(path).Select(p => VirtualDirectory.Create(self, p));
+ }
+
+ public static IEnumerable GetFiles(this IFileSystem self, FileSystemPath path)
+ {
+ return self.GetFilePaths(path).Select(p => VirtualFile.Create(self, p));
+ }
}
diff --git a/pkNX.Containers/VFS/FileSystems/LayeredFileSystem.cs b/pkNX.Containers/VFS/FileSystems/LayeredFileSystem.cs
index c24fc0df..17f32fdb 100644
--- a/pkNX.Containers/VFS/FileSystems/LayeredFileSystem.cs
+++ b/pkNX.Containers/VFS/FileSystems/LayeredFileSystem.cs
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Runtime.CompilerServices;
using System.Text;
namespace pkNX.Containers.VFS;
@@ -9,15 +10,15 @@ namespace pkNX.Containers.VFS;
public class LayeredFileSystem : IFileSystem
{
public IEnumerable FileSystems { get; }
+
public LayeredFileSystem(IEnumerable fileSystems)
{
- FileSystems = fileSystems.ToArray();
+ FileSystems = fileSystems;
}
- public LayeredFileSystem(params IFileSystem[] fileSystems)
- {
- FileSystems = fileSystems.ToArray();
- }
+ public LayeredFileSystem(params IFileSystem[] fileSystems) :
+ this(fileSystems.AsEnumerable())
+ { }
public void Dispose()
{
@@ -27,18 +28,28 @@ public void Dispose()
GC.SuppressFinalize(this);
}
- public IEnumerable GetEntities(FileSystemPath path)
+ public IEnumerable GetEntityPaths(FileSystemPath path)
{
- var entities = new SortedList();
+ var entities = new HashSet();
foreach (var fs in FileSystems.Where(fs => fs.Exists(path)))
- {
- foreach (var entity in fs.GetEntities(path))
- {
- if (!entities.ContainsKey(entity))
- entities.Add(entity, entity);
- }
- }
- return entities.Values;
+ entities.UnionWith(fs.GetEntityPaths(path));
+ return entities;
+ }
+
+ public IEnumerable GetDirectoryPaths(FileSystemPath path)
+ {
+ var directories = new HashSet();
+ foreach (var fs in FileSystems.Where(fs => fs.Exists(path)))
+ directories.UnionWith(fs.GetDirectoryPaths(path));
+ return directories;
+ }
+
+ public IEnumerable GetFilePaths(FileSystemPath path)
+ {
+ var files = new HashSet();
+ foreach (var fs in FileSystems.Where(fs => fs.Exists(path)))
+ files.UnionWith(fs.GetFilePaths(path));
+ return files;
}
public bool Exists(FileSystemPath path)
diff --git a/pkNX.Containers/VFS/FileSystems/PhysicalFileSystem.cs b/pkNX.Containers/VFS/FileSystems/PhysicalFileSystem.cs
index 80471d13..d622b361 100644
--- a/pkNX.Containers/VFS/FileSystems/PhysicalFileSystem.cs
+++ b/pkNX.Containers/VFS/FileSystems/PhysicalFileSystem.cs
@@ -7,28 +7,27 @@ namespace pkNX.Containers.VFS;
public class PhysicalFileSystem : IFileSystem
{
- #region Internals
public string PhysicalRoot { get; }
public PhysicalFileSystem(string physicalRoot)
{
- if (!Path.IsPathRooted(physicalRoot))
- physicalRoot = Path.GetFullPath(physicalRoot);
- if (physicalRoot[^1] != Path.DirectorySeparatorChar)
+ physicalRoot = Path.GetFullPath(physicalRoot);
+ if (!physicalRoot.EndsWith(Path.DirectorySeparatorChar))
physicalRoot += Path.DirectorySeparatorChar;
PhysicalRoot = physicalRoot;
}
public string GetPhysicalPath(FileSystemPath path)
{
- return Path.Combine(PhysicalRoot, path.ToString().Remove(0, 1).Replace(FileSystemPath.DirectorySeparator, Path.DirectorySeparatorChar));
+ return Path.GetFullPath(PhysicalRoot + path);
}
public FileSystemPath GetVirtualFilePath(string physicalPath)
{
if (!physicalPath.StartsWith(PhysicalRoot, StringComparison.InvariantCultureIgnoreCase))
throw new ArgumentException("The specified path is not member of the PhysicalRoot.", nameof(physicalPath));
- string virtualPath = FileSystemPath.DirectorySeparator + physicalPath.Remove(0, PhysicalRoot.Length).Replace(Path.DirectorySeparatorChar, FileSystemPath.DirectorySeparator);
+
+ string virtualPath = FileSystemPath.DirectorySeparator + physicalPath[PhysicalRoot.Length..].Replace(Path.DirectorySeparatorChar, FileSystemPath.DirectorySeparator);
return FileSystemPath.Parse(virtualPath);
}
@@ -36,56 +35,69 @@ public FileSystemPath GetVirtualDirectoryPath(string physicalPath)
{
if (!physicalPath.StartsWith(PhysicalRoot, StringComparison.InvariantCultureIgnoreCase))
throw new ArgumentException("The specified path is not member of the PhysicalRoot.", nameof(physicalPath));
- string virtualPath = FileSystemPath.DirectorySeparator + physicalPath.Remove(0, PhysicalRoot.Length).Replace(Path.DirectorySeparatorChar, FileSystemPath.DirectorySeparator);
- if (virtualPath[^1] != FileSystemPath.DirectorySeparator)
+
+ string virtualPath = FileSystemPath.DirectorySeparator + physicalPath[PhysicalRoot.Length..].Replace(Path.DirectorySeparatorChar, FileSystemPath.DirectorySeparator);
+ if (!virtualPath.EndsWith(FileSystemPath.DirectorySeparator))
virtualPath += FileSystemPath.DirectorySeparator;
return FileSystemPath.Parse(virtualPath);
}
- #endregion
-
- public IEnumerable GetEntities(FileSystemPath path)
+ public IEnumerable GetEntityPaths(FileSystemPath path)
{
- string physicalPath = GetPhysicalPath(path);
- string[] directories = System.IO.Directory.GetDirectories(physicalPath);
- string[] files = System.IO.Directory.GetFiles(physicalPath);
- var virtualDirectories = directories.Select(GetVirtualDirectoryPath);
- var virtualFiles = files.Select(GetVirtualFilePath);
- return virtualDirectories.Concat(virtualFiles);
+ return GetDirectoryPaths(path).Concat(GetFilePaths(path));
+ }
+
+ public IEnumerable GetDirectoryPaths(FileSystemPath path)
+ {
+ if (!path.IsDirectory)
+ throw new ArgumentException("This FileSystemPath is not a directory.", nameof(path));
+
+ var physicalPaths = Directory.GetDirectories(GetPhysicalPath(path));
+ return physicalPaths.Select(GetVirtualDirectoryPath);
+ }
+
+ public IEnumerable GetFilePaths(FileSystemPath path)
+ {
+ if (!path.IsDirectory)
+ throw new ArgumentException("The specified path is not a directory.", nameof(path));
+
+ var physicalPaths = Directory.GetFiles(GetPhysicalPath(path));
+ return physicalPaths.Select(GetVirtualFilePath);
}
public bool Exists(FileSystemPath path)
{
- return path.IsFile ? System.IO.File.Exists(GetPhysicalPath(path)) : System.IO.Directory.Exists(GetPhysicalPath(path));
+ var fullPath = GetPhysicalPath(path);
+ return path.IsFile ? File.Exists(fullPath) : Directory.Exists(fullPath);
}
public Stream CreateFile(FileSystemPath path)
{
if (!path.IsFile)
throw new ArgumentException("The specified path is not a file.", nameof(path));
- return System.IO.File.Create(GetPhysicalPath(path));
+ return File.Create(GetPhysicalPath(path));
}
public Stream OpenFile(FileSystemPath path, FileAccess access)
{
if (!path.IsFile)
throw new ArgumentException("The specified path is not a file.", nameof(path));
- return System.IO.File.Open(GetPhysicalPath(path), FileMode.Open, access);
+ return File.Open(GetPhysicalPath(path), FileMode.Open, access);
}
public void CreateDirectory(FileSystemPath path)
{
if (!path.IsDirectory)
throw new ArgumentException("The specified path is not a directory.", nameof(path));
- System.IO.Directory.CreateDirectory(GetPhysicalPath(path));
+ Directory.CreateDirectory(GetPhysicalPath(path));
}
public void Delete(FileSystemPath path)
{
if (path.IsFile)
- System.IO.File.Delete(GetPhysicalPath(path));
+ File.Delete(GetPhysicalPath(path));
else
- System.IO.Directory.Delete(GetPhysicalPath(path), true);
+ Directory.Delete(GetPhysicalPath(path), true);
}
public void Dispose()
diff --git a/pkNX.Containers/VFS/FileSystems/ReadOnlyFileSystem.cs b/pkNX.Containers/VFS/FileSystems/ReadOnlyFileSystem.cs
index 4b2c35a3..80f6dbf1 100644
--- a/pkNX.Containers/VFS/FileSystems/ReadOnlyFileSystem.cs
+++ b/pkNX.Containers/VFS/FileSystems/ReadOnlyFileSystem.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
+using System.Runtime.CompilerServices;
namespace pkNX.Containers.VFS;
@@ -21,16 +22,31 @@ public void Dispose()
GC.SuppressFinalize(this);
}
- public IEnumerable GetEntities(FileSystemPath path)
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public IEnumerable GetEntityPaths(FileSystemPath path)
{
- return FileSystem.GetEntities(path);
+ return FileSystem.GetEntityPaths(path);
}
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public IEnumerable GetDirectoryPaths(FileSystemPath path)
+ {
+ return FileSystem.GetDirectoryPaths(path);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public IEnumerable GetFilePaths(FileSystemPath path)
+ {
+ return FileSystem.GetFilePaths(path);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool Exists(FileSystemPath path)
{
return FileSystem.Exists(path);
}
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public Stream OpenFile(FileSystemPath path, FileAccess access)
{
if (access != FileAccess.Read)
diff --git a/pkNX.Containers/VFS/FileSystems/RelativeFileSystem.cs b/pkNX.Containers/VFS/FileSystems/RelativeFileSystem.cs
new file mode 100644
index 00000000..c02fc677
--- /dev/null
+++ b/pkNX.Containers/VFS/FileSystems/RelativeFileSystem.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+
+namespace pkNX.Containers.VFS;
+
+public delegate FileSystemPath PathTransformation(FileSystemPath arg);
+
+public class RelativeFileSystem : IFileSystem
+{
+ public IFileSystem FileSystem { get; }
+ public bool IsReadOnly => FileSystem.IsReadOnly;
+
+ public PathTransformation ToAbsolutePath { get; }
+ public PathTransformation ToRelativePath { get; }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public RelativeFileSystem(IFileSystem fileSystem, PathTransformation toAbsolutePath, PathTransformation toRelativePath)
+ {
+ FileSystem = fileSystem;
+ ToAbsolutePath = toAbsolutePath;
+ ToRelativePath = toRelativePath;
+ }
+
+ public void Dispose()
+ {
+ FileSystem.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public IEnumerable GetEntityPaths(FileSystemPath path)
+ {
+ return FileSystem.GetEntityPaths(ToAbsolutePath(path))
+ .Select(p => ToRelativePath(p));
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public IEnumerable GetDirectoryPaths(FileSystemPath path)
+ {
+ return FileSystem.GetDirectoryPaths(ToAbsolutePath(path))
+ .Select(p => ToRelativePath(p));
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public IEnumerable GetFilePaths(FileSystemPath path)
+ {
+ return FileSystem.GetFilePaths(ToAbsolutePath(path))
+ .Select(p => ToRelativePath(p));
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool Exists(FileSystemPath path)
+ {
+ return FileSystem.Exists(ToAbsolutePath(path));
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Stream OpenFile(FileSystemPath path, FileAccess access)
+ {
+ return FileSystem.OpenFile(ToAbsolutePath(path), access);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Stream CreateFile(FileSystemPath path)
+ {
+ return FileSystem.CreateFile(ToAbsolutePath(path));
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void CreateDirectory(FileSystemPath path)
+ {
+ FileSystem.CreateDirectory(ToAbsolutePath(path));
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void Delete(FileSystemPath path)
+ {
+ FileSystem.Delete(ToAbsolutePath(path));
+ }
+}
diff --git a/pkNX.Containers/VFS/Util/FileSystemEntity.cs b/pkNX.Containers/VFS/Util/FileSystemEntity.cs
deleted file mode 100644
index c3ace916..00000000
--- a/pkNX.Containers/VFS/Util/FileSystemEntity.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using System;
-
-namespace pkNX.Containers.VFS;
-
-public class FileSystemEntity : IEquatable
-{
- public IFileSystem FileSystem { get; }
- public FileSystemPath Path { get; }
- public string Name => Path.EntityName;
-
- public FileSystemEntity(IFileSystem fileSystem, FileSystemPath path)
- {
- FileSystem = fileSystem;
- Path = path;
- }
-
- public override bool Equals(object? obj)
- {
- return obj is FileSystemEntity other && ((IEquatable)this).Equals(other);
- }
-
- public override int GetHashCode()
- {
- return FileSystem.GetHashCode() ^ Path.GetHashCode();
- }
-
- bool IEquatable.Equals(FileSystemEntity? other)
- {
- return FileSystem.Equals(other?.FileSystem) && Path.Equals(other.Path);
- }
-
- public static FileSystemEntity Create(IFileSystem fileSystem, FileSystemPath path)
- {
- if (path.IsFile)
- return new VirtualFile(fileSystem, path);
-
- return new VirtualDirectory(fileSystem, path);
- }
-}
-
diff --git a/pkNX.Containers/VFS/Util/FileSystemExtensions.cs b/pkNX.Containers/VFS/Util/FileSystemExtensions.cs
index 9f280787..e2267be9 100644
--- a/pkNX.Containers/VFS/Util/FileSystemExtensions.cs
+++ b/pkNX.Containers/VFS/Util/FileSystemExtensions.cs
@@ -7,32 +7,17 @@ namespace pkNX.Containers.VFS;
public static class FileSystemExtensions
{
- public static Stream Open(this VirtualFile file, FileAccess access)
- {
- return file.FileSystem.OpenFile(file.Path, access);
- }
-
- public static void Delete(this FileSystemEntity entity)
- {
- entity.FileSystem.Delete(entity.Path);
- }
-
public static IEnumerable GetEntityPaths(this VirtualDirectory directory)
{
- return directory.FileSystem.GetEntities(directory.Path);
- }
-
- public static IEnumerable GetEntities(this VirtualDirectory directory)
- {
- var paths = directory.GetEntityPaths();
- return paths.Select(p => FileSystemEntity.Create(directory.FileSystem, p));
+ return directory.FileSystem.GetEntityPaths(directory.Path);
}
public static IEnumerable GetEntitiesRecursive(this IFileSystem fileSystem, FileSystemPath path)
{
if (!path.IsDirectory)
throw new ArgumentException("The specified path is not a directory.");
- foreach (var entity in fileSystem.GetEntities(path))
+
+ foreach (var entity in fileSystem.GetEntityPaths(path))
{
yield return entity;
diff --git a/pkNX.Containers/VFS/Util/FileSystemPath.cs b/pkNX.Containers/VFS/Util/FileSystemPath.cs
index cc8c88c3..ed0c5d5e 100644
--- a/pkNX.Containers/VFS/Util/FileSystemPath.cs
+++ b/pkNX.Containers/VFS/Util/FileSystemPath.cs
@@ -6,31 +6,30 @@
namespace pkNX.Containers.VFS;
-public readonly struct FileSystemPath : IEquatable, IComparable
+public readonly record struct FileSystemPath : IComparable
{
public const char DirectorySeparator = '/';
- public static FileSystemPath Root { get; }
+ public static FileSystemPath Root { get; } = new(DirectorySeparator.ToString());
- public string Path { get; } = "/";
-
- public bool IsDirectory => Path[^1] == DirectorySeparator;
+ public string Path { get; }
+ public bool IsDirectory => Path.EndsWith(DirectorySeparator);
public bool IsFile => !IsDirectory;
-
public bool IsRoot => Path.Length == 1;
public string EntityName
{
get
{
- string name = Path;
if (IsRoot)
return string.Empty;
- int endOfName = name.Length;
+
+ int endOfName = Path.Length;
if (IsDirectory)
- endOfName--;
- int startOfName = name.LastIndexOf(DirectorySeparator, endOfName - 1, endOfName) + 1;
- return name[startOfName..endOfName];
+ --endOfName;
+
+ int startOfName = Path.LastIndexOf(DirectorySeparator, endOfName - 1, endOfName) + 1;
+ return Path[startOfName..endOfName];
}
}
@@ -38,22 +37,16 @@ public FileSystemPath ParentPath
{
get
{
- string parentPath = Path;
if (IsRoot)
throw new InvalidOperationException("There is no parent of root.");
- int lookaheadCount = parentPath.Length;
- if (IsDirectory)
- lookaheadCount--;
- int index = parentPath.LastIndexOf(DirectorySeparator, lookaheadCount - 1, lookaheadCount);
- Debug.Assert(index >= 0);
- parentPath = parentPath.Remove(index + 1);
- return new FileSystemPath(parentPath);
- }
- }
- static FileSystemPath()
- {
- Root = new FileSystemPath(DirectorySeparator.ToString());
+ int endOfPath = Path.Length;
+ if (IsDirectory)
+ --endOfPath;
+
+ endOfPath = Path.LastIndexOf(DirectorySeparator, endOfPath - 1, endOfPath) + 1;
+ return new(Path[..endOfPath]);
+ }
}
private FileSystemPath(string path)
@@ -63,8 +56,7 @@ private FileSystemPath(string path)
public static implicit operator FileSystemPath(string path)
{
- var parsed = FileSystemPath.Parse(path);
- return parsed;
+ return Parse(path);
}
public static implicit operator string(FileSystemPath path)
@@ -74,9 +66,7 @@ private FileSystemPath(string path)
public static bool IsRooted(string s)
{
- if (s.Length == 0)
- return false;
- return s[0] == DirectorySeparator;
+ return s.StartsWith(DirectorySeparator);
}
public static FileSystemPath Parse(string s)
@@ -87,24 +77,25 @@ public static FileSystemPath Parse(string s)
throw new UriFormatException($"Could not parse input \"{s}\": Path is not rooted.");
if (s.Contains(string.Concat(DirectorySeparator, DirectorySeparator)))
throw new UriFormatException($"Could not parse input \"{s}\": Path contains double directory-separators.");
- return new FileSystemPath(s);
+ return new(s);
}
- public FileSystemPath AppendPath(string relativePath)
+ [Pure]
+ public FileSystemPath AppendPath(string strPath)
{
- if (IsRooted(relativePath))
- throw new ArgumentException("The specified path should be relative.", nameof(relativePath));
if (!IsDirectory)
throw new InvalidOperationException("This FileSystemPath is not a directory.");
- return new FileSystemPath(Path + relativePath);
+
+ if (IsRooted(strPath))
+ throw new ArgumentException("The specified path is a rooted path.", nameof(strPath));
+
+ return new(Path + strPath);
}
[Pure]
public FileSystemPath AppendPath(FileSystemPath path)
{
- if (!IsDirectory)
- throw new InvalidOperationException("This FileSystemPath is not a directory.");
- return new FileSystemPath(Path + path.Path[1..]);
+ return AppendPath(path.Path[1..]);
}
[Pure]
@@ -130,7 +121,10 @@ public FileSystemPath AppendFile(string fileName)
[Pure]
public bool IsParentOf(FileSystemPath path)
{
- return IsDirectory && Path.Length != path.Path.Length && path.Path.StartsWith(Path);
+ if (!IsDirectory)
+ throw new ArgumentException($"Path \"{Path}\" can not be a parent: it is not a directory.");
+
+ return Path.Length != path.Path.Length && path.Path.StartsWith(Path);
}
[Pure]
@@ -140,13 +134,16 @@ public bool IsChildOf(FileSystemPath path)
}
[Pure]
- public FileSystemPath RemoveParent(FileSystemPath parent)
+ public FileSystemPath MakeRelativeTo(FileSystemPath parent)
{
- if (!parent.IsDirectory)
- throw new ArgumentException("The specified path can not be the parent of this path: it is not a directory.");
- if (!Path.StartsWith(parent.Path))
- throw new ArgumentException("The specified path is not a parent of this path.");
- return new FileSystemPath(Path.Remove(0, parent.Path.Length - 1));
+ if (Path == parent.Path)
+ return Root;
+
+ if (!IsChildOf(parent))
+ throw new ArgumentException($"Path \"{parent}\" is not a parent of \"{Path}\".");
+
+ int parentPathEnd = parent.Path.Length - 1;
+ return new(Path[parentPathEnd..]);
}
[Pure]
@@ -205,32 +202,4 @@ public override string ToString()
{
return Path;
}
-
- [Pure]
- public override bool Equals(object? obj)
- {
- return obj is FileSystemPath path && Equals(path);
- }
-
- [Pure]
- public bool Equals(FileSystemPath other)
- {
- return other.Path.Equals(Path);
- }
-
- [Pure]
- public override int GetHashCode()
- {
- return Path.GetHashCode();
- }
-
- public static bool operator ==(FileSystemPath pathA, FileSystemPath pathB)
- {
- return pathA.Equals(pathB);
- }
-
- public static bool operator !=(FileSystemPath pathA, FileSystemPath pathB)
- {
- return !(pathA == pathB);
- }
}
diff --git a/pkNX.Containers/VFS/Util/IFileSystemEntity.cs b/pkNX.Containers/VFS/Util/IFileSystemEntity.cs
new file mode 100644
index 00000000..07c646dc
--- /dev/null
+++ b/pkNX.Containers/VFS/Util/IFileSystemEntity.cs
@@ -0,0 +1,31 @@
+namespace pkNX.Containers.VFS;
+
+public interface IFileSystemEntity
+{
+ IFileSystem FileSystem { get; }
+ FileSystemPath Path { get; }
+ string Name { get; }
+ VirtualDirectory ParentDirectory { get; }
+
+ internal static IFileSystemEntity Create(IFileSystem fileSystem, FileSystemPath path)
+ {
+ if (path.IsFile)
+ return VirtualFile.Create(fileSystem, path);
+
+ return VirtualDirectory.Create(fileSystem, path);
+ }
+}
+
+public static class IFileSystemEntityExtensions
+{
+ public static void Delete(this IFileSystemEntity e)
+ {
+ e.FileSystem.Delete(e.Path);
+ }
+
+ public static void Exists(this IFileSystemEntity e)
+ {
+ e.FileSystem.Exists(e.Path);
+ }
+}
+
diff --git a/pkNX.Containers/VFS/Util/VirtualDirectory.cs b/pkNX.Containers/VFS/Util/VirtualDirectory.cs
index b813f0ae..3c13c8db 100644
--- a/pkNX.Containers/VFS/Util/VirtualDirectory.cs
+++ b/pkNX.Containers/VFS/Util/VirtualDirectory.cs
@@ -1,22 +1,19 @@
using System;
+using System.Collections.Generic;
namespace pkNX.Containers.VFS;
-public class VirtualDirectory : FileSystemEntity, IEquatable
+public readonly record struct VirtualDirectory(IFileSystem FileSystem, FileSystemPath Path) : IFileSystemEntity
{
- public VirtualDirectory(IFileSystem fileSystem, FileSystemPath path) : base(fileSystem, path)
+ public string Name => Path.EntityName;
+ public VirtualDirectory ParentDirectory => Create(FileSystem, Path.ParentPath);
+
+
+ internal static VirtualDirectory Create(IFileSystem fileSystem, FileSystemPath path)
{
if (!path.IsDirectory)
throw new ArgumentException("The specified path is no directory.", nameof(path));
- }
- public bool Equals(VirtualDirectory? other)
- {
- return ((IEquatable)this).Equals(other);
- }
-
- public override bool Equals(object? obj)
- {
- return Equals(obj as VirtualDirectory);
+ return new VirtualDirectory(fileSystem, path);
}
}
diff --git a/pkNX.Containers/VFS/Util/VirtualFile.cs b/pkNX.Containers/VFS/Util/VirtualFile.cs
index 9f41bdc4..b24c9f3e 100644
--- a/pkNX.Containers/VFS/Util/VirtualFile.cs
+++ b/pkNX.Containers/VFS/Util/VirtualFile.cs
@@ -1,24 +1,23 @@
using System;
+using System.IO;
namespace pkNX.Containers.VFS;
-public class VirtualFile : FileSystemEntity, IEquatable
+public readonly record struct VirtualFile(IFileSystem FileSystem, FileSystemPath Path) : IFileSystemEntity
{
- public VirtualFile(IFileSystem fileSystem, FileSystemPath path) :
- base(fileSystem, path)
+ public string Name => Path.EntityName;
+ public VirtualDirectory ParentDirectory => VirtualDirectory.Create(FileSystem, Path.ParentPath);
+
+ public Stream Open(FileAccess access)
+ {
+ return FileSystem.OpenFile(Path, access);
+ }
+
+ internal static VirtualFile Create(IFileSystem fileSystem, FileSystemPath path)
{
if (!path.IsFile)
throw new ArgumentException("The specified path is no file.", nameof(path));
- }
- public bool Equals(VirtualFile? other)
- {
- return ((IEquatable)this).Equals(other);
- }
-
- public override bool Equals(object? obj)
- {
- return Equals(obj as VirtualFile);
+ return new VirtualFile(fileSystem, path);
}
}
-
diff --git a/pkNX.Containers/VFS/VirtualFileSystem.cs b/pkNX.Containers/VFS/VirtualFileSystem.cs
index db86247b..60ab2193 100644
--- a/pkNX.Containers/VFS/VirtualFileSystem.cs
+++ b/pkNX.Containers/VFS/VirtualFileSystem.cs
@@ -2,22 +2,36 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Runtime.CompilerServices;
namespace pkNX.Containers.VFS;
-public record MountPoint(FileSystemPath Path, IFileSystem FileSystem) : IComparable
+public record MountPoint
{
- public int CompareTo(MountPoint? other)
+ public FileSystemPath MountPath { get; }
+ public IFileSystem FileSystem { get; }
+
+ public FileSystemPath ToAbsolutePath(FileSystemPath path)
{
- return other?.Path.CompareTo(Path) ?? 1;
+ return MountPath.AppendPath(path);
+ }
+
+ public FileSystemPath ToRelativePath(FileSystemPath path)
+ {
+ return path.IsRoot ? path : path.MakeRelativeTo(MountPath);
+ }
+
+ public MountPoint(FileSystemPath mountPath, IFileSystem fileSystem)
+ {
+ MountPath = mountPath;
+ FileSystem = fileSystem.AsRelativeFileSystem(ToAbsolutePath, ToRelativePath);
}
}
public class VirtualFileSystem : IFileSystem
{
- public bool IsReadOnly => Mounts.All(x => x.FileSystem.IsReadOnly);
-
public SortedSet Mounts { get; }
+ public bool IsReadOnly => Mounts.All(x => x.FileSystem.IsReadOnly);
public VirtualFileSystem(IEnumerable mounts)
{
@@ -28,11 +42,6 @@ public VirtualFileSystem(IEnumerable mounts)
this(mounts.AsEnumerable())
{ }
- protected MountPoint Get(FileSystemPath path)
- {
- return Mounts.First(pair => pair.Path == path || pair.Path.IsParentOf(path));
- }
-
public void Dispose()
{
foreach (var fs in Mounts.Select(x => x.FileSystem))
@@ -41,40 +50,64 @@ public void Dispose()
GC.SuppressFinalize(this);
}
- public IEnumerable GetEntities(FileSystemPath path)
+ protected MountPoint GetMountPoint(FileSystemPath path)
{
- MountPoint point = Get(path);
- IEnumerable entities = point.FileSystem.GetEntities(path.IsRoot ? path : path.RemoveParent(point.Path));
- return entities.Select(p => point.Path.AppendPath(p));
+ return Mounts.First(mount => mount.MountPath == path || mount.MountPath.IsParentOf(path));
}
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public IEnumerable GetEntityPaths(FileSystemPath path)
+ {
+ var mount = GetMountPoint(path);
+ return mount.FileSystem.GetEntityPaths(path);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public IEnumerable GetDirectoryPaths(FileSystemPath path)
+ {
+ var mount = GetMountPoint(path);
+ return mount.FileSystem.GetDirectoryPaths(path);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public IEnumerable GetFilePaths(FileSystemPath path)
+ {
+ var mount = GetMountPoint(path);
+ return mount.FileSystem.GetFilePaths(path);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool Exists(FileSystemPath path)
{
- var pair = Get(path);
- return pair.FileSystem.Exists(path.RemoveParent(pair.Path));
+ var mount = GetMountPoint(path);
+ return mount.FileSystem.Exists(path);
}
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public Stream CreateFile(FileSystemPath path)
{
- var pair = Get(path);
- return pair.FileSystem.CreateFile(path.RemoveParent(pair.Path));
+ var mount = GetMountPoint(path);
+ return mount.FileSystem.CreateFile(path);
}
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public Stream OpenFile(FileSystemPath path, FileAccess access)
{
- var pair = Get(path);
- return pair.FileSystem.OpenFile(path.RemoveParent(pair.Path), access);
+ var mount = GetMountPoint(path);
+ return mount.FileSystem.OpenFile(path, access);
}
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void CreateDirectory(FileSystemPath path)
{
- var pair = Get(path);
- pair.FileSystem.CreateDirectory(path.RemoveParent(pair.Path));
+ var mount = GetMountPoint(path);
+ mount.FileSystem.CreateDirectory(path);
}
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Delete(FileSystemPath path)
{
- var pair = Get(path);
- pair.FileSystem.Delete(path.RemoveParent(pair.Path));
+ var mount = GetMountPoint(path);
+ mount.FileSystem.Delete(path);
}
}
diff --git a/pkNX.Game/GameManagerPLA.cs b/pkNX.Game/GameManagerPLA.cs
index 67dff32d..471bf74d 100644
--- a/pkNX.Game/GameManagerPLA.cs
+++ b/pkNX.Game/GameManagerPLA.cs
@@ -39,6 +39,10 @@ protected override void SetMitm()
var layeredFS = new LayeredFileSystem(moddedRomFS, cleanRomFS);
VFS = new VirtualFileSystem(new MountPoint("/romfs/", layeredFS));
var file = VFS.OpenFile("/romfs/bin/pokemon/data/poke_ai.bin", FileAccess.ReadWrite);
+
+ FileSystemPath path = "/romfs/bin/pokemon/data/poke_ai.bin";
+ var parent = path.ParentPath;
+ var relative = path.MakeRelativeTo(parent);
}
public override void Initialize()
diff --git a/pkNX.Tests/VFS/AssertExtensions.cs b/pkNX.Tests/VFS/AssertExtensions.cs
new file mode 100644
index 00000000..6b84bcee
--- /dev/null
+++ b/pkNX.Tests/VFS/AssertExtensions.cs
@@ -0,0 +1,22 @@
+using System;
+using Xunit;
+
+namespace SharpFileSystem.Tests
+{
+ public static class EAssert
+ {
+ public static void Throws(Action a)
+ where T : Exception
+ {
+ try
+ {
+ a();
+ }
+ catch (T)
+ {
+ return;
+ }
+ Assert.False(true, $"The exception '{typeof(T).FullName}' was not thrown.");
+ }
+ }
+}
diff --git a/pkNX.Tests/VFS/CopierTest.cs b/pkNX.Tests/VFS/CopierTest.cs
new file mode 100644
index 00000000..c6cc2e08
--- /dev/null
+++ b/pkNX.Tests/VFS/CopierTest.cs
@@ -0,0 +1,39 @@
+using System.IO;
+using SharpFileSystem.FileSystems;
+using Xunit;
+
+namespace SharpFileSystem.Tests
+{
+ public class CopierTest
+ {
+ [Fact]
+ public void TestCopy()
+ {
+ var memFs1 = new MemoryFileSystem();
+ var memFs2 = new MemoryFileSystem();
+
+ memFs1.CreateDirectory("/fs1/");
+ memFs1.CreateDirectory("/fs1/memory/");
+
+ memFs1.WriteAllText("/fs1/memory/test1.txt", "hello1");
+ memFs1.WriteAllText("/fs1/memory/test2.txt", "hello2");
+
+
+ memFs2.CreateDirectory("/fs2/");
+ //memFs2.CreateDirectory("/fs2/memory/");
+ var copier = new StandardEntityCopier();
+ copier.Copy(memFs1, "/fs1/memory/", memFs2, "/fs2/memory/");
+ var entities = memFs2.GetEntities("/fs2/memory/");
+ Assert.Equal(2, entities.Count);
+ Assert.True(memFs2.Exists("/fs2/memory/test1.txt"));
+ Assert.True(memFs2.Exists("/fs2/memory/test2.txt"));
+ var content1 = memFs2.ReadAllText("/fs2/memory/test1.txt");
+ Assert.Equal("hello1",content1);
+ var content2 = memFs2.ReadAllText("/fs2/memory/test2.txt");
+ Assert.Equal("hello2",content2);
+
+
+
+ }
+ }
+}
diff --git a/pkNX.Tests/VFS/ExtensionsTests.cs b/pkNX.Tests/VFS/ExtensionsTests.cs
new file mode 100644
index 00000000..b41214e2
--- /dev/null
+++ b/pkNX.Tests/VFS/ExtensionsTests.cs
@@ -0,0 +1,26 @@
+using System.Linq;
+using Xunit;
+
+namespace pkNX.Tests;
+
+public class ExtensionsTests
+{
+ [Fact]
+ public void testRecursiveDirectoryCreation()
+ {
+ var mem = new MemoryFileSystem();
+ mem.CreateDirectoryRecursive("/memory/deep/deeper/deepest/");
+ Assert.True(mem.Exists("/memory/"));
+ Assert.True(mem.Exists("/memory/deep/"));
+ Assert.True(mem.Exists("/memory/deep/deeper/"));
+ Assert.True(mem.Exists("/memory/deep/deeper/deepest/"));
+ }
+
+ [Fact]
+ public void GetEntitiesRecursiveTest()
+ {
+ EmbeddedResourceFileSystem eFS = new EmbeddedResourceFileSystem(typeof(ExtensionsTests).Assembly);
+ var entities = eFS.GetEntitiesRecursive("/");
+ Assert.Equal(4, entities.Count());
+ }
+}
diff --git a/pkNX.Tests/VFS/FileSystemPathTest.cs b/pkNX.Tests/VFS/FileSystemPathTest.cs
new file mode 100644
index 00000000..0a4e9f1a
--- /dev/null
+++ b/pkNX.Tests/VFS/FileSystemPathTest.cs
@@ -0,0 +1,309 @@
+using System.Collections.Generic;
+using System.Linq;
+using System;
+using pkNX.Containers.VFS;
+using SharpFileSystem.Tests;
+using Xunit;
+
+namespace pkNX.Tests;
+
+///
+///This is a test class for FileSystemPathTest and is intended
+///to contain all FileSystemPathTest Unit Tests
+///
+public class FileSystemPathTest
+{
+ private FileSystemPath[] _paths = { root, directoryA, fileA, directoryB, fileB };
+ private IEnumerable Directories { get { return _paths.Where(p => p.IsDirectory); } }
+ private IEnumerable Files { get { return _paths.Where(p => p.IsFile); } }
+
+ private static readonly FileSystemPath directoryA = "/directorya/";
+ private static FileSystemPath fileA = "/filea";
+ private static FileSystemPath directoryB = "/directorya/directoryb/";
+ private static FileSystemPath fileB = FileSystemPath.Parse("/directorya/fileb.txt");
+ private static FileSystemPath root = FileSystemPath.Root;
+ private FileSystemPath fileC;
+
+ ///
+ ///A test for Root
+ ///
+ [Fact]
+ public void RootTest()
+ {
+ Assert.Equal(FileSystemPath.Parse("/"), root);
+ }
+
+ ///
+ ///A test for ParentPath
+ ///
+ [Fact]
+ public void ParentPathTest()
+ {
+ Assert.True(Directories.Where(d => d.GetDirectorySegments().Count() == 1)
+ .All(d => d.ParentPath == root));
+
+ Assert.DoesNotContain(Files, f => f.RemoveChild(root.AppendFile(f.EntityName)) != f.ParentPath);
+ EAssert.Throws(() => Assert.Equal(root.ParentPath, root.ParentPath));
+ }
+
+ ///
+ ///A test for IsRoot
+ ///
+ [Fact]
+ public void IsRootTest()
+ {
+ Assert.True(root.IsRoot);
+ Assert.False(directoryA.IsRoot);
+ Assert.False(fileA.IsRoot);
+ }
+
+ ///
+ ///A test for IsFile
+ ///
+ [Fact]
+ public void IsFileTest()
+ {
+
+ Assert.True(fileA.IsFile);
+ Assert.False(directoryA.IsFile);
+ Assert.False(root.IsFile);
+ }
+
+ ///
+ ///A test for IsDirectory
+ ///
+ [Fact]
+ public void IsDirectoryTest()
+ {
+ Assert.True(directoryA.IsDirectory);
+ Assert.True(root.IsDirectory);
+ Assert.False(fileA.IsDirectory);
+ }
+
+ ///
+ ///A test for EntityName
+ ///
+ [Fact]
+ public void EntityNameTest()
+ {
+ Assert.Equal("filea", fileA.EntityName);
+ Assert.Equal("fileb.txt", fileB.EntityName);
+ Assert.Equal(string.Empty, root.EntityName);
+ }
+
+ ///
+ ///A test for ToString
+ ///
+ [Fact]
+ public void ToStringTest()
+ {
+ string s = "/directorya/";
+ Assert.Equal(s, FileSystemPath.Parse(s).ToString());
+ }
+
+ ///
+ ///A test for MakeRelativeTo
+ ///
+ [Fact]
+ public void MakeRelativeToTest()
+ {
+ Assert.Equal(directoryB.MakeRelativeTo(directoryB), root);
+ Assert.Equal(fileB.MakeRelativeTo(directoryA), FileSystemPath.Parse("/fileb.txt"));
+ Assert.Equal(root.MakeRelativeTo(root), root);
+ Assert.Equal(directoryB.MakeRelativeTo(root), directoryB);
+ EAssert.Throws(() => fileB.MakeRelativeTo(FileSystemPath.Parse("/nonexistantparent/")));
+ EAssert.Throws(() => fileB.MakeRelativeTo(FileSystemPath.Parse("/nonexistantparent")));
+ EAssert.Throws(() => fileB.MakeRelativeTo(FileSystemPath.Parse("/fileb.txt")));
+ EAssert.Throws(() => fileB.MakeRelativeTo(FileSystemPath.Parse("/directorya")));
+ }
+
+ ///
+ ///A test for RemoveChild
+ ///
+ [Fact]
+ public void RemoveChildTest()
+ {
+ Assert.Equal(fileB.RemoveChild(FileSystemPath.Parse("/fileb.txt")), directoryA);
+ Assert.Equal(directoryB.RemoveChild(FileSystemPath.Parse("/directoryb/")), directoryA);
+ Assert.Equal(directoryB.RemoveChild(directoryB), root);
+ Assert.Equal(fileB.RemoveChild(fileB), root);
+ EAssert.Throws(() => directoryA.RemoveChild(FileSystemPath.Parse("/nonexistantchild")));
+ EAssert.Throws(() => directoryA.RemoveChild(FileSystemPath.Parse("/directorya")));
+ }
+
+ ///
+ ///A test for Parse
+ ///
+ [Fact]
+ public void ParseTest()
+ {
+ Assert.True(_paths.All(p => p == FileSystemPath.Parse(p.ToString())));
+ EAssert.Throws(() => FileSystemPath.Parse("thisisnotapath"));
+ EAssert.Throws(() => FileSystemPath.Parse("/thisisainvalid//path"));
+ }
+
+ ///
+ ///A test for IsRooted
+ ///
+ [Fact]
+ public void IsRootedTest()
+ {
+ Assert.True(FileSystemPath.IsRooted("/filea"));
+ Assert.True(FileSystemPath.IsRooted("/directorya/"));
+ Assert.False(FileSystemPath.IsRooted("filea"));
+ Assert.False(FileSystemPath.IsRooted("directorya/"));
+ Assert.True(_paths.All(p => FileSystemPath.IsRooted(p.ToString())));
+ }
+
+ ///
+ ///A test for IsParentOf
+ ///
+ [Fact]
+ public void IsParentOfTest()
+ {
+ Assert.True(directoryA.IsParentOf(fileB));
+ Assert.True(directoryA.IsParentOf(directoryB));
+ Assert.True(root.IsParentOf(fileA));
+ Assert.True(root.IsParentOf(directoryA));
+ Assert.True(root.IsParentOf(fileB));
+ Assert.True(root.IsParentOf(directoryB));
+
+ EAssert.Throws(() => fileB.IsParentOf(directoryA));
+ EAssert.Throws(() => fileA.IsParentOf(root));
+ EAssert.Throws(() => fileB.IsParentOf(root));
+ Assert.False(directoryB.IsParentOf(directoryA));
+ Assert.False(directoryA.IsParentOf(root));
+ Assert.False(directoryB.IsParentOf(root));
+ }
+
+ ///
+ ///A test for IsChildOf
+ ///
+ [Fact]
+ public void IsChildOfTest()
+ {
+ Assert.True(fileB.IsChildOf(directoryA));
+ Assert.True(directoryB.IsChildOf(directoryA));
+ Assert.True(fileA.IsChildOf(root));
+ Assert.True(directoryA.IsChildOf(root));
+ Assert.True(fileB.IsChildOf(root));
+ Assert.True(directoryB.IsChildOf(root));
+
+ EAssert.Throws(() => directoryA.IsChildOf(fileB));
+ EAssert.Throws(() => root.IsChildOf(fileA));
+ EAssert.Throws(() => root.IsChildOf(fileB));
+
+ Assert.False(directoryA.IsChildOf(directoryB));
+ Assert.False(root.IsChildOf(directoryA));
+ Assert.False(root.IsChildOf(directoryB));
+ }
+
+ ///
+ ///A test for GetExtension
+ ///
+ [Fact]
+ public void GetExtensionTest()
+ {
+ Assert.Equal("", fileA.GetExtension());
+ Assert.Equal(".txt", fileB.GetExtension());
+ fileC = FileSystemPath.Parse("/directory.txt/filec");
+ Assert.Equal("", fileC.GetExtension());
+ EAssert.Throws(() => directoryA.GetExtension());
+ }
+
+ ///
+ ///A test for GetDirectorySegments
+ ///
+ [Fact]
+ public void GetDirectorySegmentsTest()
+ {
+ Assert.Empty(root.GetDirectorySegments());
+ Directories
+ .Where(d => !d.IsRoot)
+ .All(d => d.GetDirectorySegments().Count() == d.ParentPath.GetDirectorySegments().Count() - 1);
+ Files.All(f => f.GetDirectorySegments().Count() == f.ParentPath.GetDirectorySegments().Count());
+ }
+
+
+ ///
+ ///A test for CompareTo
+ ///
+ [Fact]
+ public void CompareToTest()
+ {
+ foreach (var pa in _paths)
+ {
+ foreach (var pb in _paths)
+ Assert.Equal(Math.Sign(pa.CompareTo(pb)), Math.Sign(string.Compare(pa.ToString(), pb.ToString(), StringComparison.Ordinal)));
+ }
+ }
+
+ ///
+ ///A test for ChangeExtension
+ ///
+ [Fact]
+ public void ChangeExtensionTest()
+ {
+ foreach (var p in _paths.Where(p => p.IsFile))
+ Assert.True(p.ChangeExtension(".exe").GetExtension() == ".exe");
+ EAssert.Throws(() => directoryA.ChangeExtension(".exe"));
+ }
+
+ ///
+ ///A test for AppendPath
+ ///
+ [Fact]
+ public void AppendPathTest()
+ {
+ Assert.True(Directories.All(p => p.AppendPath(root) == p));
+ Assert.True(Directories.All(p => p.AppendPath("") == p));
+
+ var subpath = FileSystemPath.Parse("/dir/file");
+ var subpathstr = "dir/file";
+ foreach (var p in Directories)
+ Assert.True(p.AppendPath(subpath).ParentPath.ParentPath == p);
+ foreach (var p in Directories)
+ Assert.True(p.AppendPath(subpathstr).ParentPath.ParentPath == p);
+ foreach (var pa in Directories)
+ {
+ foreach (var pb in _paths.Where(pb => !pb.IsRoot))
+ Assert.True(pa.AppendPath(pb).IsChildOf(pa));
+ }
+
+ EAssert.Throws(() => fileA.AppendPath(subpath));
+ EAssert.Throws(() => fileA.AppendPath(subpathstr));
+ EAssert.Throws(() => directoryA.AppendPath("/rootedpath/"));
+ }
+
+ ///
+ ///A test for AppendFile
+ ///
+ [Fact]
+ public void AppendFileTest()
+ {
+ foreach (var d in Directories)
+ Assert.True(d.AppendFile("file").IsFile);
+ foreach (var d in Directories)
+ Assert.True(d.AppendFile("file").EntityName == "file");
+ foreach (var d in Directories)
+ Assert.True(d.AppendFile("file").ParentPath == d);
+ EAssert.Throws(() => fileA.AppendFile("file"));
+ EAssert.Throws(() => directoryA.AppendFile("dir/file"));
+ }
+
+ ///
+ ///A test for AppendDirectory
+ ///
+ [Fact]
+ public void AppendDirectoryTest()
+ {
+ foreach (var d in Directories)
+ Assert.True(d.AppendDirectory("dir").IsDirectory);
+ foreach (var d in Directories)
+ Assert.True(d.AppendDirectory("dir").EntityName == "dir");
+ foreach (var d in Directories)
+ Assert.True(d.AppendDirectory("dir").ParentPath == d);
+ EAssert.Throws(() => fileA.AppendDirectory("dir"));
+ EAssert.Throws(() => root.AppendDirectory("dir/dir"));
+ }
+}
diff --git a/pkNX.Tests/VFS/FileSystems/EntityMoverRegistrationTest.cs b/pkNX.Tests/VFS/FileSystems/EntityMoverRegistrationTest.cs
new file mode 100644
index 00000000..2b903cc1
--- /dev/null
+++ b/pkNX.Tests/VFS/FileSystems/EntityMoverRegistrationTest.cs
@@ -0,0 +1,57 @@
+using SharpFileSystem.Collections;
+using SharpFileSystem.FileSystems;
+using Xunit;
+
+namespace SharpFileSystem.Tests.FileSystems
+{
+
+ public class EntityMoverRegistrationTest
+ {
+ private TypeCombinationDictionary Registration;
+ private IEntityMover physicalEntityMover = new PhysicalEntityMover();
+ private IEntityMover standardEntityMover = new StandardEntityMover();
+
+ public EntityMoverRegistrationTest()
+ {
+ Registration = new TypeCombinationDictionary();
+ Registration.AddLast(typeof(PhysicalFileSystem), typeof(PhysicalFileSystem), physicalEntityMover);
+ Registration.AddLast(typeof(IFileSystem), typeof(IFileSystem), standardEntityMover);
+ }
+
+ [Fact]
+ public void When_MovingFromPhysicalToGenericFileSystem_Expect_StandardEntityMover()
+ {
+ Assert.Equal(
+ Registration.GetSupportedRegistration(typeof(PhysicalFileSystem), typeof(IFileSystem)).Value,
+ standardEntityMover
+ );
+ }
+
+ [Fact]
+ public void When_MovingFromOtherToPhysicalFileSystem_Expect_StandardEntityMover()
+ {
+ Assert.Equal(
+ Registration.GetSupportedRegistration(typeof(IFileSystem), typeof(PhysicalFileSystem)).Value,
+ standardEntityMover
+ );
+ }
+
+ [Fact]
+ public void When_MovingFromGenericToGenericFileSystem_Expect_StandardEntityMover()
+ {
+ Assert.Equal(
+ Registration.GetSupportedRegistration(typeof(IFileSystem), typeof(IFileSystem)).Value,
+ standardEntityMover
+ );
+ }
+
+ [Fact]
+ public void When_MovingFromPhysicalToPhysicalFileSystem_Expect_PhysicalEntityMover()
+ {
+ Assert.Equal(
+ Registration.GetSupportedRegistration(typeof(PhysicalFileSystem), typeof(PhysicalFileSystem)).Value,
+ physicalEntityMover
+ );
+ }
+ }
+}
diff --git a/pkNX.Tests/VFS/FileSystems/MemoryFSTests.cs b/pkNX.Tests/VFS/FileSystems/MemoryFSTests.cs
new file mode 100644
index 00000000..d52aa47b
--- /dev/null
+++ b/pkNX.Tests/VFS/FileSystems/MemoryFSTests.cs
@@ -0,0 +1,113 @@
+using SharpFileSystem.FileSystems;
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+using SharpFileSystem.IO;
+using Xunit;
+
+namespace SharpFileSystem.Tests.FileSystems
+{
+
+ public class MemoryFSTests
+ {
+ MemoryFileSystem FileSystem { get; set; }
+ FileSystemPath RootFilePath { get; } = FileSystemPath.Root.AppendFile("x");
+
+ public MemoryFSTests()
+ {
+ FileSystem = new MemoryFileSystem();
+ }
+
+
+ [Fact]
+ public void CreateFile()
+ {
+ // File shouldn’t exist prior to creation.
+ Assert.False(FileSystem.Exists(RootFilePath));
+
+ var content = new byte[] { 0xde, 0xad, 0xbe, 0xef, };
+ using (var xStream = FileSystem.CreateFile(RootFilePath))
+ {
+ // File now should exist.
+ Assert.True(FileSystem.Exists(RootFilePath));
+
+ xStream.Write(content, 0, content.Length);
+ }
+
+ // File should still exist and have content.
+ Assert.True(FileSystem.Exists(RootFilePath));
+ using (var xStream = FileSystem.OpenFile(RootFilePath, FileAccess.Read))
+ {
+ var readContent = new byte[2 * content.Length];
+ Assert.Equal(content.Length, xStream.Read(readContent, 0, readContent.Length));
+ Assert.Equal(
+ content,
+ // trim to the length that was read.
+ readContent.Take(content.Length).ToArray());
+
+ // Trying to read beyond end of file should return 0.
+ Assert.Equal(0, xStream.Read(readContent, 0, readContent.Length));
+ }
+ }
+
+ [Fact]
+ public void CreateFile_Exists()
+ {
+ Assert.False(FileSystem.Exists(RootFilePath));
+
+ // Place initial content.
+ using (var stream = FileSystem.CreateFile(RootFilePath))
+ {
+ stream.Write(Encoding.UTF8.GetBytes("asdf"));
+ }
+
+ Assert.True(FileSystem.Exists(RootFilePath));
+
+ // Replace—truncates.
+ var content = Encoding.UTF8.GetBytes("b");
+ using (var stream = FileSystem.CreateFile(RootFilePath))
+ {
+ stream.Write(content);
+ }
+
+ Assert.True(FileSystem.Exists(RootFilePath));
+ using (var stream = FileSystem.OpenFile(RootFilePath, FileAccess.Read))
+ Assert.Equal(content, stream.ReadAllBytes());
+ }
+
+ [Fact]
+ public void CreateFile_Empty()
+ {
+ using (var stream = FileSystem.CreateFile(RootFilePath))
+ {
+ }
+
+ Assert.True(FileSystem.Exists(RootFilePath));
+
+ using (var stream = FileSystem.OpenFile(RootFilePath, FileAccess.Read))
+ {
+ Assert.Equal(
+ new byte[] { },
+ stream.ReadAllBytes());
+ }
+ }
+
+ [Fact]
+ public void GetEntities()
+ {
+ for (var i = 0; i < 10; i++)
+ {
+ Console.WriteLine($"i={i}");
+ using (var stream = FileSystem.CreateFile(RootFilePath))
+ {
+ }
+
+ // Should exist once.
+ Assert.Equal(
+ new[] { RootFilePath, },
+ FileSystem.GetEntities(FileSystemPath.Root).ToArray());
+ }
+ }
+ }
+}
diff --git a/pkNX.Tests/VFS/FileSystems/PhysicalFSTests.cs b/pkNX.Tests/VFS/FileSystems/PhysicalFSTests.cs
new file mode 100644
index 00000000..309035ba
--- /dev/null
+++ b/pkNX.Tests/VFS/FileSystems/PhysicalFSTests.cs
@@ -0,0 +1,109 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+using pkNX.Containers.VFS;
+using Xunit;
+
+namespace pkNX.Tests;
+
+public class PhysicalFSTests : IDisposable
+{
+ string Root { get; set; }
+ PhysicalFileSystem FileSystem { get; set; }
+ string AbsoluteFileName { get; set; }
+
+ string FileName { get; }
+ FileSystemPath FileNamePath { get; }
+
+ public PhysicalFSTests()
+ {
+ FileName = "x";
+ FileNamePath = FileSystemPath.Root.AppendFile(FileName);
+ Root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Root);
+ AbsoluteFileName = Path.Combine(Root, FileName);
+ FileSystem = new PhysicalFileSystem(Root);
+
+ }
+
+ public void Dispose()
+ {
+ using (FileSystem) { }
+ Directory.Delete(Root, true);
+ }
+
+ [Fact]
+ public void CreateFile()
+ {
+ Assert.False(File.Exists(AbsoluteFileName));
+ Assert.False(FileSystem.Exists(FileNamePath));
+
+ var content = "asdf"u8.ToArray();
+ using (var stream = FileSystem.CreateFile(FileNamePath))
+ {
+ // File should exist at this point.
+ Assert.True(FileSystem.Exists(FileNamePath));
+ // File should also exist irl at this point.
+ Assert.True(File.Exists(AbsoluteFileName));
+
+ stream.Write(content, 0, content.Length);
+ }
+
+ // File should contain content.
+ Assert.Equal(content, File.ReadAllBytes(AbsoluteFileName));
+
+ using (var stream = FileSystem.OpenFile(FileNamePath, FileAccess.Read))
+ {
+ // Verify that EOF type stuff works.
+ var readContent = new byte[2 * content.Length];
+ Assert.Equal(content.Length, stream.Read(readContent, 0, readContent.Length));
+ Assert.Equal(
+ content,
+ // trim to actual length.
+ readContent.Take(content.Length).ToArray());
+
+ // Trying to read beyond end of file should just return 0.
+ Assert.Equal(0, stream.Read(readContent, 0, readContent.Length));
+ }
+ }
+
+ [Fact]
+ public void CreateFile_Exists()
+ {
+ Assert.False(File.Exists(AbsoluteFileName));
+ Assert.False(FileSystem.Exists(FileNamePath));
+
+ using (var stream = FileSystem.CreateFile(FileNamePath))
+ {
+ var content1 = "asdf"u8.ToArray();
+ stream.Write(content1, 0, content1.Length);
+ }
+
+ // creating an existing file should truncate like open(O_CREAT).
+ var content2 = "b"u8.ToArray();
+ using (var stream = FileSystem.CreateFile(FileNamePath))
+ {
+ stream.Write(content2, 0, content2.Length);
+ }
+ Assert.Equal(content2, File.ReadAllBytes(AbsoluteFileName));
+ using (var stream = FileSystem.OpenFile(FileNamePath, FileAccess.Read))
+ {
+ Assert.Equal(content2, stream.ReadAllBytes());
+ }
+ }
+
+ [Fact]
+ public void CreateFile_Empty()
+ {
+ using (var stream = FileSystem.CreateFile(FileNamePath))
+ {
+ }
+
+ Assert.Equal(Array.Empty(), File.ReadAllBytes(AbsoluteFileName));
+ using (var stream = FileSystem.OpenFile(FileNamePath, FileAccess.Read))
+ {
+ Assert.Equal(Array.Empty(), stream.ReadAllBytes());
+ }
+ }
+}
diff --git a/pkNX.Tests/VFS/FileSystems/ZipArchiveFSTest.cs b/pkNX.Tests/VFS/FileSystems/ZipArchiveFSTest.cs
new file mode 100644
index 00000000..e1d1182d
--- /dev/null
+++ b/pkNX.Tests/VFS/FileSystems/ZipArchiveFSTest.cs
@@ -0,0 +1,128 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.IO.Compression;
+using pkNX.Containers.VFS;
+using Xunit;
+
+namespace pkNX.Tests;
+
+public class ZipArchiveFSTest
+{
+ private Stream zipStream;
+ private ZipArchiveFileSystem fileSystem;
+ private string fileContentString = "this is a file";
+
+ //setup
+ public ZipArchiveFSTest()
+ {
+ zipStream = new MemoryStream();
+
+ var fileContentBytes = Encoding.ASCII.GetBytes(fileContentString);
+
+ using (var zipOutput = new ZipArchive(zipStream, ZipArchiveMode.Create, true))
+ {
+ var entry = zipOutput.CreateEntry("textfileA.txt");
+ using (Stream stream = entry.Open())
+ stream.Write(fileContentBytes);
+
+ zipOutput.CreateEntry("directory/fileInDirectory.txt");
+ zipOutput.CreateEntry("scratchdirectory/scratch");
+ }
+
+ zipStream.Position = 0;
+ fileSystem = ZipArchiveFileSystem.Open(zipStream);
+ }
+
+ //teardown
+ public void Dispose()
+ {
+ fileSystem.Dispose();
+ zipStream.Dispose();
+ }
+
+ private readonly FileSystemPath directoryPath = FileSystemPath.Parse("/directory/");
+ private readonly FileSystemPath textfileAPath = FileSystemPath.Parse("/textfileA.txt");
+ private readonly FileSystemPath fileInDirectoryPath = FileSystemPath.Parse("/directory/fileInDirectory.txt");
+ private readonly FileSystemPath scratchDirectoryPath = FileSystemPath.Parse("/scratchdirectory/");
+
+ [Fact]
+ public void GetEntitiesOfRootTest()
+ {
+ Assert.Equal(new[]
+ {
+ textfileAPath,
+ directoryPath,
+ scratchDirectoryPath
+ }, fileSystem.GetEntityPaths(FileSystemPath.Root).ToArray());
+ }
+
+ [Fact]
+ public void GetEntitiesOfDirectoryTest()
+ {
+ Assert.Equal(new[]
+ {
+ fileInDirectoryPath
+ }, fileSystem.GetEntityPaths(directoryPath).ToArray());
+ }
+
+ [Fact]
+ public void ExistsTest()
+ {
+ Assert.True(fileSystem.Exists(FileSystemPath.Root));
+ Assert.True(fileSystem.Exists(textfileAPath));
+ Assert.True(fileSystem.Exists(directoryPath));
+ Assert.True(fileSystem.Exists(fileInDirectoryPath));
+ Assert.False(fileSystem.Exists(FileSystemPath.Parse("/nonExistingFile")));
+ Assert.False(fileSystem.Exists(FileSystemPath.Parse("/nonExistingDirectory/")));
+ Assert.False(fileSystem.Exists(FileSystemPath.Parse("/directory/nonExistingFileInDirectory")));
+ }
+
+ [Fact]
+ public void CanReadFile()
+ {
+ var file = fileSystem.OpenFile(textfileAPath, FileAccess.ReadWrite);
+ var text = file.ReadAllText();
+ Assert.True(string.Equals(text, fileContentString));
+ }
+
+ [Fact]
+ public void CanWriteFile()
+ {
+ var file = fileSystem.OpenFile(textfileAPath, FileAccess.ReadWrite);
+ var textBytes = Encoding.ASCII.GetBytes(fileContentString + " and a new string");
+ file.Write(textBytes);
+ file.Close();
+
+
+ file = fileSystem.OpenFile(textfileAPath, FileAccess.ReadWrite);
+ var text = file.ReadAllText();
+ Assert.True(string.Equals(text, fileContentString + " and a new string"));
+ }
+
+ [Fact]
+ public void CanAddFile()
+ {
+ var fsp = FileSystemPath.Parse("/scratchdirectory/recentlyadded.txt");
+ var file = fileSystem.CreateFile(fsp);
+ var textBytes = "recently added"u8.ToArray();
+ file.Write(textBytes);
+ file.Close();
+
+ Assert.True(fileSystem.Exists(fsp));
+
+ file = fileSystem.OpenFile(fsp, FileAccess.ReadWrite);
+ var text = file.ReadAllText();
+ Assert.True(string.Equals(text, "recently added"));
+ }
+
+ [Fact]
+ public void CanAddDirectory()
+ {
+ var fsp = FileSystemPath.Parse("/scratchdirectory/dir/");
+ fileSystem.CreateDirectory(fsp);
+
+ Assert.True(fileSystem.Exists(fsp));
+ }
+}
diff --git a/pkNX.Tests/VFS/StreamExtensions.cs b/pkNX.Tests/VFS/StreamExtensions.cs
new file mode 100644
index 00000000..e7f90c77
--- /dev/null
+++ b/pkNX.Tests/VFS/StreamExtensions.cs
@@ -0,0 +1,19 @@
+using System.IO;
+
+namespace pkNX.Tests;
+
+public static class StreamExtensions
+{
+ public static string ReadAllText(this Stream s)
+ {
+ using var reader = new StreamReader(s);
+ return reader.ReadToEnd();
+ }
+
+ public static byte[] ReadAllBytes(this Stream s)
+ {
+ using var ms = new MemoryStream();
+ s.CopyTo(ms);
+ return ms.ToArray();
+ }
+}
diff --git a/pkNX.Tests/VFS/resDir/deep/deep.txt b/pkNX.Tests/VFS/resDir/deep/deep.txt
new file mode 100644
index 00000000..4cdb2265
--- /dev/null
+++ b/pkNX.Tests/VFS/resDir/deep/deep.txt
@@ -0,0 +1 @@
+deep
diff --git a/pkNX.Tests/VFS/resDir/deep/deeper/deeper.txt b/pkNX.Tests/VFS/resDir/deep/deeper/deeper.txt
new file mode 100644
index 00000000..e39796ce
--- /dev/null
+++ b/pkNX.Tests/VFS/resDir/deep/deeper/deeper.txt
@@ -0,0 +1 @@
+deeper
diff --git a/pkNX.Tests/VFS/resDir/deepFile.txt b/pkNX.Tests/VFS/resDir/deepFile.txt
new file mode 100644
index 00000000..52d4aa48
--- /dev/null
+++ b/pkNX.Tests/VFS/resDir/deepFile.txt
@@ -0,0 +1 @@
+deep file
\ No newline at end of file
diff --git a/pkNX.Tests/VFS/test.txt b/pkNX.Tests/VFS/test.txt
new file mode 100644
index 00000000..8abbe6b7
--- /dev/null
+++ b/pkNX.Tests/VFS/test.txt
@@ -0,0 +1 @@
+test embedded resource
\ No newline at end of file
diff --git a/pkNX.Tests/VFS/test.zip b/pkNX.Tests/VFS/test.zip
new file mode 100644
index 00000000..a178593e
Binary files /dev/null and b/pkNX.Tests/VFS/test.zip differ
diff --git a/pkNX.Tests/pkNX.Tests.csproj b/pkNX.Tests/pkNX.Tests.csproj
index 7a9aff12..8e34499a 100644
--- a/pkNX.Tests/pkNX.Tests.csproj
+++ b/pkNX.Tests/pkNX.Tests.csproj
@@ -6,6 +6,13 @@
false
+
+
+
+
+
+
+