From d7f775e80c38e467af38ea99ec2266a849d1287a Mon Sep 17 00:00:00 2001 From: Cinka Date: Wed, 3 Dec 2025 23:16:18 +0300 Subject: [PATCH] - add: linux support --- .../Services/DotnetResolverService.cs | 78 ++-- Nebula.Shared/Utils/TarUtils.cs | 393 +++++++++++++++++ .../NebulaSharedTests/CryptographicTest.cs | 2 +- Nebula.UnitTest/NebulaSharedTests/TarTest.cs | 64 +++ Nebula.UpdateResolver/DotnetStandalone.cs | 69 +-- Nebula.UpdateResolver/TarUtils.cs | 395 ++++++++++++++++++ Nebula.sln.DotSettings.user | 2 + 7 files changed, 946 insertions(+), 57 deletions(-) create mode 100644 Nebula.Shared/Utils/TarUtils.cs create mode 100644 Nebula.UnitTest/NebulaSharedTests/TarTest.cs create mode 100644 Nebula.UpdateResolver/TarUtils.cs diff --git a/Nebula.Shared/Services/DotnetResolverService.cs b/Nebula.Shared/Services/DotnetResolverService.cs index 700d44c..cda3d5b 100644 --- a/Nebula.Shared/Services/DotnetResolverService.cs +++ b/Nebula.Shared/Services/DotnetResolverService.cs @@ -1,69 +1,89 @@ -using System.Diagnostics; using System.IO.Compression; using System.Runtime.InteropServices; using System.Text; +using Nebula.Shared.Utils; namespace Nebula.Shared.Services; [ServiceRegister] public class DotnetResolverService(DebugService debugService, ConfigurationService configurationService) { - private HttpClient _httpClient = new HttpClient(); - - private static readonly string FullPath = Path.Join(FileService.RootPath, "dotnet", DotnetUrlHelper.GetRuntimeIdentifier()); + private static readonly string FullPath = + Path.Join(FileService.RootPath, "dotnet", DotnetUrlHelper.GetRuntimeIdentifier()); + private static readonly string ExecutePath = Path.Join(FullPath, "dotnet" + DotnetUrlHelper.GetExtension()); - - public async Task EnsureDotnet(){ - if(!Directory.Exists(FullPath)) + private readonly HttpClient _httpClient = new(); + + public async Task EnsureDotnet() + { + if (!Directory.Exists(FullPath)) await Download(); - + return ExecutePath; } - private async Task Download(){ - - debugService.GetLogger("DotnetResolver").Log($"Downloading dotnet {DotnetUrlHelper.GetRuntimeIdentifier()}..."); - var ridExt = - DotnetUrlHelper.GetCurrentPlatformDotnetUrl(configurationService.GetConfigValue(CurrentConVar.DotnetUrl)!); - using var response = await _httpClient.GetAsync(ridExt); - using var zipArchive = new ZipArchive(await response.Content.ReadAsStreamAsync()); + private async Task Download() + { + var debugLogger = debugService.GetLogger(this); + debugLogger.Log($"Downloading dotnet {DotnetUrlHelper.GetRuntimeIdentifier()}..."); + + var url = DotnetUrlHelper.GetCurrentPlatformDotnetUrl( + configurationService.GetConfigValue(CurrentConVar.DotnetUrl)! + ); + + using var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(); + Directory.CreateDirectory(FullPath); - zipArchive.ExtractToDirectory(FullPath); - debugService.GetLogger("DotnetResolver").Log($"Downloading dotnet complete."); + + if (url.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + using var zipArchive = new ZipArchive(stream); + zipArchive.ExtractToDirectory(FullPath, true); + } + else if (url.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) + || url.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase)) + { + TarUtils.ExtractTarGz(stream, FullPath); + } + else + { + throw new NotSupportedException("Unsupported archive format."); + } + + debugLogger.Log("Downloading dotnet complete."); } } public static class DotnetUrlHelper { + [Obsolete("FOR TEST USING ONLY!")] + public static string? RidOverrideTest = null; // FOR TEST PURPOSES ONLY!!! + public static string GetExtension() { if (OperatingSystem.IsWindows()) return ".exe"; return ""; } - + public static string GetCurrentPlatformDotnetUrl(Dictionary dotnetUrl) { - string? rid = GetRuntimeIdentifier(); + var rid = GetRuntimeIdentifier(); - if (dotnetUrl.TryGetValue(rid, out var url)) - { - return url; - } + if (dotnetUrl.TryGetValue(rid, out var url)) return url; throw new PlatformNotSupportedException($"No download URL available for the current platform: {rid}"); } public static string GetRuntimeIdentifier() { + if(RidOverrideTest != null) return RidOverrideTest; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { return Environment.Is64BitProcess ? "win-x64" : "win-x86"; - } - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - return "linux-x64"; - } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "linux-x64"; throw new PlatformNotSupportedException("Unsupported operating system"); } diff --git a/Nebula.Shared/Utils/TarUtils.cs b/Nebula.Shared/Utils/TarUtils.cs new file mode 100644 index 0000000..042a45a --- /dev/null +++ b/Nebula.Shared/Utils/TarUtils.cs @@ -0,0 +1,393 @@ +using System.IO.Compression; +using System.Text; + +namespace Nebula.Shared.Utils; + +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; + } + } +} \ No newline at end of file diff --git a/Nebula.UnitTest/NebulaSharedTests/CryptographicTest.cs b/Nebula.UnitTest/NebulaSharedTests/CryptographicTest.cs index 4cce6f7..c16a0d9 100644 --- a/Nebula.UnitTest/NebulaSharedTests/CryptographicTest.cs +++ b/Nebula.UnitTest/NebulaSharedTests/CryptographicTest.cs @@ -10,7 +10,7 @@ public class CryptographicTest public async Task EncryptDecrypt() { var key = CryptographicStore.GetComputerKey(); - Console.WriteLine($"Key: {key}"); + Console.WriteLine($"Key: {Convert.ToBase64String(key)}"); var entry = new TestEncryptEntry("Hello", "World"); Console.WriteLine($"Raw data: {entry}"); var encrypt = CryptographicStore.Encrypt(entry, key); diff --git a/Nebula.UnitTest/NebulaSharedTests/TarTest.cs b/Nebula.UnitTest/NebulaSharedTests/TarTest.cs new file mode 100644 index 0000000..c134980 --- /dev/null +++ b/Nebula.UnitTest/NebulaSharedTests/TarTest.cs @@ -0,0 +1,64 @@ +using System.IO.Compression; +using Microsoft.Extensions.DependencyInjection; +using Nebula.Shared; +using Nebula.Shared.Services; +using Nebula.Shared.Services.Logging; +using Nebula.Shared.Utils; + +namespace Nebula.UnitTest.NebulaSharedTests; + +[TestFixture] +[TestOf(typeof(DotnetResolverService))] +public class TarTest : BaseSharedTest +{ + private FileService _fileService = default!; + private ConfigurationService _configurationService = default!; + private readonly HttpClient _httpClient = new(); + + public override void BeforeServiceBuild(IServiceCollection services) + { + TestServiceHelper.InitFileServiceTest(); + } + + [SetUp] + public override void Setup() + { + base.Setup(); + _fileService = _sharedUnit.GetService(); + _configurationService = _sharedUnit.GetService(); + } + + [Test] + public async Task DownloadTarAndUnzipTest() + { + DotnetUrlHelper.RidOverrideTest = "linux-x64"; + Console.WriteLine($"Downloading dotnet {DotnetUrlHelper.GetRuntimeIdentifier()}..."); + + var url = DotnetUrlHelper.GetCurrentPlatformDotnetUrl( + _configurationService.GetConfigValue(CurrentConVar.DotnetUrl)! + ); + + using var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(); + + Directory.CreateDirectory(FileService.RootPath); + + if (url.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + using var zipArchive = new ZipArchive(stream); + zipArchive.ExtractToDirectory(FileService.RootPath, true); + } + else if (url.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) + || url.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase)) + { + TarUtils.ExtractTarGz(stream, FileService.RootPath); + } + else + { + throw new NotSupportedException("Unsupported archive format."); + } + + Console.WriteLine("Downloading dotnet complete."); + } +} \ No newline at end of file diff --git a/Nebula.UpdateResolver/DotnetStandalone.cs b/Nebula.UpdateResolver/DotnetStandalone.cs index 8510c3d..ac15329 100644 --- a/Nebula.UpdateResolver/DotnetStandalone.cs +++ b/Nebula.UpdateResolver/DotnetStandalone.cs @@ -13,15 +13,17 @@ namespace Nebula.UpdateResolver; public static class DotnetStandalone { - private static readonly HttpClient HttpClient = new HttpClient(); + private static readonly HttpClient HttpClient = new(); + + private static readonly string FullPath = + Path.Join(MainWindow.RootPath, "dotnet", DotnetUrlHelper.GetRuntimeIdentifier()); - private static readonly string FullPath = Path.Join(MainWindow.RootPath, "dotnet", DotnetUrlHelper.GetRuntimeIdentifier()); private static readonly string ExecutePath = Path.Join(FullPath, "dotnet" + DotnetUrlHelper.GetExtension()); - + public static async Task Run(string dllPath) { await EnsureDotnet(); - + return Process.Start(new ProcessStartInfo { FileName = ExecutePath, @@ -33,22 +35,43 @@ public static class DotnetStandalone StandardOutputEncoding = Encoding.UTF8 }); } - - private static async Task EnsureDotnet(){ - if(!Directory.Exists(FullPath)) + + private static async Task EnsureDotnet() + { + if (!Directory.Exists(FullPath)) await Download(); } - private static async Task Download(){ - + private static async Task Download() + { LogStandalone.Log($"Downloading dotnet {DotnetUrlHelper.GetRuntimeIdentifier()}..."); - var ridExt = - DotnetUrlHelper.GetCurrentPlatformDotnetUrl(ConfigurationStandalone.GetConfigValue(UpdateConVars.DotnetUrl)!); - using var response = await HttpClient.GetAsync(ridExt); - using var zipArchive = new ZipArchive(await response.Content.ReadAsStreamAsync()); + + var url = DotnetUrlHelper.GetCurrentPlatformDotnetUrl( + ConfigurationStandalone.GetConfigValue(UpdateConVars.DotnetUrl)! + ); + + using var response = await HttpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(); + Directory.CreateDirectory(FullPath); - zipArchive.ExtractToDirectory(FullPath); - LogStandalone.Log($"Downloading dotnet complete."); + + if (url.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + using var zipArchive = new ZipArchive(stream); + zipArchive.ExtractToDirectory(FullPath, true); + } + else if (url.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) + || url.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase)) + { + TarUtils.ExtractTarGz(stream, FullPath); + } + else + { + throw new NotSupportedException("Unsupported archive format."); + } + + LogStandalone.Log("Downloading dotnet complete."); } } @@ -59,15 +82,12 @@ public static class DotnetUrlHelper if (OperatingSystem.IsWindows()) return ".exe"; return ""; } - + public static string GetCurrentPlatformDotnetUrl(Dictionary dotnetUrl) { - string? rid = GetRuntimeIdentifier(); + var rid = GetRuntimeIdentifier(); - if (dotnetUrl.TryGetValue(rid, out var url)) - { - return url; - } + if (dotnetUrl.TryGetValue(rid, out var url)) return url; throw new PlatformNotSupportedException($"No download URL available for the current platform: {rid}"); } @@ -75,14 +95,9 @@ public static class DotnetUrlHelper public static string GetRuntimeIdentifier() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { return Environment.Is64BitProcess ? "win-x64" : "win-x86"; - } - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - return "linux-x64"; - } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "linux-x64"; throw new PlatformNotSupportedException("Unsupported operating system"); } diff --git a/Nebula.UpdateResolver/TarUtils.cs b/Nebula.UpdateResolver/TarUtils.cs new file mode 100644 index 0000000..47d0e5f --- /dev/null +++ b/Nebula.UpdateResolver/TarUtils.cs @@ -0,0 +1,395 @@ +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; + } + } +} \ No newline at end of file diff --git a/Nebula.sln.DotSettings.user b/Nebula.sln.DotSettings.user index 09ae3ff..2a9f965 100644 --- a/Nebula.sln.DotSettings.user +++ b/Nebula.sln.DotSettings.user @@ -52,6 +52,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -102,5 +103,6 @@ <TestId>NUnit3x::735691F8-949C-4476-B9E4-5DF6FF8D3D0B::net9.0::Nebula.UnitTest.NebulaSharedTests.ConfigurationServiceTests</TestId> <TestId>NUnit3x::735691F8-949C-4476-B9E4-5DF6FF8D3D0B::net9.0::Nebula.UnitTest.NebulaSharedTests.CryptographicTest.EncryptDecrypt</TestId> <TestId>NUnit3x::735691F8-949C-4476-B9E4-5DF6FF8D3D0B::net9.0::Nebula.UnitTest.NebulaSharedTests.CryptographicTest</TestId> + <TestId>NUnit3x::735691F8-949C-4476-B9E4-5DF6FF8D3D0B::net9.0::Nebula.UnitTest.NebulaSharedTests.TarTest.Download</TestId> </TestAncestor> </SessionState> \ No newline at end of file