// Copyright (C) 2026, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. #nullable enable using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Duplicati.Library.Interface; using Duplicati.Library.Logging; using Duplicati.Library.Utility; using Duplicati.StreamUtil; namespace Duplicati.Library.Compression.TarZstdCompression; /// /// Abstract base class for Tar-based compression implementations. /// File format: [Compressed Stream] containing [Tar Archive] + [.eof-header with dictionary] /// public abstract class FileArchiveTarBased : ICompression { private static readonly string LOGTAG = Log.LogTagFromType(); // Constant ustar header fields // Mode: 0000644 (8 bytes at offset 100) private static readonly byte[] UstarModeBytes = "0000644 "u8.ToArray(); // UID: 0 (8 bytes at offset 108) private static readonly byte[] UstarUidBytes = "0000000 "u8.ToArray(); // GID: 0 (8 bytes at offset 116) private static readonly byte[] UstarGidBytes = "0000000 "u8.ToArray(); // Magic: "ustar\0" (6 bytes at offset 257) private static readonly byte[] UstarMagicBytes = "ustar\0"u8.ToArray(); // Version: "00" (2 bytes at offset 263) private static readonly byte[] UstarVersionBytes = "00"u8.ToArray(); private readonly ArchiveMode m_mode; private readonly Stream? m_inputStream; private readonly Stream? m_outputStream; private readonly string? m_tempFilePath; private FileStream? m_tempFileStream; private readonly int m_compressionLevel; private readonly Dictionary m_entries; // Write mode fields private Stream? m_compressorStream; private readonly Dictionary m_writeEntries; private long m_tarPosition; private int m_entryCount; private PendingEntryStream? m_currentStream; private readonly bool m_useMemoryBuffer; private long m_bytesWritten; private bool m_disposed; /// /// The filename extension for this compression format /// public abstract string FilenameExtension { get; } /// /// The display name for this compression format /// public abstract string DisplayName { get; } /// /// The description for this compression format /// public abstract string Description { get; } /// /// The option name for compression level /// protected abstract string CompressionLevelOption { get; } /// /// The default compression level /// protected abstract int DefaultCompressionLevel { get; } /// /// The minimum compression level /// protected abstract int MinCompressionLevel { get; } /// /// The maximum compression level /// protected abstract int MaxCompressionLevel { get; } /// /// The option name for using memory buffer instead of temp files /// protected abstract string MemoryBufferOption { get; } /// /// Creates a decompressor stream wrapper /// protected abstract Stream CreateDecompressorStream(Stream inputStream); /// /// Creates a compressor stream wrapper /// protected abstract Stream CreateCompressorStream(Stream outputStream, int compressionLevel); /// /// Default constructor for module discovery /// protected FileArchiveTarBased() { m_mode = ArchiveMode.Read; m_compressionLevel = DefaultCompressionLevel; m_entries = null!; m_writeEntries = null!; } protected FileArchiveTarBased(Stream stream, ArchiveMode mode, IReadOnlyDictionary options) { m_mode = mode; m_entries = new Dictionary(StringComparer.Ordinal); m_writeEntries = []; m_compressionLevel = Math.Clamp( Utility.Utility.ParseIntOption(options, CompressionLevelOption, DefaultCompressionLevel), MinCompressionLevel, MaxCompressionLevel); if (mode == ArchiveMode.Read) { m_inputStream = stream ?? throw new ArgumentNullException(nameof(stream)); if (!m_inputStream.CanRead) throw new ArgumentException("Stream must be readable", nameof(stream)); m_tempFilePath = Path.GetTempFileName(); m_tempFileStream = new FileStream(m_tempFilePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, 64 * 1024, FileOptions.DeleteOnClose); DecompressToTempFile(); m_tempFileStream.Position = 0; if (!TryReadEofHeader(m_tempFileStream, out var entries)) { m_tempFileStream.Position = 0; entries = BuildDictionaryByScanning(m_tempFileStream); } foreach (var entry in entries) m_entries[entry.Key] = entry.Value; } else { m_outputStream = stream ?? throw new ArgumentNullException(nameof(stream)); if (!m_outputStream.CanWrite) throw new ArgumentException("Stream must be writable", nameof(stream)); m_useMemoryBuffer = Utility.Utility.ParseBoolOption(options, MemoryBufferOption); m_compressorStream = CreateCompressorStream(m_outputStream, m_compressionLevel); m_tarPosition = 0; m_entryCount = 0; m_bytesWritten = 0; } } private void DecompressToTempFile() { if (m_inputStream == null || m_tempFileStream == null) return; try { using var decompressor = CreateDecompressorStream(m_inputStream); decompressor.CopyTo(m_tempFileStream); m_tempFileStream.Flush(); } catch (Exception ex) { throw new InvalidDataException("Failed to decompress stream", ex); } } public abstract IList SupportedCommands { get; } public long Size => m_mode switch { ArchiveMode.Read => m_inputStream?.Length ?? 0, ArchiveMode.Write => m_bytesWritten, // Track raw bytes written (assuming zero compression) _ => 0 }; public long FlushBufferSize { get { if (m_mode != ArchiveMode.Write) return 0; // Current file being written + its header (if any) long total = m_currentStream?.Length + 512 ?? 0; // EOF header estimate: header (512) + JSON (~60 bytes per entry) + padding + trailer (14) // JSON format: {"path":{"Offset":N,"Size":N,"LastWriteTime":N}} // Rough estimate: 60 chars per entry long eofJsonSize = m_entryCount * 60 + 100; long eofHeaderSize = 512 + ((eofJsonSize + 511) / 512 * 512) + 14; eofHeaderSize = (eofHeaderSize + 511) / 512 * 512; total += eofHeaderSize; return total; } } public string[] ListFiles(string? prefix) { if (m_mode != ArchiveMode.Read) throw new InvalidOperationException("Cannot read while writing"); var files = m_entries.Keys; if (string.IsNullOrEmpty(prefix)) return files.ToArray(); return files .Where(f => f.StartsWith(prefix, StringComparison.Ordinal) || f.Replace('\\', '/').StartsWith(prefix, StringComparison.Ordinal)) .ToArray(); } public IEnumerable> ListFilesWithSize(string? prefix) { if (m_mode != ArchiveMode.Read) throw new InvalidOperationException("Cannot read while writing"); foreach (var entry in m_entries) { if (string.IsNullOrEmpty(prefix) || entry.Key.StartsWith(prefix, StringComparison.Ordinal) || entry.Key.Replace('\\', '/').StartsWith(prefix, StringComparison.Ordinal)) { yield return new KeyValuePair(entry.Key, entry.Value.Size); } } } public Stream? OpenRead(string file) { if (m_mode != ArchiveMode.Read) throw new InvalidOperationException("Cannot read while writing"); var normalizedFile = file.Replace('\\', '/'); if (!m_entries.TryGetValue(normalizedFile, out var entry)) { if (file != normalizedFile) m_entries.TryGetValue(file, out entry); } if (entry == null) return null; if (m_tempFileStream == null) return null; return new ReadLimitLengthStream(m_tempFileStream, entry.Offset, entry.Size); } public DateTime GetLastWriteTime(string file) { if (m_mode != ArchiveMode.Read) throw new InvalidOperationException("Cannot read while writing"); var normalizedFile = file.Replace('\\', '/'); if (m_entries.TryGetValue(normalizedFile, out var entry)) return entry.LastWriteTime; if (file != normalizedFile && m_entries.TryGetValue(file, out entry)) return entry.LastWriteTime; throw new FileNotFoundException($"File not found: {file}"); } public bool FileExists(string file) { if (m_mode != ArchiveMode.Read) throw new InvalidOperationException("Cannot read while writing"); var normalizedFile = file.Replace('\\', '/'); return m_entries.ContainsKey(normalizedFile) || (file != normalizedFile && m_entries.ContainsKey(file)); } public Stream CreateFile(string file, CompressionHint hint, DateTime lastWrite) { if (m_mode != ArchiveMode.Write) throw new InvalidOperationException("Cannot write while reading"); var normalizedFile = file.Replace('\\', '/'); if (m_writeEntries.ContainsKey(normalizedFile)) throw new InvalidOperationException($"File already exists: {normalizedFile}"); if (m_currentStream != null) throw new InvalidOperationException("Cannot create a new file while another file is still open"); m_currentStream = new PendingEntryStream(this, normalizedFile, lastWrite, m_useMemoryBuffer); return m_currentStream; } public void Dispose() { if (m_disposed) return; try { if (m_mode == ArchiveMode.Write) { WriteEofHeader(); m_compressorStream?.Flush(); m_compressorStream?.Dispose(); } } catch (Exception ex) { Log.WriteErrorMessage(LOGTAG, "DisposeError", ex, "Error during disposal"); } finally { m_tempFileStream?.Dispose(); if (m_tempFilePath != null && File.Exists(m_tempFilePath)) { try { File.Delete(m_tempFilePath); } catch { } } m_disposed = true; } } private void WriteEntryToTar(string name, DateTime lastWriteTime, Stream contentStream, long size) { if (m_compressorStream == null) throw new InvalidOperationException("Not in write mode"); // Record content offset (after the 512-byte header) var contentOffset = m_tarPosition + 512; // Write tar header (512 bytes) WriteUstarHeader(m_compressorStream, name, size, lastWriteTime); m_bytesWritten += 512; // Write content from stream contentStream.CopyTo(m_compressorStream); m_bytesWritten += size; // Pad to 512-byte boundary var padding = 512 - (int)(size % 512); if (padding != 512) { m_compressorStream.Write(new byte[padding], 0, padding); m_tarPosition += padding; m_bytesWritten += padding; } m_tarPosition += 512 + size; m_currentStream = null; // Track entry for EOF header m_writeEntries[name] = new SerializableEntry( contentOffset, size, Utility.Utility.NormalizeDateTimeToEpochSeconds(lastWriteTime) ); m_entryCount++; } private void WriteEofHeader() { if (m_compressorStream == null || m_writeEntries.Count == 0) return; var headerStartPosition = m_tarPosition; var json = JsonSerializer.Serialize(m_writeEntries); var jsonBytes = Encoding.UTF8.GetBytes(json); // Calculate total size: JSON + trailer (14 bytes), then pad to 512 bytes const int trailerSize = 14; // 8 offset + 6 magic var contentSize = jsonBytes.Length + trailerSize; var paddedSize = (contentSize + 511) / 512 * 512; var paddingSize = paddedSize - contentSize; // Write ustar header for .eof-header (512 bytes) WriteUstarHeader(m_compressorStream, TarBaseConstants.EofHeaderFileName, paddedSize, DateTime.UtcNow); m_bytesWritten += 512; // Write content: JSON + padding + trailer m_compressorStream.Write(jsonBytes, 0, jsonBytes.Length); m_bytesWritten += jsonBytes.Length; m_compressorStream.Write(new byte[paddingSize], 0, paddingSize); m_bytesWritten += paddingSize; // Write trailer: offset (8 bytes, little-endian) + magic (6 bytes) var offsetBytes = BitConverter.GetBytes(headerStartPosition); m_compressorStream.Write(offsetBytes, 0, 8); m_bytesWritten += 8; var magicBytes = Encoding.ASCII.GetBytes(TarBaseConstants.EofHeaderMagic); m_compressorStream.Write(magicBytes, 0, 6); m_bytesWritten += 6; } private static void WriteUstarHeader(Stream stream, string fileName, long size, DateTime mtime) { var header = new byte[512]; // Name (100 bytes) - null terminated var nameBytes = Encoding.UTF8.GetBytes(fileName); var nameLen = Math.Min(nameBytes.Length, 99); Array.Copy(nameBytes, 0, header, 0, nameLen); header[nameLen] = 0; // Mode (8 bytes, octal) - 0644 Array.Copy(UstarModeBytes, 0, header, 100, 8); // UID (8 bytes, octal) - 0 Array.Copy(UstarUidBytes, 0, header, 108, 8); // GID (8 bytes, octal) - 0 Array.Copy(UstarGidBytes, 0, header, 116, 8); // Size (12 bytes, octal) - space terminated var sizeStr = Convert.ToString(size, 8).PadLeft(11, '0'); var sizeBytes = Encoding.ASCII.GetBytes(sizeStr + " "); Array.Copy(sizeBytes, 0, header, 124, 12); // Mtime (12 bytes, octal) - space terminated var mtimeValue = new DateTimeOffset(mtime).ToUnixTimeSeconds(); var mtimeStr = Convert.ToString(mtimeValue, 8).PadLeft(11, '0'); var mtimeBytes = Encoding.ASCII.GetBytes(mtimeStr + " "); Array.Copy(mtimeBytes, 0, header, 136, 12); // Checksum placeholder (8 bytes) - filled with spaces for calculation Array.Fill(header, (byte)' ', 148, 8); // Type flag (1 byte) - '0' for regular file header[156] = (byte)'0'; // Magic (6 bytes) - "ustar\0" Array.Copy(UstarMagicBytes, 0, header, 257, 6); // Version (2 bytes) - "00" Array.Copy(UstarVersionBytes, 0, header, 263, 2); // Calculate checksum var checksum = header.Sum(b => (int)b); var checksumStr = Convert.ToString(checksum, 8).PadLeft(6, '0'); var checksumBytes = Encoding.ASCII.GetBytes(checksumStr + "\0 "); Array.Copy(checksumBytes, 0, header, 148, 8); stream.Write(header, 0, header.Length); } private static bool TryReadEofHeader(Stream stream, out Dictionary entries) { entries = new Dictionary(); try { if (stream.Length < TarBaseConstants.EofHeaderTrailerSize) return false; stream.Seek(-TarBaseConstants.EofHeaderTrailerSize, SeekOrigin.End); var offsetBytes = new byte[TarBaseConstants.EofHeaderOffsetSize]; stream.ReadExactly(offsetBytes, 0, offsetBytes.Length); var headerOffset = BitConverter.ToInt64(offsetBytes, 0); var magicBytes = new byte[TarBaseConstants.EofHeaderMagicSize]; stream.ReadExactly(magicBytes, 0, magicBytes.Length); var magic = Encoding.ASCII.GetString(magicBytes); if (magic != TarBaseConstants.EofHeaderMagic) return false; if (headerOffset < 0 || headerOffset >= stream.Length - TarBaseConstants.EofHeaderTrailerSize) return false; stream.Seek(headerOffset, SeekOrigin.Begin); using var tarReader = new System.Formats.Tar.TarReader(stream, leaveOpen: true); var entry = tarReader.GetNextEntry(); if (entry == null || entry.Name != TarBaseConstants.EofHeaderFileName) return false; if (entry.DataStream == null) return false; using var ms = new MemoryStream(); entry.DataStream.CopyTo(ms); var contentBytes = ms.ToArray(); if (contentBytes.Length < TarBaseConstants.EofHeaderTrailerSize) return false; var jsonBytes = contentBytes[..^TarBaseConstants.EofHeaderTrailerSize]; int jsonLength = jsonBytes.Length; while (jsonLength > 0 && jsonBytes[jsonLength - 1] == 0) jsonLength--; var json = Encoding.UTF8.GetString(jsonBytes, 0, jsonLength); var deserialized = JsonSerializer.Deserialize>(json); if (deserialized == null) return false; entries = deserialized.ToDictionary( kvp => kvp.Key, kvp => new FileEntry(kvp.Key, kvp.Value.Offset, kvp.Value.Size, DateTime.UnixEpoch.AddSeconds(kvp.Value.LastWriteTime)) ); return true; } catch (Exception ex) { Log.WriteWarningMessage(LOGTAG, "EofHeaderReadError", ex, "Failed to read EOF header"); entries = new Dictionary(); return false; } } private static Dictionary BuildDictionaryByScanning(Stream stream) { var entries = new Dictionary(StringComparer.Ordinal); stream.Seek(0, SeekOrigin.Begin); try { using var tarReader = new System.Formats.Tar.TarReader(stream, leaveOpen: true); while (true) { var entryStartPosition = stream.Position; var entry = tarReader.GetNextEntry(); if (entry == null) break; if (entry.Name == TarBaseConstants.EofHeaderFileName) continue; if (entry.EntryType != System.Formats.Tar.TarEntryType.RegularFile && entry.EntryType != System.Formats.Tar.TarEntryType.V7RegularFile) continue; // For ustar format, header is 512 bytes, so content starts at entryStartPosition + 512 // For pax format, headers can be variable size, but we primarily use ustar // Since TarReader advances the stream to after the content, we calculate backwards: // stream.Position is now at end of entry (after content padding) // Content size with padding = ((entry.Length + 511) / 512) * 512 var paddedContentSize = (entry.Length + 511) / 512 * 512; var contentOffset = stream.Position - paddedContentSize; entries[entry.Name] = new FileEntry( entry.Name, contentOffset, entry.Length, entry.ModificationTime.UtcDateTime ); } } catch (Exception ex) { Log.WriteWarningMessage(LOGTAG, "TarScanError", ex, "Error scanning tar archive"); } Log.WriteInformationMessage(LOGTAG, "DictionaryBuiltByScanning", "Built file dictionary by scanning {0} entries", entries.Count); return entries; } private record SerializableEntry(long Offset, long Size, long LastWriteTime); private class PendingEntryStream : WrappingAsyncStream { private readonly FileArchiveTarBased m_parent; private readonly string m_name; private readonly DateTime m_lastWriteTime; private bool m_closed; public PendingEntryStream(FileArchiveTarBased parent, string name, DateTime lastWriteTime, bool useMemoryBuffer) : base(useMemoryBuffer ? new MemoryStream() : Utility.TempFileStream.Create()) { m_parent = parent; m_name = name; m_lastWriteTime = lastWriteTime; } public bool IsClosed => m_closed; protected override Task ReadImplAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => throw new NotSupportedException(); protected override Task WriteImplAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { if (m_closed) throw new InvalidOperationException("Stream is closed"); return base.BaseStream.WriteAsync(buffer, offset, count, cancellationToken); } protected override void Dispose(bool disposing) { if (!m_closed) { m_closed = true; var size = base.BaseStream.Length; base.BaseStream.Position = 0; m_parent.WriteEntryToTar(m_name, m_lastWriteTime, base.BaseStream, size); } base.Dispose(disposing); base.BaseStream.Dispose(); } } }