NdsForge.NET 1.0.1
Read, validate, edit, compare, and build Nintendo DS and DSi images from .NET
Loading...
Searching...
No Matches
NitroFileSystemParser.cs
1using System.Text;
2
3namespace NdsForge;
4
6internal static class NitroFileSystemParser
7{
9 private const ushort RootDirectoryId = 0xF000;
10
16 public static NdsFileSystem Parse(
17 IImageDataSource source,
18 NdsHeader header,
19 NdsReadOptions options)
20 {
21 (byte[] fnt, byte[] fat) = ReadTables(source, header, options);
22 return ParseTables(source, fnt, fat, options);
23 }
24
31 public static async ValueTask<NdsFileSystem> ParseAsync(
32 IImageDataSource source,
33 NdsHeader header,
34 NdsReadOptions options,
35 CancellationToken cancellationToken)
36 {
37 ValidateTableLengths(header, options);
38 byte[] fnt = new byte[header.FileNameTable.Length];
39 byte[] fat = new byte[header.FileAllocationTable.Length];
40 await source.ReadExactlyAsync(
41 header.FileNameTable.Offset,
42 fnt,
43 cancellationToken).ConfigureAwait(false);
44 await source.ReadExactlyAsync(
45 header.FileAllocationTable.Offset,
46 fat,
47 cancellationToken).ConfigureAwait(false);
48 return ParseTables(source, fnt, fat, options);
49 }
50
56 private static (byte[] Fnt, byte[] Fat) ReadTables(
57 IImageDataSource source,
58 NdsHeader header,
59 NdsReadOptions options)
60 {
61 ValidateTableLengths(header, options);
62 byte[] fnt = new byte[header.FileNameTable.Length];
63 byte[] fat = new byte[header.FileAllocationTable.Length];
64 source.ReadExactly(header.FileNameTable.Offset, fnt);
65 source.ReadExactly(header.FileAllocationTable.Offset, fat);
66 return (fnt, fat);
67 }
68
75 private static NdsFileSystem ParseTables(
76 IImageDataSource source,
77 byte[] fnt,
78 byte[] fat,
79 NdsReadOptions options)
80 {
81 NdsFileAllocation[] allocations = ParseAllocations(source.Length, fat);
82 if (fnt.Length == 0)
83 {
84 var emptyRoot = new NdsDirectory(RootDirectoryId, string.Empty, "/", null);
85 return new(emptyRoot, [emptyRoot], [], allocations);
86 }
87
88 if (fnt.Length < 8)
89 {
90 throw new InvalidDataException("The NitroFS filename table is smaller than its root directory record.");
91 }
92
93 int directoryCount = NdsBinary.ReadUInt16(fnt, 6);
94 if (directoryCount == 0 || directoryCount > options.MaximumDirectoryCount || directoryCount > fnt.Length / 8)
95 {
96 throw new InvalidDataException($"The NitroFS directory count {directoryCount} is invalid.");
97 }
98
99 var directoriesById = new Dictionary<ushort, NdsDirectory>();
100 var files = new List<NdsFile>();
101 var visiting = new HashSet<ushort>();
102 NdsDirectory root = ReadDirectory(RootDirectoryId, string.Empty, "/", null, 0);
103 if (directoriesById.Count != directoryCount)
104 {
105 throw new InvalidDataException(
106 $"The NitroFS declares {directoryCount} directories, but {directoriesById.Count} are reachable from the root.");
107 }
108
109 NdsDirectory[] directories = directoriesById.Values.OrderBy(static directory => directory.Id).ToArray();
110 NdsFile[] orderedFiles = files.OrderBy(static file => file.Id).ToArray();
111 return new(root, directories, orderedFiles, allocations);
112
113 // This local recursion shares the cycle set and partially built indexes above. Keeping that
114 // state lexical prevents malformed images from observing or retaining an incomplete tree.
115 NdsDirectory ReadDirectory(
116 ushort directoryId,
117 string name,
118 string fullPath,
119 NdsDirectory? parent,
120 int depth)
121 {
122 if (depth > options.MaximumDirectoryDepth || directoryId < RootDirectoryId)
123 {
124 throw new InvalidDataException($"NitroFS directory 0x{directoryId:X4} exceeds the configured limits.");
125 }
126
127 int index = directoryId - RootDirectoryId;
128 if (index >= directoryCount || !visiting.Add(directoryId) || directoriesById.ContainsKey(directoryId))
129 {
130 throw new InvalidDataException($"NitroFS directory 0x{directoryId:X4} is invalid, duplicated, or cyclic.");
131 }
132
133 int recordOffset = index * 8;
134 int subTableOffset = checked((int)NdsBinary.ReadUInt32(fnt, recordOffset));
135 int fileId = NdsBinary.ReadUInt16(fnt, recordOffset + 4);
136 ushort recordedParent = NdsBinary.ReadUInt16(fnt, recordOffset + 6);
137 ushort expectedParent = parent?.Id ?? checked((ushort)directoryCount);
138 if (recordedParent != expectedParent || subTableOffset < directoryCount * 8 || subTableOffset >= fnt.Length)
139 {
140 throw new InvalidDataException($"NitroFS directory record 0x{directoryId:X4} is inconsistent.");
141 }
142
143 var directory = new NdsDirectory(directoryId, name, fullPath, parent);
144 directoriesById.Add(directoryId, directory);
145 var childDirectories = new List<NdsDirectory>();
146 var childFiles = new List<NdsFile>();
147 var childNames = new HashSet<string>(StringComparer.Ordinal);
148 int cursor = subTableOffset;
149 while (cursor < fnt.Length)
150 {
151 byte descriptor = fnt[cursor++];
152 if (descriptor == 0)
153 {
154 directory.SetChildren(childDirectories, childFiles);
155 visiting.Remove(directoryId);
156 return directory;
157 }
158
159 bool isDirectory = (descriptor & 0x80) != 0;
160 int nameLength = descriptor & 0x7F;
161 if (nameLength == 0 || cursor > fnt.Length - nameLength)
162 {
163 throw new InvalidDataException($"NitroFS directory 0x{directoryId:X4} contains a truncated name.");
164 }
165
166 string childName = Encoding.Latin1.GetString(fnt.AsSpan(cursor, nameLength));
167 cursor += nameLength;
168 ValidateName(childName, childNames);
169 string childPath = fullPath == "/" ? "/" + childName : fullPath + "/" + childName;
170 if (isDirectory)
171 {
172 if (cursor > fnt.Length - 2)
173 {
174 throw new InvalidDataException($"NitroFS child directory '{childPath}' has no directory ID.");
175 }
176
177 ushort childId = NdsBinary.ReadUInt16(fnt, cursor);
178 cursor += 2;
179 childDirectories.Add(ReadDirectory(childId, childName, childPath, directory, depth + 1));
180 }
181 else
182 {
183 if ((uint)fileId >= allocations.Length)
184 {
185 throw new InvalidDataException($"NitroFS file '{childPath}' references missing FAT ID {fileId}.");
186 }
187
188 NdsFileAllocation allocation = allocations[fileId];
189 var file = new NdsFile(source, fileId, childName, childPath, allocation.Data, directory);
190 childFiles.Add(file);
191 files.Add(file);
192 fileId++;
193 }
194 }
195
196 throw new InvalidDataException($"NitroFS directory 0x{directoryId:X4} has no terminator.");
197 }
198 }
199
204 private static NdsFileAllocation[] ParseAllocations(long imageLength, ReadOnlySpan<byte> fat)
205 {
206 if (fat.Length % 8 != 0)
207 {
208 throw new InvalidDataException("The NitroFS file allocation table length is not a multiple of eight.");
209 }
210
211 var allocations = new NdsFileAllocation[fat.Length / 8];
212 for (int fileId = 0; fileId < allocations.Length; fileId++)
213 {
214 int offset = fileId * 8;
215 uint start = NdsBinary.ReadUInt32(fat, offset);
216 uint end = NdsBinary.ReadUInt32(fat, offset + 4);
217 if (end < start || end > imageLength)
218 {
219 throw new InvalidDataException($"NitroFS FAT entry {fileId} lies outside the image.");
220 }
221
222 allocations[fileId] = new(fileId, new(start, end - start));
223 }
224
225 return allocations;
226 }
227
231 private static void ValidateTableLengths(NdsHeader header, NdsReadOptions options)
232 {
233 if (header.FileNameTable.Length > options.MaximumFileNameTableBytes ||
234 header.FileAllocationTable.Length > options.MaximumFileAllocationTableBytes ||
235 header.FileNameTable.Length > Array.MaxLength ||
236 header.FileAllocationTable.Length > Array.MaxLength)
237 {
238 throw new InvalidDataException("A NitroFS table exceeds the configured parsing limits.");
239 }
240
241 if (header.FileNameTable.IsEmpty && !header.FileAllocationTable.IsEmpty)
242 {
243 throw new InvalidDataException("A NitroFS allocation table cannot be interpreted without a filename table.");
244 }
245 }
246
250 private static void ValidateName(string name, HashSet<string> siblingNames)
251 {
252 if (string.IsNullOrEmpty(name) ||
253 name is "." or ".." ||
254 name.Contains('/', StringComparison.Ordinal) ||
255 name.Contains('\\', StringComparison.Ordinal))
256 {
257 throw new InvalidDataException("NitroFS contains an unsafe or empty entry name.");
258 }
259
260 if (!siblingNames.Add(name))
261 {
262 throw new InvalidDataException($"NitroFS contains duplicate sibling name '{name}'.");
263 }
264 }
265}