6internal sealed class NdsImageExtractor
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;
27 public NdsImageExtractor(NdsImage image,
string destination, NdsExtractionOptions options)
31 _root = Path.GetFullPath(destination);
37 public async ValueTask<NdsExtractionResult> ExtractAsync(CancellationToken cancellationToken)
39 EnsureSafeDirectory(_root, create:
true);
42 await WriteMemoryAsync(
"header.bin", _image.Header.RawData, cancellationToken).ConfigureAwait(
false);
47 await WriteMemoryAsync(
"logo.bin", _image.Header.RawData.Slice(0xC0, 156), cancellationToken).ConfigureAwait(
false);
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)
56 await WriteRegionAsync(
"arm9i.bin", _image.Header.Arm9i.Data, cancellationToken).ConfigureAwait(
false);
59 if (_image.Header.Arm7i is not
null)
61 await WriteRegionAsync(
"arm7i.bin", _image.Header.Arm7i.Data, cancellationToken).ConfigureAwait(
false);
67 await WriteRegionAsync(
"tables/fnt.bin", _image.Header.FileNameTable, cancellationToken).ConfigureAwait(
false);
68 await WriteRegionAsync(
"tables/fat.bin", _image.Header.FileAllocationTable, cancellationToken).ConfigureAwait(
false);
73 await WriteMemoryAsync(
"banner.bin", _image.Banner.RawData, cancellationToken).ConfigureAwait(
false);
78 await ExtractOverlaysAsync(cancellationToken).ConfigureAwait(
false);
83 foreach (NdsFile file
in _image.FileSystem.Files)
85 cancellationToken.ThrowIfCancellationRequested();
86 if (_options.FileFilter is
null || _options.FileFilter(file))
88 await WriteRegionAsync(
"data" + file.FullPath, file.Data, cancellationToken).ConfigureAwait(
false);
93 return new(_writtenFiles, _skippedFiles, _writtenBytes);
98 private async ValueTask ExtractOverlaysAsync(CancellationToken cancellationToken)
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);
109 foreach (NdsOverlay overlay
in _image.Arm9Overlays.Concat(_image.Arm7Overlays))
111 cancellationToken.ThrowIfCancellationRequested();
112 if (overlay.Data is
null)
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);
128 private async ValueTask WriteMemoryAsync(
130 ReadOnlyMemory<byte> data,
131 CancellationToken cancellationToken)
133 string? output = PrepareOutput(relativePath);
139 await WriteAtomicallyAsync(
141 async (stream, token) => await stream.WriteAsync(data, token).ConfigureAwait(
false),
142 cancellationToken).ConfigureAwait(
false);
144 _writtenBytes += data.Length;
151 private async ValueTask WriteRegionAsync(
154 CancellationToken cancellationToken)
156 string? output = PrepareOutput(relativePath);
162 await WriteAtomicallyAsync(
164 async (destination, token) =>
166 using Stream source = _image.OpenRead(region);
167 await source.CopyToAsync(destination, token).ConfigureAwait(
false);
169 cancellationToken).ConfigureAwait(
false);
171 _writtenBytes += region.Length;
177 private string? PrepareOutput(
string relativePath)
179 string[] segments = relativePath.Replace(
'\\',
'/').Split(
'/', StringSplitOptions.RemoveEmptyEntries);
180 if (segments.Length == 0)
182 throw new InvalidDataException(
"An extraction target has no filename.");
185 foreach (
string segment
in segments)
187 ValidatePortableName(segment);
190 string output = Path.GetFullPath(Path.Combine([_root, .. segments]));
191 string rootPrefix = _root.EndsWith(Path.DirectorySeparatorChar)
193 : _root + Path.DirectorySeparatorChar;
194 if (!output.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase))
196 throw new InvalidDataException($
"Extraction path '{relativePath}' escapes the destination root.");
199 string directory = Path.GetDirectoryName(output)!;
200 EnsureSafeDirectory(directory, create:
true);
201 if (!File.Exists(output))
206 RejectReparsePoint(output);
207 return _options.OverwritePolicy
switch
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."),
226 private async ValueTask WriteAtomicallyAsync(
228 Func<FileStream, CancellationToken, Task> write,
229 CancellationToken cancellationToken)
231 string temporary = output +
".ndsforge-" + Guid.NewGuid().ToString(
"N");
234 var stream =
new FileStream(
240 FileOptions.Asynchronous | FileOptions.SequentialScan);
241 await
using (stream.ConfigureAwait(
false))
243 await write(stream, cancellationToken).ConfigureAwait(
false);
244 await stream.FlushAsync(cancellationToken).ConfigureAwait(
false);
247 File.Move(temporary, output, _options.OverwritePolicy ==
NdsOverwritePolicy.Overwrite);
251 File.Delete(temporary);
258 private static void EnsureSafeDirectory(
string directory,
bool create)
260 string? parent = directory;
261 var missing =
new Stack<string>();
262 while (!
string.IsNullOrEmpty(parent) && !Directory.Exists(parent))
264 missing.Push(parent);
265 parent = Path.GetDirectoryName(parent);
268 if (!
string.IsNullOrEmpty(parent))
270 RejectReparsePoint(parent);
278 while (missing.TryPop(out
string? path))
280 Directory.CreateDirectory(path);
281 RejectReparsePoint(path);
287 private static void RejectReparsePoint(
string path)
289 if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
291 throw new IOException($
"Extraction refuses to traverse reparse point: {path}");
297 private static void ValidatePortableName(
string name)
299 if (name is
"." or
".." ||
301 name.EndsWith(
' ') ||
302 name.EndsWith(
'.') ||
303 name.AsSpan().ContainsAny(PortableInvalidNameCharacters) ||
304 name.Any(
static character =>
char.IsControl(character)))
306 throw new InvalidDataException($
"NitroFS name '{name}' is unsafe to extract portably.");
313 private bool Includes(
NdsImageComponent component) => (_options.Components & component) != 0;
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.