// ============================================================================= // TeslaLauncher — MiniZip // ============================================================================= // Minimal ZIP extractor for net40: System.IO.Compression.ZipFile/ZipArchive are // net45+, so they do not exist on the XP-compatible framework. This reads the // archive via the central directory and supports exactly what the Console's // install packages use: // - compression methods 0 (stored) and 8 (deflate; DeflateStream is net20+) // - UTF-8 (general-purpose flag bit 11) or ANSI/CP437 entry names // - ZIP64 sizes/offsets/entry counts (archives > 4 GB or > 65535 entries) // CRC is not verified — archives arrive from our own Console over the // OFB-encrypted link, and the install already fails loudly on truncation. // ============================================================================= using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Text; namespace Tesla.Launcher { internal static class MiniZip { /// Extracts every entry of under /// (existing files overwritten), reporting /// (entriesDone, entriesTotal) after each entry. Entries that would /// escape the destination directory (zip-slip) are skipped. public static void ExtractToDirectory(string zipPath, string destDir, Action onEntry) { using (var fs = new FileStream(zipPath, FileMode.Open, FileAccess.Read, FileShare.Read)) { var entries = ReadCentralDirectory(fs); var destRoot = Path.GetFullPath(destDir).TrimEnd('\\'); int done = 0; foreach (var e in entries) { ExtractEntry(fs, e, destRoot); done++; if (onEntry != null) onEntry(done, entries.Count); } } } private sealed class Entry { public string Name; public int Method; public long CompressedSize; public long LocalHeaderOffset; } // ── Central directory ───────────────────────────────────────────────── private static List ReadCentralDirectory(FileStream fs) { // End-of-central-directory record: fixed 22 bytes + up to 64K comment. long fileLen = fs.Length; int maxScan = (int)Math.Min(fileLen, 22 + 65535); var tail = new byte[maxScan]; fs.Seek(fileLen - maxScan, SeekOrigin.Begin); ReadExact(fs, tail, maxScan); int eocd = -1; for (int i = maxScan - 22; i >= 0; i--) { if (tail[i] == 0x50 && tail[i + 1] == 0x4B && tail[i + 2] == 0x05 && tail[i + 3] == 0x06) { eocd = i; break; } } if (eocd < 0) throw new IOException("Not a ZIP archive (no end-of-central-directory record)."); long eocdPos = fileLen - maxScan + eocd; long totalEntries = BitConverter.ToUInt16(tail, eocd + 10); long cdOffset = BitConverter.ToUInt32(tail, eocd + 16); // ZIP64: any maxed-out field means the real values live in the // ZIP64 EOCD record, found via the locator 20 bytes before EOCD. if (totalEntries == 0xFFFF || cdOffset == 0xFFFFFFFF) { long locPos = eocdPos - 20; if (locPos >= 0) { var loc = new byte[20]; fs.Seek(locPos, SeekOrigin.Begin); ReadExact(fs, loc, 20); if (loc[0] == 0x50 && loc[1] == 0x4B && loc[2] == 0x06 && loc[3] == 0x07) { long z64Pos = BitConverter.ToInt64(loc, 8); var z64 = new byte[56]; fs.Seek(z64Pos, SeekOrigin.Begin); ReadExact(fs, z64, 56); if (!(z64[0] == 0x50 && z64[1] == 0x4B && z64[2] == 0x06 && z64[3] == 0x06)) throw new IOException("Corrupt ZIP64 end-of-central-directory record."); totalEntries = BitConverter.ToInt64(z64, 32); cdOffset = BitConverter.ToInt64(z64, 48); } } } var list = new List((int)totalEntries); fs.Seek(cdOffset, SeekOrigin.Begin); var br = new BinaryReader(fs); // not disposed — would close fs for (long i = 0; i < totalEntries; i++) { if (br.ReadUInt32() != 0x02014B50) throw new IOException("Corrupt central directory (bad entry signature)."); br.ReadUInt16(); // version made by br.ReadUInt16(); // version needed ushort flags = br.ReadUInt16(); ushort method = br.ReadUInt16(); br.ReadUInt32(); // DOS mod time/date br.ReadUInt32(); // CRC-32 long comp = br.ReadUInt32(); long uncomp = br.ReadUInt32(); ushort nameLen = br.ReadUInt16(); ushort extraLen = br.ReadUInt16(); ushort commentLen = br.ReadUInt16(); br.ReadUInt16(); // disk number start br.ReadUInt16(); // internal attributes br.ReadUInt32(); // external attributes long lho = br.ReadUInt32(); byte[] nameBytes = br.ReadBytes(nameLen); byte[] extra = br.ReadBytes(extraLen); if (commentLen > 0) br.ReadBytes(commentLen); // ZIP64 extended-information extra field (id 0x0001): 8-byte // values present, in order, only for headers that were 0xFFFFFFFF. int p = 0; while (p + 4 <= extra.Length) { ushort id = BitConverter.ToUInt16(extra, p); ushort sz = BitConverter.ToUInt16(extra, p + 2); if (id == 0x0001) { int q = p + 4; if (uncomp == 0xFFFFFFFF && q + 8 <= p + 4 + sz) { uncomp = BitConverter.ToInt64(extra, q); q += 8; } if (comp == 0xFFFFFFFF && q + 8 <= p + 4 + sz) { comp = BitConverter.ToInt64(extra, q); q += 8; } if (lho == 0xFFFFFFFF && q + 8 <= p + 4 + sz) { lho = BitConverter.ToInt64(extra, q); } } p += 4 + sz; } bool utf8 = (flags & 0x0800) != 0; var name = (utf8 ? Encoding.UTF8 : Encoding.Default).GetString(nameBytes); list.Add(new Entry { Name = name, Method = method, CompressedSize = comp, LocalHeaderOffset = lho }); } return list; } // ── Entry extraction ────────────────────────────────────────────────── private static void ExtractEntry(FileStream fs, Entry e, string destRoot) { if (string.IsNullOrEmpty(e.Name)) return; string destPath; try { destPath = Path.GetFullPath(Path.Combine(destRoot, e.Name.Replace('/', '\\'))); } catch { return; } // illegal characters in name — skip // Zip-slip protection: never write outside the destination root. if (!destPath.StartsWith(destRoot + "\\", StringComparison.OrdinalIgnoreCase) && !destPath.Equals(destRoot, StringComparison.OrdinalIgnoreCase)) return; if (e.Name.EndsWith("/") || e.Name.EndsWith("\\")) { Directory.CreateDirectory(destPath); return; } // Local header: its name/extra lengths can differ from the central // directory's, so read them from the local header itself. fs.Seek(e.LocalHeaderOffset, SeekOrigin.Begin); var lh = new byte[30]; ReadExact(fs, lh, 30); if (!(lh[0] == 0x50 && lh[1] == 0x4B && lh[2] == 0x03 && lh[3] == 0x04)) throw new IOException("Corrupt local header for entry '" + e.Name + "'."); int lNameLen = BitConverter.ToUInt16(lh, 26); int lExtraLen = BitConverter.ToUInt16(lh, 28); fs.Seek(lNameLen + lExtraLen, SeekOrigin.Current); Directory.CreateDirectory(Path.GetDirectoryName(destPath)); using (var outFs = File.Create(destPath)) { if (e.Method == 0) // stored { CopyExactly(fs, outFs, e.CompressedSize); } else if (e.Method == 8) // deflate { var limited = new LimitedReadStream(fs, e.CompressedSize); using (var inflate = new DeflateStream(limited, CompressionMode.Decompress, leaveOpen: true)) inflate.CopyTo(outFs); } else { throw new IOException(string.Format( "Unsupported compression method {0} for entry '{1}'.", e.Method, e.Name)); } } } private static void CopyExactly(Stream src, Stream dst, long count) { var buffer = new byte[65536]; while (count > 0) { int n = src.Read(buffer, 0, (int)Math.Min(buffer.Length, count)); if (n == 0) throw new IOException("Unexpected end of archive."); dst.Write(buffer, 0, n); count -= n; } } private static void ReadExact(Stream stream, byte[] buf, int count) { int off = 0; while (off < count) { int n = stream.Read(buf, off, count - off); if (n == 0) throw new IOException("Unexpected end of archive."); off += n; } } /// Caps reads at N bytes of the underlying stream so DeflateStream's /// read-ahead cannot consume the next entry's bytes. Does not own the inner /// stream. private sealed class LimitedReadStream : Stream { private readonly Stream _inner; private long _remaining; public LimitedReadStream(Stream inner, long limit) { _inner = inner; _remaining = limit; } public override int Read(byte[] buffer, int offset, int count) { if (_remaining <= 0) return 0; int n = _inner.Read(buffer, offset, (int)Math.Min(count, _remaining)); _remaining -= n; return n; } public override bool CanRead => true; public override bool CanWrite => false; public override bool CanSeek => false; public override void Flush() { } public override long Length => throw new NotSupportedException(); public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); } } }