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>
274 lines
12 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|