Files
NebulaLauncher/Nebula.UpdateResolver/TarUtils.cs
2025-12-03 23:16:18 +03:00

395 lines
14 KiB
C#

using System;
using System.IO;
using System.IO.Compression;
using System.Text;
namespace Nebula.UpdateResolver;
public static class TarUtils
{
public static void ExtractTarGz(Stream stream, string destinationDirectory)
{
if (destinationDirectory == null) throw new ArgumentNullException(nameof(destinationDirectory));
using (var gzs = new GZipStream(stream, CompressionMode.Decompress, leaveOpen: false))
{
// GZipStream does not expose length, so just pass as streaming source
TarExtractor.ExtractTar(gzs, destinationDirectory);
}
}
}
public static class TarExtractor
{
private const int BlockSize = 512;
public static void ExtractTar(Stream tarStream, string destinationDirectory)
{
if (tarStream == null) throw new ArgumentNullException(nameof(tarStream));
if (destinationDirectory == null) throw new ArgumentNullException(nameof(destinationDirectory));
Directory.CreateDirectory(destinationDirectory);
string pendingLongName = null;
string pendingLongLink = null;
var block = new byte[BlockSize];
var zeroBlockCount = 0;
while (true)
{
var read = ReadExactly(tarStream, block, 0, BlockSize);
if (read == 0)
break;
if (IsAllZero(block))
{
zeroBlockCount++;
if (zeroBlockCount >= 2) break; // two consecutive zero blocks -> end of archive
continue;
}
zeroBlockCount = 0;
var header = TarHeader.FromBlock(block);
// validate header checksum (best-effort)
if (!header.IsValidChecksum(block))
{
// Not fatal, but warn (we're not writing warnings to console by default).
}
// Some tar implementations supply the long filename in a preceding entry whose typeflag is 'L'.
// If present, use that name for the following file.
if (header.TypeFlag == 'L') // GNU long name
{
// read content blocks with size header.Size
var size = header.Size;
var nameBytes = new byte[size];
ReadExactly(tarStream, nameBytes, 0, (int)size);
// skip padding to full 512 block
SkipPadding(tarStream, size);
pendingLongName = ReadNullTerminatedString(nameBytes);
continue;
}
if (header.TypeFlag == 'K') // GNU long linkname
{
var size = header.Size;
var linkBytes = new byte[size];
ReadExactly(tarStream, linkBytes, 0, (int)size);
SkipPadding(tarStream, size);
pendingLongLink = ReadNullTerminatedString(linkBytes);
continue;
}
// Determine final name
var entryName = !string.IsNullOrEmpty(pendingLongName) ? pendingLongName : header.GetName();
var entryLinkName = !string.IsNullOrEmpty(pendingLongLink) ? pendingLongLink : header.LinkName;
// reset pending longs after use
pendingLongName = null;
pendingLongLink = null;
// sanitize path separators and avoid absolute paths
entryName = entryName.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar)
.TrimStart(Path.DirectorySeparatorChar);
var targetPath = Path.Combine(destinationDirectory, entryName);
switch (header.TypeFlag)
{
case '0':
case '\0': // normal file
case '7': // regular file (SUSv4)
EnsureParentDirectoryExists(targetPath);
using (var outFile = File.Open(targetPath, FileMode.Create, FileAccess.Write))
{
CopyExact(tarStream, outFile, header.Size);
}
SkipPadding(tarStream, header.Size);
TrySetTimes(targetPath, header.ModTime);
break;
case '5': // directory
Directory.CreateDirectory(targetPath);
TrySetTimes(targetPath, header.ModTime);
break;
case '2': // symlink
// Creating symlinks require privileges on Windows and may fail.
// To keep things robust across platforms, write a small .symlink-info file for Windows fallback,
// and attempt real symlink creation on Unix-like platforms.
EnsureParentDirectoryExists(targetPath);
TryCreateSymlink(entryLinkName, targetPath);
break;
case '1': // hard link - we will try to create by copying if target exists; otherwise skip
var linkTargetPath = Path.Combine(destinationDirectory,
entryLinkName.Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(linkTargetPath))
{
EnsureParentDirectoryExists(targetPath);
File.Copy(linkTargetPath, targetPath, true);
}
break;
case '3': // character device - skip
case '4': // block device - skip
case '6': // contiguous file - treat as regular
// To be safe, treat as file if size > 0
if (header.Size > 0)
{
EnsureParentDirectoryExists(targetPath);
using (var outFile = File.Open(targetPath, FileMode.Create, FileAccess.Write))
{
CopyExact(tarStream, outFile, header.Size);
}
SkipPadding(tarStream, header.Size);
TrySetTimes(targetPath, header.ModTime);
}
break;
default:
// Unknown type - skip the file data
if (header.Size > 0)
{
Skip(tarStream, header.Size);
SkipPadding(tarStream, header.Size);
}
break;
}
}
}
private static void TryCreateSymlink(string linkTarget, string symlinkPath)
{
try
{
// On Unix-like systems we can try to create a symlink
if (IsWindows())
{
// don't try symlinks on Windows by default - write a .symlink-info file instead
File.WriteAllText(symlinkPath + ".symlink-info", $"symlink -> {linkTarget}");
}
else
{
// Unix: use symlink
var dir = Path.GetDirectoryName(symlinkPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
// Use native syscall via Mono.Posix? Not allowed. Fall back to invoking 'ln -s' is not allowed.
// Instead use System.IO.File.CreateSymbolicLink if available (net core 2.1+)
#if NETCOREAPP2_1_OR_GREATER || NET5_0_OR_GREATER
var sym = new FileInfo(symlinkPath);
sym.CreateAsSymbolicLink(linkTarget);
#else
// If unavailable, write a .symlink-info file.
File.WriteAllText(symlinkPath + ".symlink-info", $"symlink -> {linkTarget}");
#endif
}
}
catch
{
// Ignore failures to create symlink; write fallback info
try
{
File.WriteAllText(symlinkPath + ".symlink-info", $"symlink -> {linkTarget}");
}
catch
{
}
}
}
private static bool IsWindows()
{
return Path.DirectorySeparatorChar == '\\';
}
private static void TrySetTimes(string path, DateTimeOffset modTime)
{
try
{
var dt = modTime.UtcDateTime;
// convert to local to set file time sensibly
File.SetLastWriteTimeUtc(path, dt);
}
catch
{
/* best-effort */
}
}
private static void EnsureParentDirectoryExists(string path)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
}
// Read exactly count bytes or throw if cannot
private static int ReadExactly(Stream s, byte[] buffer, int offset, int count)
{
var total = 0;
while (total < count)
{
var r = s.Read(buffer, offset + total, count - total);
if (r == 0) break;
total += r;
}
return total;
}
// Skip count bytes by reading and discarding
private static void Skip(Stream s, long count)
{
var tmp = new byte[8192];
var remaining = count;
while (remaining > 0)
{
var toRead = (int)Math.Min(tmp.Length, remaining);
var r = s.Read(tmp, 0, toRead);
if (r == 0) break;
remaining -= r;
}
}
private static void CopyExact(Stream source, Stream dest, long count)
{
var buf = new byte[8192];
var remaining = count;
while (remaining > 0)
{
var toRead = (int)Math.Min(buf.Length, remaining);
var r = source.Read(buf, 0, toRead);
if (r == 0) break;
dest.Write(buf, 0, r);
remaining -= r;
}
}
private static void SkipPadding(Stream s, long size)
{
var pad = (BlockSize - size % BlockSize) % BlockSize;
if (pad > 0) Skip(s, pad);
}
private static bool IsAllZero(byte[] block)
{
for (var i = 0; i < block.Length; i++)
if (block[i] != 0)
return false;
return true;
}
private static string ReadNullTerminatedString(byte[] bytes)
{
var len = 0;
while (len < bytes.Length && bytes[len] != 0) len++;
return Encoding.UTF8.GetString(bytes, 0, len);
}
private class TarHeader
{
public string Name { get; private set; }
public int Mode { get; private set; }
public int Uid { get; private set; }
public int Gid { get; private set; }
public long Size { get; private set; }
public DateTimeOffset ModTime { get; private set; }
public int Checksum { get; private set; }
public char TypeFlag { get; private set; }
public string LinkName { get; private set; }
public string Magic { get; private set; }
public string Version { get; private set; }
public string UName { get; private set; }
public string GName { get; private set; }
public string DevMajor { get; private set; }
public string DevMinor { get; private set; }
public string Prefix { get; private set; }
public static TarHeader FromBlock(byte[] block)
{
var h = new TarHeader();
h.Name = ReadString(block, 0, 100);
h.Mode = (int)ReadOctal(block, 100, 8);
h.Uid = (int)ReadOctal(block, 108, 8);
h.Gid = (int)ReadOctal(block, 116, 8);
h.Size = ReadOctal(block, 124, 12);
var mtime = ReadOctal(block, 136, 12);
h.ModTime = DateTimeOffset.FromUnixTimeSeconds(mtime);
h.Checksum = (int)ReadOctal(block, 148, 8);
h.TypeFlag = (char)block[156];
h.LinkName = ReadString(block, 157, 100);
h.Magic = ReadString(block, 257, 6);
h.Version = ReadString(block, 263, 2);
h.UName = ReadString(block, 265, 32);
h.GName = ReadString(block, 297, 32);
h.DevMajor = ReadString(block, 329, 8);
h.DevMinor = ReadString(block, 337, 8);
h.Prefix = ReadString(block, 345, 155);
return h;
}
public string GetName()
{
if (!string.IsNullOrEmpty(Prefix))
return $"{Prefix}/{Name}".Trim('/');
return Name;
}
public bool IsValidChecksum(byte[] block)
{
// compute checksum where checksum field (148..155) is spaces (0x20)
long sum = 0;
for (var i = 0; i < block.Length; i++)
if (i >= 148 && i < 156) sum += 32; // space
else sum += block[i];
// stored checksum could be octal until null
var stored = Checksum;
return Math.Abs(sum - stored) <= 1; // allow +/-1 tolerance
}
private static string ReadString(byte[] buf, int offset, int length)
{
var end = offset;
var max = offset + length;
while (end < max && buf[end] != 0) end++;
if (end == offset) return string.Empty;
return Encoding.ASCII.GetString(buf, offset, end - offset);
}
private static long ReadOctal(byte[] buf, int offset, int length)
{
// Many tars store as ASCII octal, possibly padded with nulls or spaces.
var end = offset + length;
var i = offset;
// skip leading spaces and nulls
while (i < end && (buf[i] == 0 || buf[i] == (byte)' ')) i++;
long val = 0;
var found = false;
for (; i < end; i++)
{
var b = buf[i];
if (b == 0 || b == (byte)' ') break;
if (b >= (byte)'0' && b <= (byte)'7')
{
found = true;
val = (val << 3) + (b - (byte)'0');
}
// some implementations use base-10 ascii or binary; ignore invalid chars
}
if (!found) return 0;
return val;
}
}
}