using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; namespace ChanSort.Loader.TCL; /// /// Minimal implementation to support the contents of TCL's .tar. /// Reading supports all "ustar" formats including old-GNU and POSIX 1990 .tar flavors. /// Saving uses the "old-GNU" format, that's used by TCL. /// /// Unlike all tools and libraries available under Windows, this implementation preserves unix metadata like: /// file mode, user-id, group-id, user name, group name and device major/minor number /// /// Information about GNU tar can be found on https://www.gnu.org/software/tar/manual/html_node/Standard.html /// internal class GnuTar { private static readonly DateTime Epoc = new (1970, 1, 1); private static readonly byte[] Padding = new byte[512]; public Encoding Encoding { get; set; } = Encoding.UTF8; public List Entries { get; } = new(); #region ExtractToDirectory() public void ExtractToDirectory(string tarPath, string targetDir) { var data = File.ReadAllBytes(tarPath); this.Read(data); foreach (var entry in this.Entries) { if (entry.TypeFlag == TarEntryTypes.Directory) { entry.Path = Path.Combine(targetDir, entry.Name.TrimEnd('/', '\\')); Directory.CreateDirectory(entry.Path); } else if (entry.TypeFlag == TarEntryTypes.File || entry.TypeFlag == TarEntryTypes.File0) { var path = Path.Combine(targetDir, entry.Name); Directory.CreateDirectory(Path.GetDirectoryName(path)); using (var outStream = new FileStream(path, FileMode.Create)) outStream.Write(data, entry.Position, entry.Size); entry.Path = path; File.SetLastWriteTimeUtc(path, entry.LastModified); } else throw new NotImplementedException($"unsupported tar entry type {(char)entry.TypeFlag} for {entry.Name}"); } } #endregion #region Read() public void Read(string path) { var stream = new FileStream(path, FileMode.Open); Read(stream, false); } public void Read(byte[] data) { using var stream = new MemoryStream(data); Read(stream); } public void Read(Stream stream, bool keepOpen = true) { try { Entries.Clear(); var header = new byte[512]; var memStream = stream as MemoryStream; while (true) { var len = stream.Read(header, 0, 512); if (len < 512) break; if (header.All(b => b == 0)) // end block is all-zero break; var position = stream.Position; var entry = ReadEntryHeader(header); entry.Data = memStream?.ToArray(); entry.Position = (int)position; Entries.Add(entry); var extra = entry.Size % 512; if (extra != 0) extra = 512 - extra; stream.Seek(entry.Size + extra, SeekOrigin.Current); } } finally { if (!keepOpen) stream.Dispose(); } } #endregion #region ReadEntryHeader() private TarEntry ReadEntryHeader(byte[] header) { var e = new TarEntry(); e.Name = ReadString(header, 0, 100, this.Encoding); e.Mode = (ushort)ReadNumber(header, 100, 8); e.UserId = ReadNumber(header, 108, 8); e.GroupId = ReadNumber(header, 116, 8); e.Size = ReadNumber(header, 124, 12); var mtime = (uint)ReadNumber(header, 136, 12); e.LastModified = Epoc.AddSeconds(mtime); e.Checksum = (uint)ReadNumber(header, 148, 8); e.TypeFlag = (TarEntryTypes)header[156]; e.LinkName = ReadString(header, 157, 100, this.Encoding); e.Magic = ReadString(header, 257, 6); e.Version = ReadString(header, 263, 2); if (e.Magic != "ustar" && !(e.Magic == "ustar " && e.Version == " ")) throw new InvalidOperationException("not a POSIX/GNU or old-GNU tar"); e.Username = ReadString(header, 265, 32, this.Encoding); e.Groupname = ReadString(header, 297, 32, this.Encoding); e.DeviceMajor = ReadOptionalNumber(header, 329, 8); e.DeviceMinor = ReadOptionalNumber(header, 337, 8); e.Prefix = ReadString(header, 345, 155, this.Encoding); return e; } #endregion #region ReadString(), ReadNumber() private string ReadString(byte[] data, int offset, int length, Encoding encoding = null) { encoding ??= Encoding.ASCII; int idx = Array.IndexOf(data, (byte)0, offset); if (idx == 0) idx = data.Length; if (idx > 0) length = Math.Min(length, idx - offset); return encoding.GetString(data, offset, length); } private int ReadNumber(byte[] data, int offset, int length) { var val = ReadOptionalNumber(data, offset, length); return val ?? 0; } private int? ReadOptionalNumber(byte[] data, int offset, int length) { var nr = ReadString(data, offset, length).TrimEnd(' '); return nr.Length == 0 ? null : Convert.ToInt32(nr, 8); } #endregion #region UpdateFromDirectory() public void UpdateFromDirectory(string tarPath) { using var outStream = new FileStream(tarPath, FileMode.Create); using var memStream = new MemoryStream(512); foreach (var entry in this.Entries) { byte[] data = null; if (entry.TypeFlag == TarEntryTypes.Directory) { if (!Directory.Exists(entry.Path)) continue; } else { var info = new FileInfo(entry.Path); if (!info.Exists) continue; data = File.ReadAllBytes(entry.Path); entry.Size = data.Length; entry.LastModified = info.LastWriteTimeUtc; } // prepare header in a memory stream and patch checksum into it memStream.Seek(0, SeekOrigin.Begin); WriteEntryHeader(entry, memStream); entry.Checksum = CalcChecksum(memStream.GetBuffer()); memStream.Seek(148, SeekOrigin.Begin); WriteNumber(memStream, (ushort)entry.Checksum, 7); outStream.Write(memStream.GetBuffer(), 0, 512); // write file data if (data != null) outStream.Write(data, 0, data.Length); // padding zeros to 512 var padlen = entry.Size % 512; if (padlen != 0) outStream.Write(Padding, 0, 512 - padlen); } // end-of-file marker: 2x 512 byte blocks with all zeros outStream.Write(Padding, 0, Padding.Length); outStream.Write(Padding, 0, Padding.Length); } #endregion #region CalcChecksum() private uint CalcChecksum(byte[] data) { uint sum = 0; int count = data.Length; for (int i=0; count>0; i++, count--) sum += data[i]; return sum; } #endregion #region WriteEntryHeader() private void WriteEntryHeader(TarEntry e, Stream strm) { WriteString(strm, e.Name, 100, this.Encoding); WriteNumber(strm, e.Mode, 8); WriteNumber(strm, e.UserId, 8); WriteNumber(strm, e.GroupId, 8); WriteNumber(strm, e.Size, 12); WriteNumber(strm, (int)(e.LastModified - Epoc).TotalSeconds, 12); strm.Write(" "u8.ToArray(), 0, 8); // placeholder for checksum strm.WriteByte((byte)e.TypeFlag); WriteString(strm, e.LinkName, 100, this.Encoding); var bytes = Encoding.ASCII.GetBytes(e.Magic); strm.Write(bytes, 0, Math.Min(bytes.Length, 6)); strm.Write(Padding, 0, 6 - bytes.Length); WriteString(strm, e.Version, 2); WriteString(strm, e.Username, 32); WriteString(strm, e.Groupname, 32); WriteNumber(strm, e.DeviceMajor, 8); WriteNumber(strm, e.DeviceMinor, 8); WriteString(strm, e.Prefix, 155, this.Encoding); strm.Write(Padding, 0, 512 - 500); } #endregion #region WriteString() private void WriteString(Stream strm, string str, int length, Encoding enc = null) { enc ??= Encoding.UTF8; var bytes = enc.GetBytes(str); if (bytes.Length >= length) { strm.Write(bytes, 0, length-1); strm.WriteByte(0); return; } strm.Write(bytes, 0, bytes.Length); strm.Write(Padding, 0, length - bytes.Length); } #endregion #region WriteNumber() private void WriteNumber(Stream strm, int number, int length) { var str = Convert.ToString((uint)number, 8); if (str.Length >= length) throw new ArgumentException($"{number} is too long for {length} octal digits"); for (int i=length - str.Length - 1; i>0; i--) strm.WriteByte((byte)'0'); var bytes = Encoding.ASCII.GetBytes(str); strm.Write(bytes, 0, bytes.Length); strm.WriteByte(0); } private void WriteNumber(Stream strm, int? number, int length) { if (number.HasValue) WriteNumber(strm, number.Value, length); else WriteString(strm, "", length); } #endregion } #region enum TarEntryTypes public enum TarEntryTypes : byte { File0 = (byte)'\0', File = (byte)'0', Link = (byte)'1', Sym = (byte)'2', CharDevice = (byte)'3', BlockDevice = (byte)'4', Directory = (byte)'5', FiFo = (byte)'6', Cont = (byte)'7', ExtendedFileHeader = (byte)'x', GlobalHeader = (byte)'g' } #endregion #region class TarEntry class TarEntry { // original tar V7 public string Name { get; set; } public ushort Mode { get; set; } public int UserId { get; set; } public int GroupId { get; set; } public int Size { get; set; } public DateTime LastModified { get; set; } public uint Checksum { get; set; } public TarEntryTypes TypeFlag { get; set; } public string LinkName { get; set; } // UStar (POSIX 1003.1-1990) / GNU / old-GNU public string Magic { get; set; } public string Version { get; set; } public string Username { get; set; } public string Groupname { get; set; } public int? DeviceMajor { get; set; } public int? DeviceMinor { get; set; } public string Prefix { get; set; } // internal public byte[] Data { get; set; } public int Position { get; set; } public string Path { get; set; } } #endregion