diff --git a/DiscUtils.sln.DotSettings b/DiscUtils.sln.DotSettings index d0ab9c465..71ad46345 100644 --- a/DiscUtils.sln.DotSettings +++ b/DiscUtils.sln.DotSettings @@ -219,9 +219,11 @@ True True True + True True True True True True - True \ No newline at end of file + True + True \ No newline at end of file diff --git a/Library/DiscUtils.Fat/Directory.cs b/Library/DiscUtils.Fat/Directory.cs index 04683f29e..3feb3f4c7 100644 --- a/Library/DiscUtils.Fat/Directory.cs +++ b/Library/DiscUtils.Fat/Directory.cs @@ -35,14 +35,23 @@ internal class Directory : IDisposable private readonly long _parentId; private long _endOfEntries; - private Dictionary _entries; - private List _freeEntries; + private readonly Dictionary _entries; + private readonly Dictionary _shortFileNameToEntry; + private readonly Dictionary _fullFileNameToEntry; + + private FreeDirectoryEntryTable _freeDirectoryEntryTable; + private DirectoryEntry _parentEntry; private long _parentEntryLocation; private DirectoryEntry _selfEntry; private long _selfEntryLocation; + /// + /// Delegate to check if a short name exists in the directory used by + /// + internal readonly Func CheckIfShortNameExists; + /// /// Initializes a new instance of the Directory class. Use this constructor to represent non-root directories. /// @@ -51,8 +60,13 @@ internal class Directory : IDisposable internal Directory(Directory parent, long parentId) { FileSystem = parent.FileSystem; + _entries = new(); + _freeDirectoryEntryTable = new(); + _shortFileNameToEntry = new(StringComparer.OrdinalIgnoreCase); + _fullFileNameToEntry = new(StringComparer.OrdinalIgnoreCase); _parent = parent; _parentId = parentId; + CheckIfShortNameExists = CheckIfShortNameExistsImpl; var dirEntry = ParentsChildEntry; _dirStream = new ClusterStream(FileSystem, FileAccess.ReadWrite, dirEntry.FirstCluster, uint.MaxValue); @@ -68,7 +82,12 @@ internal Directory(Directory parent, long parentId) internal Directory(FatFileSystem fileSystem, Stream dirStream) { FileSystem = fileSystem; + _entries = new(); + _freeDirectoryEntryTable = new(); + _shortFileNameToEntry = new(StringComparer.OrdinalIgnoreCase); + _fullFileNameToEntry = new(StringComparer.OrdinalIgnoreCase); _dirStream = dirStream; + CheckIfShortNameExists = CheckIfShortNameExistsImpl; LoadEntries(); } @@ -112,7 +131,7 @@ public DirectoryEntry GetEntry(long id) return id < 0 ? null : _entries[id]; } - public Directory GetChildDirectory(FileName name) + public Directory GetChildDirectory(string name) { var id = FindEntry(name); if (id < 0) @@ -128,7 +147,7 @@ public Directory GetChildDirectory(FileName name) return FileSystem.GetDirectory(this, id); } - internal Directory CreateChildDirectory(FileName name) + internal Directory CreateChildDirectory(string name) { var id = FindEntry(name); if (id >= 0) @@ -150,7 +169,17 @@ internal Directory CreateChildDirectory(FileName name) FileSystem.Fat.SetEndOfChain(firstCluster); - var newEntry = new DirectoryEntry(FileSystem.FatOptions, name, FatAttributes.Directory, + FatFileName fatFileName; + try + { + fatFileName = FatFileName.FromName(name, FileSystem.FatOptions.FileNameEncodingTable, CheckIfShortNameExists); + } + catch (ArgumentException ex) + { + throw new IOException($"Invalid directory name {name}", ex); + } + + var newEntry = new DirectoryEntry(FileSystem.FatOptions, fatFileName, FatAttributes.Directory, FileSystem.FatVariant) { FirstCluster = firstCluster, @@ -172,7 +201,7 @@ internal Directory CreateChildDirectory(FileName name) } } - internal void AttachChildDirectory(FileName name, Directory newChild) + internal void AttachChildDirectory(string name, Directory newChild) { var id = FindEntry(name); if (id >= 0) @@ -180,16 +209,20 @@ internal void AttachChildDirectory(FileName name, Directory newChild) throw new IOException("Directory entry already exists"); } - var newEntry = new DirectoryEntry(newChild.ParentsChildEntry) + FatFileName fatFileName; + try { - Name = name - }; + fatFileName = FatFileName.FromName(name, FileSystem.FatOptions.FileNameEncodingTable, CheckIfShortNameExists); + } + catch (ArgumentException ex) + { + throw new IOException($"Invalid directory name {name}", ex); + } + + var newEntry = new DirectoryEntry(newChild.ParentsChildEntry, fatFileName); AddEntry(newEntry); - var newParentEntry = new DirectoryEntry(SelfEntry) - { - Name = FileName.ParentEntryName - }; + var newParentEntry = new DirectoryEntry(SelfEntry, FatFileName.ParentEntryName); newChild.ParentEntry = newParentEntry; } @@ -207,27 +240,50 @@ internal long FindVolumeId() return -1; } - internal long FindEntry(FileName name) + internal long FindEntry(string name) { - foreach (var id in _entries.Keys) +#if NET6_0_OR_GREATER + return _fullFileNameToEntry.GetValueOrDefault(name, -1); +#else + if (_fullFileNameToEntry.TryGetValue(name, out var id)) { - var focus = _entries[id]; - if (focus.Name == name && (focus.Attributes & FatAttributes.VolumeId) == 0) - { - return id; - } + return id; } return -1; +#endif } - internal FatFileStream OpenFile(FileName name, FileMode mode, FileAccess fileAccess) + public void ReplaceShortName(long id, string name) { - if (mode is FileMode.Append or FileMode.Truncate) + var entry = _entries[id]; + + if (entry.Name.ShortName.Equals(name, StringComparison.OrdinalIgnoreCase)) { - throw new NotImplementedException(); + return; } + if (_shortFileNameToEntry.ContainsKey(name)) + { + throw new IOException("Short name already exists in directory"); + } + + // Save the current name so we can update the lookup tables if the rename is successful + var previousName = entry.Name; + + // This might fail so we can only update the dictionary lookup tables if it succeeds + entry.ReplaceShortName(name, FileSystem.FatOptions.FileNameEncodingTable); + + _shortFileNameToEntry.Remove(previousName.ShortName); + _fullFileNameToEntry.Remove(previousName.FullName); + _shortFileNameToEntry.Add(entry.Name.ShortName, id); + _fullFileNameToEntry.Add(entry.Name.FullName, id); + } + + private bool CheckIfShortNameExistsImpl(string shortName) => _shortFileNameToEntry.ContainsKey(shortName); + + internal FatFileStream OpenFile(string name, FileMode mode, FileAccess fileAccess) + { var fileId = FindEntry(name); var exists = fileId != -1; @@ -236,19 +292,22 @@ internal FatFileStream OpenFile(FileName name, FileMode mode, FileAccess fileAcc throw new IOException("File already exists"); } - if (mode == FileMode.Open && !exists) + if ((mode == FileMode.Open || mode == FileMode.Truncate || mode == FileMode.Append) && !exists) { - throw new FileNotFoundException("File not found", - name.GetDisplayName(FileSystem.FatOptions.FileNameEncoding)); + throw new FileNotFoundException("File not found", name); } - if ((mode == FileMode.Open || mode == FileMode.OpenOrCreate || mode == FileMode.Create) && exists) + if ((mode == FileMode.Open || mode == FileMode.OpenOrCreate || mode == FileMode.Create || mode == FileMode.Truncate || mode == FileMode.Append) && exists) { var stream = new FatFileStream(FileSystem, this, fileId, fileAccess); - if (mode == FileMode.Create) + if (mode == FileMode.Create || mode == FileMode.Truncate) { stream.SetLength(0); } + else if (mode == FileMode.Append) + { + stream.Seek(0, SeekOrigin.End); + } HandleAccessed(false); @@ -257,8 +316,18 @@ internal FatFileStream OpenFile(FileName name, FileMode mode, FileAccess fileAcc if ((mode == FileMode.OpenOrCreate || mode == FileMode.CreateNew || mode == FileMode.Create) && !exists) { + FatFileName fatFileName; + try + { + fatFileName = FatFileName.FromName(name, FileSystem.FatOptions.FileNameEncodingTable, CheckIfShortNameExists); + } + catch (ArgumentException ex) + { + throw new IOException("Invalid file name", ex); + } + // Create new file - var newEntry = new DirectoryEntry(FileSystem.FatOptions, name, FatAttributes.Archive, + var newEntry = new DirectoryEntry(FileSystem.FatOptions, fatFileName, FatAttributes.Archive, FileSystem.FatVariant) { FirstCluster = 0, // i.e. Zero-length @@ -278,25 +347,22 @@ internal FatFileStream OpenFile(FileName name, FileMode mode, FileAccess fileAcc internal long AddEntry(DirectoryEntry newEntry) { // Unlink an entry from the free list (or add to the end of the existing directory) - long pos; - if (_freeEntries.Count > 0) - { - pos = _freeEntries[0]; - _freeEntries.RemoveAt(0); - } - else + var entryCount = newEntry.EntryCount; + var pos = _freeDirectoryEntryTable.Allocate(newEntry.EntryCount); + + if (pos < 0) { pos = _endOfEntries; - _endOfEntries += 32; + _endOfEntries += entryCount * DirectoryEntry.SizeOf; } // Put the new entry into it's slot _dirStream.Position = pos; - newEntry.WriteTo(_dirStream); + newEntry.WriteTo(_dirStream, FileSystem.FatOptions.FileNameEncodingTable); // Update internal structures to reflect new entry (as if read from disk) - _entries.Add(pos, newEntry); - + AddEntryRaw(pos, newEntry); + HandleAccessed(forWrite: true); return pos; @@ -313,12 +379,10 @@ internal void DeleteEntry(long id, bool releaseContents) { var entry = _entries[id]; - var copy = new DirectoryEntry(entry) - { - Name = entry.Name.Deleted() - }; + var entryCount = entry.EntryCount; + _dirStream.Position = id; - copy.WriteTo(_dirStream); + DirectoryEntry.WriteDeletedEntry(_dirStream, entryCount); if (releaseContents) { @@ -326,8 +390,13 @@ internal void DeleteEntry(long id, bool releaseContents) } _entries.Remove(id); - _freeEntries.Add(id); + _freeDirectoryEntryTable.AddFreeRange(id, entryCount); + + // Remove from the short and full name lookup tables + _shortFileNameToEntry.Remove(entry.Name.ShortName); + _fullFileNameToEntry.Remove(entry.Name.FullName); + HandleAccessed(true); } finally @@ -344,39 +413,47 @@ internal void UpdateEntry(long id, DirectoryEntry entry) } _dirStream.Position = id; - entry.WriteTo(_dirStream); + entry.WriteTo(_dirStream, FileSystem.FatOptions.FileNameEncodingTable); _entries[id] = entry; } - private void LoadEntries() + private void AddEntryRaw(long pos, DirectoryEntry entry) { - _entries = []; - _freeEntries = []; + _entries.Add(pos, entry); + // Update the short and full name lookup tables + _shortFileNameToEntry.Add(entry.Name.ShortName, pos); + _fullFileNameToEntry.Add(entry.Name.FullName, pos); + } + private void LoadEntries() + { _selfEntryLocation = -1; _parentEntryLocation = -1; + long beginFreePosition = -1; + long endFreePosition = -1; while (_dirStream.Position < _dirStream.Length) { + var streamPos = _dirStream.Position; var entry = new DirectoryEntry(FileSystem.FatOptions, _dirStream, FileSystem.FatVariant); - var streamPos = _dirStream.Position - 32; - - if (entry.Attributes == - (FatAttributes.ReadOnly | FatAttributes.Hidden | FatAttributes.System | FatAttributes.VolumeId)) + + if ((entry.Attributes & FatAttributes.LongFileNameMask) == FatAttributes.LongFileName) { - // Long File Name entry + // Orphaned Long File Name entry + AddFreeEntry(streamPos); } else if (entry.Name.IsDeleted()) { // E5 = Free Entry - _freeEntries.Add(streamPos); + // LFN Orphane entries (that are not part of a valid entry) are also marked as free + AddFreeEntry(streamPos); } - else if (entry.Name == FileName.SelfEntryName) + else if (entry.Name == FatFileName.SelfEntryName) { _selfEntry = entry; _selfEntryLocation = streamPos; } - else if (entry.Name == FileName.ParentEntryName) + else if (entry.Name == FatFileName.ParentEntryName) { _parentEntry = entry; _parentEntryLocation = streamPos; @@ -389,11 +466,36 @@ private void LoadEntries() } else { - _entries.Add(streamPos, entry); + AddEntryRaw(streamPos, entry); } } + + // Record any pending free entry + AddFreeEntry(-1); + + void AddFreeEntry(long pos) + { + if (beginFreePosition >= 0) + { + // If a free entry comes after the previous one, and is contiguous, extend the count + if (endFreePosition + DirectoryEntry.SizeOf == pos) + { + endFreePosition = pos; + return; + } + + // Record any pending range + AddFreeRange(beginFreePosition, (int)((endFreePosition - beginFreePosition) / DirectoryEntry.SizeOf)); + } + + beginFreePosition = pos; + endFreePosition = pos + DirectoryEntry.SizeOf; + } } + private void AddFreeRange(long position, int count) + => _freeDirectoryEntryTable.AddFreeRange(position, count); + private void HandleAccessed(bool forWrite) { if (FileSystem.CanWrite && _parent != null) @@ -428,20 +530,16 @@ private void PopulateNewChildDirectory(DirectoryEntry newEntry) using var stream = new ClusterStream(FileSystem, FileAccess.Write, newEntry.FirstCluster, uint.MaxValue); // First is the self-referencing entry... - var selfEntry = new DirectoryEntry(newEntry) - { - Name = FileName.SelfEntryName - }; - selfEntry.WriteTo(stream); + var selfEntry = new DirectoryEntry(newEntry, FatFileName.SelfEntryName); + selfEntry.WriteTo(stream, FileSystem.FatOptions.FileNameEncodingTable); // Second is a clone of our self entry (i.e. parent) - though dates are odd... - var parentEntry = new DirectoryEntry(SelfEntry) + var parentEntry = new DirectoryEntry(SelfEntry, FatFileName.ParentEntryName) { - Name = FileName.ParentEntryName, CreationTime = newEntry.CreationTime, LastWriteTime = newEntry.LastWriteTime }; - parentEntry.WriteTo(stream); + parentEntry.WriteTo(stream, FileSystem.FatOptions.FileNameEncodingTable); } private void Dispose(bool disposing) @@ -460,7 +558,7 @@ internal DirectoryEntry ParentsChildEntry { if (_parent == null) { - return new DirectoryEntry(FileSystem.FatOptions, FileName.ParentEntryName, FatAttributes.Directory, + return new DirectoryEntry(FileSystem.FatOptions, FatFileName.ParentEntryName, FatAttributes.Directory, FileSystem.FatVariant); } @@ -477,7 +575,7 @@ internal DirectoryEntry SelfEntry if (_parent == null) { // If we're the root directory, simulate the parent entry with a dummy record - return new DirectoryEntry(FileSystem.FatOptions, FileName.Null, FatAttributes.Directory, + return new DirectoryEntry(FileSystem.FatOptions, FatFileName.Null, FatAttributes.Directory, FileSystem.FatVariant); } @@ -489,7 +587,7 @@ internal DirectoryEntry SelfEntry if (_selfEntryLocation >= 0) { _dirStream.Position = _selfEntryLocation; - value.WriteTo(_dirStream); + value.WriteTo(_dirStream, FileSystem.FatOptions.FileNameEncodingTable); _selfEntry = value; } } @@ -507,7 +605,7 @@ internal DirectoryEntry ParentEntry } _dirStream.Position = _parentEntryLocation; - value.WriteTo(_dirStream); + value.WriteTo(_dirStream, FileSystem.FatOptions.FileNameEncodingTable); _parentEntry = value; } } diff --git a/Library/DiscUtils.Fat/DirectoryEntry.cs b/Library/DiscUtils.Fat/DirectoryEntry.cs index f739c7b7d..42cd35835 100644 --- a/Library/DiscUtils.Fat/DirectoryEntry.cs +++ b/Library/DiscUtils.Fat/DirectoryEntry.cs @@ -23,6 +23,7 @@ using System; using System.Buffers; using System.IO; +using System.Text; using DiscUtils.Streams; using DiscUtils.Streams.Compatibility; @@ -30,9 +31,15 @@ namespace DiscUtils.Fat; internal class DirectoryEntry { + /// + /// Size of a directory entry in bytes. + /// + public const int SizeOf = 32; + private readonly FatType _fatVariant; private readonly FatFileSystemOptions _options; - private byte _attr; + private FatFileName _name; + private FatAttributes _attr; private ushort _creationDate; private ushort _creationTime; private byte _creationTimeTenth; @@ -48,33 +55,39 @@ internal DirectoryEntry(FatFileSystemOptions options, Stream stream, FatType fat _options = options; _fatVariant = fatVariant; - var bufferLength = 32; + var bufferLength = SizeOf; var buffer = ArrayPool.Shared.Rent(bufferLength); try { + var initialPosition = stream.Position; stream.ReadExactly(buffer, 0, bufferLength); // LFN entry - if ((buffer[0] & 0xc0) == 0x40 && buffer[11] == 0x0f) + if (FatFileName.TryGetLfnDirectoryEntryCount(buffer, out var lfnDirectoryEntryCount)) { - var lfn_entries = buffer[0] & 0x3F; - - bufferLength += 32 * lfn_entries; + bufferLength += SizeOf * lfnDirectoryEntryCount; if (buffer.Length < bufferLength) { var new_buffer = ArrayPool.Shared.Rent(bufferLength); - System.Buffer.BlockCopy(buffer, 0, new_buffer, 0, 32); + System.Buffer.BlockCopy(buffer, 0, new_buffer, 0, SizeOf); ArrayPool.Shared.Return(buffer); buffer = new_buffer; } - stream.ReadExactly(buffer, 32, 32 * lfn_entries); + stream.ReadExactly(buffer, SizeOf, SizeOf * lfnDirectoryEntryCount); } - Load(buffer, 0, bufferLength); + Load(buffer, bufferLength, options.FileNameEncodingTable, out var bytesProcessed); + + // If we have processed less than expected (because LFN entries were orphaned) + // seek to the actual position of what has been processed instead of what was prefetched above + if (bytesProcessed != bufferLength) + { + stream.Position = initialPosition + bytesProcessed; + } } finally { @@ -85,19 +98,19 @@ internal DirectoryEntry(FatFileSystemOptions options, Stream stream, FatType fat } } - internal DirectoryEntry(FatFileSystemOptions options, FileName name, FatAttributes attrs, FatType fatVariant) + internal DirectoryEntry(FatFileSystemOptions options, in FatFileName name, FatAttributes attrs, FatType fatVariant) { - _options = options; _fatVariant = fatVariant; - Name = name; - _attr = (byte)attrs; + _options = options; + _name = name; + _attr = attrs; } - internal DirectoryEntry(DirectoryEntry toCopy) + internal DirectoryEntry(DirectoryEntry toCopy, in FatFileName name) { - _options = toCopy._options; _fatVariant = toCopy._fatVariant; - Name = toCopy.Name; + _options = toCopy._options; + _name = name; _attr = toCopy._attr; _creationTimeTenth = toCopy._creationTimeTenth; _creationTime = toCopy._creationTime; @@ -109,10 +122,12 @@ internal DirectoryEntry(DirectoryEntry toCopy) _fileSize = toCopy._fileSize; } + public int EntryCount => 1 + Name.LfnDirectoryEntryCount; + public FatAttributes Attributes { - get => (FatAttributes)_attr; - set => _attr = (byte)value; + get => _attr; + set => _attr = value; } public DateTime CreationTime @@ -162,27 +177,50 @@ public DateTime LastWriteTime set => DateTimeToFileTime(value, out _lastWriteDate, out _lastWriteTime); } - public FileName Name { get; set; } + public ref readonly FatFileName Name => ref _name; - internal void WriteTo(Stream stream) + public void ReplaceShortName(string name, FastEncodingTable encodingTable) { - Span buffer = stackalloc byte[32]; - - Name.GetBytes(buffer); - buffer[11] = _attr; - buffer[13] = _creationTimeTenth; - EndianUtilities.WriteBytesLittleEndian(_creationTime, buffer.Slice(14)); - EndianUtilities.WriteBytesLittleEndian(_creationDate, buffer.Slice(16)); - EndianUtilities.WriteBytesLittleEndian(_lastAccessDate, buffer.Slice(18)); - EndianUtilities.WriteBytesLittleEndian(_firstClusterHi, buffer.Slice(20)); - EndianUtilities.WriteBytesLittleEndian(_lastWriteTime, buffer.Slice(22)); - EndianUtilities.WriteBytesLittleEndian(_lastWriteDate, buffer.Slice(24)); - EndianUtilities.WriteBytesLittleEndian(_firstClusterLo, buffer.Slice(26)); - EndianUtilities.WriteBytesLittleEndian(_fileSize, buffer.Slice(28)); + try + { + _name = _name.ReplaceShortName(name, encodingTable); + } + catch (ArgumentException ex) + { + throw new IOException("Failed to replace short name", ex); + } + } + + internal void WriteTo(Stream stream, FastEncodingTable encodingTable) + { + Span buffer = stackalloc byte[EntryCount * SizeOf]; + + Name.ToDirectoryEntryBytes(buffer, encodingTable); + int offset = buffer.Length - SizeOf; + buffer[offset + 11] = (byte)_attr; + buffer[offset + 13] = _creationTimeTenth; + EndianUtilities.WriteBytesLittleEndian(_creationTime, buffer.Slice(offset + 14)); + EndianUtilities.WriteBytesLittleEndian(_creationDate, buffer.Slice(offset + 16)); + EndianUtilities.WriteBytesLittleEndian(_lastAccessDate, buffer.Slice(offset + 18)); + EndianUtilities.WriteBytesLittleEndian(_firstClusterHi, buffer.Slice(offset + 20)); + EndianUtilities.WriteBytesLittleEndian(_lastWriteTime, buffer.Slice(offset + 22)); + EndianUtilities.WriteBytesLittleEndian(_lastWriteDate, buffer.Slice(offset + 24)); + EndianUtilities.WriteBytesLittleEndian(_firstClusterLo, buffer.Slice(offset + 26)); + EndianUtilities.WriteBytesLittleEndian(_fileSize, buffer.Slice(offset + 28)); stream.Write(buffer); } + public static void WriteDeletedEntry(Stream stream, int count) + { + var deletedBuffer = DeleteBuffer(); + for (int i = 0; i < count; i++) + { + stream.Write(deletedBuffer); + } + static ReadOnlySpan DeleteBuffer() => [0xE5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + } + private static DateTime FileTimeToDateTime(ushort date, ushort time, byte tenths) { if (date is 0 or 0xFFFF) @@ -226,21 +264,28 @@ private static void DateTimeToFileTime(DateTime value, out ushort date, out usho tenths = (byte)(value.Second % 2 * 100 + value.Millisecond / 10); } - private void Load(byte[] data, int offset, int count) + private void Load(byte[] data, int count, FastEncodingTable fileNameEncoding, out int bytesProcessed) { - Name = new FileName(data.AsSpan(offset)); + _name = FatFileName.FromDirectoryEntryBytes(data.AsSpan(0, count), fileNameEncoding, out bytesProcessed); - offset += count - 32; + var offset = bytesProcessed - SizeOf; + _attr = (FatAttributes)data[offset + 11]; - _attr = data[offset + 11]; - _creationTimeTenth = data[offset + 13]; - _creationTime = EndianUtilities.ToUInt16LittleEndian(data, offset + 14); - _creationDate = EndianUtilities.ToUInt16LittleEndian(data, offset + 16); - _lastAccessDate = EndianUtilities.ToUInt16LittleEndian(data, offset + 18); - _firstClusterHi = EndianUtilities.ToUInt16LittleEndian(data, offset + 20); - _lastWriteTime = EndianUtilities.ToUInt16LittleEndian(data, offset + 22); - _lastWriteDate = EndianUtilities.ToUInt16LittleEndian(data, offset + 24); - _firstClusterLo = EndianUtilities.ToUInt16LittleEndian(data, offset + 26); - _fileSize = EndianUtilities.ToUInt32LittleEndian(data, offset + 28); + if (((_attr & FatAttributes.LongFileNameMask) == FatAttributes.LongFileName) || _name.IsDeleted()) + { + // This is a deleted entry or an orphaned LFN entry, so we don't care about the other fields + } + else + { + _creationTimeTenth = data[offset + 13]; + _creationTime = EndianUtilities.ToUInt16LittleEndian(data, offset + 14); + _creationDate = EndianUtilities.ToUInt16LittleEndian(data, offset + 16); + _lastAccessDate = EndianUtilities.ToUInt16LittleEndian(data, offset + 18); + _firstClusterHi = EndianUtilities.ToUInt16LittleEndian(data, offset + 20); + _lastWriteTime = EndianUtilities.ToUInt16LittleEndian(data, offset + 22); + _lastWriteDate = EndianUtilities.ToUInt16LittleEndian(data, offset + 24); + _firstClusterLo = EndianUtilities.ToUInt16LittleEndian(data, offset + 26); + _fileSize = EndianUtilities.ToUInt32LittleEndian(data, offset + 28); + } } } \ No newline at end of file diff --git a/Library/DiscUtils.Fat/DiscUtils.Fat.csproj b/Library/DiscUtils.Fat/DiscUtils.Fat.csproj index bc787753c..6f00d4bc3 100644 --- a/Library/DiscUtils.Fat/DiscUtils.Fat.csproj +++ b/Library/DiscUtils.Fat/DiscUtils.Fat.csproj @@ -3,10 +3,13 @@ DiscUtils FAT filesystem parser Kenneth Bell;LordMike;Olof Lagerkvist DiscUtils;Filesystem;FAT + True + + diff --git a/Library/DiscUtils.Fat/FastEncodingTable.cs b/Library/DiscUtils.Fat/FastEncodingTable.cs new file mode 100644 index 000000000..acf6b5813 --- /dev/null +++ b/Library/DiscUtils.Fat/FastEncodingTable.cs @@ -0,0 +1,125 @@ +// +// Copyright (c) 2024, Olof Lagerkvist and contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. +#if NET8_0_OR_GREATER +using System; +using System.Collections.Frozen; +#endif +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; + +namespace DiscUtils.Fat; + +/// +/// Fast encoding 1-byte to 1-char table to help encoding/decoding short file names. +/// +internal sealed unsafe class FastEncodingTable +{ + private readonly char[] _mapByteToChar; +#if NET8_0_OR_GREATER + private readonly FrozenDictionary _mapUpperCharToByte; +#else + private readonly Dictionary _mapUpperCharToByte; +#endif + +#if !NETFRAMEWORK + static FastEncodingTable() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } +#endif + + /// + /// Gets the default encoding table for the IBM PC code page 437. + /// + public static readonly FastEncodingTable Default = new(Encoding.GetEncoding(437)); + + /// + /// Used to replace characters that cannot be encoded. + /// + public const char ReplacementChar = '\ufffd'; + + /// + /// + /// + /// + public FastEncodingTable(Encoding encoding) + { + Debug.Assert(encoding.IsSingleByte); + Encoding = encoding; + + _mapByteToChar = new char[256]; + var mapUpperCharToByte = new Dictionary(256); + + // Calculate the mapping from byte to char + for (int i = 0; i < 256; ++i) + { + byte b = (byte)i; + char c = (char)0; + int encoded = encoding.GetChars(&b, 1, &c, 1); + if (encoded != 1) + { + c = ReplacementChar; + } + _mapByteToChar[i] = c; + mapUpperCharToByte[c] = b; + } + + // Calculate the mapping from upper char to byte + for (int i = 0; i < 256; ++i) + { + var c = _mapByteToChar[i]; + var upperChar = char.ToUpperInvariant(c); + if (mapUpperCharToByte.TryGetValue(upperChar, out var bUpper)) + { + mapUpperCharToByte[c] = bUpper; + } + } +#if NET8_0_OR_GREATER + _mapUpperCharToByte = mapUpperCharToByte.ToFrozenDictionary(); +#else + _mapUpperCharToByte = mapUpperCharToByte; +#endif + } + + /// + /// The encoding used to create the table. + /// + public Encoding Encoding { get; } + + /// + /// Gets the char for the specified byte. + /// + /// The byte to convert. + /// The char converted or if no mapping exists. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public char GetCharFromByte(byte b) => _mapByteToChar[(int)b]; + + /// + /// Gets the upper case byte for the specified char. + /// + /// The char to convert. + /// The upper case char converted if this method returns true + /// true if the char was converted; otherwise, false. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryGetCharToByteUpperCase(char c, out byte b) => _mapUpperCharToByte.TryGetValue(c, out b); +} \ No newline at end of file diff --git a/Library/DiscUtils.Fat/FatAttributes.cs b/Library/DiscUtils.Fat/FatAttributes.cs index 303026656..edde14ecb 100644 --- a/Library/DiscUtils.Fat/FatAttributes.cs +++ b/Library/DiscUtils.Fat/FatAttributes.cs @@ -32,5 +32,7 @@ internal enum FatAttributes : byte System = 0x04, VolumeId = 0x08, Directory = 0x10, - Archive = 0x20 + Archive = 0x20, + LongFileName = 0x0F, + LongFileNameMask = 0x3F, } \ No newline at end of file diff --git a/Library/DiscUtils.Fat/FatFileName.cs b/Library/DiscUtils.Fat/FatFileName.cs new file mode 100644 index 000000000..a2b1abdea --- /dev/null +++ b/Library/DiscUtils.Fat/FatFileName.cs @@ -0,0 +1,959 @@ +// +// Copyright (c) 2008-2011, Kenneth Bell +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. +// + +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using DiscUtils.Internal; +using DiscUtils.Streams; +using DiscUtils.Streams.Compatibility; + +namespace DiscUtils.Fat; + +/// +/// Implementation for reading the name of a file in a FAT directory entry. +/// +/// +/// http://elm-chan.org/docs/fat_e.html +/// https://en.wikipedia.org/wiki/8.3_filename +/// Long File Name support (lfn) in VFAT is described at https://www.kernel.org/doc/Documentation/filesystems/vfat.txt +/// Other documentation used: https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system +/// +internal struct FatFileName : IEquatable +{ + private const byte SpaceByte = 0x20; + + const string InvalidCharsForShortName = "\"*+,./:;<=>?[\\]|"; + + public static readonly FatFileName SelfEntryName = new("."); + + public static readonly FatFileName ParentEntryName = new(".."); + + public static readonly FatFileName Null = new("\0\0\0\0\0\0\0\0\0\0\0\0"); + + private readonly string _shortName; // null indicates a deleted / orphaned entry + + private readonly string _longName; + + private FatFileName(string shortName, string longName = null) + { + _shortName = shortName; + _longName = longName; + } + + public readonly string ShortName => _shortName; + + public readonly string LongName => _longName; + + public readonly string FullName => _longName ?? _shortName; + + /// + /// Gets the number of additional directory entries used by the long file name. + /// + public readonly int LfnDirectoryEntryCount => _longName is not null ? (_longName.Length + 12) / 13 : 0; + + public readonly bool Equals(FatFileName other) => Equals(this, other); + + public static bool Equals(in FatFileName a, in FatFileName b) + { + if (a._longName != null) + { + if (StringComparer.OrdinalIgnoreCase.Equals(a._longName, b._longName) || + StringComparer.OrdinalIgnoreCase.Equals(a._longName, b._shortName)) + { + return true; + } + } + else if (b._longName != null) + { + if (StringComparer.OrdinalIgnoreCase.Equals(a._shortName, b._longName)) + { + return true; + } + } + else + { + if (StringComparer.OrdinalIgnoreCase.Compare(a._shortName, b._shortName) == 0) + { + return true; + } + } + + return false; + } + + public readonly FatFileName ReplaceShortName(string name, FastEncodingTable encodingTable) + { + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + var idx = 0; + var indexOfDot = -1; + + for (var i = 0; i < name.Length; i++) + { + if (idx >= 11) + { + throw new ArgumentException($"File name too long '{name}'", nameof(name)); + } + + var c = name[i]; + + if (c < 0x20) + { + throw new ArgumentException($"Invalid control character at index {i} in short name '{name}'", nameof(name)); + } + + if (c == '.') + { + if (indexOfDot >= 0) + { + throw new ArgumentException($"Multiple dots at index {i} in short name '{name}'", nameof(name)); + } + + indexOfDot = idx; + } + else if (c == ' ') + { + throw new ArgumentException($"Invalid space character at index {i} in short name '{name}'", nameof(name)); + } + else if (InvalidCharsForShortName.IndexOf(c) >= 0 || !encodingTable.TryGetCharToByteUpperCase(c, out _)) + { + throw new ArgumentException($"Invalid character `{c}` at index {i} in short name '{name}'", nameof(name)); + } + + idx++; + } + + if (idx == 0) + { + throw new ArgumentException("Empty file name", nameof(name)); + } + + if (indexOfDot > 0 && idx - indexOfDot > 4) + { + throw new ArgumentException($"Extension too long in short name '{name}'", nameof(name)); + } + + return new(name, _longName); + } + + public readonly bool IsMatch(Func filter) + { + var searchName = FullName; + if (searchName is null) + { + return false; + } + + if (searchName.IndexOf('.') < 0) + { + searchName += '.'; + } + + return filter(searchName); + } + + public readonly bool IsDeleted() => _shortName is null; + + public readonly bool IsEndMarker() => _shortName is not null && _shortName.Equals(Null._shortName, StringComparison.Ordinal); + + public readonly override bool Equals(object other) + => other is FatFileName otherName && Equals(this, otherName); + + public readonly override int GetHashCode() + { + var displayName = FullName; + return displayName != null ? displayName.GetHashCode() : 0; + } + + public readonly override string ToString() => FullName; + + /// + /// Writes this to a buffer as proper directory entries headers for the long file name and the short file name. + /// + /// The buffer to receive the directory entries initialized with the long file name and the short file name. + /// Encoding table + public readonly void ToDirectoryEntryBytes(Span buffer, FastEncodingTable encodingTable) + { + if (_shortName is null) throw new InvalidOperationException("Cannot write a deleted file name"); + + if (_shortName.Equals(Null._shortName, StringComparison.Ordinal)) + { + buffer.Fill(0x00); + return; + } + + var lfnCount = LfnDirectoryEntryCount; + if (buffer.Length < (lfnCount + 1) * DirectoryEntry.SizeOf) + { + throw new ArgumentException("Buffer is too small", nameof(buffer)); + } + + // Make sure that the directory entry is fully initialized to 0 + var offsetToSfnEntry = lfnCount * DirectoryEntry.SizeOf; + var sfnEntry = buffer.Slice(offsetToSfnEntry, DirectoryEntry.SizeOf); + sfnEntry.Slice(12).Fill(0); + + var finalBytes = sfnEntry.Slice(0, 11); + finalBytes.Fill((byte)' '); + + // Initialize the buffer with the short name + var indexOfDot = _shortName.IndexOf('.'); + int idxFinal = 0; + + var baseNameLength = indexOfDot > 0 ? indexOfDot : _shortName.Length; + + // Process the base name (at max 8 characters) + bool hasLowerCaseBase = false; + + for (var i = 0; i < baseNameLength; i++, idxFinal++) + { + var c = _shortName[i]; + + // We have the guarantee if the short name that if there is a lower case base name character, then all letters are lower case + if (char.IsLetter(c) && char.IsLower(c)) + { + hasLowerCaseBase = true; + } + + // Make sure that we convert it back to upper case + var isValidChar = encodingTable.TryGetCharToByteUpperCase(c, out finalBytes[idxFinal]); + Debug.Assert(isValidChar); + } + + // Mark the base name as lower case if needed + if (hasLowerCaseBase) + { + sfnEntry[12] |= 1 << 3; + } + + // Copy the extension part + if (indexOfDot > 0) + { + var hasLowerCaseExt = false; + idxFinal = 8; + for (int i = indexOfDot + 1; i < _shortName.Length; i++, idxFinal++) + { + var c = _shortName[i]; + + if (char.IsLetter(c) && char.IsLower(c)) + { + hasLowerCaseExt = true; + } + + // Make sure that we convert it back to upper case + var isValidChar = encodingTable.TryGetCharToByteUpperCase(c, out finalBytes[idxFinal]); + Debug.Assert(isValidChar); + } + + // Mark the extension as lower case if needed + if (hasLowerCaseExt) + { + sfnEntry[12] |= 1 << 4; + } + } + + var offset = 0; + if (_longName != null) + { + // Calculate the checksum for the short name + var checksumByte = SfnChecksum(finalBytes); + + var lfnBytes = MemoryMarshal.AsBytes(_longName.AsSpan()); + + var length13 = _longName.Length % 13; + + for (var i = lfnCount; i > 0; i--, offset += DirectoryEntry.SizeOf) + { + if (i == lfnCount && length13 > 0) + { + // We fill all characters with 0xff (other entries will be filled with characters or initialized below) + + var remainingLength = length13; + buffer.Slice(offset, DirectoryEntry.SizeOf).Fill(0xff); + + // TODO: This code is not endian-safe. Long file names will be messed up when this code runs on big endian machines. + var nextLength = Math.Min(length13, 5); + + var localOffset = offset + 1; + lfnBytes.Slice(26 * (i - 1), nextLength * 2).CopyTo(buffer.Slice(localOffset)); + localOffset += nextLength * 2; + remainingLength -= nextLength; + if (remainingLength > 0) + { + localOffset = offset + 14; + nextLength = Math.Min(length13 - 5, 6); + lfnBytes.Slice(26 * (i - 1) + 10, nextLength * 2).CopyTo(buffer.Slice(localOffset)); + localOffset += nextLength * 2; + remainingLength -= nextLength; + if (remainingLength > 0) + { + localOffset = offset + 28; + nextLength = Math.Min(length13 - 11, 2); + lfnBytes.Slice(26 * (i - 1) + 22, nextLength * 2).CopyTo(buffer.Slice(localOffset)); + localOffset += nextLength * 2; + } + else if (localOffset == offset + 26) + { + // We need to write the null terminator on the next field name + localOffset = offset + 28; + } + } + else if (localOffset == offset + 11) + { + // We need to write the null terminator on the next field name + localOffset = offset + 14; + } + + buffer[localOffset] = 0; + buffer[localOffset + 1] = 0; + } + else + { + // TODO: This code is not endian-safe. Long file names will be messed up when this code runs on big endian machines. + lfnBytes.Slice(26 * (i - 1), 10).CopyTo(buffer.Slice(offset + 1)); + lfnBytes.Slice(26 * (i - 1) + 10, 12).CopyTo(buffer.Slice(offset + 14)); + lfnBytes.Slice(26 * (i - 1) + 22, 4).CopyTo(buffer.Slice(offset + 28)); + } + + var seq = (byte)i; + if (i == lfnCount) + { + seq |= 0x40; + } + + // Initialize the fields of the Directory Entry + buffer[offset] = seq; + buffer[offset + 11] = 0x0f; + buffer[offset + 12] = 0; + buffer[offset + 13] = checksumByte; + buffer[offset + 26] = 0; + buffer[offset + 27] = 0; + } + } + } + + /// + /// Create a new from a long name. + /// + /// The file name to encode. + /// The encoding table to use. + /// A function that validates if short name is already used. + /// The instance. + public static unsafe FatFileName FromName(string name, FastEncodingTable encodingTable, Func shortNameExistFunction) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(name); +#else + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } +#endif + + if (name.Length > 255) + { + throw new IOException($"Too long name: '{name}'"); + } + + if (name.Length == 0) + { + throw new ArgumentException("Empty file name", nameof(name)); + } + + ValidateCharsFromLongName(name); + + var shortNameBytes = stackalloc byte[12]; + + // Trailing . are entirely removed from the original long name + name = name.TrimEnd('.'); + + if (name.Length == 0) + { + throw new ArgumentException("A file name cannot contain only '.' in its name", nameof(name)); + } + + // Now we are going to process the long name to generate the short name + var nameSpan = name.AsSpan(); + // Strip all leading and embedded spaces from the long name. + nameSpan = nameSpan.TrimStart(' '); + // Strip all leading periods from the long name. + nameSpan = nameSpan.TrimStart('.'); + + // Spot the last dot in the name for the extension + var indexOfDot = nameSpan.LastIndexOf('.'); + var baseLength = indexOfDot; + if (baseLength < 0) + { + baseLength = nameSpan.Length; + } + + // If we have trimmed already the name, this is a lossy conversion to short name + var lossy = nameSpan.Length != name.Length; + var hasNonSupportedChar = lossy; + var isBaseAllUpper = true; + var isBaseAllLower = true; + var shortLength = 0; + + // Process the base name (at max 8 characters) + ProcessChars(nameSpan, 0, baseLength, 8, ref lossy, ref isBaseAllUpper, ref isBaseAllLower, shortNameBytes, ref shortLength, ref hasNonSupportedChar, encodingTable); + + if (shortLength == 0) + { + lossy = true; + } + + // Process the extension (at max 3 characters) + var baseNameLength = shortLength; + var extensionStart = baseNameLength; + + var isExtensionAllUpper = true; + var isExtensionAllLower = true; + if (indexOfDot > 0) + { + // In case of a base name with non-supported encoding characters, we will append a hash + if (hasNonSupportedChar && shortLength <= 2) + { + var hash = LfnHash(name); + shortLength = Math.Min(shortLength, 2); + ConvertToHex(hash, new Span(shortNameBytes + shortLength, 4)); + shortLength += 4; + + baseNameLength = shortLength; + extensionStart = baseNameLength; + } + + // 6. Insert a dot at the end of the primary components of the basis-name iff the basis name has an extension after the last period in the name. + shortNameBytes[shortLength++] = (byte)'.'; + var unused = false; + ProcessChars(nameSpan, indexOfDot + 1, nameSpan.Length, 3, ref lossy, ref isExtensionAllUpper, ref isExtensionAllLower, shortNameBytes, ref shortLength, ref unused, encodingTable); + } + + // Initialize the temp buffer with the short name (that will be used to generate the short name and handle collisions) + var tempBytes = stackalloc byte[12]; + var tempLength = shortLength; + for (var i = 0; i < shortLength; i++) + { + tempBytes[i] = shortNameBytes[i]; + } + + // If we have mixed characters in the base name, we need to use the long name + // If we have mixed characters in the extension, we need to use the long name + // Mixed cases shortname without lossy means that we might not need to append the numeric tail e.g ~1 + // So differentiate this case from lossy + var mixedCases = (!isBaseAllLower && !isBaseAllUpper) || (!isExtensionAllLower && !isExtensionAllUpper); + + // If the short name is not lossy, then we can keep the state of lower/upper case in the shortname + // as we will use the byte 0x0c in the directory entry to store this information + if (!lossy && !mixedCases) + { + if (isBaseAllLower) + { + for(var i = 0; i < baseNameLength; i++) + { + tempBytes[i] = (byte)char.ToLowerInvariant((char)tempBytes[i]); + } + } + + if (isExtensionAllLower) + { + for (var i = extensionStart; i < shortLength; i++) + { + tempBytes[i] = (byte)char.ToLowerInvariant((char)tempBytes[i]); + } + } + } + + // The "~n" string can range from "~1" to "~999999". + const int MaxTailNumber = 999999; + var tailNumberLengthAscii = 1; + var tailNumber = 1; + var forceTrailingOnFirstLossy = lossy; + var bufferChar = stackalloc char[12]; + var encoding = encodingTable.Encoding; + + while (tailNumber <= MaxTailNumber) + { + var encodedShortNameChar = encoding.GetChars(tempBytes, tempLength, bufferChar, 12); + var shortName = new string(bufferChar, 0, encodedShortNameChar); + + if (forceTrailingOnFirstLossy || shortNameExistFunction(shortName)) + { + lossy = true; + forceTrailingOnFirstLossy = false; + + // From https://en.wikipedia.org/wiki/8.3_filename + // If at least 4 files or folders already exist with the same extension and first 6 characters in their short names, + // the stripped LFN is instead truncated to the first 2 letters of the basename (or 1 if the basename has only 1 letter), + // followed by 4 hexadecimal digits derived from an undocumented hash of the filename + if (!hasNonSupportedChar && tailNumber > 4) + { + var hash = LfnHash(name); + + // At max 2 characters + baseNameLength = Math.Min(baseNameLength, 2); + for (var i = 0; i < baseNameLength; i++) + { + tempBytes[i] = shortNameBytes[i]; + } + + // 4 hexadecimal digits + ConvertToHex(hash, new Span(tempBytes + baseNameLength, 4)); + baseNameLength += 4; + + hasNonSupportedChar = true; + + // Reset numbering + tailNumberLengthAscii = 1; + tailNumber = 1; + } + + var trailCount = 1 + tailNumberLengthAscii; + var maxTrail = 8 - trailCount; + if (baseNameLength > maxTrail) + { + baseNameLength = maxTrail; + } + + // Append the numeric tail ~n + var remaining = tailNumber; + var idx = baseNameLength + trailCount - 1; + while (remaining > 0) + { + remaining = Math.DivRem(remaining, 10, out var digit); + tempBytes[idx] = (byte)('0' + digit); + idx--; + } + tempBytes[idx] = (byte)'~'; + + // Append the extension + idx = baseNameLength + trailCount; + for (var i = extensionStart; i < shortLength; i++, idx++) + { + tempBytes[idx] = shortNameBytes[i]; + } + tempLength = idx; + + // Increment the numeric tail number for the next round + tailNumber++; + tailNumberLengthAscii += (tailNumber % 10) == 0 ? 1 : 0; + } + else + { + return new(shortName, lossy || mixedCases ? name : null); + } + } + + throw new IOException($"Too many files with the same name '{name}'"); + } + + /// + /// Create a new from a serialized byte array of directory entries. + /// + /// The buffer to decode the filename from. + /// The encoding table to use to decode shortname. + /// The amount of data actually processed from buffer. + /// + public static unsafe FatFileName FromDirectoryEntryBytes(ReadOnlySpan data, FastEncodingTable encodingTable, out int offset) + { + offset = 0; + + string longName = null; + + // LFN + if (TryGetLfnDirectoryEntryCount(data, out var lfnDirectoryEntryCount) && lfnDirectoryEntryCount > 0) + { + // Update the number of directory entries used + Span lfn_chars = stackalloc char[13 * lfnDirectoryEntryCount]; + + lfn_chars.Clear(); + + var lfn_bytes = MemoryMarshal.AsBytes(lfn_chars); + + for (var i = lfnDirectoryEntryCount; i > 0 ; i--, offset += DirectoryEntry.SizeOf) + { + if ((data[offset] & 0x3f) == i && ((FatAttributes)data[offset + 11] & FatAttributes.LongFileNameMask) == FatAttributes.LongFileName) + { + // TODO: This code is not endian-safe. Long file names will be messed up when this code runs on big endian machines. + data.Slice(offset + 1, 10).CopyTo(lfn_bytes.Slice(26 * (i - 1))); + data.Slice(offset + 14, 12).CopyTo(lfn_bytes.Slice(26 * (i - 1) + 10)); + data.Slice(offset + 28, 4).CopyTo(lfn_bytes.Slice(26 * (i - 1) + 22)); + } + else + { + // We have an orphaned long file name entry, we need to reset to the first entry and skip it + offset = DirectoryEntry.SizeOf; // inform that we only processed one entry + return new FatFileName(null, null); + } + } + + var nullpos = lfn_chars.IndexOf('\0'); + if (nullpos < 0) + { + nullpos = lfn_chars.Length; + } + + longName = lfn_chars.Slice(0, nullpos).ToString(); + } + + // If we still have long file name entry, this is invalid + if (((FatAttributes)data[offset + 11] & FatAttributes.LongFileNameMask) == FatAttributes.LongFileName) + { + // We have an orphaned long file name entry, we need to reset to the first entry and skip it + offset = DirectoryEntry.SizeOf; // inform that we only processed one entry + return new(null, null); + } + + // Check if the shortname is entirely zeroed + bool isNull = MemoryMarshal.Cast(data.Slice(0, 8))[0] == 0 + && MemoryMarshal.Cast(data.Slice(11 - 4, 4))[0] == 0; + if (isNull) + { + offset = DirectoryEntry.SizeOf; // inform that we only processed one entry + return Null; + } + + // Verifies the checksum of the long file name entries + if (lfnDirectoryEntryCount > 0) + { + var checksum = SfnChecksum(data.Slice(offset, 11)); + for (int i = 0; i < lfnDirectoryEntryCount; i++) + { + var lfnChecksum = data[i * DirectoryEntry.SizeOf + 13]; + if (lfnChecksum != checksum) + { + // We have an orphaned long file name entry, we need to reset to the first entry and skip it + offset = DirectoryEntry.SizeOf; // inform that we only processed one entry + return new(null, null); + } + } + } + + // Deleted entry + if (data[offset] == 0xe5) + { + offset += DirectoryEntry.SizeOf; + return new(null, null); + } + + // Process the name part in the short name 8.3 + var tmpBuffer = stackalloc byte[12]; // 8.3 + including `.` + int tmpLength = 0; + for (var i = 0; i < 8; i++) + { + var b = data[offset + i]; + if (b == (byte)' ') // trimming space + { + break; + } + + tmpBuffer[tmpLength++] = b; + } + + int nameLength = tmpLength; + + // Process the extension part in the short name 8.3 + var hasExtension = false; + + for (var i = 0; i < 3; i++) + { + var b = data[offset + 8 + i]; + if (b == (byte)' ') // trimming space + { + break; + } + + if (!hasExtension) + { + tmpBuffer[tmpLength++] = (byte)'.'; // add dot + hasExtension = true; + } + + tmpBuffer[tmpLength++] = b; + } + + Debug.Assert(tmpLength > 0); + + string shortName = null; + if (tmpLength == 1 && tmpBuffer[0] == '.') + { + Debug.Assert(longName is null); + shortName = "."; + } + else if (tmpLength == 2 && tmpBuffer[0] == '.' && tmpBuffer[1] == '.') + { + Debug.Assert(longName is null); + shortName = ".."; + } + else + { + // The character 0xE5 is a valid character but it is used to mark a deleted file + // So the character 0x05 is used to represent the character 0xE5 + // When reading back we need to convert it back to 0xE5 + if (tmpBuffer[0] == 0x05) + { + tmpBuffer[0] = 0xe5; + } + + // Bits 3 and 4 in offset 12 of the directory entry indicate if the name and extension are lowercase + var d0C = data[offset + 12]; + var nameIsLowercase = (d0C & (1 << 3)) != 0; + var extIsLowercase = (d0C & (1 << 4)) != 0; + + var utf16Buffer = stackalloc char[12]; + var utf16Length = encodingTable.Encoding.GetChars(tmpBuffer, tmpLength, utf16Buffer, 12); + Debug.Assert(utf16Length == tmpLength); + + // We apply case information recovered from the directory entry + if (nameIsLowercase || extIsLowercase) + { + if (nameIsLowercase) + { + for (var i = 0; i < nameLength; i++) + { + utf16Buffer[i] = char.ToLowerInvariant(utf16Buffer[i]); + } + } + + if (extIsLowercase) + { + for (var i = nameLength + 1; i < tmpLength; i++) + { + utf16Buffer[i] = char.ToLowerInvariant(utf16Buffer[i]); + } + } + } + + shortName = new string(utf16Buffer, 0, utf16Length); + } + + offset += DirectoryEntry.SizeOf; + return new(shortName, longName); + } + + /// + /// Create a new from a path. + /// + /// The parent directory to create the entry + /// + /// + /// + public static FatFileName NewPath(Directory parentDir, string path, FastEncodingTable encodingTable) => FromName(Utilities.GetFileFromPath(path), encodingTable, parentDir.CheckIfShortNameExists); + + /// + /// Trie to get the number of additional directory entries used by the long file name. + /// + /// The buffer containing the first directory entry to process. + /// The number of additional long file name directory entries if this function returns true. + /// true if the buffer contains a long file name directory entry, false otherwise. + public static bool TryGetLfnDirectoryEntryCount(ReadOnlySpan data, out int count) + { + count = 0; + + var d0 = data[0]; + if ((d0 & 0xc0) == 0x40 && data[11] == 0x0f) + { + count = d0 & 0x3f; + return true; + } + + return false; + } + + public static bool operator ==(FatFileName a, FatFileName b) => Equals(a, b); + + public static bool operator !=(FatFileName a, FatFileName b) => !Equals(a, b); + + private static bool Contains(byte[] array, byte val) => Array.IndexOf(array, val) >= 0; + + /// + /// Verify that the name does not contain invalid characters. + /// + /// The name to verify. + /// The name contains invalid characters. + private static void ValidateCharsFromLongName(string name) + { + // The invalid chars are composed of the InvalidCharsForShortName minus +,;=[] + const string invalidCharsForLongName = "\"*/:<>?\\|"; + + // Check invalid chars + var indexOfInvalidChar = name.AsSpan().IndexOfAny(invalidCharsForLongName.AsSpan()); + if (indexOfInvalidChar > 0) + { + throw new ArgumentException($"Invalid character in file name '{name[indexOfInvalidChar]}'", nameof(name)); + } + + for (var i = 0; i < name.Length; i++) + { + if (name[i] < 0x20) + { + throw new ArgumentException($"Invalid character in file name '{name[i]}'", nameof(name)); + } + } + } + + /// + /// Function used by to process the characters of the long name and compute the short name for both the base name and the extension. + /// + private static unsafe void ProcessChars(ReadOnlySpan name, int start, int end, int maxCount, ref bool lossy, ref bool isBaseAllUpper, ref bool isBaseAllLower, byte* shortNameBytes, ref int shortLength, ref bool hasNonSupportedChar, FastEncodingTable encodingTable) + { + for (int i = start; i < end; i++) + { + if (i - start == maxCount) + { + lossy = true; + break; + } + + var c = name[i]; + + // 3. Strip all leading and embedded spaces from the long name. + if (c == ' ') + { + hasNonSupportedChar = true; + lossy = true; + continue; + } + + if (c == '.') + { + lossy = true; + continue; + } + + if (char.IsLetter(c)) + { + if (!char.IsUpper(c)) + { + isBaseAllUpper = false; + } + + if (!char.IsLower(c)) + { + isBaseAllLower = false; + } + } + + if (InvalidCharsForShortName.IndexOf(c) >= 0) + { + lossy = true; + shortNameBytes[shortLength] = (byte)'_'; + } + else + { + if (!encodingTable.TryGetCharToByteUpperCase(c, out var b)) + { + hasNonSupportedChar = true; + lossy = true; + continue; + } + shortNameBytes[shortLength] = b; + } + + shortLength++; + } + } + + /// + /// Calculates the hash for a long file name. + /// + /// The long filename + /// The u16 hash + private static ushort LfnHash(string name) + { + // Amazing work from Tom Galvin to recover this algorithm + // http://tomgalvin.uk/blog/gen/2015/06/09/filenames/ + // https://web.archive.org/web/20230825135743/https://tomgalvin.uk/blog/gen/2015/06/09/filenames/ + ushort checksum = 0; + + for (int i = 0; i < name.Length; i++) + { + checksum = (ushort)(checksum * 0x25 + name[i]); + } + + int temp = checksum * 314159269; + if (temp < 0) temp = -temp; + temp -= (int)(((ulong)((long)temp * 1152921497) >> 60) * 1000000007); + checksum = (ushort)temp; + + // reverse nibble order + checksum = (ushort)( + ((checksum & 0xf000) >> 12) | + ((checksum & 0x0f00) >> 4) | + ((checksum & 0x00f0) << 4) | + ((checksum & 0x000f) << 12)); + + return checksum; + } + + /// + /// Calculate the checksum for a short file name. + /// + /// The short file name + /// The checksum + private static byte SfnChecksum(ReadOnlySpan shortName) + { + byte sum = 0; + for (var i = 0; i < shortName.Length; i++) + { + sum = (byte)((sum << 7) + (sum >> 1) + shortName[i]); + } + return sum; + } + + private static void ConvertToHex(ushort value, Span buffer) + { + buffer[0] = ByteToHex((value >> 12) & 0x0f); + buffer[1] = ByteToHex((value >> 8) & 0x0f); + buffer[2] = ByteToHex((value >> 4) & 0x0f); + buffer[3] = ByteToHex(value & 0x0f); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static byte ByteToHex(int b) => Unsafe.Add(ref MemoryMarshal.GetReference(ByteToHexLookup), b); + + private static ReadOnlySpan ByteToHexLookup => + [ + (byte)'0', + (byte)'1', + (byte)'2', + (byte)'3', + (byte)'4', + (byte)'5', + (byte)'6', + (byte)'7', + (byte)'8', + (byte)'9', + (byte)'A', + (byte)'B', + (byte)'C', + (byte)'D', + (byte)'E', + (byte)'F', + ]; +} \ No newline at end of file diff --git a/Library/DiscUtils.Fat/FatFileSystem.cs b/Library/DiscUtils.Fat/FatFileSystem.cs index 0fdf20f3d..5c2e075cb 100644 --- a/Library/DiscUtils.Fat/FatFileSystem.cs +++ b/Library/DiscUtils.Fat/FatFileSystem.cs @@ -326,7 +326,7 @@ public override string VolumeLabel return _bsVolLab; } - return _rootDir.GetEntry(volId).Name.GetRawName(FatOptions.FileNameEncoding); + return _rootDir.GetEntry(volId).Name.ShortName; } } @@ -396,7 +396,7 @@ public override SparseStream OpenFile(string path, FileMode mode, FileAccess acc if (entryId < 0) { - return parent.OpenFile(FileName.FromPath(path, FatOptions.FileNameEncoding), mode, access); + return parent.OpenFile(Utilities.GetFileFromPath(path), mode, access); } var dirEntry = parent.GetEntry(entryId); @@ -406,7 +406,7 @@ public override SparseStream OpenFile(string path, FileMode mode, FileAccess acc throw new IOException("Attempt to open directory as a file"); } - return parent.OpenFile(dirEntry.Name, mode, access); + return parent.OpenFile(dirEntry.Name.FullName, mode, access); } public IEnumerable PathToExtents(string path) @@ -557,8 +557,8 @@ public string GetShortName(string path) { return null; } - - return GetDirectoryEntry(path).Name.GetShortName(FatOptions.FileNameEncoding); + + return GetDirectoryEntry(path).Name.ShortName.ToUpperInvariant(); } public void SetShortName(string path, string name) @@ -568,7 +568,13 @@ public void SetShortName(string path, string name) throw new InvalidOperationException("Cannot set short name on root directory"); } - GetDirectoryEntry(path).Name.SetShortName(name, FatOptions.FileNameEncoding); + var id = GetDirectoryEntry(_rootDir, path, out var parent); + if (id < 0) + { + throw new FileNotFoundException("No such file or directory", path); + } + + parent.ReplaceShortName(id, name); } /// @@ -879,13 +885,7 @@ public override void CopyFile(string sourceFile, string destinationFile, bool ov { throw new IOException("The source file is a directory"); } - - var newEntry = new DirectoryEntry(sourceEntry) - { - Name = FileName.FromPath(destinationFile, FatOptions.FileNameEncoding), - FirstCluster = 0 - }; - + var destEntryId = GetDirectoryEntry(destinationFile, out var destDir); if (destDir == null) @@ -893,19 +893,20 @@ public override void CopyFile(string sourceFile, string destinationFile, bool ov throw new DirectoryNotFoundException($"The destination directory for '{destinationFile}' was not found"); } + var resolvedDestinationFile = destinationFile; + // If the destination is a directory, use the old file name to construct a full path. if (destEntryId >= 0) { var destEntry = destDir.GetEntry(destEntryId); if ((destEntry.Attributes & FatAttributes.Directory) != 0) { - newEntry.Name = FileName.FromPath(sourceFile, FatOptions.FileNameEncoding); + resolvedDestinationFile = sourceFile; destinationFile = Utilities.CombinePaths(destinationFile, Utilities.GetFileFromPath(sourceFile)); - destEntryId = GetDirectoryEntry(destinationFile, out destDir); } } - + // If there's an existing entry... if (destEntryId >= 0) { @@ -925,6 +926,21 @@ public override void CopyFile(string sourceFile, string destinationFile, bool ov destDir.DeleteEntry(destEntryId, true); } + FatFileName sourceFileName; + try + { + sourceFileName = FatFileName.NewPath(destDir, resolvedDestinationFile, FatOptions.FileNameEncodingTable); + } + catch (ArgumentException ex) + { + throw new IOException($"Failed to create file name '{resolvedDestinationFile}'", ex); + } + + var newEntry = new DirectoryEntry(sourceEntry, sourceFileName) + { + FirstCluster = 0 + }; + // Add the new file's entry destEntryId = destDir.AddEntry(newEntry); @@ -946,18 +962,16 @@ public override void CreateDirectory(string path) foreach (var pathElement in pathElements) { - FileName name; + var pathName = pathElement.ToString(); try { - name = new FileName(pathElement.ToString(), FatOptions.FileNameEncoding); + var child = focusDir.GetChildDirectory(pathName) ?? focusDir.CreateChildDirectory(pathName); + focusDir = child; } - catch (ArgumentException ae) + catch (ArgumentException ex) { - throw new IOException("Invalid path", ae); + throw new IOException($"Failed to create directory '{pathName}'", ex); } - - var child = focusDir.GetChildDirectory(name) ?? focusDir.CreateChildDirectory(name); - focusDir = child; } } @@ -1078,7 +1092,7 @@ public override IEnumerable GetDirectories(string path) foreach (var dirEntry in entries) { - yield return Utilities.CombinePaths(path, dirEntry.Name.GetDisplayName(FatOptions.FileNameEncoding)); + yield return Utilities.CombinePaths(path, dirEntry.Name.FullName); } } @@ -1110,7 +1124,7 @@ public override IEnumerable GetFiles(string path) foreach (var dirEntry in entries) { - yield return Utilities.CombinePaths(path, dirEntry.Name.GetDisplayName(FatOptions.FileNameEncoding)); + yield return Utilities.CombinePaths(path, dirEntry.Name.FullName); } } @@ -1142,7 +1156,7 @@ public override IEnumerable GetFileSystemEntries(string path) foreach (var dirEntry in entries) { - yield return Utilities.CombinePaths(path, dirEntry.Name.GetDisplayName(FatOptions.FileNameEncoding)); + yield return Utilities.CombinePaths(path, dirEntry.Name.FullName); } } @@ -1162,9 +1176,9 @@ public override IEnumerable GetFileSystemEntries(string path, string sea foreach (var dirEntry in entries) { - if (re is null || dirEntry.Name.IsMatch(re, FatOptions.FileNameEncoding)) + if (re is null || dirEntry.Name.IsMatch(re)) { - yield return Utilities.CombinePaths(path, dirEntry.Name.GetDisplayName(FatOptions.FileNameEncoding)); + yield return Utilities.CombinePaths(path, dirEntry.Name.FullName); } } } @@ -1207,8 +1221,7 @@ public override void MoveDirectory(string sourceDirectoryName, string destinatio throw new IOException("Source directory doesn't exist"); } - destParent.AttachChildDirectory(FileName.FromPath(destinationDirectoryName, FatOptions.FileNameEncoding), - GetDirectory(sourceDirectoryName)); + destParent.AttachChildDirectory(destinationDirectoryName,GetDirectory(sourceDirectoryName)); sourceParent.DeleteEntry(sourceId, false); } @@ -1235,11 +1248,6 @@ public override void MoveFile(string sourceName, string destinationName, bool ov throw new IOException("The source file is a directory"); } - var newEntry = new DirectoryEntry(sourceEntry) - { - Name = FileName.FromPath(destinationName, FatOptions.FileNameEncoding) - }; - var destEntryId = GetDirectoryEntry(destinationName, out var destDir); if (destDir == null) @@ -1247,15 +1255,16 @@ public override void MoveFile(string sourceName, string destinationName, bool ov throw new DirectoryNotFoundException($"The destination directory for '{destinationName}' was not found"); } + var resolvedDestinationName = destinationName; + // If the destination is a directory, use the old file name to construct a full path. if (destEntryId >= 0) { var destEntry = destDir.GetEntry(destEntryId); if ((destEntry.Attributes & FatAttributes.Directory) != 0) { - newEntry.Name = FileName.FromPath(sourceName, FatOptions.FileNameEncoding); + resolvedDestinationName = sourceName; destinationName = Utilities.CombinePaths(destinationName, Utilities.GetFileFromPath(sourceName)); - destEntryId = GetDirectoryEntry(destinationName, out destDir); } } @@ -1278,6 +1287,18 @@ public override void MoveFile(string sourceName, string destinationName, bool ov // Remove the old file destDir.DeleteEntry(destEntryId, true); } + + FatFileName sourceFileName; + try + { + sourceFileName = FatFileName.NewPath(destDir, resolvedDestinationName, FatOptions.FileNameEncodingTable); + } + catch (ArgumentException ex) + { + throw new IOException($"Failed to create file name '{resolvedDestinationName}'", ex); + } + + var newEntry = new DirectoryEntry(sourceEntry, sourceFileName); // Add the new file's entry and remove the old link to the file's contents destDir.AddEntry(newEntry); @@ -1296,7 +1317,6 @@ internal DateTime ConvertFromUtc(DateTime dateTime) internal Directory GetDirectory(string path) { - if (string.IsNullOrEmpty(path) || path == @"\" || path == "/") { return _rootDir; @@ -1709,14 +1729,19 @@ private void ReadBS(int offset) } private long GetDirectoryEntry(Directory dir, string path, out Directory parent) + { + return GetDirectoryEntry(dir, path, out parent, out _); + } + + private long GetDirectoryEntry(Directory dir, string path, out Directory parent, out string lastPathName) { var pathElements = path.AsMemory().Split('\\', '/', StringSplitOptions.RemoveEmptyEntries).ToArray(); - return GetDirectoryEntry(dir, pathElements, 0, out parent); + return GetDirectoryEntry(dir, pathElements, 0, out parent, out lastPathName); } - private long GetDirectoryEntry(Directory dir, ReadOnlyMemory[] pathEntries, int pathOffset, out Directory parent) + private long GetDirectoryEntry(Directory dir, ReadOnlyMemory[] pathEntries, int pathOffset, out Directory parent, out string lastPathName) { - long entryId; + lastPathName = null; if (pathEntries.Length == 0) { @@ -1725,20 +1750,29 @@ private long GetDirectoryEntry(Directory dir, ReadOnlyMemory[] pathEntries return 0; } - entryId = dir.FindEntry(new FileName(pathEntries[pathOffset].ToString(), FatOptions.FileNameEncoding)); - if (entryId >= 0) + while (true) { - if (pathOffset == pathEntries.Length - 1) + long entryId = dir.FindEntry(pathEntries[pathOffset].ToString()); + if (entryId >= 0) { - parent = dir; - return entryId; - } + if (pathOffset == pathEntries.Length - 1) + { + parent = dir; + return entryId; + } - return GetDirectoryEntry(GetDirectory(dir, entryId), pathEntries, pathOffset + 1, out parent); + dir = GetDirectory(dir, entryId); + pathOffset++; + } + else + { + break; + } } if (pathOffset == pathEntries.Length - 1) { + lastPathName = pathEntries[pathOffset].ToString(); parent = dir; return -1; } @@ -1760,15 +1794,15 @@ private IEnumerable DoSearch(string path, Func filter, boo if ((isDir && dirs) || (!isDir && files)) { - if (filter is null || de.Name.IsMatch(filter, FatOptions.FileNameEncoding)) + if (filter is null || de.Name.IsMatch(filter)) { - yield return Utilities.CombinePaths(path, de.Name.GetDisplayName(FatOptions.FileNameEncoding)); + yield return Utilities.CombinePaths(path, de.Name.FullName); } } if (subFolders && isDir) { - foreach (var subdirentry in DoSearch(Utilities.CombinePaths(path, de.Name.GetDisplayName(FatOptions.FileNameEncoding)), + foreach (var subdirentry in DoSearch(Utilities.CombinePaths(path, de.Name.FullName), filter, subFolders, dirs, files)) { yield return subdirentry; diff --git a/Library/DiscUtils.Fat/FatFileSystemOptions.cs b/Library/DiscUtils.Fat/FatFileSystemOptions.cs index 953f1840f..5117ad191 100644 --- a/Library/DiscUtils.Fat/FatFileSystemOptions.cs +++ b/Library/DiscUtils.Fat/FatFileSystemOptions.cs @@ -30,38 +30,36 @@ namespace DiscUtils.Fat; /// public sealed class FatFileSystemOptions : DiscFileSystemOptions { - private Encoding _encoding; - -#if !NETFRAMEWORK - static FatFileSystemOptions() - { - Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - } -#endif + private FastEncodingTable _encodingTable; internal FatFileSystemOptions() { - FileNameEncoding = Encoding.GetEncoding(437); + _encodingTable = FastEncodingTable.Default; } internal FatFileSystemOptions(FileSystemParameters parameters) { - if (parameters != null && parameters.FileNameEncoding != null) + if (parameters.FileNameEncoding is not null) { FileNameEncoding = parameters.FileNameEncoding; } else { - FileNameEncoding = Encoding.GetEncoding(437); + _encodingTable = FastEncodingTable.Default; } } + /// + /// Gets the fast encoding table used for file names. + /// + internal FastEncodingTable FileNameEncodingTable => _encodingTable; + /// /// Gets or sets the character encoding used for file names. /// public Encoding FileNameEncoding { - get => _encoding; + get => _encodingTable.Encoding; set { @@ -70,7 +68,7 @@ public Encoding FileNameEncoding throw new ArgumentException($"{value.EncodingName} is not a single byte encoding"); } - _encoding = value; + _encodingTable = new FastEncodingTable(value); } } } \ No newline at end of file diff --git a/Library/DiscUtils.Fat/FileName.cs b/Library/DiscUtils.Fat/FileName.cs deleted file mode 100644 index 7152658ac..000000000 --- a/Library/DiscUtils.Fat/FileName.cs +++ /dev/null @@ -1,433 +0,0 @@ -// -// Copyright (c) 2008-2011, Kenneth Bell -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. -// - -using System; -using System.IO; -using System.Runtime.InteropServices; -using System.Text; -using DiscUtils.Internal; -using DiscUtils.Streams; -using DiscUtils.Streams.Compatibility; - -namespace DiscUtils.Fat; - -internal sealed class FileName : IEquatable -{ - private const byte SpaceByte = 0x20; - - public static readonly FileName SelfEntryName = - new(". "u8); - - public static readonly FileName ParentEntryName = - new(".. "u8); - - public static readonly FileName Null = - new("\0\0\0\0\0\0\0\0\0\0\0"u8); - - private static readonly byte[] InvalidBytes = "\"*+,./:;<=>?[\\]|"u8.ToArray(); - - private readonly byte[] _raw; - - private readonly string _lfn; - - public FileName(ReadOnlySpan data) - { - var offset = 0; - - // LFN - // ToDo: This code is not endian-safe. Long file names will be messed - // up when this code runs on big endian machines. - if ((data[0] & 0xc0) == 0x40 && data[11] == 0x0f) - { - var lfn_entries = data[0] & 0x3f; - - Span lfn_chars = stackalloc char[13 * lfn_entries]; - - lfn_chars.Clear(); - - var lfn_bytes = MemoryMarshal.AsBytes(lfn_chars); - - for (var i = lfn_entries; - i > 0 && (data[offset] & 0x3f) == i && data[offset + 11] == 0x0f; - i--, offset += 32) - { - data.Slice(offset + 1, 10).CopyTo(lfn_bytes.Slice(26 * (i - 1))); - data.Slice(offset + 14, 12).CopyTo(lfn_bytes.Slice(26 * (i - 1) + 10)); - data.Slice(offset + 28, 4).CopyTo(lfn_bytes.Slice(26 * (i - 1) + 22)); - } - - var nullpos = lfn_chars.IndexOf('\0'); - - if (nullpos < 0) - { - nullpos = lfn_chars.Length; - } - - _lfn = lfn_chars.Slice(0, nullpos).ToString(); - } - - _raw = new byte[11]; - - data.Slice(offset, 11).CopyTo(_raw); - } - - public FileName(string name, Encoding encoding) - { -#if NET6_0_OR_GREATER - ArgumentNullException.ThrowIfNull(name); -#else - if (name is null) - { - throw new ArgumentNullException(nameof(name)); - } -#endif - - if (name.Length > 255) - { - throw new IOException($"Too long name: '{name}'"); - } - - _raw = new byte[11]; - - Span bytes = stackalloc byte[encoding.GetByteCount(name)]; - - encoding.GetBytes(name, bytes); - - if (bytes.Length == 0) - { - throw new ArgumentException($"File name too short '{name}'", nameof(name)); - } - - var nameIdx = 0; - var rawIdx = 0; - - var extensionPosition = bytes.LastIndexOf((byte)'.'); - - // If not dot, or last dot is at the beginning, the name - // is considered to have no extension - if (extensionPosition <= 0) - { - extensionPosition = bytes.Length; - } - -#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP - Span buffer = stackalloc char[4]; -#endif - - for (; nameIdx < extensionPosition && rawIdx < 8;nameIdx++) - { - var b = bytes[nameIdx]; - - if (b == '.') - { - continue; - } - - if (b == '?') - { -#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP - ((int)name[nameIdx]).TryFormat(buffer, out var hexLength, format: "X"); -#else - var buffer = ((int)name[nameIdx]).ToString("X"); - var hexLength = buffer.Length; -#endif - - for (var i = 0; i < hexLength && rawIdx < 8; i++) - { - _raw[rawIdx++] = (byte)buffer[i]; - } - - continue; - } - - if (b < 0x20 || Contains(InvalidBytes, b)) - { - throw new ArgumentException($"Invalid character in file name '{(char)b}'", nameof(name)); - } - - _raw[rawIdx++] = (byte)char.ToUpperInvariant((char)b); - } - - if (rawIdx > 8) - { - //throw new ArgumentException($"File name too long '{name}'", nameof(name)); - } - - if (rawIdx == 0) - { - //throw new ArgumentException($"File name too short '{name}'", nameof(name)); - } - - while (rawIdx < 8) - { - _raw[rawIdx++] = SpaceByte; - } - - if (nameIdx < bytes.Length && bytes[nameIdx] == '.') - { - ++nameIdx; - } - - for (; nameIdx < bytes.Length && rawIdx < _raw.Length; nameIdx++) - { - var b = bytes[nameIdx]; - - if (b == '?') - { -#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP - ((int)name[nameIdx]).TryFormat(buffer, out var hexLength, format: "X"); -#else - var buffer = ((int)name[nameIdx]).ToString("X"); - var hexLength = buffer.Length; -#endif - - for (var i = 0; i < hexLength && rawIdx < _raw.Length; i++) - { - _raw[rawIdx++] = (byte)buffer[i]; - } - - continue; - } - - if (b < 0x20 || Contains(InvalidBytes, b)) - { - throw new ArgumentException($"Invalid character in file extension '{(char)b}'", nameof(name)); - } - - _raw[rawIdx++] = b; - } - - while (rawIdx < 11) - { - _raw[rawIdx++] = SpaceByte; - } - - if (nameIdx != bytes.Length) - { - //throw new ArgumentException($"File extension too long '{name}'", nameof(name)); - } - - _lfn = name; - } - - public bool Equals(FileName other) => Equals(this, other); - - public static bool Equals(FileName a, FileName b) - { - if (ReferenceEquals(a, b)) - { - return true; - } - - if (a is null || b is null) - { - return false; - } - - if (a._lfn != null) - { - if (StringComparer.OrdinalIgnoreCase.Equals(a._lfn, b._lfn) || - StringComparer.OrdinalIgnoreCase.Equals(a._lfn, b.GetShortName(Encoding.ASCII))) - { - return true; - } - } - else if (b._lfn != null) - { - if (StringComparer.OrdinalIgnoreCase.Equals(a.GetShortName(Encoding.ASCII), b._lfn)) - { - return true; - } - } - else - { - if (CompareRawNames(a, b) == 0) - { - return true; - } - } - - return false; - } - - public static FileName FromPath(string path, Encoding encoding) - { - return new FileName(Utilities.GetFileFromPath(path), encoding); - } - - public static bool operator ==(FileName a, FileName b) => Equals(a, b); - - public static bool operator !=(FileName a, FileName b) => !Equals(a, b); - - public string GetDisplayName(Encoding encoding) - { - return _lfn ?? GetShortName(encoding); - } - - public string GetShortName(Encoding encoding) - { - return $"{encoding.GetString(_raw, 0, 8).TrimEnd()}.{encoding.GetString(_raw, 8, 3)}".TrimEnd('.', ' '); - } - - public void SetShortName(string name, Encoding encoding) - { - var bytes = encoding.GetBytes(name.ToUpperInvariant()); - - var nameIdx = 0; - var rawIdx = 0; - - while (nameIdx < bytes.Length && bytes[nameIdx] != '.' && rawIdx < _raw.Length) - { - var b = bytes[nameIdx++]; - if (b < 0x20 || Contains(InvalidBytes, b)) - { - throw new ArgumentException($"Invalid character in file name '{(char)b}'", nameof(name)); - } - - _raw[rawIdx++] = b; - } - - if (rawIdx > 8) - { - throw new ArgumentException($"File name too long '{name}'", nameof(name)); - } - - if (rawIdx == 0) - { - throw new ArgumentException($"File name too short '{name}'", nameof(name)); - } - - while (rawIdx < 8) - { - _raw[rawIdx++] = SpaceByte; - } - - if (nameIdx < bytes.Length && bytes[nameIdx] == '.') - { - ++nameIdx; - } - - while (nameIdx < bytes.Length && rawIdx < _raw.Length) - { - var b = bytes[nameIdx++]; - if (b < 0x20 || Contains(InvalidBytes, b)) - { - throw new ArgumentException($"Invalid character in file extension '{(char)b}'", nameof(name)); - } - - _raw[rawIdx++] = b; - } - - while (rawIdx < 11) - { - _raw[rawIdx++] = SpaceByte; - } - - if (nameIdx != bytes.Length) - { - throw new ArgumentException($"File extension too long '{name}'", nameof(name)); - } - } - - public bool IsMatch(Func filter, Encoding encoding) - { - var search_name = GetDisplayName(encoding); - if (search_name.IndexOf('.') < 0) - { - search_name += '.'; - } - - return filter(search_name); - } - - public string GetRawName(Encoding encoding) => encoding.GetString(_raw, 0, 11).TrimEnd(); - - public FileName Deleted() - { - Span data = stackalloc byte[11]; - _raw.AsSpan(0, 11).CopyTo(data); - data[0] = 0xE5; - - return new FileName(data); - } - - public bool IsDeleted() - { - return _raw[0] == 0xE5; - } - - public bool IsEndMarker() - { - return _raw[0] == 0x00; - } - - public void GetBytes(Span data) - { - _raw.AsSpan(0, 11).CopyTo(data); - } - - public override bool Equals(object other) - => other is FileName otherName && Equals(this, otherName); - - public override int GetHashCode() - { - if (_lfn != null) - { - return _lfn.GetHashCode(); - } - - var val = new HashCode(); - - for (var i = 0; i < 11; ++i) - { - val.Add(_raw[i]); - } - - return val.ToHashCode(); - } - - private static int CompareRawNames(FileName a, FileName b) - { - if (a._lfn != null || b._lfn != null) - { - return StringComparer.OrdinalIgnoreCase.Compare(a.ToString(), b.ToString()); - } - - for (var i = 0; i < 11; ++i) - { - if (a._raw[i] != b._raw[i]) - { - return a._raw[i] - b._raw[i]; - } - } - - return 0; - } - - private static bool Contains(byte[] array, byte val) => Array.IndexOf(array, val) >= 0; - - public string Lfn => _lfn; - - public string ShortName => GetShortName(Encoding.ASCII); - - public override string ToString() => GetDisplayName(Encoding.ASCII); -} \ No newline at end of file diff --git a/Library/DiscUtils.Fat/FreeDirectoryEntryTable.cs b/Library/DiscUtils.Fat/FreeDirectoryEntryTable.cs new file mode 100644 index 000000000..a02352f17 --- /dev/null +++ b/Library/DiscUtils.Fat/FreeDirectoryEntryTable.cs @@ -0,0 +1,153 @@ +// +// Copyright (c) 2024, Olof Lagerkvist and contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Numerics; + +namespace DiscUtils.Fat; + +/// +/// Free directory entry table to manage free directory entries. +/// +/// +/// NOTE: This allocator could be optimized further to share/recycle allocations of SortedSet<long> instances through the . +/// +internal struct FreeDirectoryEntryTable +{ + private const int MaxBucketCount = 32; + + private uint _bucketMask; + private readonly SortedSet[] _buckets; + private readonly Stack> _freeList; + + /// + /// Initializes a new instance of the struct. + /// + public FreeDirectoryEntryTable() + { + _buckets = new SortedSet[MaxBucketCount]; + _freeList = new(); + } + + /// + /// Add a free-range of directory entries. + /// + /// The first position to be free + /// The number of directory entries free after the position. + public void AddFreeRange(long position, int count) + { + if (count <= 0) + { + throw new ArgumentOutOfRangeException(nameof(count), "The number of directory entries to add must be greater than zero."); + } + + while (count > 0) + { + var bucket = Math.Min(count, MaxBucketCount); + AddInternal(position, bucket); + count -= bucket; + position += bucket * DirectoryEntry.SizeOf; + } + } + + /// + /// Allocate a free directory entry. + /// + /// The number of directory entries to allocate. + /// The position of the first directory entry allocated or -1 if no free directory entries are available. + public long Allocate(int originalCount) + { + if (originalCount <= 0) + { + throw new ArgumentOutOfRangeException(nameof(originalCount), "The number of directory entries to allocate must be greater than zero."); + } + + if (originalCount > MaxBucketCount) + { + throw new ArgumentOutOfRangeException(nameof(originalCount), "The number of directory entries to allocate is too large."); + } + var bucketIndex = originalCount - 1; + var bucketMask = _bucketMask >>> bucketIndex; + // If there are no buckets or the requested count is larger than the largest bucket, return -1 + if (bucketMask == 0) + { + return -1; + } + + // Find the first bucket with free space + var offset = TrailingZeroCount(bucketMask); + + // Resolve the bucket index + var resolvedBucketIndex = bucketIndex + offset; + + // Get the first position in the bucket + ref var bucket = ref _buckets[resolvedBucketIndex]; + // There is a guarantee that the bucket is not null since we have a mask bit set + var pos = bucket.Min; + // We can remove the position from the bucket + bucket.Remove(pos); + + // If we have a larger bucket, split it and add the remaining to the free list + if (offset > 0) + { + // offset becomes the number of remaining entries that were not used by the allocation + AddFreeRange(pos + originalCount * DirectoryEntry.SizeOf, offset); + } + + // If the bucket is empty, remove it + if (bucket.Count == 0) + { + _bucketMask &= ~(1U << resolvedBucketIndex); + _freeList.Push(bucket); + bucket = null; + } + + return pos; + } + + private void AddInternal(long position, int bucketIndex) + { + Debug.Assert(bucketIndex > 0); + bucketIndex--; + ref var bucket = ref _buckets[bucketIndex]; + bucket ??= _freeList.Count > 0 ? _freeList.Pop() : new(); + bucket.Add(position); + _bucketMask |= 1U << bucketIndex; + } + + private static int TrailingZeroCount(uint value) + { +#if NET6_0_OR_GREATER + return BitOperations.TrailingZeroCount(value); +#else + // This could be more optimized but this is for old .NET framework versions + var count = 0; + while ((value & 1) == 0) + { + count++; + value >>>= 1; + } + + return count; +#endif + } +} \ No newline at end of file diff --git a/Tests/LibraryTests/Fat/FatFileNameTest.cs b/Tests/LibraryTests/Fat/FatFileNameTest.cs new file mode 100644 index 000000000..97e2219d6 --- /dev/null +++ b/Tests/LibraryTests/Fat/FatFileNameTest.cs @@ -0,0 +1,99 @@ +// +// Copyright (c) 2024, Olof Lagerkvist and contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. +using System; +using DiscUtils.Fat; +using Xunit; +using Xunit.Abstractions; + +namespace LibraryTests.Fat; + +public class FatFileNameTest +{ + + private readonly ITestOutputHelper _output; + public FatFileNameTest(ITestOutputHelper output) + { + _output = output; + } + + // Generate short name from long name Xunit tests cases + [Theory] + [InlineData("A", "A", false)] + [InlineData("A.B", "A.B", false)] + [InlineData("a", "a", false)] + [InlineData("a.b", "a.b", false)] + [InlineData("a.B", "a.B", false)] + [InlineData("A1234567", "A1234567", false)] + [InlineData("A1234567.ext", "A1234567.ext", false)] + [InlineData("this_is_a_long_name", "THIS_I~1", true)] + [InlineData("V1Abcd_this_is_to_long.TXT", "V1ABCD~2.TXT", true)] + [InlineData("V2Abcd_this_is_to_long.TXT", "V2DB58~1.TXT", true)] + [InlineData("✨.txt", "6393~1.TXT", true)] + [InlineData("abcdef🙂.txt", "ABCDEF~1.TXT", true)] + [InlineData("abc🙂.txt", "ABC~1.TXT", true)] + [InlineData("ab🙂.txt", "AB1F60~1.TXT", true)] + [InlineData("c d.txt", "CD2C67~1.TXT", true)] + [InlineData("...txt", "TXT~1", true)] + [InlineData("..a.txt", "A2632~1.TXT", true)] + [InlineData("txt...", "txt", false)] + [InlineData("a+b=c", "A_B_C~1", true)] + [InlineData("ab .txt", "AB1775~1.TXT", true)] + [InlineData("✨TAT", "TAT~1", true)] + [InlineData("a.b..c.d", "ABC~1.D", true)] + [InlineData("Mixed.Cas", "MIXED.CAS", true)] + [InlineData("Mixed.txt", "MIXED.TXT", true)] + [InlineData("mixed.Txt", "MIXED.TXT", true)] + public void TestShortName(string name, string expectedShortName, bool expectedLongName) + { + Func resolver = name.StartsWith("V1") ? IsShortNameExits1 : name.StartsWith("V2") ? IsShortNameExists2 : shortName => false; + var fileName = FatFileName.FromName(name, FastEncodingTable.Default, resolver); + Assert.Equal(expectedShortName, fileName.ShortName); + Assert.Equal(expectedLongName, fileName.LongName is not null); + if (expectedLongName) + { + Assert.Equal(name, fileName.LongName); + } + + var size = (1 + fileName.LfnDirectoryEntryCount) * DirectoryEntry.SizeOf; + var buffer = new byte[size]; + fileName.ToDirectoryEntryBytes(buffer, FastEncodingTable.Default); + + var fileName2 = FatFileName.FromDirectoryEntryBytes(buffer, FastEncodingTable.Default, out int offset); + + Assert.Equal(size, offset); + Assert.Equal(fileName.ShortName, fileName2.ShortName); + Assert.Equal(fileName.LongName, fileName2.LongName); + + + static bool IsShortNameExits1(string name) + { + return name.Equals("V1ABCD~1.TXT", StringComparison.OrdinalIgnoreCase); + } + + static bool IsShortNameExists2(string name) + { + return name.Equals("V2ABCD~1.TXT", StringComparison.OrdinalIgnoreCase) + || name.Equals("V2ABCD~2.TXT", StringComparison.OrdinalIgnoreCase) + || name.Equals("V2ABCD~3.TXT", StringComparison.OrdinalIgnoreCase) + || name.Equals("V2ABCD~4.TXT", StringComparison.OrdinalIgnoreCase); + } + } +} \ No newline at end of file diff --git a/Tests/LibraryTests/Fat/FatFileSystemTest.cs b/Tests/LibraryTests/Fat/FatFileSystemTest.cs index c3d5fe3b9..6378386ae 100644 --- a/Tests/LibraryTests/Fat/FatFileSystemTest.cs +++ b/Tests/LibraryTests/Fat/FatFileSystemTest.cs @@ -41,7 +41,7 @@ public void FormatFloppy() var fs = FatFileSystem.FormatFloppy(ms, FloppyDiskType.HighDensity, "KBFLOPPY "); } - [Fact(Skip = "Saving LFN not yet implemented")] + [Fact] public void Cyrillic() { SetupHelper.RegisterAssembly(typeof(FatFileSystem).Assembly); @@ -240,4 +240,222 @@ public void InvalidImageThrowsException() stream.Position = 0; Assert.Throws(() => new FatFileSystem(stream)); } + + [Fact] + public void TestShortNameDeletedEntries() + { + var diskStream = new SparseMemoryStream(); + { + using var fs = FatFileSystem.FormatFloppy(diskStream, FloppyDiskType.HighDensity, "FLOPPY_IMG "); + + fs.CreateDirectory(@"FOO1"); + fs.CreateDirectory(@"FOO2"); + fs.CreateDirectory(@"FOO3"); + fs.CreateDirectory(@"FOO4"); + fs.CreateDirectory(@"BAR"); + fs.CreateDirectory(@"BAR1"); + fs.CreateDirectory(@"BAR2"); + fs.CreateDirectory(@"BAR3"); + + fs.DeleteDirectory(@"FOO1"); + fs.DeleteDirectory(@"FOO2"); + fs.DeleteDirectory(@"FOO3"); + fs.DeleteDirectory(@"FOO4"); + fs.DeleteDirectory(@"BAR1"); + fs.DeleteDirectory(@"BAR2"); + fs.DeleteDirectory(@"BAR3"); + fs.CreateDirectory(@"01234567890123456789.txt"); + } + + { + var fs = new FatFileSystem(diskStream); + var entries = fs.GetFileSystemEntries("\\").OrderBy(x => x).ToList(); + Assert.Equal(2, entries.Count); + Assert.Equal("\\01234567890123456789.txt", entries[0]); + Assert.Equal("\\BAR", entries[1]); + + fs.CreateDirectory("abcdefghijklmnop.txt"); + + entries = fs.GetFileSystemEntries("\\").OrderBy(x => x).ToList(); + Assert.Equal(3, entries.Count); + Assert.Equal("\\01234567890123456789.txt", entries[0]); + Assert.Equal("\\abcdefghijklmnop.txt", entries[1]); + Assert.Equal("\\BAR", entries[2]); + } + } + + [Fact] + public void TestLongNameDeletedEntries() + { + var diskStream = new SparseMemoryStream(); + { + using var fs = FatFileSystem.FormatFloppy(diskStream, FloppyDiskType.HighDensity, "FLOPPY_IMG "); + + fs.CreateDirectory(@"FOO_This_is_a_long_entry_1"); + fs.CreateDirectory(@"FOO_This_is_a_long_entry_2"); + fs.CreateDirectory(@"FOO_This_is_a_long_entry_3"); + fs.CreateDirectory(@"FOO_This_is_a_long_entry_4"); + + fs.DeleteDirectory(@"FOO_This_is_a_long_entry_1"); // 26 characters, should take 3 entries (2 for LFN + 1 for SFN) + fs.CreateDirectory("TA"); // Should take the entry of the previously deleted entry + fs.CreateDirectory("TB"); + fs.CreateDirectory("TC"); + } + + { + var fs = new FatFileSystem(diskStream); + var entries = fs.GetFileSystemEntries("\\").OrderBy(x => x).ToList(); + Assert.Equal(6, entries.Count); + Assert.Equal("\\FOO_This_is_a_long_entry_2", entries[0]); + Assert.Equal("\\FOO_This_is_a_long_entry_3", entries[1]); + Assert.Equal("\\FOO_This_is_a_long_entry_4", entries[2]); + Assert.Equal("\\TA", entries[3]); + Assert.Equal("\\TB", entries[4]); + Assert.Equal("\\TC", entries[5]); + } + } + + [Fact] + public void TestCreateDirectoryAndFailure() + { + var diskStream = new SparseMemoryStream(); + { + using var fs = FatFileSystem.FormatFloppy(diskStream, FloppyDiskType.HighDensity, "FLOPPY_IMG "); + + fs.CreateDirectory(@"BAR\BAZ\QUX"); + fs.CreateDirectory(@"BAR\BAZ\QUX"); // Nothing is happening here + fs.CreateDirectory(@"BAR"); + { + using var file = fs.OpenFile(@"BAR\BAZ\QUX\TEST", FileMode.Create); + file.WriteByte(0); + } + + Assert.Throws(() => fs.CreateDirectory(@"BAR\BAZ\QUX\TEST")); + } + } + + [Fact] + public void TestLargeFileCreateOpenAppendTruncate() + { + var diskStream = new SparseMemoryStream(); + { + using var fs = FatFileSystem.FormatFloppy(diskStream, FloppyDiskType.HighDensity, "FLOPPY_IMG "); + + var buffer = new byte[1024 * 1024]; + var rnd = new Random(0); + rnd.NextBytes(buffer); + using (var file = fs.OpenFile("TEST", FileMode.Create)) + { + file.Write(buffer, 0, buffer.Length); + } + + using (var file = fs.OpenFile("TEST", FileMode.Open)) + { + var buffer2 = new byte[buffer.Length]; + int length = file.Read(buffer2, 0, buffer2.Length); + Assert.Equal(length, buffer2.Length); + + for (int i = 0; i < buffer.Length; i++) + { + Assert.Equal(buffer[i], buffer2[i]); + } + } + + using (var file = fs.OpenFile("TEST", FileMode.Append)) + { + var smallerBuffer = new byte[] { 1, 2, 3, 4 }; + file.Write(smallerBuffer, 0, smallerBuffer.Length); + } + + using (var file = fs.OpenFile("TEST", FileMode.Open)) + { + var buffer2 = new byte[buffer.Length + 4]; + int length = file.Read(buffer2, 0, buffer2.Length); + Assert.Equal(length, buffer2.Length); + + for (int i = 0; i < buffer.Length; i++) + { + Assert.Equal(buffer[i], buffer2[i]); + } + + for (int i = 0; i < 4; i++) + { + Assert.Equal(i + 1, buffer2[buffer.Length + i]); + } + } + + using (var file = fs.OpenFile("TEST", FileMode.Truncate)) + { + file.Write([0]); + } + + var attr = fs.GetFileLength("TEST"); + Assert.Equal(1, attr); + + fs.DeleteFile("TEST"); + + Assert.Throws(() => fs.GetFileLength("TEST")); + + using (var file = fs.OpenFile("ANOTHER", FileMode.Create)) + { + file.Write(buffer, 0, buffer.Length); + } + + Assert.True(fs.FileExists("ANOTHER")); + } + } + + [Fact] + public void TestShortName() + { + var diskStream = new SparseMemoryStream(); + { + using var fs = FatFileSystem.FormatFloppy(diskStream, FloppyDiskType.HighDensity, "FLOPPY_IMG "); + + fs.CreateDirectory("A"); + fs.CreateDirectory("A.B"); + fs.CreateDirectory("a"); + fs.CreateDirectory("a.b"); + fs.CreateDirectory("a.B"); + fs.CreateDirectory("A1234567"); + fs.CreateDirectory("A1234567.ext"); + fs.CreateDirectory("this_is_a_long_name"); + fs.CreateDirectory("V1Abcd_this_is_to_long.TXT"); + fs.CreateDirectory("V2Abcd_this_is_to_long.TXT"); + fs.CreateDirectory("✨.txt"); + fs.CreateDirectory("abcdef🙂.txt"); + fs.CreateDirectory("abc🙂.txt"); + fs.CreateDirectory("ab🙂.txt"); + fs.CreateDirectory("c d.txt"); + fs.CreateDirectory("...txt"); + fs.CreateDirectory("..a.txt"); + fs.CreateDirectory("txt..."); + fs.CreateDirectory("a+b=c"); + fs.CreateDirectory("ab .txt"); + fs.CreateDirectory("✨TAT"); + fs.CreateDirectory("a.b..c.d"); + fs.CreateDirectory("Mixed.Cas"); + fs.CreateDirectory("Mixed.txt"); + fs.CreateDirectory("mixed.Txt"); + + Assert.Equal("A", fs.GetShortName("A")); + Assert.Equal("A.B", fs.GetShortName("A.B")); + Assert.Equal("A1234567", fs.GetShortName("A1234567")); + Assert.Equal("A1234567.EXT", fs.GetShortName("A1234567.ext")); + Assert.Equal("THIS_I~1", fs.GetShortName("this_is_a_long_name")); + Assert.Equal("V1ABCD~1.TXT", fs.GetShortName("V1Abcd_this_is_to_long.TXT")); + Assert.Equal("V2ABCD~1.TXT", fs.GetShortName("V2Abcd_this_is_to_long.TXT")); + Assert.Equal("6393~1.TXT", fs.GetShortName("✨.txt")); + Assert.Equal("ABCDEF~1.TXT", fs.GetShortName("abcdef🙂.txt")); + Assert.Equal("ABC~1.TXT", fs.GetShortName("abc🙂.txt")); + Assert.Equal("AB1F60~1.TXT", fs.GetShortName("ab🙂.txt")); + + // Force changing the short name + fs.SetShortName("abcdef🙂.txt", "HELLO.TXT"); + Assert.Equal("HELLO.TXT", fs.GetShortName("abcdef🙂.txt")); + + // This should not be possible because the entry HELLO.TXT already exists + Assert.Throws(() => fs.SetShortName("abc🙂.txt", "HELLO.TXT")); + } + } } diff --git a/Tests/LibraryTests/Fat/FreeDirectoryEntryTableTest.cs b/Tests/LibraryTests/Fat/FreeDirectoryEntryTableTest.cs new file mode 100644 index 000000000..5d3c8833b --- /dev/null +++ b/Tests/LibraryTests/Fat/FreeDirectoryEntryTableTest.cs @@ -0,0 +1,106 @@ +// +// Copyright (c) 2024, Olof Lagerkvist and contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using DiscUtils.Fat; +using Xunit; + +namespace LibraryTests.Fat; + +/// +/// Tests for . +/// +public class FreeDirectoryEntryTableTest +{ + [Fact] + public void TestSimple() + { + var freeTable = new FreeDirectoryEntryTable(); + + // Allocation should fail, we don't have any range free + Assert.Equal(-1, freeTable.Allocate(1)); + + // Allocate 10 entries + freeTable.AddFreeRange(0, 10); + Assert.Equal(0, freeTable.Allocate(10)); + Assert.Equal(-1, freeTable.Allocate(1)); + + // Allocate 2 x 5 entries + freeTable.AddFreeRange(0, 10); + Assert.Equal(0, freeTable.Allocate(5)); + Assert.Equal(5 * DirectoryEntry.SizeOf, freeTable.Allocate(5)); + Assert.Equal(-1, freeTable.Allocate(1)); + + // Allocate 20 = 10 + 5 + 4 + 1 entries + freeTable.AddFreeRange(0, 20); + Assert.Equal(0, freeTable.Allocate(10)); + Assert.Equal(10 * DirectoryEntry.SizeOf, freeTable.Allocate(5)); + Assert.Equal(15 * DirectoryEntry.SizeOf, freeTable.Allocate(4)); + Assert.Equal(19 * DirectoryEntry.SizeOf, freeTable.Allocate(1)); + Assert.Equal(-1, freeTable.Allocate(1)); + + // Add 50 free range + // 32 + 18 + freeTable.AddFreeRange(0, 50); + // The free table allows to allocate 32 entries, so the first entry available will be at offset 32 + // 32 + 18 => takes on entry 18 + // 32 + 8 left + Assert.Equal(32 * DirectoryEntry.SizeOf, freeTable.Allocate(10)); + // Cannot allocate on 8, so allocate on 32 + // 22 + 8 left + Assert.Equal(0 * DirectoryEntry.SizeOf, freeTable.Allocate(10)); + // 12 + 8 left + Assert.Equal(10 * DirectoryEntry.SizeOf, freeTable.Allocate(10)); + // 2 + 8 left + Assert.Equal(20 * DirectoryEntry.SizeOf, freeTable.Allocate(10)); + // Cannot allocate 10 consecutive entries + Assert.Equal(-1, freeTable.Allocate(10)); + + // 2 entries consecutive left + Assert.Equal(30 * DirectoryEntry.SizeOf, freeTable.Allocate(1)); + Assert.Equal(31 * DirectoryEntry.SizeOf, freeTable.Allocate(1)); + + // 8 entries consecutive left + Assert.Equal(42 * DirectoryEntry.SizeOf, freeTable.Allocate(1)); + Assert.Equal(43 * DirectoryEntry.SizeOf, freeTable.Allocate(1)); + Assert.Equal(44 * DirectoryEntry.SizeOf, freeTable.Allocate(1)); + Assert.Equal(45 * DirectoryEntry.SizeOf, freeTable.Allocate(1)); + Assert.Equal(46 * DirectoryEntry.SizeOf, freeTable.Allocate(1)); + Assert.Equal(47 * DirectoryEntry.SizeOf, freeTable.Allocate(1)); + Assert.Equal(48 * DirectoryEntry.SizeOf, freeTable.Allocate(1)); + Assert.Equal(49 * DirectoryEntry.SizeOf, freeTable.Allocate(1)); + + // We should not be able to allocate from there + Assert.Equal(-1, freeTable.Allocate(1)); + } + + [Fact] + public void TestInvalidValues() + { + var freeTable = new FreeDirectoryEntryTable(); + + Assert.Throws(() => freeTable.AddFreeRange(0, 0)); + Assert.Throws(() => freeTable.AddFreeRange(0, -1)); + + Assert.Throws(() => freeTable.Allocate(0)); + Assert.Throws(() => freeTable.Allocate(50)); + } +} \ No newline at end of file diff --git a/Tests/LibraryTests/LibraryTests.csproj b/Tests/LibraryTests/LibraryTests.csproj index ea913913b..f3e7fa44f 100644 --- a/Tests/LibraryTests/LibraryTests.csproj +++ b/Tests/LibraryTests/LibraryTests.csproj @@ -26,6 +26,7 @@ + @@ -38,6 +39,7 @@ +