// 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);
}