Files
TeslaSuite/Launcher/MiniZip.cs
T
CydandClaude Fable 5 8730b9b966 XP11: single net40 binary runs on XP SP3 through Windows 11
Merge TeslaLauncherService (Session 0) + TeslaLauncherAgent (tray) into one
userland TeslaLauncher.exe. The split existed only to work around Vista+
Session 0 isolation; running everything in the auto-logged-in admin session
needs no service, no named pipe, and no flat<->wire conversion layer. The
original Elsewhen software was likewise a single binary.

net40 is the newest framework XP SP3 can install, and net40 assemblies load
in-place on the 4.8 runtime in Win10/11 -- one exe covers both.

- Contract: multi-target net48;net40. The net40 leg of PodRpcProtocol uses
  Newtonsoft.Json (STJ has no net40 target); JSON is shape-identical on the
  wire, and the request reader keeps date strings raw so Ping echoes
  byte-identically. Console keeps the untouched net48/STJ leg.
- MiniZip.cs: central-directory ZIP extractor (stored/deflate/ZIP64) since
  net40 has no ZipFile; zip-slip guarded.
- SecureConfig: RSACryptoServiceProvider instead of RSA.Create (net46+),
  no leaveOpen BinaryWriter/Reader, struct instead of ValueTuple, and no
  SetCompatibleTextRenderingDefault on the passcode thread (the merged app
  already has a form). netsh "interface ip" syntax was already XP-correct.
- Volume: nircmd -> CoreAudio (Vista+) -> winmm waveOutSetVolume (XP).
- Paths: CommonApplicationData resolved per-OS (XP has no C:\ProgramData);
  launcher log moved next to the key/config in the data dir.
- install.bat: dual-OS (cacls/icacls, netsh firewall/advfirewall, dism and
  UAC/notification steps skipped on XP, .NET 4.0 redist check, XP DHCP reset
  via netsh); no service registration -- HKLM Run key + auto-login on both;
  RegisterApplicationRestart supplies crash-restart on Vista+.
- Bench switches: /skipconfig, /port:NNNN.

Verified: Console + diff suite (103 tests) still green on the net48 leg;
E2E smoke test drove the net40 launcher over real TCP with the Console's own
PodManagerConnection (STJ client vs Newtonsoft server) -- Ping echo, volume,
app registry, launch/kill/watch, InstallProduct zip transfer + extract, and
uninstall orphan cleanup: 17/17 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:59:06 -05:00

274 lines
12 KiB
C#

// =============================================================================
// 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
{
/// <summary>Extracts every entry of <paramref name="zipPath"/> under
/// <paramref name="destDir"/> (existing files overwritten), reporting
/// (entriesDone, entriesTotal) after each entry. Entries that would
/// escape the destination directory (zip-slip) are skipped.</summary>
public static void ExtractToDirectory(string zipPath, string destDir, Action<int, int> 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<Entry> 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<Entry>((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;
}
}
/// <summary>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.</summary>
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();
}
}
}