AnkiIO 1.0.2
Build, validate, import, and export Anki-compatible decks from .NET
Loading...
Searching...
No Matches
AnkiPackageReader.cs
1using System.IO.Compression;
2using System.Text.Json;
3
4namespace AnkiIO;
5
43public static class AnkiPackageReader
44{
76 public static async Task<AnkiPackage> ReadAsync(string path, AnkiPackageLimits? limits = null, CancellationToken cancellationToken = default)
77 {
78 ArgumentException.ThrowIfNullOrWhiteSpace(path);
79 limits ??= AnkiPackageLimits.Default;
80 limits.Validate();
81 await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan);
82 return await ReadAsync(stream, limits, cancellationToken).ConfigureAwait(false);
83 }
84
115 public static async Task<AnkiPackage> ReadAsync(Stream source, AnkiPackageLimits? limits = null, CancellationToken cancellationToken = default)
116 {
117 ArgumentNullException.ThrowIfNull(source);
118 if (!source.CanRead || !source.CanSeek)
119 {
120 throw new ArgumentException("Package input must be readable and seekable so archive limits can be checked before extraction.", nameof(source));
121 }
122
123 limits ??= AnkiPackageLimits.Default;
124 limits.Validate();
125 var tempDirectory = Path.Combine(Path.GetTempPath(), "AnkiIO-read-" + Guid.NewGuid().ToString("N"));
126 Directory.CreateDirectory(tempDirectory);
127 try
128 {
129 using var archive = new ZipArchive(source, ZipArchiveMode.Read, leaveOpen: true);
130 ValidateArchive(archive, limits);
131 var collectionEntry = archive.GetEntry("collection.anki2") ?? throw new NotSupportedException("This package does not contain legacy collection.anki2. Modern collection.anki21b packages require a future adapter.");
132 if (collectionEntry.Length > limits.MaximumCollectionBytes)
133 {
134 throw new AnkiPackageSecurityException($"Collection database exceeds the {limits.MaximumCollectionBytes} byte limit.");
135 }
136
137 var databasePath = Path.Combine(tempDirectory, "collection.anki2");
138 await using (var input = collectionEntry.Open())
139 await using (var output = new FileStream(databasePath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous))
140 {
141 await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
142 }
143
144 var decks = await LegacyCollectionDatabase.ReadAsync(databasePath, cancellationToken).ConfigureAwait(false);
145 var media = new AnkiMediaCollection();
146 var diagnostics = new List<AnkiDiagnostic>
147 {
148 new(AnkiDiagnosticSeverity.Information, "PKG001", "Read legacy collection.anki2 package representation. Unknown SQLite columns and schema-18 protobuf metadata are not handled by this adapter."),
149 };
150 var mediaMapEntry = archive.GetEntry("media");
151 if (mediaMapEntry is not null)
152 {
153 await using var mapStream = mediaMapEntry.Open();
154 var map = await ReadMediaMapAsync(mapStream, cancellationToken).ConfigureAwait(false);
155 foreach (var pair in map.OrderBy(pair => pair.Key, StringComparer.Ordinal))
156 {
157 try
158 {
159 AnkiMediaCollection.ValidateFileName(pair.Value);
160 }
161 catch (ArgumentException)
162 {
163 throw new AnkiPackageSecurityException($"Media map entry '{pair.Key}' contains an unsafe filename.");
164 }
165
166 if (pair.Key.Length == 0 || !pair.Key.All(char.IsAsciiDigit))
167 {
168 throw new AnkiPackageSecurityException($"Media entry key '{pair.Key}' is not numeric.");
169 }
170
171 var entry = archive.GetEntry(pair.Key) ?? throw new InvalidDataException($"Media map references missing entry '{pair.Key}'.");
172 if (entry.Length > int.MaxValue)
173 {
174 throw new AnkiPackageSecurityException($"Media '{pair.Value}' is too large for extracted in-memory ownership.");
175 }
176
177 await using var content = entry.Open();
178 using var memory = new MemoryStream((int)entry.Length);
179 await content.CopyToAsync(memory, cancellationToken).ConfigureAwait(false);
180 media.AddBytes(pair.Value, memory.ToArray());
181 }
182 }
183
184 return new AnkiPackage(decks, media, diagnostics);
185 }
186 finally
187 {
188 if (Directory.Exists(tempDirectory))
189 {
190 Directory.Delete(tempDirectory, recursive: true);
191 }
192 }
193 }
194
195 private static async Task<IReadOnlyDictionary<string, string>> ReadMediaMapAsync(Stream stream, CancellationToken cancellationToken)
196 {
197 using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
198 if (document.RootElement.ValueKind != JsonValueKind.Object)
199 {
200 throw new JsonException("The media map must be a JSON object whose property names are numeric archive entries and whose values are filenames.");
201 }
202
203 var map = new Dictionary<string, string>(StringComparer.Ordinal);
204 foreach (var property in document.RootElement.EnumerateObject())
205 {
206 if (property.Value.ValueKind != JsonValueKind.String)
207 {
208 throw new JsonException($"Media map entry '{property.Name}' must contain a JSON string filename.");
209 }
210
211 if (!map.TryAdd(property.Name, property.Value.GetString()!))
212 {
213 throw new JsonException($"The media map contains duplicate entry key '{property.Name}'.");
214 }
215 }
216
217 return map;
218 }
219
220 private static void ValidateArchive(ZipArchive archive, AnkiPackageLimits limits)
221 {
222 if (archive.Entries.Count > limits.MaximumEntries)
223 {
224 throw new AnkiPackageSecurityException($"Archive contains {archive.Entries.Count} entries; limit is {limits.MaximumEntries}.");
225 }
226
227 var names = new HashSet<string>(StringComparer.Ordinal);
228 long total = 0;
229 foreach (var entry in archive.Entries)
230 {
231 if (!names.Add(entry.FullName))
232 {
233 throw new AnkiPackageSecurityException($"Duplicate archive path '{entry.FullName}'.");
234 }
235
236 if (entry.FullName.Length == 0 || Path.IsPathRooted(entry.FullName) || entry.FullName.Contains('/') || entry.FullName.Contains('\\') || entry.FullName is "." or "..")
237 {
238 throw new AnkiPackageSecurityException($"Unsafe archive path '{entry.FullName}'.");
239 }
240
241 var unixFileType = (entry.ExternalAttributes >> 16) & 0xF000;
242 if (unixFileType == 0xA000)
243 {
244 throw new AnkiPackageSecurityException($"Symbolic-link archive entry '{entry.FullName}' is not allowed.");
245 }
246
247 if (entry.Length > limits.MaximumEntryBytes)
248 {
249 throw new AnkiPackageSecurityException($"Entry '{entry.FullName}' exceeds the per-entry limit.");
250 }
251
252 total = checked(total + entry.Length);
253 if (total > limits.MaximumTotalBytes)
254 {
255 throw new AnkiPackageSecurityException("Archive exceeds the total uncompressed-size limit.");
256 }
257
258 if (entry.Length > 0 && entry.CompressedLength == 0)
259 {
260 throw new AnkiPackageSecurityException($"Non-empty entry '{entry.FullName}' reports zero compressed bytes.");
261 }
262
263 if (entry.CompressedLength > 0 && entry.Length / (double)entry.CompressedLength > limits.MaximumCompressionRatio)
264 {
265 throw new AnkiPackageSecurityException($"Entry '{entry.FullName}' exceeds the compression-ratio limit.");
266 }
267 }
268 }
269}
Owns media registrations for a deck and prevents unsafe or colliding names.
Defines archive-resource limits enforced before an untrusted Anki package is extracted.
static AnkiPackageLimits Default
Gets the shared default limits.
Reads guarded legacy-compatible .apkg archives into AnkiIO's in-memory package model.
static async Task< AnkiPackage > ReadAsync(string path, AnkiPackageLimits? limits=null, CancellationToken cancellationToken=default)
Reads a package file without modifying the source file or an Anki profile.
static async Task< AnkiPackage > ReadAsync(Stream source, AnkiPackageLimits? limits=null, CancellationToken cancellationToken=default)
Reads a package from a readable, seekable caller-owned stream and leaves it open.
Indicates that an untrusted package was deliberately rejected by an AnkiIO archive-safety rule.
Contains the supported deck graphs, media, and diagnostics read from one Anki package.
AnkiDiagnosticSeverity
Classifies whether a diagnostic is explanatory, lossy, or blocks a validated write.