NdsForge.NET 1.0.1
Read, validate, edit, compare, and build Nintendo DS and DSi images from .NET
Loading...
Searching...
No Matches
NdsImageExtractor.cs
1using System.Buffers;
2
3namespace NdsForge;
4
6internal sealed class NdsImageExtractor
7{
9 private static readonly SearchValues<char> PortableInvalidNameCharacters = SearchValues.Create("<>:\"/\\|?*");
11 private readonly NdsImage _image;
13 private readonly NdsExtractionOptions _options;
15 private readonly string _root;
17 private int _writtenFiles;
19 private int _skippedFiles;
21 private long _writtenBytes;
22
27 public NdsImageExtractor(NdsImage image, string destination, NdsExtractionOptions options)
28 {
29 _image = image;
30 _options = options;
31 _root = Path.GetFullPath(destination);
32 }
33
37 public async ValueTask<NdsExtractionResult> ExtractAsync(CancellationToken cancellationToken)
38 {
39 EnsureSafeDirectory(_root, create: true);
40 if (Includes(NdsImageComponent.Header))
41 {
42 await WriteMemoryAsync("header.bin", _image.Header.RawData, cancellationToken).ConfigureAwait(false);
43 }
44
45 if (Includes(NdsImageComponent.Logo))
46 {
47 await WriteMemoryAsync("logo.bin", _image.Header.RawData.Slice(0xC0, 156), cancellationToken).ConfigureAwait(false);
48 }
49
50 if (Includes(NdsImageComponent.Programs))
51 {
52 await WriteRegionAsync("arm9.bin", _image.Header.Arm9.CompleteData, cancellationToken).ConfigureAwait(false);
53 await WriteRegionAsync("arm7.bin", _image.Header.Arm7.Data, cancellationToken).ConfigureAwait(false);
54 if (_image.Header.Arm9i is not null)
55 {
56 await WriteRegionAsync("arm9i.bin", _image.Header.Arm9i.Data, cancellationToken).ConfigureAwait(false);
57 }
58
59 if (_image.Header.Arm7i is not null)
60 {
61 await WriteRegionAsync("arm7i.bin", _image.Header.Arm7i.Data, cancellationToken).ConfigureAwait(false);
62 }
63 }
64
65 if (Includes(NdsImageComponent.FileSystemTables))
66 {
67 await WriteRegionAsync("tables/fnt.bin", _image.Header.FileNameTable, cancellationToken).ConfigureAwait(false);
68 await WriteRegionAsync("tables/fat.bin", _image.Header.FileAllocationTable, cancellationToken).ConfigureAwait(false);
69 }
70
71 if (Includes(NdsImageComponent.Banner) && _image.Banner is not null)
72 {
73 await WriteMemoryAsync("banner.bin", _image.Banner.RawData, cancellationToken).ConfigureAwait(false);
74 }
75
76 if (Includes(NdsImageComponent.Overlays))
77 {
78 await ExtractOverlaysAsync(cancellationToken).ConfigureAwait(false);
79 }
80
81 if (Includes(NdsImageComponent.NitroFileSystem))
82 {
83 foreach (NdsFile file in _image.FileSystem.Files)
84 {
85 cancellationToken.ThrowIfCancellationRequested();
86 if (_options.FileFilter is null || _options.FileFilter(file))
87 {
88 await WriteRegionAsync("data" + file.FullPath, file.Data, cancellationToken).ConfigureAwait(false);
89 }
90 }
91 }
92
93 return new(_writtenFiles, _skippedFiles, _writtenBytes);
94 }
95
98 private async ValueTask ExtractOverlaysAsync(CancellationToken cancellationToken)
99 {
100 await WriteRegionAsync(
101 "tables/arm9-overlays.bin",
102 _image.Header.Arm9OverlayTable,
103 cancellationToken).ConfigureAwait(false);
104 await WriteRegionAsync(
105 "tables/arm7-overlays.bin",
106 _image.Header.Arm7OverlayTable,
107 cancellationToken).ConfigureAwait(false);
108
109 foreach (NdsOverlay overlay in _image.Arm9Overlays.Concat(_image.Arm7Overlays))
110 {
111 cancellationToken.ThrowIfCancellationRequested();
112 if (overlay.Data is null)
113 {
114 continue;
115 }
116
117 string processor = overlay.Processor == NdsProcessor.Arm9 ? "arm9" : "arm7";
118 string filename = FormattableString.Invariant(
119 $"overlays/{processor}/overlay_{overlay.Id:D4}_file_{overlay.FileId:D5}.bin");
120 await WriteRegionAsync(filename, overlay.Data.Value, cancellationToken).ConfigureAwait(false);
121 }
122 }
123
128 private async ValueTask WriteMemoryAsync(
129 string relativePath,
130 ReadOnlyMemory<byte> data,
131 CancellationToken cancellationToken)
132 {
133 string? output = PrepareOutput(relativePath);
134 if (output is null)
135 {
136 return;
137 }
138
139 await WriteAtomicallyAsync(
140 output,
141 async (stream, token) => await stream.WriteAsync(data, token).ConfigureAwait(false),
142 cancellationToken).ConfigureAwait(false);
143 _writtenFiles++;
144 _writtenBytes += data.Length;
145 }
146
151 private async ValueTask WriteRegionAsync(
152 string relativePath,
153 NdsRegion region,
154 CancellationToken cancellationToken)
155 {
156 string? output = PrepareOutput(relativePath);
157 if (output is null)
158 {
159 return;
160 }
161
162 await WriteAtomicallyAsync(
163 output,
164 async (destination, token) =>
165 {
166 using Stream source = _image.OpenRead(region);
167 await source.CopyToAsync(destination, token).ConfigureAwait(false);
168 },
169 cancellationToken).ConfigureAwait(false);
170 _writtenFiles++;
171 _writtenBytes += region.Length;
172 }
173
177 private string? PrepareOutput(string relativePath)
178 {
179 string[] segments = relativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
180 if (segments.Length == 0)
181 {
182 throw new InvalidDataException("An extraction target has no filename.");
183 }
184
185 foreach (string segment in segments)
186 {
187 ValidatePortableName(segment);
188 }
189
190 string output = Path.GetFullPath(Path.Combine([_root, .. segments]));
191 string rootPrefix = _root.EndsWith(Path.DirectorySeparatorChar)
192 ? _root
193 : _root + Path.DirectorySeparatorChar;
194 if (!output.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase))
195 {
196 throw new InvalidDataException($"Extraction path '{relativePath}' escapes the destination root.");
197 }
198
199 string directory = Path.GetDirectoryName(output)!;
200 EnsureSafeDirectory(directory, create: true);
201 if (!File.Exists(output))
202 {
203 return output;
204 }
205
206 RejectReparsePoint(output);
207 return _options.OverwritePolicy switch
208 {
209 NdsOverwritePolicy.Fail => throw new IOException($"Extraction target already exists: {output}"),
210 NdsOverwritePolicy.Overwrite => output,
211 NdsOverwritePolicy.Skip => Skip(),
212 _ => throw new InvalidOperationException("The extraction overwrite policy is invalid."),
213 };
214
215 string? Skip()
216 {
217 _skippedFiles++;
218 return null;
219 }
220 }
221
226 private async ValueTask WriteAtomicallyAsync(
227 string output,
228 Func<FileStream, CancellationToken, Task> write,
229 CancellationToken cancellationToken)
230 {
231 string temporary = output + ".ndsforge-" + Guid.NewGuid().ToString("N");
232 try
233 {
234 var stream = new FileStream(
235 temporary,
236 FileMode.CreateNew,
237 FileAccess.Write,
238 FileShare.None,
239 64 * 1024,
240 FileOptions.Asynchronous | FileOptions.SequentialScan);
241 await using (stream.ConfigureAwait(false))
242 {
243 await write(stream, cancellationToken).ConfigureAwait(false);
244 await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
245 }
246
247 File.Move(temporary, output, _options.OverwritePolicy == NdsOverwritePolicy.Overwrite);
248 }
249 finally
250 {
251 File.Delete(temporary);
252 }
253 }
254
258 private static void EnsureSafeDirectory(string directory, bool create)
259 {
260 string? parent = directory;
261 var missing = new Stack<string>();
262 while (!string.IsNullOrEmpty(parent) && !Directory.Exists(parent))
263 {
264 missing.Push(parent);
265 parent = Path.GetDirectoryName(parent);
266 }
267
268 if (!string.IsNullOrEmpty(parent))
269 {
270 RejectReparsePoint(parent);
271 }
272
273 if (!create)
274 {
275 return;
276 }
277
278 while (missing.TryPop(out string? path))
279 {
280 Directory.CreateDirectory(path);
281 RejectReparsePoint(path);
282 }
283 }
284
287 private static void RejectReparsePoint(string path)
288 {
289 if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
290 {
291 throw new IOException($"Extraction refuses to traverse reparse point: {path}");
292 }
293 }
294
297 private static void ValidatePortableName(string name)
298 {
299 if (name is "." or ".." ||
300 name.Length == 0 ||
301 name.EndsWith(' ') ||
302 name.EndsWith('.') ||
303 name.AsSpan().ContainsAny(PortableInvalidNameCharacters) ||
304 name.Any(static character => char.IsControl(character)))
305 {
306 throw new InvalidDataException($"NitroFS name '{name}' is unsafe to extract portably.");
307 }
308 }
309
313 private bool Includes(NdsImageComponent component) => (_options.Components & component) != 0;
314}
NdsImageComponent
Identifies image components that can be exported.
NdsOverwritePolicy
Controls how extraction handles existing regular files.
@ Skip
Omits linked files or directory subtrees and reports how many entries were skipped.