// Copyright (C) 2025, 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. using System.IO.Pipelines; using System.Runtime.CompilerServices; using Duplicati.Library.Common.IO; using Duplicati.Library.Interface; using Duplicati.Library.Utility; namespace Duplicati.Library.SourceProvider; /// /// Creates a new backend source entry /// /// The parent backend /// The path of the entry /// True if the entry is a folder /// True if the entry is a meta entry /// The creation time of the entry /// The last modification time of the entry /// The size of the entry public class BackendSourceFileEntry(BackendSourceProvider parent, string path, bool isFolder, bool isRootEntry, DateTime createdUtc, DateTime lastModificationUtc, long size) : ISourceProviderEntry { /// /// The log tag for this instance /// private static string LOGTAG = Logging.Log.LogTagFromType(); /// public bool IsFolder => isFolder; /// public bool IsMetaEntry => false; /// public bool IsRootEntry => isRootEntry; /// public DateTime CreatedUtc => createdUtc; /// public DateTime LastModificationUtc => lastModificationUtc; /// public string Path => SystemIO.IO_OS.PathCombine(parent?.MountedPath, NormalizePathToLocalSystem(path)); /// public long Size => size; /// public bool IsSymlink => false; /// public string? SymlinkTarget => null; /// public FileAttributes Attributes => IsFolder ? FileAttributes.Directory : FileAttributes.Normal; /// public Dictionary MinorMetadata => new Dictionary(); /// public bool IsBlockDevice => false; /// public bool IsCharacterDevice => false; /// public bool IsAlternateStream => false; /// public string? HardlinkTargetId => null; /// /// An async enumerator for the entries /// private IAsyncEnumerator? preparedEnumerator = null; /// /// A flag to indicate if the prepared enumerator has any entries /// private bool preparedEnumeratorAny = false; /// /// Prepares the enumerator for this entry /// /// The cancellation token /// The prepared enumerator public async Task PrepareEnumerator(CancellationToken cancellationToken) { if (!isFolder || !isRootEntry) throw new InvalidOperationException("PrepareEnumerator can only be called on root folders"); if (preparedEnumerator != null) throw new InvalidOperationException("PrepareEnumerator can only be called once"); var result = EnumerateInternal(cancellationToken).GetAsyncEnumerator(cancellationToken); var prev = Interlocked.Exchange(ref preparedEnumerator, result); if (prev != null) throw new InvalidOperationException("PrepareEnumerator can only be called once"); // Advance to the first entry, so we are sure it does not throw exceptions preparedEnumeratorAny = await result.MoveNextAsync().ConfigureAwait(false); } /// public async IAsyncEnumerable Enumerate([EnumeratorCancellation] CancellationToken cancellationToken) { if (!isFolder) throw new InvalidOperationException("Enumerate can only be called on folders"); if (isRootEntry) { // If we have a prepared enumerator, consume and use it var enumerator = Interlocked.Exchange(ref preparedEnumerator, null); if (enumerator != null) { if (preparedEnumeratorAny) { // It has already been advanced, so return the current value yield return enumerator.Current; while (await enumerator.MoveNextAsync()) yield return enumerator.Current; } yield break; } } // Otherwise, enumerate the entries await foreach (var entry in EnumerateInternal(cancellationToken).ConfigureAwait(false)) yield return entry; } private IAsyncEnumerable EnumerateInternal(CancellationToken cancellationToken) => parent.WrappedBackend.ListAsync(NormalizePathTo(path, '/'), cancellationToken) // Remove the current and parent folder entries .Where(x => !string.IsNullOrWhiteSpace(x.Name) && x.Name != "." && x.Name != "..") // Remove sub-folder entries .Where(x => !x.Name[0..^1].Contains('\\') && !x.Name[0..^1].Contains('/')) // Convert to source file entries .Select(x => { var localPath = SystemIO.IO_OS.PathCombine(path, NormalizePathToLocalSystem(x.Name)); if (x.IsFolder) localPath = Util.AppendDirSeparator(localPath); return new BackendSourceFileEntry( parent, localPath, x.IsFolder, false, x.Created, x.LastModification, x.Size ); }); /// public async Task FileExists(string path, CancellationToken cancellationToken) { if (!isFolder) throw new InvalidOperationException("FileExists cannot be called on folders"); try { var entry = await parent.WrappedBackend.GetEntryAsync(NormalizePathTo(path, '/'), cancellationToken); return entry != null && !entry.IsFolder; } catch (FileNotFoundException) { return false; } } /// public Task OpenMetadataRead(CancellationToken cancellationToken) => Task.FromResult(null); /// /// Helper class for reporting th length of a stream /// /// The stream to wrap /// The length to report private class LengthReportingStream(Stream stream, long reportedLength) : OverrideableStream(stream) { /// /// Track the position /// private long position = 0; /// public override long Length => reportedLength; /// public override long Position { get => position; set => position = base.Position = value; } /// public override void Write(byte[] buffer, int offset, int count) { base.Write(buffer, offset, count); position += count; } /// public override int Read(byte[] buffer, int offset, int count) { var read = base.Read(buffer, offset, count); position += read; return read; } /// public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { var read = await base.ReadAsync(buffer, offset, count, cancellationToken); position += read; return read; } /// public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { await base.WriteAsync(buffer, offset, count, cancellationToken); position += count; } } /// public async Task OpenRead(CancellationToken cancellationToken) { if (isFolder) throw new InvalidOperationException("OpenRead can only be called on files"); if (parent.WrappedBackend is IStreamingBackend streamingBackend) { var pipe = new Pipe(); // Start writing data to the pipe asynchronously _ = Task.Run(async () => { try { await streamingBackend.GetAsync(NormalizePathTo(path, '/'), pipe.Writer.AsStream(), cancellationToken).ConfigureAwait(false); } catch (Exception ex) { await pipe.Writer.CompleteAsync(ex).ConfigureAwait(false); } finally { await pipe.Writer.CompleteAsync().ConfigureAwait(false); } }) .ContinueWith(t => { if (t.IsFaulted) Logging.Log.WriteWarningMessage(LOGTAG, "ErrorPipingStream", t.Exception, "Error piping stream for {0}", this.Path); }); // Return the readable stream so the caller can read data as it's produced return new LengthReportingStream(pipe.Reader.AsStream(), size); } else { TempFile? tempFile = null; TempFileStream? file = null; try { tempFile = new TempFile(); await parent.WrappedBackend.GetAsync(path, tempFile, cancellationToken); file = TempFileStream.Create(tempFile); return file; } catch { file?.Dispose(); tempFile?.Dispose(); throw; } } } /// /// Normalizes the path, turning backslashes into forward slashes, /// or vice versa, depending on the platform /// /// The path to normalize /// The normalized path public static string NormalizePathToLocalSystem(string path) => NormalizePathTo(path, System.IO.Path.DirectorySeparatorChar); /// /// Normalizes the path, turning backslashes into forward slashes, /// or vice versa, depending on the platform /// /// The path to normalize /// The normalized path public static string NormalizePathTo(string path, char separator) { if (string.IsNullOrEmpty(path)) return path; return path .Replace('/', separator) .Replace('\\', separator); } /// /// Creates a new backend source entry from a file entry /// /// The parent backend /// The file entry /// The prefix to add to the path /// The new backend source entry public static BackendSourceFileEntry FromFileEntry(BackendSourceProvider parent, string prefix, IFileEntry entry) => new BackendSourceFileEntry(parent, SystemIO.IO_OS.PathCombine(prefix, entry.Name), entry.IsFolder, false, entry.Created, entry.LastModification, entry.Size); }