Merge branch 'master' into feature/duplicati-storage-backend

This commit is contained in:
Carl Johnsen
2025-11-20 11:01:41 +01:00
270 changed files with 13681 additions and 15830 deletions
+69 -66
View File
@@ -1,66 +1,69 @@
##########################################
# Common Settings
##########################################
# This file is the top-most EditorConfig file
root = true
# All Files
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
##########################################
# File Extension Settings
##########################################
# Visual Studio Solution Files
[*.sln]
indent_style = tab
# Visual Studio XML Project Files
[*.{csproj,vbproj,vcxproj.filters,proj,projitems,shproj}]
indent_size = 2
# XML Configuration Files
[*.{xml,config,props,targets,nuspec,resx,ruleset,vsixmanifest,vsct}]
indent_size = 2
# JSON Files
[*.{json,json5,webmanifest}]
indent_size = 2
# YAML Files
[*.{yml,yaml}]
indent_size = 2
# Markdown Files
[*.{md,mdx}]
trim_trailing_whitespace = false
# Web Files
[*.{htm,html,js,jsm,ts,tsx,cjs,cts,ctsx,mjs,mts,mtsx,css,sass,scss,less,pcss,svg,vue}]
indent_size = 2
# Batch Files
[*.{cmd,bat}]
end_of_line = crlf
# Bash Files
[*.sh]
end_of_line = lf
# Makefiles
[Makefile]
indent_style = tab
[*.aspx.designer.cs]
trim_trailing_whitespace = false
end_of_line = crlf
[*.js]
indent_size = 2
##########################################
# Common Settings
##########################################
# This file is the top-most EditorConfig file
root = true
# All Files
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
##########################################
# File Extension Settings
##########################################
# Visual Studio Solution Files
[*.sln]
indent_style = tab
[*.slnx]
indent_style = tab
# Visual Studio XML Project Files
[*.{csproj,vbproj,vcxproj.filters,proj,projitems,shproj}]
indent_size = 2
# XML Configuration Files
[*.{xml,config,props,targets,nuspec,resx,ruleset,vsixmanifest,vsct}]
indent_size = 2
# JSON Files
[*.{json,json5,webmanifest}]
indent_size = 2
# YAML Files
[*.{yml,yaml}]
indent_size = 2
# Markdown Files
[*.{md,mdx}]
trim_trailing_whitespace = false
# Web Files
[*.{htm,html,js,jsm,ts,tsx,cjs,cts,ctsx,mjs,mts,mtsx,css,sass,scss,less,pcss,svg,vue}]
indent_size = 2
# Batch Files
[*.{cmd,bat}]
end_of_line = crlf
# Bash Files
[*.sh]
end_of_line = lf
# Makefiles
[Makefile]
indent_style = tab
[*.aspx.designer.cs]
trim_trailing_whitespace = false
end_of_line = crlf
[*.js]
indent_size = 2
+2
View File
@@ -26,6 +26,7 @@
###############################################################################
*.sln text eol=lf
*.slnx text eol=lf
*.csproj text eol=lf
*.vbproj text eol=lf
*.vcxproj text eol=lf
@@ -45,6 +46,7 @@
#*.sln merge=binary
#*.slnx merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
+68 -68
View File
@@ -273,17 +273,17 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Setup Testcontainers Cloud Client
uses: atomicjar/testcontainers-cloud-setup-action@v1
with:
@@ -294,7 +294,7 @@ jobs:
run: >-
dotnet test --no-build --filter="ClassName~FtpTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
test_pcloud:
needs: check_secrets
@@ -313,17 +313,17 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Run pCloud tests
env:
TESTCREDENTIAL_PCLOUD_SERVER: "${{ secrets.TESTCREDENTIAL_PCLOUD_SERVER }}"
@@ -332,7 +332,7 @@ jobs:
run: >-
dotnet test --no-build --filter="ClassName~pCloudTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
test_s3:
needs: check_secrets
@@ -351,17 +351,17 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Run S3 tests
env:
TC_CLOUD_TOKEN: "${{ secrets.TC_CLOUD_TOKEN }}"
@@ -372,7 +372,7 @@ jobs:
run: >-
dotnet test --no-build --filter="ClassName~S3Tests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
test_azure:
needs: check_secrets
if: needs.check_secrets.outputs.azure_secrets_available == 'true'
@@ -390,17 +390,17 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Run Azure tests
env:
READ_WRITE_TIMEOUT_SECONDS: 18000
@@ -410,7 +410,7 @@ jobs:
run: >-
dotnet test --no-build --filter="ClassName~AzureTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
test_webdav:
needs: check_secrets
if: needs.check_secrets.outputs.testcontainers_secrets_available == 'true'
@@ -428,17 +428,17 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Setup Testcontainers Cloud Client
uses: atomicjar/testcontainers-cloud-setup-action@v1
with:
@@ -449,7 +449,7 @@ jobs:
run: >-
dotnet test --no-build --filter="ClassName~WebDavTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
test_dropbox:
needs: check_secrets
if: needs.check_secrets.outputs.dropbox_secrets_available == 'true'
@@ -467,17 +467,17 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Run Dropbox tests
env:
TC_CLOUD_TOKEN: "${{ secrets.TC_CLOUD_TOKEN }}"
@@ -486,7 +486,7 @@ jobs:
run: >-
dotnet test --no-build --filter="ClassName~DropBoxTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
test_ssh:
needs: check_secrets
if: needs.check_secrets.outputs.testcontainers_secrets_available == 'true'
@@ -504,17 +504,17 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Setup Testcontainers Cloud Client
uses: atomicjar/testcontainers-cloud-setup-action@v1
with:
@@ -525,7 +525,7 @@ jobs:
run: >-
dotnet test --no-build --filter="ClassName~SshTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
test_googledrive:
needs: check_secrets
if: needs.check_secrets.outputs.google_secrets_available == 'true'
@@ -543,17 +543,17 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Run Googledrive tests
env:
TESTCREDENTIAL_GOOGLEDRIVE_TOKEN: "${{ secrets.TESTCREDENTIAL_GOOGLEDRIVE_TOKEN }}"
@@ -561,7 +561,7 @@ jobs:
run: >-
dotnet test --no-build --filter="ClassName~GoogleDriveTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
test_cifs:
needs: check_secrets
if: needs.check_secrets.outputs.testcontainers_secrets_available == 'true'
@@ -577,22 +577,22 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Run CIFS tests
run: >-
dotnet test --no-build --filter="ClassName~CIFSTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
test_b2:
needs: check_secrets
if: needs.check_secrets.outputs.b2_secrets_available == 'true'
@@ -610,17 +610,17 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Run B2 tests
env:
TESTCREDENTIAL_B2_BUCKET: "${{ secrets.TESTCREDENTIAL_B2_BUCKET }}"
@@ -630,7 +630,7 @@ jobs:
run: >-
dotnet test --no-build --filter="ClassName~B2Tests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
test_box:
needs: check_secrets
if: needs.check_secrets.outputs.box_secrets_available == 'true'
@@ -648,17 +648,17 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Run Box.com tests
env:
TESTCREDENTIAL_BOX_FOLDER: "${{ secrets.TESTCREDENTIAL_BOX_FOLDER }}"
@@ -666,7 +666,7 @@ jobs:
run: >-
dotnet test --no-build --filter="ClassName~Box.BoxTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
test_filen:
needs: check_secrets
if: needs.check_secrets.outputs.filen_secrets_available == 'true'
@@ -684,17 +684,17 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Run Filen.io tests
env:
TESTCREDENTIAL_FILEN_FOLDER: "${{ secrets.TESTCREDENTIAL_FILEN_FOLDER }}"
@@ -703,7 +703,7 @@ jobs:
run: >-
dotnet test --no-build --filter="ClassName~Filen.FilenTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
# test_filejump:
# needs: check_secrets
# if: needs.check_secrets.outputs.filejump_secrets_available == 'true'
@@ -721,17 +721,17 @@ jobs:
# - name: Set up .NET
# uses: actions/setup-dotnet@v4
# with:
# dotnet-version: 8.x
# dotnet-version: 10.x
# - name: Checkout source
# uses: actions/checkout@v4
# - name: Restore NuGet dependencies
# run: >-
# dotnet restore
# LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
# LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
# - name: Build project
# run: >-
# dotnet build --no-restore
# LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
# LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
# - name: Run Filejump tests
# env:
# TESTCREDENTIAL_FILEJUMP_FOLDER: "${{ secrets.TESTCREDENTIAL_FILEJUMP_FOLDER }}"
@@ -739,7 +739,7 @@ jobs:
# run: >-
# dotnet test --no-build --filter="ClassName~Filejump.FilejumpTests"
# --logger:"console;verbosity=detailed"
# LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
# LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
test_onedrive:
needs: check_secrets
if: needs.check_secrets.outputs.onedrive_secrets_available == 'true'
@@ -757,17 +757,17 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Run OneDrive tests
env:
TESTCREDENTIAL_ONEDRIVE_FOLDER: "${{ secrets.TESTCREDENTIAL_ONEDRIVE_FOLDER }}"
@@ -775,7 +775,7 @@ jobs:
run: >-
dotnet test --no-build --filter="ClassName~OneDrive.OneDriveTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
test_idrivee2:
needs: check_secrets
@@ -794,17 +794,17 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Run iDrivee2 tests
env:
TESTCREDENTIAL_IDRIVEE2_BUCKET: "${{ secrets.TESTCREDENTIAL_IDRIVEE2_BUCKET }}"
@@ -815,7 +815,7 @@ jobs:
run: >-
dotnet test --no-build --filter="ClassName~iDrivee2.iDrivee2Tests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
test_gcs:
needs: check_secrets
if: needs.check_secrets.outputs.gcs_secrets_available == 'true'
@@ -833,17 +833,17 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Run GCS tests
env:
TESTCREDENTIAL_GCS_BUCKET: "${{ secrets.TESTCREDENTIAL_GCS_BUCKET }}"
@@ -853,7 +853,7 @@ jobs:
run: >-
dotnet test --no-build --filter="ClassName~GCS.GCSTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
test_cloudstack:
needs: check_secrets
if: needs.check_secrets.outputs.cloudstack_secrets_available == 'true'
@@ -871,17 +871,17 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: >-
dotnet restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Build project
run: >-
dotnet build --no-restore
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
- name: Run CloudStack tests
env:
TESTCREDENTIAL_CLOUDSTACK_USERNAME: "${{ secrets.TESTCREDENTIAL_CLOUDSTACK_USERNAME }}"
@@ -894,4 +894,4 @@ jobs:
run: >-
dotnet test --no-build --filter="ClassName~CloudStack.CloudStackTests"
--logger:"console;verbosity=detailed"
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.sln
LiveTests/Duplicati.Backend.Tests/Duplicati.Backend.Tests.slnx
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
+89 -17
View File
@@ -19,19 +19,28 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: dotnet restore Duplicati.sln
run: dotnet restore Duplicati.slnx
- name: Build Duplicati
run: dotnet build --no-restore Duplicati.sln
run: dotnet build --no-restore Duplicati.slnx
- name: Run unit tests
run: dotnet test --no-build --verbosity minimal --filter "Category!=Integration" Duplicati.sln
- name: Run unit tests with coverage
run: |
mkdir -p "$GITHUB_WORKSPACE/TestResults/unit"
dotnet test --no-build --verbosity minimal --filter "Category!=Integration" --collect:"XPlat Code Coverage" --results-directory "$GITHUB_WORKSPACE/TestResults/unit" Duplicati.slnx
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ${{ github.workspace }}/TestResults/integration/**/coverage.cobertura.xml
flags: unittest,${{ matrix.os }}
integration_tests:
name: Integration tests
@@ -45,24 +54,87 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.x
dotnet-version: 10.x
- name: Checkout source
uses: actions/checkout@v4
- name: Restore NuGet dependencies
run: dotnet restore Duplicati.sln
run: dotnet restore Duplicati.slnx
- name: Build Duplicati
run: dotnet build --no-restore Duplicati.sln
run: dotnet build --no-restore Duplicati.slnx
- name: Run integration tests
run: dotnet test --no-build --verbosity minimal --filter "Category=Integration" Duplicati.sln
- name: Run integration tests with coverage
run: |
mkdir -p "$GITHUB_WORKSPACE/TestResults/integration"
dotnet test --no-build --verbosity minimal --filter "Category=Integration" --collect:"XPlat Code Coverage" --results-directory "$GITHUB_WORKSPACE/TestResults/integration" Duplicati.slnx
# Disabled, as a new test needs to be written for the new UI
# selenium:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v4
# - name: Selenium
# run: pipeline/selenium/test.sh
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ${{ github.workspace }}/TestResults/integration/**/coverage.cobertura.xml
flags: integration,${{ matrix.os }}
playwright_tests:
name: Playwright UI tests
runs-on: ubuntu-latest
steps:
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.x
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: actions/checkout@v4
- name: Install NPM dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Publish Duplicati server
run: dotnet publish -c Debug -o published Duplicati.slnx
- name: Start server
run: |
./published/Duplicati.Server --disable-database-encryption --webservice-password=easy1234 > server.log 2>&1 &
SERVER_PID=$!
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
timeout 30 bash -c 'until printf "" 2>>/dev/null >>/dev/tcp/127.0.0.1/8200; do sleep 1; echo waiting; done'
echo "Server started with PID: $SERVER_PID"
- name: Load web UI
run: |
curl -f http://localhost:8200/ngclient/index.html
echo "Web UI loaded successfully"
- name: Check server status
run: |
echo "Server process status:"
ps aux | grep Duplicati.Server || true
echo "Server log (last 50 lines):"
tail -n 50 server.log || true
- name: Run Playwright tests
run: npx playwright test --reporter=list,html,github
- name: Capture server logs on failure
if: failure()
run: |
echo "=== Full Server Log ==="
cat server.log || echo "No server log found"
- name: Upload Playwright test results on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-test-results
path: |
test-results/
playwright-report/
server.log
retention-days: 7
- name: Upload Playwright HTML report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-html-report
path: playwright-report/
retention-days: 7
+12 -12
View File
@@ -9,7 +9,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.GUI.TrayIcon/bin/Debug/net8.0/Duplicati.GUI.TrayIcon",
"program": "${workspaceFolder}/Executables/Duplicati.GUI.TrayIcon/bin/Debug/net10.0/Duplicati.GUI.TrayIcon",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
@@ -20,7 +20,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.Server/bin/Debug/net8.0/Duplicati.Server",
"program": "${workspaceFolder}/Executables/Duplicati.Server/bin/Debug/net10.0/Duplicati.Server",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
@@ -31,7 +31,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine/bin/Debug/net8.0/Duplicati.CommandLine",
"program": "${workspaceFolder}/Executables/Duplicati.CommandLine/bin/Debug/net10.0/Duplicati.CommandLine",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
@@ -42,7 +42,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.AutoUpdater/bin/Debug/net8.0/Duplicati.CommandLine.AutoUpdater",
"program": "${workspaceFolder}/Executables/Duplicati.CommandLine.AutoUpdater/bin/Debug/net10.0/Duplicati.CommandLine.AutoUpdater",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
@@ -53,7 +53,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.RecoveryTool/bin/Debug/net8.0/Duplicati.CommandLine.RecoveryTool",
"program": "${workspaceFolder}/Executables/Duplicati.CommandLine.RecoveryTool/bin/Debug/net10.0/Duplicati.CommandLine.RecoveryTool",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
@@ -64,7 +64,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.ServerUtil/bin/Debug/net8.0/Duplicati.CommandLine.ServerUtil",
"program": "${workspaceFolder}/Executables/Duplicati.CommandLine.ServerUtil/bin/Debug/net10.0/Duplicati.CommandLine.ServerUtil",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
@@ -75,7 +75,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.BackendTool/bin/Debug/net8.0/Duplicati.CommandLine.BackendTool",
"program": "${workspaceFolder}/Executables/Duplicati.CommandLine.BackendTool/bin/Debug/net10.0/Duplicati.CommandLine.BackendTool",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
@@ -86,7 +86,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.Agent/bin/Debug/net8.0/Duplicati.Agent",
"program": "${workspaceFolder}/Executables/Duplicati.Agent/bin/Debug/net10.0/Duplicati.Agent",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
@@ -97,7 +97,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.BackendTester/bin/Debug/net8.0/Duplicati.CommandLine.BackendTester",
"program": "${workspaceFolder}/Executables/Duplicati.CommandLine.BackendTester/bin/Debug/net10.0/Duplicati.CommandLine.BackendTester",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
@@ -108,7 +108,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.SecretTool/bin/Debug/net8.0/Duplicati.CommandLine.SecretTool",
"program": "${workspaceFolder}/Executables/Duplicati.CommandLine.SecretTool/bin/Debug/net10.0/Duplicati.CommandLine.SecretTool",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
@@ -119,7 +119,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.SourceTool/bin/Debug/net8.0/Duplicati.CommandLine.SourceTool",
"program": "${workspaceFolder}/Executables/Duplicati.CommandLine.SourceTool/bin/Debug/net10.0/Duplicati.CommandLine.SourceTool",
"args": []
},
{
@@ -127,7 +127,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Executables/net8/Duplicati.CommandLine.DatabaseTool/bin/Debug/net8.0/Duplicati.CommandLine.DatabaseTool",
"program": "${workspaceFolder}/Executables/Duplicati.CommandLine.DatabaseTool/bin/Debug/net10.0/Duplicati.CommandLine.DatabaseTool",
"args": []
}
]
+1 -1
View File
@@ -9,7 +9,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "dotnet: build",
"program": "${workspaceFolder}/bin/Debug/net9.0/LicenseUpdater.dll",
"program": "${workspaceFolder}/bin/Debug/net10.0/LicenseUpdater.dll",
"args": ["../.."],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>license_upgrader</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
@@ -1,25 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.002.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LicenseUpdater", "LicenseUpdater.csproj", "{37205C47-20D1-4E7B-9AFB-B833C7FC949F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{37205C47-20D1-4E7B-9AFB-B833C7FC949F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{37205C47-20D1-4E7B-9AFB-B833C7FC949F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{37205C47-20D1-4E7B-9AFB-B833C7FC949F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{37205C47-20D1-4E7B-9AFB-B833C7FC949F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F9144130-7643-4C0E-9545-ECEF7EEF0423}
EndGlobalSection
EndGlobal
@@ -0,0 +1,3 @@
<Solution>
<Project Path="LicenseUpdater.csproj" />
</Solution>
-1364
View File
File diff suppressed because it is too large Load Diff
+103
View File
@@ -0,0 +1,103 @@
<Solution>
<Folder Name="/Backends/">
<Project Path="Duplicati/Library/Backend/AliyunOSS/Duplicati.Library.Backend.AliyunOSS.csproj" />
<Project Path="Duplicati/Library/Backend/AzureBlob/Duplicati.Library.Backend.AzureBlob.csproj" />
<Project Path="Duplicati/Library/Backend/Backblaze/Duplicati.Library.Backend.Backblaze.csproj" />
<Project Path="Duplicati/Library/Backend/Box/Duplicati.Library.Backend.Box.csproj" />
<Project Path="Duplicati/Library/Backend/Dropbox/Duplicati.Library.Backend.Dropbox.csproj" />
<Project Path="Duplicati/Library/Backend/File/Duplicati.Library.Backend.File.csproj" />
<Project Path="Duplicati/Library/Backend/Filejump/Duplicati.Library.Backend.Filejump.csproj" />
<Project Path="Duplicati/Library/Backend/Filen/Duplicati.Library.Backend.Filen.csproj" />
<Project Path="Duplicati/Library/Backend/FTP/Duplicati.Library.Backend.FTP.csproj" />
<Project Path="Duplicati/Library/Backend/GoogleServices/Duplicati.Library.Backend.GoogleServices.csproj" />
<Project Path="Duplicati/Library/Backend/Idrivee2/Duplicati.Library.Backend.Idrivee2.csproj">
<BuildDependency Project="Duplicati/Library/Backend/S3/Duplicati.Library.Backend.S3.csproj" />
</Project>
<Project Path="Duplicati/Library/Backend/Jottacloud/Duplicati.Library.Backend.Jottacloud.csproj" />
<Project Path="Duplicati/Library/Backend/Mega/Duplicati.Library.Backend.Mega.csproj" />
<Project Path="Duplicati/Library/Backend/OneDrive/Duplicati.Library.Backend.OneDrive.csproj" />
<Project Path="Duplicati/Library/Backend/OpenStack/Duplicati.Library.Backend.OpenStack.csproj" />
<Project Path="Duplicati/Library/Backend/pCloud/Duplicati.Library.Backend.pCloud.csproj" />
<Project Path="Duplicati/Library/Backend/Rclone/Duplicati.Library.Backend.Rclone.csproj" />
<Project Path="Duplicati/Library/Backend/S3/Duplicati.Library.Backend.S3.csproj" />
<Project Path="Duplicati/Library/Backend/SharePoint/Duplicati.Library.Backend.SharePoint.csproj" />
<Project Path="Duplicati/Library/Backend/SMB/Duplicati.Library.Backend.SMB.csproj" />
<Project Path="Duplicati/Library/Backend/SSHv2/Duplicati.Library.Backend.SSHv2.csproj" />
<Project Path="Duplicati/Library/Backend/Storj/Duplicati.Library.Backend.Storj.csproj" />
<Project Path="Duplicati/Library/Backend/TahoeLAFS/Duplicati.Library.Backend.TahoeLAFS.csproj" />
<Project Path="Duplicati/Library/Backend/TencentCOS/Duplicati.Library.Backend.TencentCOS.csproj" />
<Project Path="Duplicati/Library/Backend/WEBDAV/Duplicati.Library.Backend.WEBDAV.csproj" />
</Folder>
<Folder Name="/Executables/Implementation/">
<Project Path="Duplicati/Agent/Duplicati.Agent.csproj" />
<Project Path="Duplicati/CommandLine/BackendTester/Duplicati.CommandLine.BackendTester.csproj" />
<Project Path="Duplicati/CommandLine/BackendTool/Duplicati.CommandLine.BackendTool.csproj" />
<Project Path="Duplicati/CommandLine/CLI/Duplicati.CommandLine.csproj" />
<Project Path="Duplicati/CommandLine/DatabaseTool/Duplicati.CommandLine.DatabaseTool.csproj" />
<Project Path="Duplicati/CommandLine/RecoveryTool/Duplicati.CommandLine.RecoveryTool.csproj" />
<Project Path="Duplicati/CommandLine/SecretTool/Duplicati.CommandLine.SecretTool.csproj" />
<Project Path="Duplicati/CommandLine/ServerUtil/Duplicati.CommandLine.ServerUtil.csproj" />
<Project Path="Duplicati/CommandLine/SourceTool/Duplicati.CommandLine.SourceTool.csproj" />
<Project Path="Duplicati/GUI/Duplicati.GUI.TrayIcon/Duplicati.GUI.TrayIcon.csproj" />
<Project Path="Duplicati/Server/Duplicati.Server.csproj" />
<Project Path="Duplicati/Service/Duplicati.Service.csproj" />
<Project Path="Duplicati/WindowsService/Duplicati.WindowsService.csproj" />
</Folder>
<Folder Name="/Executables/">
<Project Path="Executables/Duplicati.Agent/Duplicati.Agent.csproj" />
<Project Path="Executables/Duplicati.CommandLine.AutoUpdater/Duplicati.CommandLine.AutoUpdater.csproj" />
<Project Path="Executables/Duplicati.CommandLine.BackendTester/Duplicati.CommandLine.BackendTester.csproj" />
<Project Path="Executables/Duplicati.CommandLine.BackendTool/Duplicati.CommandLine.BackendTool.csproj" />
<Project Path="Executables/Duplicati.CommandLine.DatabaseTool/Duplicati.CommandLine.DatabaseTool.csproj" />
<Project Path="Executables/Duplicati.CommandLine.RecoveryTool/Duplicati.CommandLine.RecoveryTool.csproj" />
<Project Path="Executables/Duplicati.CommandLine.SecretTool/Duplicati.CommandLine.SecretTool.csproj" />
<Project Path="Executables/Duplicati.CommandLine.ServerUtil/Duplicati.CommandLine.ServerUtil.csproj" />
<Project Path="Executables/Duplicati.CommandLine.SharpAESCrypt/Duplicati.CommandLine.SharpAESCrypt.csproj" />
<Project Path="Executables/Duplicati.CommandLine.Snapshots/Duplicati.CommandLine.Snapshots.csproj" />
<Project Path="Executables/Duplicati.CommandLine.SourceTool/Duplicati.CommandLine.SourceTool.csproj" />
<Project Path="Executables/Duplicati.CommandLine.SyncTool/Duplicati.CommandLine.SyncTool.csproj" />
<Project Path="Executables/Duplicati.CommandLine/Duplicati.CommandLine.csproj" />
<Project Path="Executables/Duplicati.GUI.TrayIcon/Duplicati.GUI.TrayIcon.csproj" />
<Project Path="Executables/Duplicati.Server/Duplicati.Server.csproj" />
<Project Path="Executables/Duplicati.Service/Duplicati.Service.csproj" />
<Project Path="Executables/Duplicati.WindowsModulesLoader/Duplicati.WindowsModulesLoader.csproj" />
<Project Path="Executables/Duplicati.WindowsService/Duplicati.WindowsService.csproj" />
</Folder>
<Folder Name="/Helper Libraries/">
<Project Path="Duplicati/Library/Compression/Duplicati.Library.Compression.csproj" />
<Project Path="Duplicati/Library/Crashlog/Duplicati.Library.Crashlog.csproj" />
<Project Path="Duplicati/Library/DynamicLoader/Duplicati.Library.DynamicLoader.csproj" />
<Project Path="Duplicati/Library/SQLiteHelper/Duplicati.Library.SQLiteHelper.csproj" />
<Project Path="Duplicati/PackageRef/Duplicati.PackageRef.csproj" />
</Folder>
<Folder Name="/SourceProviders/">
<Project Path="Duplicati/Library/SourceProvider/Builtin/Duplicati.Library.SourceProvider.Builtin.csproj" />
</Folder>
<Folder Name="/Tools/">
<Project Path="Tools/RemoteSynchronization/RemoteSynchronization.csproj" />
<Project Path="WebserverCore.Client.UsageExample/WebserverCore.Client.UsageExample.csproj" />
</Folder>
<Project Path="Duplicati/Library/AutoUpdater/Duplicati.Library.AutoUpdater.csproj" />
<Project Path="Duplicati/Library/Backend/OAuthHelper/Duplicati.Library.OAuthHelper.csproj" />
<Project Path="Duplicati/Library/Backends/Duplicati.Library.Backends.csproj" />
<Project Path="Duplicati/Library/Common/Duplicati.Library.Common.csproj" />
<Project Path="Duplicati/Library/Encryption/Duplicati.Library.Encryption.csproj" />
<Project Path="Duplicati/Library/Interface/Duplicati.Library.Interface.csproj" />
<Project Path="Duplicati/Library/Localization/Duplicati.Library.Localization.csproj" />
<Project Path="Duplicati/Library/Logging/Duplicati.Library.Logging.csproj" />
<Project Path="Duplicati/Library/Main/Duplicati.Library.Main.csproj" />
<Project Path="Duplicati/Library/Modules/Builtin/Duplicati.Library.Modules.Builtin.csproj" />
<Project Path="Duplicati/Library/RemoteControl/Duplicati.Library.RemoteControl.csproj" />
<Project Path="Duplicati/Library/RestAPI/Duplicati.Library.RestAPI.csproj" />
<Project Path="Duplicati/Library/SecretProvider/Duplicati.Library.SecretProvider.csproj" />
<Project Path="Duplicati/Library/Snapshots/Duplicati.Library.Snapshots.csproj" />
<Project Path="Duplicati/Library/SourceProviders/Duplicati.Library.SourceProviders.csproj" />
<Project Path="Duplicati/Library/UsageReporter/Duplicati.Library.UsageReporter.csproj" />
<Project Path="Duplicati/Library/Utility/Duplicati.Library.Utility.csproj" />
<Project Path="Duplicati/Library/WindowsModules/Duplicati.Library.WindowsModules.csproj" />
<Project Path="Duplicati/License/Duplicati.License.csproj" />
<Project Path="Duplicati/Server/Duplicati.Server.Serialization/Duplicati.Server.Serialization.csproj" />
<Project Path="Duplicati/Tools/Duplicati.Tools.csproj" />
<Project Path="Duplicati/UnitTest/Duplicati.UnitTest.csproj" />
<Project Path="Duplicati/WebserverCore/Duplicati.WebserverCore.csproj" />
</Solution>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Duplicati.Agent.Implementation</AssemblyName>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Description>A backend debugging tool for Duplicati</Description>
<AssemblyName>Duplicati.CommandLine.BackendTester.Implementation</AssemblyName>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>Duplicati.CommandLine.BackendTool.Implementation</AssemblyName>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Description>A commandline version of Duplicati</Description>
<AssemblyName>Duplicati.CommandLine.Implementation</AssemblyName>
<ApplicationIcon>TrayWarning.ico</ApplicationIcon>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Duplicati.CommandLine.DatabaseTool.Implementation</AssemblyName>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Description>RecoveryTool for Duplicati</Description>
<AssemblyName>Duplicati.CommandLine.RecoveryTool.Implementation</AssemblyName>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Duplicati.CommandLine.SecretTool.Implementation</AssemblyName>
@@ -451,10 +451,11 @@ public class Connection
}
try
{
var lastLogResultString = ((JsonElement)parsedLogs[0]["Message"]).GetString();
var lastLogResultString = ((JsonElement)parsedLogs[0]["Message"]).GetString() ?? throw new InvalidOperationException("Failed to get last log message");
var lastLogResult = JsonSerializer.Deserialize<JsonElement>(lastLogResultString);
return lastLogResult.GetProperty("ParsedResult").ToString();
} catch
}
catch
{
return "Failed to parse backup log";
}
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Duplicati.CommandLine.ServerUtil.Implementation</AssemblyName>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>Duplicati.CommandLine.SourceTool.Implementation</AssemblyName>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Description>The Duplicati Tray implementation</Description>
<AssemblyName>Duplicati.GUI.TrayIcon.Implementation</AssemblyName>
<RootNamespace>Duplicati.GUI.TrayIcon</RootNamespace>
@@ -251,8 +251,6 @@ No password provided, unable to connect to server, exiting");
try
{
ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault;
using (Connection = new HttpServerConnection(hosted?.applicationSettings, serverURL, password, passwordSource, disableTrayIconLogin, acceptedHostCertificate, options))
{
// Make sure we have the latest status, but don't care if it fails
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -20,7 +20,6 @@
<ItemGroup>
<PackageReference Include="Aliyun.OSS.SDK.NetCore" Version="2.14.1" />
<PackageReference Include="Microsoft.CSharp" Version="4.7.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<Nullable>enable</Nullable>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<Nullable>enable</Nullable>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<Nullable>enable</Nullable>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Description>Dropbox backend for Duplicati</Description>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<Nullable>enable</Nullable>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<Nullable>enable</Nullable>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Duplicati.Library.Backend</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Duplicati.Library.Backend</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<Nullable>enable</Nullable>
@@ -164,8 +164,7 @@ public static class FilenCrypto
/// <returns>The derived key.</returns>
public static byte[] DeriveKeyFromPassword(string password, string salt, int iterations, int bitLength)
{
using var deriveBytes = new Rfc2898DeriveBytes(password, Encoding.UTF8.GetBytes(salt), iterations, HashAlgorithmName.SHA512);
return deriveBytes.GetBytes(bitLength / 8);
return Rfc2898DeriveBytes.Pbkdf2(password, Encoding.UTF8.GetBytes(salt), iterations, HashAlgorithmName.SHA512, bitLength / 8);
}
/// <summary>
+1 -1
View File
@@ -26,7 +26,7 @@ namespace Duplicati.Library.Backend.Strings
public static string Description => LC.L(@"This backend can read and write data to Filen.io using its REST protocol. Supported format is ""filen://folder/subfolder"".");
public static string DisplayName => LC.L(@"Filen.io");
public static string TwoFactorShort => LC.L(@"Optional 2-factor code");
public static string TwoFactorLong => LC.L(@"The 2-factor code to use for authentication, leave empty if the account is not MFA protected. Not that a new code must be provided by the user for each authentication attempt.");
public static string TwoFactorLong => LC.L(@"The 2-factor code to use for authentication, leave empty if the account is not MFA protected. Note that a new code must be provided by the user for each authentication attempt.");
public static string MoveToTrashShort => LC.L(@"Move to trash");
public static string MoveToTrashLong => LC.L(@"If set, files will be moved to the trash instead of being deleted permanently.");
}
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<Nullable>enable</Nullable>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Duplicati.Library.Backend</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Duplicati.Library.Backend</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<Nullable>enable</Nullable>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
</PropertyGroup>
@@ -26,8 +26,6 @@ using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Duplicati.Library.Utility;
namespace Duplicati.Library
{
public class OAuthHttpClient : HttpClient
@@ -46,17 +44,6 @@ namespace Duplicati.Library
{
this.m_authenticator = authenticator;
// Set the overall timeout
if (HttpContextSettings.OperationTimeout > TimeSpan.Zero)
{
this.Timeout = HttpContextSettings.OperationTimeout;
}
else
{
// If no timeout is set, default to infinite
this.Timeout = System.Threading.Timeout.InfiniteTimeSpan;
}
// Set the default user agent
this.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Duplicati", USER_AGENT_VERSION));
}
@@ -95,19 +82,13 @@ namespace Duplicati.Library
this.PreventAuthentication(request);
}
// The HttpCompletionOptions are a nice way to emulate the BufferRequests behavior.
// When set to ResponseContentRead, async call will not complete until the response has been
// read and is cached in memory (somehow).
// When set to ResponseHeadersRead, it looks like both Mono and .NET don't buffer the result.
HttpCompletionOption httpCompletionOption = HttpContextSettings.BufferRequests ? HttpCompletionOption.ResponseContentRead : HttpCompletionOption.ResponseHeadersRead;
// The HttpClient.SendAsync method throws an OperationCanceledException when the timeout is exceeded.
// In order to provide a more informative exception, we will detect this case and throw a TimeoutException
// instead. This will also allow the BackendUploader to differentiate between cancellations requested by
// the user and those generated by timeouts.
try
{
return await this.SendAsync(request, httpCompletionOption, cancellationToken).ConfigureAwait(false);
return await this.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Duplicati.Library.Backend</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<Nullable>enable</Nullable>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<Nullable>enable</Nullable>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Duplicati.Library.Backend</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<Nullable>enable</Nullable>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Duplicati.Library.Backend</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Duplicati.Library.Backend</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Description>Storj backend for Duplicati</Description>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -13,9 +13,9 @@
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.10" />
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="uplink.NET" Version="2.14.3598" />
<PackageReference Include="uplink.NET.Win" Version="2.14.3598" />
<PackageReference Include="uplink.NET.Linux" Version="2.14.3598" />
<PackageReference Include="uplink.NET" Version="2.14.3623" />
<PackageReference Include="uplink.NET.Win" Version="2.14.3623" />
<PackageReference Include="uplink.NET.Linux" Version="2.14.3623" />
<PackageReference Include="uplink.NET.Mac" Version="2.13.3484" />
</ItemGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Duplicati.Library.Backend</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<Nullable>enable</Nullable>
@@ -10,7 +10,6 @@
<ItemGroup>
<PackageReference Include="Tencent.QCloud.Cos.Sdk" Version="5.4.11" />
<PackageReference Include="Microsoft.CSharp" Version="4.7.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Duplicati.Library.Backend</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<Nullable>enable</Nullable>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Description>Central reference to all backends</Description>
<RootNamespace>Duplicati.Library.Backend</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Duplicati.Library.IO</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Duplicati.Library.Compression</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Description>Crashlog implementation</Description>
<RootNamespace>Duplicati.Library.Crashlog</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Duplicati.Library.Encryption</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Duplicati.Library.Interface</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -12,8 +12,4 @@
<ProjectReference Include="..\Localization\Duplicati.Library.Localization.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Linq.Async" Version="6.0.3" />
</ItemGroup>
</Project>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
</PropertyGroup>
@@ -2024,9 +2024,9 @@ namespace Duplicati.Library.Main.Database
while (await rd.ReadAsync(token).ConfigureAwait(false))
yield return new FileRequest(
rd.ConvertValueToInt64(0),
rd.ConvertValueToString(1),
rd.ConvertValueToString(2),
rd.ConvertValueToString(3),
rd.ConvertValueToString(1) ?? throw new InvalidOperationException("OriginalPath cannot be null"),
rd.ConvertValueToString(2) ?? throw new InvalidOperationException("TargetPath cannot be null"),
rd.ConvertValueToString(3) ?? throw new InvalidOperationException("Hash cannot be null"),
rd.ConvertValueToInt64(4),
rd.ConvertValueToInt64(5)
);
@@ -2071,9 +2071,9 @@ namespace Duplicati.Library.Main.Database
while (await rd.ReadAsync(token).ConfigureAwait(false))
yield return new FileRequest(
rd.ConvertValueToInt64(0),
rd.ConvertValueToString(1),
rd.ConvertValueToString(2),
rd.ConvertValueToString(3),
rd.ConvertValueToString(1) ?? throw new InvalidOperationException("OriginalPath cannot be null"),
rd.ConvertValueToString(2) ?? throw new InvalidOperationException("TargetPath cannot be null"),
rd.ConvertValueToString(3) ?? throw new InvalidOperationException("Hash cannot be null"),
rd.ConvertValueToInt64(4),
rd.ConvertValueToInt64(5)
);
@@ -2171,7 +2171,7 @@ namespace Duplicati.Library.Main.Database
yield return new BlockRequest(
reader.ConvertValueToInt64(0),
i,
reader.ConvertValueToString(1),
reader.ConvertValueToString(1) ?? throw new InvalidOperationException("Block hash cannot be null"),
reader.ConvertValueToInt64(2),
reader.ConvertValueToInt64(3),
BlockRequestType.Download
@@ -2222,7 +2222,7 @@ namespace Duplicati.Library.Main.Database
yield return new BlockRequest(
reader.ConvertValueToInt64(0),
i,
reader.ConvertValueToString(1),
reader.ConvertValueToString(1) ?? throw new InvalidOperationException("Block hash cannot be null"),
reader.ConvertValueToInt64(2),
reader.ConvertValueToInt64(3),
BlockRequestType.Download
@@ -2236,6 +2236,30 @@ namespace Duplicati.Library.Main.Database
}
}
/// <summary>
/// Finds the largest volume size among all 'Blocks' type remote volumes.
/// </summary>
/// <param name="token">The cancellation token.</param>
/// <returns>The size of the largest volume, in bytes, or -1 if no volumes are found.</returns>
public async Task<long> GetLargestVolumeAsync(CancellationToken token)
{
using var cmd = m_connection.CreateCommand(@"
SELECT
MAX(""Size"")
FROM ""RemoteVolume""
WHERE ""Type"" = 'Blocks'
")
.SetTransaction(m_rtr);
var result = await cmd.ExecuteScalarAsync(token)
.ConfigureAwait(false);
if (result == DBNull.Value || result == null)
return -1;
return Convert.ToInt64(result);
}
/// <summary>
/// Returns the volume information for the given volume ID. It is used by the <see cref="VolumeManager"/> to get the volume information for the given volume ID.
/// </summary>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<RootNamespace>Duplicati.Library.Main</RootNamespace>
File diff suppressed because it is too large Load Diff
@@ -124,7 +124,7 @@ namespace Duplicati.Library.Main.Operation
}
}
return SnapshotUtility.CreateNoSnapshot(sources, options.IgnoreAdvisoryLocking, options.SymlinkPolicy == Options.SymlinkStrategy.Follow, useSeBackup);
return SnapshotUtility.CreateNoSnapshot(sources, options.IgnoreAdvisoryLocking, options.SymlinkPolicy == Options.SymlinkStrategy.Follow, useSeBackup, options.HandleMacOSPhotoLibrary, options.MacOSPhotoLibraryPath);
}
/// <summary>
@@ -232,7 +232,7 @@ namespace Duplicati.Library.Main.Operation
// all of the local database classes handle this), as the constructor
// is not async and shouldn't be executing "heavy" code, such as
// database queries.
var journalData = m_database.GetChangeJournalData(lastfilesetid, m_taskReader.ProgressToken).ToEnumerable();
var journalData = m_database.GetChangeJournalData(lastfilesetid, m_taskReader.ProgressToken).ToBlockingEnumerable();
var service = new UsnJournalService(fileProvider.SnapshotService, filter, m_options.FileAttributeFilter, m_options.SkipFilesLargerThan,
journalData, cancellationTokenSource.Token);
@@ -586,11 +586,11 @@ namespace Duplicati.Library.Main.Operation
.ConfigureAwait(false);
// Calculate the number of samples to test, using the largest number of file of a given type
long remoteVolumeCount = await m_database
var remoteVolumeCount = await m_database
.GetRemoteVolumes(m_result.TaskControl.ProgressToken)
.Where(x => x.State == RemoteVolumeState.Verified)
.GroupBy(x => x.Type)
.SelectAwait(async x => await x.LongCountAsync())
.Select(x => x.LongCount())
.MaxAsync()
.ConfigureAwait(false);
@@ -222,7 +222,7 @@ namespace Duplicati.Library.Main.Operation
s.Serialize(stream,
db.GetRemoteVolumes(cancellationToken)
.Where(x => x.State != RemoteVolumeState.Temporary)
.ToEnumerable()
.ToBlockingEnumerable()
.Cast<IRemoteVolume>()
.ToArray()
);
@@ -188,7 +188,7 @@ namespace Duplicati.Library.Main.Operation
);
if (callback != null)
callback(m_result, lst.ToEnumerable());
callback(m_result, lst.ToBlockingEnumerable());
return;
}
@@ -73,7 +73,7 @@ internal static class ListFolderHandler
{
result.Entries = await db
.ListFolder(
db.GetPrefixIds(folders, result.TaskControl.ProgressToken).ToEnumerable(),
db.GetPrefixIds(folders, result.TaskControl.ProgressToken).ToBlockingEnumerable(),
filesetIds[0],
offset,
limit,
@@ -1147,7 +1147,7 @@ namespace Duplicati.Library.Main.Operation
continue;
}
using var snapshot = Snapshots.SnapshotUtility.CreateNoSnapshot([block.Path], true, true, PermissionHelper.HasSeBackupPrivilege());
using var snapshot = Snapshots.SnapshotUtility.CreateNoSnapshot([block.Path], m_options.IgnoreAdvisoryLocking, m_options.SymlinkPolicy == Options.SymlinkStrategy.Follow, PermissionHelper.HasSeBackupPrivilege(), m_options.HandleMacOSPhotoLibrary, m_options.MacOSPhotoLibraryPath);
var entry = snapshot.GetFilesystemEntry(block.Path, isDir);
if (entry == null)
{
@@ -323,7 +323,7 @@ namespace Duplicati.Library.Main.Operation.Restore
sw_get_write?.Stop();
// Add a timeout monitor
var timeout = TimeSpan.FromMilliseconds(DeadlockTimer.MaxProcessingTime);
var timeout = TimeSpan.FromMilliseconds(DeadlockTimer.MaxProcessingTime * 2);
using var tcs1 = new CancellationTokenSource();
var t = await Task.WhenAny(
Task.Delay(timeout, tcs1.Token),
@@ -452,9 +452,6 @@ namespace Duplicati.Library.Main.Operation.Restore
m_retired = true;
m_volume_request.Retire();
}
// Stop the deadlock timer
DeadlockTimer.Token.Cancel();
}
/// <summary>
@@ -505,6 +502,7 @@ namespace Duplicati.Library.Main.Operation.Restore
{
Stopwatch? sw_read = options.InternalProfiling ? new() : null;
Stopwatch? sw_set = options.InternalProfiling ? new() : null;
Stopwatch? sw_deadlock = options.InternalProfiling ? new() : null;
try
{
while (true)
@@ -518,6 +516,11 @@ namespace Duplicati.Library.Main.Operation.Restore
sw_set?.Start();
cache.Set(block_request.BlockID, data);
sw_set?.Stop();
Logging.Log.WriteExplicitMessage(LOGTAG, "VolumeConsumer", null, "Updating deadlock timer for block {0} from volume {1}", block_request.BlockID, block_request.VolumeID);
sw_deadlock?.Start();
DeadlockTimer.MaxProcessingTime = Math.Max(DeadlockTimer.MaxProcessingTime, (int)(DateTime.Now - block_request.TimestampMilliseconds).TotalMilliseconds);
sw_deadlock?.Stop();
}
}
catch (RetiredException)
@@ -526,7 +529,7 @@ namespace Duplicati.Library.Main.Operation.Restore
if (options.InternalProfiling)
{
Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", $"Volume consumer - Read: {sw_read!.ElapsedMilliseconds}ms, Set: {sw_set!.ElapsedMilliseconds}ms");
Logging.Log.WriteProfilingMessage(LOGTAG, "InternalTimings", $"Volume consumer - Read: {sw_read!.ElapsedMilliseconds}ms, Set: {sw_set!.ElapsedMilliseconds}ms, Deadlock timer: {sw_deadlock!.ElapsedMilliseconds}ms");
}
// Cancel any remaining readers - although there shouldn't be any.
@@ -49,52 +49,17 @@ namespace Duplicati.Library.Main.Operation.Restore
/// </summary>
private static readonly string LOGTAG =
Logging.Log.LogTagFromType<DeadlockTimer>();
/// <summary>
/// Five minutes in milliseconds.
/// Initial threshold (in milliseconds) for detecting deadlocks.
/// </summary>
private static readonly int five_minutes_ms = (int)TimeSpan.FromMinutes(5).TotalMilliseconds;
/// <summary>
/// Cancellation token for stopping the deadlock timer.
/// </summary>
public static CancellationTokenSource Token = new();
public static int initial_threshold =
(int)TimeSpan.FromMinutes(5).TotalMilliseconds;
/// <summary>
/// Maximum processing time (in milliseconds) recorded for any block
/// request.
/// </summary>
public static int MaxProcessingTime = five_minutes_ms;
/// <summary>
/// Runs the deadlock timer process. It runs every second and updates
/// the maximum processing time based on the maximum processing times
/// of the active VolumeDownloaders, VolumeDecryptors and
/// VolumeDecompressors.
/// </summary>
/// <remarks>It will keep running until the cancellation token
/// `DeadlockTimer.token` is cancelled.</remarks>
/// <returns>An awaitable task.</returns>
public static async Task Run()
{
try
{
while (!Token.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(1), Token.Token)
.ConfigureAwait(false);
int download = VolumeDownloader.MaxProcessingTimes.Max();
int decrypt = VolumeDecryptor.MaxProcessingTimes.Max();
int decompress = VolumeDecompressor.MaxProcessingTimes.Max();
MaxProcessingTime = Math.Max(
five_minutes_ms,
(download + decrypt + decompress) * 2
);
}
}
catch (TaskCanceledException)
{
// Ignore
}
}
public static int MaxProcessingTime = initial_threshold;
}
}
@@ -156,13 +156,14 @@ namespace Duplicati.Library.Main.Operation.Restore
/// <param name="volumeID">The ID of the volume in which the block is stored remotely.</param>
/// <param name="cacheDecrEvict">Flag indicating that this block request should either decrement the block counter for BlockID (for BlockManager) or evict the VolumeID (for VolumeDownloader).</param>
public class BlockRequest(long blockID, long blockOffset, string blockHash, long blockSize, long volumeID, BlockRequestType requestType)
{ // Total = 77 bytes
{ // Total = 81 bytes
public long BlockID { get; } = blockID;
public long BlockOffset { get; } = blockOffset;
public string BlockHash { get; } = blockHash;
public long BlockSize { get; } = blockSize;
public long VolumeID { get; } = volumeID;
public BlockRequestType RequestType { get; set; } = requestType;
public DateTime TimestampMilliseconds { get; } = DateTime.Now;
}
/// <summary>
@@ -42,15 +42,6 @@ namespace Duplicati.Library.Main.Operation.Restore
/// </summary>
private static readonly string LOGTAG = Logging.Log.LogTagFromType<VolumeDecompressor>();
/// <summary>
/// Id of the next decompressor. Used to give each decompressor a unique index.
/// </summary>
public static int IdCounter = -1;
/// <summary>
/// Maximum processing times for each active decompressor.
/// </summary>
public static int[] MaxProcessingTimes = [];
/// <summary>
/// Runs the volume decompressor process.
/// </summary>
@@ -74,9 +65,6 @@ namespace Duplicati.Library.Main.Operation.Restore
Stopwatch? sw_decompress_read = options.InternalProfiling ? new() : null;
Stopwatch? sw_verify = options.InternalProfiling ? new() : null;
Stopwatch sw_processing = new();
int id = Interlocked.Increment(ref IdCounter);
try
{
using var block_hasher = HashFactory.CreateHasher(options.BlockHashAlgorithm);
@@ -90,7 +78,6 @@ namespace Duplicati.Library.Main.Operation.Restore
sw_read?.Stop();
Logging.Log.WriteExplicitMessage(LOGTAG, "DecompressBlock", "Decompressing block {0} from volume {1}", block_request.BlockID, block_request.VolumeID);
sw_processing.Restart();
sw_decompress_alloc?.Start();
var data = ArrayPool<byte>.Shared.Rent(options.Blocksize);
sw_decompress_alloc?.Stop();
@@ -119,9 +106,6 @@ namespace Duplicati.Library.Main.Operation.Restore
}
sw_verify?.Stop();
Logging.Log.WriteExplicitMessage(LOGTAG, "DecompressBlock", "Verified block {0} from volume {1}", block_request.BlockID, block_request.VolumeID);
sw_processing.Stop();
// This is the only writing process to that int, so an update is safe.
MaxProcessingTimes[id] = Math.Max(MaxProcessingTimes[id], (int)sw_processing.ElapsedMilliseconds);
sw_write?.Start();
// Send the block to the `BlockManager` process.
@@ -42,15 +42,6 @@ namespace Duplicati.Library.Main.Operation.Restore
/// </summary>
private static readonly string LOGTAG = Logging.Log.LogTagFromType<VolumeDecryptor>();
/// <summary>
/// Id of the next decryptor. Used to give each decryptor a unique index.
/// </summary>
public static int IdCounter = -1;
/// <summary>
/// Maximum processing times for each active decryptor.
/// </summary>
public static int[] MaxProcessingTimes = [];
/// <summary>
/// Runs the volume decryptor process.
/// </summary>
@@ -73,9 +64,6 @@ namespace Duplicati.Library.Main.Operation.Restore
Stopwatch? sw_bvr = options.InternalProfiling ? new() : null;
Stopwatch? sw_vw = options.InternalProfiling ? new() : null;
Stopwatch sw_processing = new();
int id = Interlocked.Increment(ref IdCounter);
try
{
while (true)
@@ -86,7 +74,6 @@ namespace Duplicati.Library.Main.Operation.Restore
sw_read?.Stop();
Logging.Log.WriteExplicitMessage(LOGTAG, "DecryptVolume", null, "Decrypting volume {0} (ID: {1})", volume_name, volume_id);
sw_processing.Restart();
// Decrypt the volume.
sw_decrypt?.Start();
var tmpfile = backend.DecryptFile(volume, volume_name, options);
@@ -101,9 +88,6 @@ namespace Duplicati.Library.Main.Operation.Restore
var volume_wrapper = new VolumeWrapper(tmpfile, bvr);
sw_vw?.Stop();
Logging.Log.WriteExplicitMessage(LOGTAG, "BlockVolumeReader", null, "Created BlockVolumeReader for volume {0} (ID: {1})", volume_name, volume_id);
sw_processing.Stop();
// This is the only writing process to that int, so an update is safe.
MaxProcessingTimes[id] = Math.Max(MaxProcessingTimes[id], (int)sw_processing.ElapsedMilliseconds);
sw_write?.Start();
// Pass the decrypted volume to the `VolumeDecompressor` process.
@@ -43,16 +43,6 @@ namespace Duplicati.Library.Main.Operation.Restore
/// </summary>
private static readonly string LOGTAG = Logging.Log.LogTagFromType<VolumeDownloader>();
/// <summary>
/// Id of the next downloader. Used to give each downloader a unique index.
/// </summary>
public static int IdCounter = -1;
/// <summary>
/// Maximum processing times for each active downloader.
/// </summary>
public static int[] MaxProcessingTimes = [];
/// <summary>
/// Runs the volume downloader process.
/// </summary>
@@ -75,9 +65,6 @@ namespace Duplicati.Library.Main.Operation.Restore
Stopwatch? sw_write = options.InternalProfiling ? new() : null;
Stopwatch? sw_wait = options.InternalProfiling ? new() : null;
Stopwatch sw_processing = new();
var id = Interlocked.Increment(ref IdCounter);
try
{
while (true)
@@ -90,7 +77,6 @@ namespace Duplicati.Library.Main.Operation.Restore
// Trigger the download.
sw_wait?.Start();
sw_processing.Restart();
TempFile f;
var (volume_name, size, hash) = await db
.GetVolumeInfo(volume_id, results.TaskControl.ProgressToken)
@@ -107,9 +93,6 @@ namespace Duplicati.Library.Main.Operation.Restore
throw;
}
sw_processing.Stop();
// This is the only writing process to that int, so an update is safe.
MaxProcessingTimes[id] = Math.Max(MaxProcessingTimes[id], (int)sw_processing.ElapsedMilliseconds);
sw_wait?.Stop();
Logging.Log.WriteExplicitMessage(LOGTAG, "DownloadVolume", null, "Downloaded volume {0} (ID: {1})", volume_name, volume_id);
@@ -349,13 +349,13 @@ namespace Duplicati.Library.Main.Operation
// Configure channels and process parameters
Restore.Channels channels = new(m_options);
// Set the deadlock timer threshold to 1 minute per 10 MB of volume size
var volsize = await database.GetLargestVolumeAsync(cancellationToken).ConfigureAwait(false);
volsize = volsize > 0 ? volsize : m_options.VolumeSize;
Restore.DeadlockTimer.initial_threshold = (int)TimeSpan.FromMinutes(1).TotalMilliseconds * Math.Max(1, (int)(volsize / (10L * 1024L * 1024L)));
Restore.FileProcessor.file_processors_restoring_files = m_options.RestoreFileProcessors;
Restore.VolumeDownloader.MaxProcessingTimes = new int[m_options.RestoreVolumeDownloaders];
Restore.VolumeDecryptor.MaxProcessingTimes = new int[m_options.RestoreVolumeDecryptors];
Restore.VolumeDecompressor.MaxProcessingTimes = new int[m_options.RestoreVolumeDecompressors];
// Create the process network
var deadlock_timer = Restore.DeadlockTimer.Run();
var filelister = Restore.FileLister.Run(channels, database, m_options, m_result);
var fileprocessors = Enumerable.Range(0, m_options.RestoreFileProcessors).Select(i => Restore.FileProcessor.Run(channels, database, fileprocessor_requests[i], fileprocessor_responses[i], m_options, m_result)).ToArray();
var blockmanager = Restore.BlockManager.Run(channels, database, fileprocessor_requests, fileprocessor_responses, m_options, m_result);
@@ -369,7 +369,6 @@ namespace Duplicati.Library.Main.Operation
// Wait for the network to complete
Task[] all =
[
deadlock_timer,
filelister,
..fileprocessors,
blockmanager,
@@ -396,11 +395,6 @@ namespace Duplicati.Library.Main.Operation
kill_updater.Cancel();
}
// Cleanup the process Id counters
Restore.VolumeDownloader.IdCounter = -1;
Restore.VolumeDecryptor.IdCounter = -1;
Restore.VolumeDecompressor.IdCounter = -1;
await database.Transaction
.CommitAsync("CommitAfterRestore", token: cancellationToken)
.ConfigureAwait(false);
+22 -2
View File
@@ -29,11 +29,10 @@ using Duplicati.Library.Utility;
using System.Globalization;
using System.Threading;
using Duplicati.Library.Utility.Options;
using Duplicati.Library.SQLiteHelper;
using System.Diagnostics.CodeAnalysis;
using Duplicati.Library.Snapshots;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Duplicati.Library.Snapshots.MacOS;
namespace Duplicati.Library.Main
{
@@ -124,6 +123,11 @@ namespace Duplicati.Library.Main
/// </summary>
private static readonly OptimizationStrategy DEFAULT_BACKUPREAD_POLICY = OptimizationStrategy.Off;
/// <summary>
/// The default MacOS photos handling strategy
/// </summary>
private static readonly MacOSPhotosHandling DEFAULT_MACOS_PHOTOS_HANDLING = MacOSPhotosHandling.PhotosAndLibrary;
/// <summary>
/// The default number of compressor instances
/// </summary>
@@ -366,6 +370,12 @@ namespace Duplicati.Library.Main
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
yield return new CommandLineArgument("ignore-advisory-locking", CommandLineArgument.ArgumentType.Boolean, Strings.Options.IgnoreadvisorylockingShort, Strings.Options.IgnoreadvisorylockingLong, "false");
if (OperatingSystem.IsMacOS())
{
yield return new CommandLineArgument("photos-handling", CommandLineArgument.ArgumentType.Enumeration, Strings.Options.DisablephotohandlingShort, Strings.Options.DisablephotohandlingLong, DEFAULT_MACOS_PHOTOS_HANDLING.ToString(), null, Enum.GetNames(typeof(MacOSPhotosHandling)));
yield return new CommandLineArgument("photos-library-path", CommandLineArgument.ArgumentType.Path, Strings.Options.MacosphotoslibrarypathShort, Strings.Options.MacosphotoslibrarypathLong);
}
}
/// <summary>
@@ -1713,6 +1723,16 @@ namespace Duplicati.Library.Main
/// </summary>
public bool RestorePreAllocate => GetBool("restore-pre-allocate");
/// <summary>
/// Gets whether to handle MacOS Photo Libraries specially
/// </summary>
public MacOSPhotosHandling HandleMacOSPhotoLibrary => GetEnum("photos-handling", DEFAULT_MACOS_PHOTOS_HANDLING);
/// <summary>
/// Gets the path to the MacOS Photos Library
/// </summary>
public string? MacOSPhotoLibraryPath => GetString("photos-library-path", null);
// <summary>
// Gets the size of the volume cache used during restore, in MB.
// </summary>
+5 -1
View File
@@ -284,7 +284,7 @@ namespace Duplicati.Library.Main.Strings
public static string ConcurrencyblockhashersShort { get { return LC.L(@"Specify the number of concurrent hashing processes"); } }
public static string ConcurrencycompressorsLong { get { return LC.L(@"Use this option to set the number of processes that perform compression of output data."); } }
public static string ConcurrencycompressorsShort { get { return LC.L(@"Specify the number of concurrent compression processes"); } }
public static string ConcurrencyfileprocessorsShort { get { return LC.L(@"[EXPERIMENTAL]Specify the number of concurrent files to open"); } }
public static string ConcurrencyfileprocessorsShort { get { return LC.L(@"Specify the number of concurrent files to open"); } }
public static string ConcurrencyfileprocessorsLong { get { return LC.L(@"Use this option to set the number of concurrent files to open. This could accelerate big backups involving lot of files, such as an initial backup"); } }
public static string DisablesyntehticfilelistLong { get { return LC.L(@"If Duplicati detects that the previous backup did not complete, it will generate a filelist that is a merge of the last completed backup and the contents that were uploaded in the incomplete backup session."); } }
public static string DisablesyntheticfilelistShort { get { return LC.L(@"Disable synthetic filelist"); } }
@@ -375,6 +375,10 @@ namespace Duplicati.Library.Main.Strings
public static string InternalProfilingLong { get { return LC.L("Use this option to enable internal profiling. Profiling is used to measure the performance of the internal code. The profiling data is written to the log file and can be used to identify performance bottlenecks."); } }
public static string IgnoreUpdateIfVersionExistsShort { get { return LC.L("Ignore update if version exists"); } }
public static string IgnoreUpdateIfVersionExistsLong { get { return LC.L("Use this option to ignore the update if the version already exists. This can be used to avoid errors if asking to update the database with a version that already exists."); } }
public static string DisablephotohandlingShort { get { return LC.L("Disable special handling for photo libraries on MacOS"); } }
public static string DisablephotohandlingLong { get { return LC.L("Use this option to disable special handling for photo libraries on MacOS. By default, Duplicati will attempt to read the contents of photo libraries and back up the individual photos instead of the on-disk library contents itself. This option disables that behavior and backs up the library as a regular folder."); } }
public static string MacosphotoslibrarypathShort { get { return LC.L("Path to the Photos library"); } }
public static string MacosphotoslibrarypathLong { get { return LC.L("Use this option to specify the path to the Photos library on MacOS. This option is only relevant if the Photos library is not in the default location."); } }
}
internal static class Common
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Duplicati.Library.Modules.Builtin</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -9,7 +9,7 @@
<ItemGroup>
<!-- Force version of dependency in Artalk.Xmpp, sync with Duplicati.Server.csproj -->
<PackageReference Include="System.Drawing.Common" Version="9.0.6" />
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
<PackageReference Include="Artalk.Xmpp" Version="1.0.5" />
<PackageReference Include="DnsClient" Version="1.8.0" />
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<RootNamespace>Duplicati.Library.RemoteControl</RootNamespace>
<Nullable>enable</Nullable>
@@ -68,8 +68,7 @@ public record PbkdfConfig(string Algorithm, int Version, string Salt, int Iterat
prng.GetBytes(buf);
var salt = Convert.ToBase64String(buf);
var pbkdf2 = new Rfc2898DeriveBytes(password, buf, _Iterations, new HashAlgorithmName(_HashAlorithm));
var pwd = Convert.ToBase64String(pbkdf2.GetBytes(_HashSize));
var pwd = Convert.ToBase64String(Rfc2898DeriveBytes.Pbkdf2(password, buf, _Iterations, new HashAlgorithmName(_HashAlorithm), _HashSize));
return new PbkdfConfig(_Algorithm, _Version, salt, _Iterations, _HashAlorithm, pwd);
}
@@ -81,8 +80,7 @@ public record PbkdfConfig(string Algorithm, int Version, string Salt, int Iterat
/// <returns>The hashed password</returns>
private string ComputeHash(string password)
{
var pbkdf2 = new Rfc2898DeriveBytes(password, Convert.FromBase64String(Salt), Iterations, new HashAlgorithmName(HashAlorithm));
return Convert.ToBase64String(pbkdf2.GetBytes(_HashSize));
return Convert.ToBase64String(Rfc2898DeriveBytes.Pbkdf2(password, Convert.FromBase64String(Salt), Iterations, new HashAlgorithmName(HashAlorithm), _HashSize));
}
/// <summary>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
</PropertyGroup>
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<AssemblyName>SQLiteHelper</AssemblyName>
<Description>SQLiteHelper for Duplicati</Description>
@@ -76,7 +76,7 @@ public static class SQLiteRC4Decrypter
using (var probefs = new FileStream(databasePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
{
var probebuf = new byte[MAGIC_HEADER.Length];
probefs.Read(probebuf, 0, probebuf.Length);
var len = probefs.Read(probebuf, 0, probebuf.Length);
return !MAGIC_HEADER.SequenceEqual(probebuf);
}
}
@@ -123,11 +123,16 @@ public class AzureSecretProvider : ISecretProvider
else if (cfg.AuthenticationType == AuthenticationType.UsernamePassword && (string.IsNullOrWhiteSpace(cfg.TenantId) || string.IsNullOrWhiteSpace(cfg.ClientId) || string.IsNullOrWhiteSpace(cfg.Username) || string.IsNullOrWhiteSpace(cfg.Password)))
throw new UserInformationException($"The settings {ArgName(nameof(AzureSecretProviderConfig.TenantId))}, {ArgName(nameof(AzureSecretProviderConfig.ClientId))}, {ArgName(nameof(AzureSecretProviderConfig.Username))}, and {ArgName(nameof(AzureSecretProviderConfig.Password))} are required for username/password authentication", "MissingUsernamePasswordSettings");
#pragma warning disable CS0618 // Type or member is obsolete
// Disable warnings for UsernamePasswordCredential being obsolete as we still want to support it
var creds = new UsernamePasswordCredential(cfg.Username, cfg.Password, cfg.TenantId, cfg.ClientId);
#pragma warning restore CS0618 // Type or member is obsolete
TokenCredential credential = cfg.AuthenticationType switch
{
AuthenticationType.ClientSecret => new ClientSecretCredential(cfg.TenantId, cfg.ClientId, cfg.ClientSecret),
AuthenticationType.ManagedIdentity => new DefaultAzureCredential(),
AuthenticationType.UsernamePassword => new UsernamePasswordCredential(cfg.Username, cfg.Password, cfg.TenantId, cfg.ClientId),
AuthenticationType.UsernamePassword => creds,
_ => throw new UserInformationException($"Authentication type {cfg.AuthenticationType} is not supported", "UnsupportedAuthenticationType")
};
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Duplicati.Library.SecretProvider</RootNamespace>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
@@ -11,7 +11,7 @@
<ItemGroup>
<PackageReference Include="AWSSDK.SecretsManager.Caching" Version="2.0.0" />
<PackageReference Include="Azure.Identity" Version="1.14.0" />
<PackageReference Include="Azure.Identity" Version="1.17.0" />
<PackageReference Include="Azure.Security.KeyVault.Secrets" Version="4.8.0" />
<PackageReference Include="Google.Cloud.SecretManager.V1" Version="2.6.0" />
<PackageReference Include="SharpAESCrypt" Version="2.0.3" />
@@ -19,6 +19,8 @@
<PackageReference Include="Meziantou.Framework.Win32.CredentialManager" Version="1.7.4" />
<!-- Important! Avalonia also depends on this package, so make sure the versions are in sync -->
<PackageReference Include="Tmds.DBus.Protocol" Version="0.21.2" />
<!-- Force load a newer version here -->
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
@@ -1,14 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Copyright>Copyright © 2025 Team Duplicati, MIT license</Copyright>
<RootNamespace>Duplicati.Library.Snapshots</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Microsoft.Win32.SystemEvents" Version="9.0.6" />
<PackageReference Include="Microsoft.Win32.SystemEvents" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
@@ -31,6 +31,9 @@
<Content Include="lvm-scripts\remove-lvm-snapshot.sh">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="../../../Tools/MacOSPhotosNative/libDuplicatiPhotos.dylib" Condition="$([MSBuild]::IsOSPlatform('OSX'))">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,104 @@
// 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.
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using Duplicati.Library.Interface;
namespace Duplicati.Library.Snapshots.MacOS;
[SupportedOSPlatform("macOS")]
internal sealed class MacOSPhotoAssetEntry : ISourceProviderEntry
{
private readonly MacOSPhotosLibrary library;
private readonly string libraryRootPath;
private readonly MacOSPhotoAsset asset;
private readonly Dictionary<string, string> metadata;
public MacOSPhotoAssetEntry(MacOSPhotosLibrary library, string rootPath, MacOSPhotoAsset asset)
{
this.library = library ?? throw new ArgumentNullException(nameof(library));
libraryRootPath = rootPath ?? throw new ArgumentNullException(nameof(rootPath));
this.asset = asset ?? throw new ArgumentNullException(nameof(asset));
metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["LocalIdentifier"] = asset.Identifier,
["OriginalFileName"] = asset.FileName
};
if (!string.IsNullOrEmpty(asset.UniformTypeIdentifier))
metadata["UniformTypeIdentifier"] = asset.UniformTypeIdentifier!;
metadata["MediaType"] = asset.MediaType.ToString();
}
public string RelativePath => asset.RelativePath;
public bool IsFolder => false;
public bool IsMetaEntry => false;
public bool IsRootEntry => false;
public DateTime CreatedUtc => asset.CreatedUtc ?? asset.ModifiedUtc ?? DateTime.UnixEpoch;
public DateTime LastModificationUtc => asset.ModifiedUtc ?? asset.CreatedUtc ?? DateTime.UnixEpoch;
public string Path => System.IO.Path.Combine(libraryRootPath, asset.RelativePath);
public long Size => asset.Size ?? -1;
public bool IsSymlink => false;
public string? SymlinkTarget => null;
public FileAttributes Attributes => FileAttributes.Normal;
public Dictionary<string, string> MinorMetadata => metadata;
public bool IsBlockDevice => false;
public bool IsCharacterDevice => false;
public bool IsAlternateStream => false;
public string? HardlinkTargetId => null;
public async Task<Stream> OpenRead(CancellationToken cancellationToken)
=> await library.OpenAssetStreamAsync(asset, cancellationToken).ConfigureAwait(false);
public Task<Stream?> OpenMetadataRead(CancellationToken cancellationToken)
=> Task.FromResult<Stream?>(new MemoryStream(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(metadata)));
public Task<bool> FileExists(string filename, CancellationToken cancellationToken)
=> Task.FromResult(false);
public IAsyncEnumerable<ISourceProviderEntry> Enumerate(CancellationToken cancellationToken)
=> AsyncEnumerable.Empty<ISourceProviderEntry>();
}
@@ -0,0 +1,108 @@
// 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.
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
using Duplicati.Library.Common.IO;
using Duplicati.Library.Interface;
namespace Duplicati.Library.Snapshots.MacOS;
/// <summary>
/// Implements a subfolder within a MacOS Photos library export structure
/// </summary>
/// <param name="parent">The library parent</param>
/// <param name="subpath">The subfolder path</param>
/// <param name="children">The child entries within the subfolder</param>
[SupportedOSPlatform("macOS")]
internal class MacOSPhotoSubFolder(MacOSPhotosLibraryEntry parent, string subpath, IReadOnlyList<ISourceProviderEntry> children) : ISourceProviderEntry
{
/// <inheritdoc/>
public bool IsFolder => true;
/// <inheritdoc/>
public bool IsMetaEntry => false;
/// <inheritdoc/>
public bool IsRootEntry => false;
/// <inheritdoc/>
public DateTime CreatedUtc => parent.CreatedUtc;
/// <inheritdoc/>
public DateTime LastModificationUtc => parent.LastModificationUtc;
/// <inheritdoc/>
public string Path => Util.AppendDirSeparator(SystemIO.IO_OS.PathCombine(parent.Path, subpath));
/// <inheritdoc/>
public long Size => -1;
/// <inheritdoc/>
public bool IsSymlink => false;
/// <inheritdoc/>
public string? SymlinkTarget => null;
/// <inheritdoc/>
public FileAttributes Attributes => parent.Attributes;
/// <inheritdoc/>
public Dictionary<string, string> MinorMetadata => new Dictionary<string, string>();
/// <inheritdoc/>
public bool IsBlockDevice => false;
/// <inheritdoc/>
public bool IsCharacterDevice => false;
/// <inheritdoc/>
public bool IsAlternateStream => false;
/// <inheritdoc/>
public string? HardlinkTargetId => null;
/// <inheritdoc/>
public IAsyncEnumerable<ISourceProviderEntry> Enumerate(CancellationToken cancellationToken)
=> children.ToAsyncEnumerable();
/// <inheritdoc/>
public Task<bool> FileExists(string filename, CancellationToken cancellationToken)
{
var fullpath = System.IO.Path.Combine(Path, filename);
return Task.FromResult(children.Any(e => e.Path.Equals(fullpath, Utility.Utility.ClientFilenameStringComparison)));
}
/// <inheritdoc/>
public Task<Stream?> OpenMetadataRead(CancellationToken cancellationToken)
=> Task.FromResult<Stream?>(null);
/// <inheritdoc/>
public Task<Stream> OpenRead(CancellationToken cancellationToken)
=> throw new InvalidOperationException("Cannot open a folder for reading");
}
@@ -0,0 +1,20 @@
namespace Duplicati.Library.Snapshots.MacOS;
/// <summary>
/// Enum for how to handle MacOS Photos libraries
/// </summary>
public enum MacOSPhotosHandling
{
/// <summary>
/// Do not handle MacOS Photos libraries specially, just back them up as regular folders
/// </summary>
LibraryOnly,
/// <summary>
/// Only back up photos from MacOS Photos libraries
/// </summary>
PhotosOnly,
/// <summary>
/// Back up both photos and the library structure
/// </summary>
PhotosAndLibrary,
}
@@ -0,0 +1,382 @@
// 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.
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.Versioning;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Duplicati.Library.Interface;
namespace Duplicati.Library.Snapshots.MacOS;
/// <summary>
/// Implements support functions around a MacOS Photos library
/// </summary>
[SupportedOSPlatform("macOS")]
internal sealed class MacOSPhotosLibrary
{
/// <summary>
/// Subfolder within the exported structure where photos assets are placed,
/// to avoid collisions with other files in the library.
/// This means that restores will place photos under this subfolder, and not inside Photos
/// </summary>
public const string EXPORT_SUBFOLDER = "dup_backup";
/// <summary>
/// Creates a new Photos library helper for the specified path
/// </summary>
/// <param name="path">The path to the Photos library</param>
public MacOSPhotosLibrary(string path)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Path must be a non-empty string", nameof(path));
}
/// <summary>
/// Attempts to wrap the specified entry as a Photos library entry
/// </summary>
/// <param name="entry">The entry to wrap</param>
/// <param name="macOSPhotosHandling">The Photos handling strategy</param>
/// <param name="forcedPath">An optional forced path where the Photos library exists; if null the system default path is probed</param>
/// <returns>The wrapped entry if it is a Photos library, or the original entry otherwise</returns>
public static ISourceProviderEntry TryWrap(ISourceProviderEntry entry, MacOSPhotosHandling macOSPhotosHandling, string? forcedPath)
=> entry.IsFolder && !(entry is MacOSPhotosLibraryEntry) && IsPhotosLibrary(entry.Path, forcedPath)
? new MacOSPhotosLibraryEntry(entry, macOSPhotosHandling)
: entry;
/// <summary>
/// Determines whether the specified path is a MacOS Photos library
/// </summary>
/// <param name="path">The path to check</param>
/// <param name="forcedPath">An optional forced path where the Photos library exists; if null the system default path is probed</param>
/// <returns>True if the path is a Photos library, false otherwise</returns>
internal static bool IsPhotosLibrary(string path, string? forcedPath)
{
if (!OperatingSystem.IsMacOS())
return false;
if (string.IsNullOrWhiteSpace(path))
return false;
var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (!trimmed.EndsWith(".photoslibrary", Utility.Utility.ClientFilenameStringComparison))
return false;
try
{
if (!Directory.Exists(trimmed))
return false;
if (!string.IsNullOrEmpty(forcedPath))
return string.Equals(trimmed, forcedPath, Utility.Utility.ClientFilenameStringComparison);
// Only return true if this is the current user's system photo library
var userPhotoLibraryPath = GetSystemPhotoLibraryPath();
if (string.IsNullOrEmpty(userPhotoLibraryPath))
return false;
// Compare the resolved paths to handle symlinks and relative paths
var resolvedPath = Path.GetFullPath(trimmed);
var resolvedUserPath = Path.GetFullPath(userPhotoLibraryPath);
return string.Equals(resolvedPath, resolvedUserPath, Utility.Utility.ClientFilenameStringComparison);
}
catch
{
return false;
}
}
/// <summary>
/// Gets the path to the current user's system photo library
/// </summary>
/// <returns>The path to the system photo library, or null if not found</returns>
private static string? GetSystemPhotoLibraryPath()
{
try
{
// The default system photo library is located in the Pictures folder
// Use MyPictures special folder to handle localized folder names
var picturesDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);
if (string.IsNullOrEmpty(picturesDirectory) || !Directory.Exists(picturesDirectory))
return null;
// Find .photoslibrary directories in the Pictures folder
// The system library name can be localized (e.g., "Photos Library.photoslibrary",
// "Fotomediathek.photoslibrary" in German, "Photothèque.photoslibrary" in French, etc.)
var photoLibraries = Directory.GetDirectories(picturesDirectory, "*.photoslibrary", SearchOption.TopDirectoryOnly);
// Return the first .photoslibrary found, as typically there's only one system library
// If multiple exist, the first one is likely the system library
return photoLibraries.Length > 0 ? photoLibraries[0] : null;
}
catch
{
return null;
}
}
/// <summary>
/// Gets the list of assets in the Photos library
/// </summary>
/// <param name="cancellationToken">The cancellation token</param>
/// <returns>The list of assets</returns>
public async Task<IReadOnlyList<MacOSPhotoAsset>> GetAssetsAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var nativeAssets = MacOSPhotosNative.ListAssets();
var results = new List<MacOSPhotoAsset>(nativeAssets.Count);
foreach (var native in nativeAssets)
{
cancellationToken.ThrowIfCancellationRequested();
var safeFileName = CreateSafeFileName(native.Identifier, native.FileName, native.UniformTypeIdentifier, native.MediaType);
var relativePath = Path.Combine(EXPORT_SUBFOLDER, safeFileName);
DateTime? creation = native.CreationSeconds.HasValue ? FromUnixSeconds(native.CreationSeconds.Value) : null;
DateTime? modification = native.ModificationSeconds.HasValue ? FromUnixSeconds(native.ModificationSeconds.Value) : null;
long? size = native.Size >= 0 ? native.Size : null;
results.Add(new MacOSPhotoAsset(
native.Identifier,
safeFileName,
relativePath,
native.UniformTypeIdentifier,
native.MediaType,
size,
native.PixelWidth,
native.PixelHeight,
creation,
modification));
}
return results;
}
/// <summary>
/// Opens a read stream for the specified asset
/// </summary>
/// <param name="asset">The asset to open</param>
/// <param name="cancellationToken">The cancellation token</param>
/// <returns>>An open read stream for the asset</returns>
public Task<Stream> OpenAssetStreamAsync(MacOSPhotoAsset asset, CancellationToken cancellationToken)
{
if (asset is null)
throw new ArgumentNullException(nameof(asset));
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(MacOSPhotosNative.OpenReadStream(asset.Identifier, asset.Size));
}
/// <summary>
/// Converts Unix time in seconds to a DateTime object
/// </summary>
/// <param name="seconds">The Unix time in seconds</param>
/// <returns>A DateTime object representing the specified Unix time</returns>
private static DateTime FromUnixSeconds(double seconds)
{
var rounded = (long)Math.Round(seconds, MidpointRounding.AwayFromZero);
return DateTimeOffset.FromUnixTimeSeconds(rounded).UtcDateTime;
}
/// <summary>
/// Creates a safe file name for the asset
/// </summary>
/// <param name="identifier">The unique identifier of the asset</param>
/// <param name="filename">The original file name</param>
/// <param name="uti">The Uniform Type Identifier of the asset</param>
/// <param name="mediaType">The media type of the asset</param>
/// <returns>A safe file name for the asset</returns>
private static string CreateSafeFileName(string identifier, string? filename, string? uti, MacOSPhotoMediaType mediaType)
{
var candidate = string.IsNullOrWhiteSpace(filename) ? $"asset{GetDefaultExtension(mediaType, uti)}" : filename!;
var sanitized = SanitizeFileName(candidate);
if (string.IsNullOrWhiteSpace(Path.GetExtension(sanitized)))
sanitized = sanitized + GetDefaultExtension(mediaType, uti);
return $"{SanitizeComponent(identifier)}_{sanitized}";
}
/// <summary>
/// Gets the default file extension for the specified media type and UTI
/// </summary>
/// <param name="mediaType">The media type of the asset</param>
/// <param name="uti">The Uniform Type Identifier of the asset</param>
/// <returns>The default file extension for the specified media type and UTI</returns>
private static string GetDefaultExtension(MacOSPhotoMediaType mediaType, string? uti)
{
if (!string.IsNullOrEmpty(uti))
{
var lower = uti.ToLowerInvariant();
if (lower.Contains("heic") || lower.Contains("heif"))
return ".heic";
if (lower.Contains("png"))
return ".png";
if (lower.Contains("gif"))
return ".gif";
if (lower.Contains("jpeg") || lower.Contains("jpg"))
return ".jpg";
if (lower.Contains("tiff"))
return ".tif";
if (lower.Contains("mov"))
return ".mov";
if (lower.Contains("mp4") || lower.Contains("m4v"))
return ".mp4";
if (lower.Contains("m4a") || lower.Contains("aac"))
return ".m4a";
}
return mediaType switch
{
MacOSPhotoMediaType.Image => ".jpg",
MacOSPhotoMediaType.Video => ".mov",
MacOSPhotoMediaType.Audio => ".m4a",
_ => ".bin"
};
}
/// <summary>
/// Sanitizes a file name by replacing invalid characters with underscores
/// </summary>
/// <param name="name">The file name to sanitize</param>
/// <returns>>The sanitized file name</returns>
private static string SanitizeFileName(string name)
{
var invalid = Path.GetInvalidFileNameChars();
var builder = new StringBuilder(name.Length);
foreach (var ch in name)
{
if (invalid.Contains(ch) || ch == Path.DirectorySeparatorChar || ch == Path.AltDirectorySeparatorChar)
builder.Append('_');
else
builder.Append(ch);
}
var result = builder.ToString().Trim();
return string.IsNullOrEmpty(result) ? "asset" : result;
}
/// <summary>
/// Sanitizes a path component by replacing invalid characters with underscores
/// </summary>
/// <param name="value">The path component to sanitize</param>
/// <returns>The sanitized path component</returns>
private static string SanitizeComponent(string value)
{
var invalid = Path.GetInvalidPathChars();
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
if (invalid.Contains(ch) || ch == Path.DirectorySeparatorChar || ch == Path.AltDirectorySeparatorChar)
builder.Append('_');
else
builder.Append(ch);
}
var result = builder.ToString().Trim();
return string.IsNullOrEmpty(result) ? "asset" : result;
}
}
/// <summary>
/// Represents a photo asset within a MacOS Photos library
/// </summary>
/// <param name="Identifier">The unique identifier of the asset</param>
/// <param name="FileName">The file name of the asset</param>
/// <param name="RelativePath">The relative path of the asset within the export structure</param>
/// <param name="UniformTypeIdentifier">The Uniform Type Identifier of the asset</param>
/// <param name="MediaType">The media type of the asset</param>
/// <param name="Size">The size of the asset in bytes</param>
/// <param name="PixelWidth">The width of the asset in pixels</param>
/// <param name="PixelHeight">>The height of the asset in pixels</param>
/// <param name="CreatedUtc">The creation time of the asset in UTC</param>
/// <param name="ModifiedUtc">>The modification time of the asset in UTC</param>
[SupportedOSPlatform("macOS")]
public sealed record MacOSPhotoAsset(
string Identifier,
string FileName,
string RelativePath,
string? UniformTypeIdentifier,
MacOSPhotoMediaType MediaType,
long? Size,
int PixelWidth,
int PixelHeight,
DateTime? CreatedUtc,
DateTime? ModifiedUtc
);
/// <summary>
/// Defines media types for MacOS Photos assets
/// </summary>
public enum MacOSPhotoMediaType
{
/// <summary>
/// Unknown media type
/// </summary>
Unknown = 0,
/// <summary>
/// Image media type
/// </summary>
Image = 1,
/// <summary>
/// Video media type
/// </summary>
Video = 2,
/// <summary>
/// Audio media type
/// </summary>
Audio = 3
}
/// <summary>
/// Represents errors that occur during MacOS Photos library operations
/// </summary>
public sealed class MacOSPhotosException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="MacOSPhotosException"/> class
/// </summary>
/// <param name="message">The error message</param>
public MacOSPhotosException(string message)
: base(message)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="MacOSPhotosException"/> class
/// </summary>
/// <param name="message">The error message</param>
/// <param name="innerException">The inner exception</param>
public MacOSPhotosException(string message, Exception innerException)
: base(message, innerException)
{
}
}
@@ -0,0 +1,148 @@
// 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.
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.Versioning;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Duplicati.Library.Interface;
using Duplicati.Library.Logging;
namespace Duplicati.Library.Snapshots.MacOS;
[SupportedOSPlatform("macOS")]
internal sealed class MacOSPhotosLibraryEntry : ISourceProviderEntry
{
private static readonly string LOGTAG = Log.LogTagFromType<MacOSPhotosLibraryEntry>();
private readonly ISourceProviderEntry inner;
private readonly MacOSPhotosLibrary photosLibrary;
private readonly SemaphoreSlim cacheLock = new(1, 1);
private IReadOnlyList<MacOSPhotoAssetEntry>? cachedEntries;
private Dictionary<string, MacOSPhotoAssetEntry>? cachedEntriesByRelativePath;
private readonly MacOSPhotosHandling photosHandling;
internal MacOSPhotosLibraryEntry(ISourceProviderEntry entry, MacOSPhotosHandling photosHandling)
{
inner = entry;
photosLibrary = new MacOSPhotosLibrary(entry.Path);
this.photosHandling = photosHandling;
}
public bool IsFolder => inner.IsFolder;
public bool IsMetaEntry => inner.IsMetaEntry;
public bool IsRootEntry => inner.IsRootEntry;
public DateTime CreatedUtc => inner.CreatedUtc;
public DateTime LastModificationUtc => inner.LastModificationUtc;
public string Path => inner.Path;
public long Size => inner.Size;
public bool IsSymlink => inner.IsSymlink;
public string? SymlinkTarget => inner.SymlinkTarget;
public FileAttributes Attributes => inner.Attributes;
public Dictionary<string, string> MinorMetadata => inner.MinorMetadata;
public bool IsBlockDevice => inner.IsBlockDevice;
public bool IsCharacterDevice => inner.IsCharacterDevice;
public bool IsAlternateStream => inner.IsAlternateStream;
public string? HardlinkTargetId => inner.HardlinkTargetId;
public Task<Stream> OpenRead(CancellationToken cancellationToken)
=> inner.OpenRead(cancellationToken);
public Task<Stream?> OpenMetadataRead(CancellationToken cancellationToken)
=> inner.OpenMetadataRead(cancellationToken);
public async Task<bool> FileExists(string filename, CancellationToken cancellationToken)
{
await EnsureEntriesAsync(cancellationToken).ConfigureAwait(false);
if (cachedEntriesByRelativePath == null)
return false;
var key = NormalizeRelativePath(filename);
return cachedEntriesByRelativePath.ContainsKey(key);
}
public async IAsyncEnumerable<ISourceProviderEntry> Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
{
if (photosHandling != MacOSPhotosHandling.PhotosOnly)
{
await foreach (var item in inner.Enumerate(cancellationToken))
yield return item;
}
if (photosHandling != MacOSPhotosHandling.LibraryOnly)
{
await EnsureEntriesAsync(cancellationToken).ConfigureAwait(false);
if (cachedEntries == null)
yield break;
// Wrap the assets into a virtual subfolder to avoid collisions with other files in the library
yield return new MacOSPhotoSubFolder(this, MacOSPhotosLibrary.EXPORT_SUBFOLDER, cachedEntries);
}
}
private async Task EnsureEntriesAsync(CancellationToken cancellationToken)
{
if (cachedEntries != null)
return;
await cacheLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (cachedEntries != null)
return;
var assets = await photosLibrary.GetAssetsAsync(cancellationToken).ConfigureAwait(false);
var entries = assets.Select(asset => new MacOSPhotoAssetEntry(photosLibrary, inner.Path, asset)).ToList();
cachedEntries = entries;
cachedEntriesByRelativePath = entries.ToDictionary(x => NormalizeRelativePath(x.RelativePath), x => x, StringComparer.OrdinalIgnoreCase);
}
finally
{
cacheLock.Release();
}
}
private static string NormalizeRelativePath(string path)
=> path.Replace(System.IO.Path.AltDirectorySeparatorChar, System.IO.Path.DirectorySeparatorChar);
}
@@ -0,0 +1,377 @@
// 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.
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace Duplicati.Library.Snapshots.MacOS;
/// <summary>
/// Wrapper for native MacOS Photos library access
/// </summary>
[SupportedOSPlatform("macOS")]
internal static class MacOSPhotosNative
{
/// <summary>
/// The name of the native library
/// </summary>
private const string LibraryName = "DuplicatiPhotos";
/// <summary>
/// Lists all assets in the Photos library
/// </summary>
/// <returns>The list of assets</returns>
public static IReadOnlyList<NativeAsset> ListAssets()
{
var result = NativeMethods.DuplicatiPhotosEnumerateAssets(out var assetsPtr, out var count, out var errorPtr);
try
{
if (result != 0)
throw new MacOSPhotosException(ConsumeErrorMessage(ref errorPtr) ?? "Failed to enumerate Photos assets.");
if (assetsPtr == IntPtr.Zero || count == UIntPtr.Zero)
return Array.Empty<NativeAsset>();
var assetCount = checked((int)count.ToUInt64());
var structSize = Marshal.SizeOf<NativeAssetNative>();
var assets = new List<NativeAsset>(assetCount);
for (var index = 0; index < assetCount; index++)
{
var entryPtr = assetsPtr + (index * structSize);
var native = Marshal.PtrToStructure<NativeAssetNative>(entryPtr);
var identifier = Marshal.PtrToStringUTF8(native.Identifier) ?? string.Empty;
var fileName = Marshal.PtrToStringUTF8(native.FileName) ?? string.Empty;
var uti = Marshal.PtrToStringUTF8(native.UniformTypeIdentifier);
assets.Add(new NativeAsset(
identifier,
fileName,
uti,
(MacOSPhotoMediaType)native.MediaType,
native.Size,
native.PixelWidth,
native.PixelHeight,
NormalizeOptional(native.CreationSeconds),
NormalizeOptional(native.ModificationSeconds)));
}
return assets;
}
finally
{
if (assetsPtr != IntPtr.Zero)
NativeMethods.DuplicatiPhotosFreeAssets(assetsPtr, count);
if (errorPtr != IntPtr.Zero)
NativeMethods.DuplicatiPhotosFreeString(errorPtr);
}
}
/// <summary>
/// Opens a read stream for the specified asset identifier
/// </summary>
/// <param name="identifier">The unique identifier of the asset</param>
/// <param name="size">The size of the asset, or null to determine size automatically</param>
/// <returns>The read stream for the asset</returns>
public static Stream OpenReadStream(string identifier, long? size = null)
{
if (string.IsNullOrWhiteSpace(identifier))
throw new ArgumentException("Identifier is required", nameof(identifier));
var result = NativeMethods.DuplicatiPhotosOpenAsset(identifier, out var handle, out var errorPtr);
if (result != 0 || handle.IsInvalid)
{
var message = ConsumeErrorMessage(ref errorPtr) ?? "Failed to open Photos asset.";
handle.Dispose();
throw new MacOSPhotosException(message);
}
// If size is not provided, try to get it from the handle
var assetSize = size ?? GetAssetSize(handle);
return new MacOSPhotosNativeStream(handle, assetSize);
}
/// <summary>
/// Gets the size of the asset from the handle
/// </summary>
/// <param name="handle">The asset handle</param>
/// <returns>The size of the asset</returns>
internal static long GetAssetSize(SafeAssetHandle handle)
{
if (handle.IsInvalid)
throw new ObjectDisposedException(nameof(SafeAssetHandle));
var result = NativeMethods.DuplicatiPhotosGetAssetSize(handle, out var size, out var errorPtr);
if (result != 0)
{
var message = ConsumeErrorMessage(ref errorPtr) ?? "Failed to get Photos asset size.";
throw new MacOSPhotosException(message);
}
if (errorPtr != IntPtr.Zero)
{
NativeMethods.DuplicatiPhotosFreeString(errorPtr);
}
return size;
}
/// <summary>
/// Reads data from the asset into the provided buffer
/// </summary>
/// <param name="handle">The asset handle</param>
/// <param name="buffer">The buffer to read data into</param>
/// <returns>The number of bytes read</returns>
internal static int ReadAsset(SafeAssetHandle handle, Span<byte> buffer)
{
if (handle.IsInvalid)
throw new ObjectDisposedException(nameof(SafeAssetHandle));
if (buffer.Length == 0)
return 0;
var errorPtr = IntPtr.Zero;
int bytesRead;
// TODO: Optimize to read directly into the provided buffer
var tempbuffer = new byte[buffer.Length];
var result = NativeMethods.DuplicatiPhotosReadAsset(handle, tempbuffer, (nuint)tempbuffer.Length, out errorPtr);
if (result < 0)
{
var message = ConsumeErrorMessage(ref errorPtr) ?? "Failed to read Photos asset data.";
throw new MacOSPhotosException(message);
}
tempbuffer.AsSpan(0, (int)result).CopyTo(buffer);
bytesRead = checked((int)result);
if (errorPtr != IntPtr.Zero)
{
NativeMethods.DuplicatiPhotosFreeString(errorPtr);
errorPtr = IntPtr.Zero;
}
return bytesRead;
}
/// <summary>
/// Normalizes an optional double value, returning null if it is NaN
/// </summary>
/// <param name="value">The double value</param>
/// <returns>>The normalized value or null</returns>
private static double? NormalizeOptional(double value)
=> double.IsNaN(value) ? null : value;
/// <summary>
/// Consumes an error message pointer and frees the native memory
/// </summary>
/// <param name="pointer">The error message pointer</param>
/// <returns>The error message string or null</returns>
private static string? ConsumeErrorMessage(ref IntPtr pointer)
{
if (pointer == IntPtr.Zero)
return null;
try
{
return Marshal.PtrToStringUTF8(pointer);
}
finally
{
NativeMethods.DuplicatiPhotosFreeString(pointer);
pointer = IntPtr.Zero;
}
}
/// <summary>
/// Represents a native asset in the Photos library
/// </summary>
/// <param name="Identifier">The unique identifier of the asset</param>
/// <param name="FileName">The original file name</param>
/// <param name="UniformTypeIdentifier">The Uniform Type Identifier of the asset</param>
/// <param name="MediaType">The media type of the asset</param>
/// <param name="Size">The size of the asset in bytes</param>
/// <param name="PixelWidth">The pixel width of the asset</param>
/// <param name="PixelHeight">>The pixel height of the asset</param>
/// <param name="CreationSeconds">The creation time in seconds since Unix epoch</param>
/// <param name="ModificationSeconds">>The modification time in seconds since Unix epoch</param>
public sealed record NativeAsset(
string Identifier,
string FileName,
string? UniformTypeIdentifier,
MacOSPhotoMediaType MediaType,
long Size,
int PixelWidth,
int PixelHeight,
double? CreationSeconds,
double? ModificationSeconds);
/// <summary>
/// Native representation of an asset
/// </summary>
[StructLayout(LayoutKind.Sequential)]
private struct NativeAssetNative
{
/// <summary>
/// The unique identifier of the asset
/// </summary>
internal IntPtr Identifier;
/// <summary>
/// The original file name
/// </summary>
internal IntPtr FileName;
/// <summary>
/// The Uniform Type Identifier of the asset
/// </summary>
internal IntPtr UniformTypeIdentifier;
/// <summary>
/// The size of the asset in bytes
/// </summary>
internal long Size;
/// <summary>
/// The media type of the asset
/// </summary>
internal int MediaType;
/// <summary>
/// The pixel width of the asset
/// </summary>
internal int PixelWidth;
/// <summary>
/// The pixel height of the asset
/// </summary>
internal int PixelHeight;
/// <summary>
/// The creation time in seconds since Unix epoch
/// </summary>
internal double CreationSeconds;
/// <summary>
/// The modification time in seconds since Unix epoch
/// </summary>
internal double ModificationSeconds;
}
/// <summary>
/// Safe handle for a Photos asset
/// </summary>
internal sealed class SafeAssetHandle : SafeHandle
{
/// <summary>
/// Initializes a new instance of the <see cref="SafeAssetHandle"/> class
/// </summary>
private SafeAssetHandle()
: base(IntPtr.Zero, true)
{
}
/// <summary>
/// Indicates if the handle is invalid
/// </summary>
public override bool IsInvalid => handle == IntPtr.Zero;
/// <summary>
/// Releases the handle
/// </summary>
/// <returns>True if the handle was released successfully; otherwise, false</returns>
protected override bool ReleaseHandle()
{
if (!IsInvalid)
NativeMethods.DuplicatiPhotosCloseAsset(handle);
return true;
}
}
/// <summary>
/// Native method imports
/// </summary>
private static class NativeMethods
{
/// <summary>
/// Enumerates the assets in the Photos library
/// </summary>
/// <param name="assets">The pointer to the array of assets</param>
/// <param name="count">The number of assets</param>
/// <param name="errorMessage">The error message, if any</param>
/// <returns>The result code</returns>
[DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
internal static extern int DuplicatiPhotosEnumerateAssets(out IntPtr assets, out UIntPtr count, out IntPtr errorMessage);
/// <summary>
/// Frees the assets array
/// </summary>
/// <param name="assets">The pointer to the array of assets</param>
/// <param name="count">The number of assets</param>
[DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
internal static extern void DuplicatiPhotosFreeAssets(IntPtr assets, UIntPtr count);
/// <summary>
/// Frees a string allocated by the native library
/// </summary>
/// <param name="value">The string pointer</param>
[DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
internal static extern void DuplicatiPhotosFreeString(IntPtr value);
/// <summary>
/// Opens an asset for reading
/// </summary>
/// <param name="identifier">The unique identifier of the asset</param>
/// <param name="handle">The output asset handle</param>
/// <param name="errorMessage">The error message, if any</param>
/// <returns>The result code</returns>
[DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
internal static extern int DuplicatiPhotosOpenAsset([MarshalAs(UnmanagedType.LPUTF8Str)] string identifier, out SafeAssetHandle handle, out IntPtr errorMessage);
/// <summary>
/// Reads data from the asset into the provided buffer
/// </summary>
/// <param name="handle">The asset handle</param>
/// <param name="buffer">The buffer to read data into</param>
/// <param name="length">The length of the buffer</param>
/// <param name="errorMessage"> The error message, if any</param>
/// <returns>The number of bytes read</returns>
[DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
internal static extern nint DuplicatiPhotosReadAsset(SafeAssetHandle handle, byte[] buffer, nuint length, out IntPtr errorMessage);
/// <summary>
/// Closes the asset handle
/// </summary>
/// <param name="handle">The asset handle</param>
[DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
internal static extern void DuplicatiPhotosCloseAsset(IntPtr handle);
/// <summary>
/// Gets the size of the asset
/// </summary>
/// <param name="handle">The asset handle</param>
/// <param name="size">The size of the asset</param>
/// <param name="errorMessage">The error message, if any</param>
/// <returns>The result code</returns>
[DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
internal static extern int DuplicatiPhotosGetAssetSize(SafeAssetHandle handle, out long size, out IntPtr errorMessage);
}
}
@@ -0,0 +1,158 @@
// 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.
#nullable enable
using System;
using System.IO;
using System.Runtime.Versioning;
using System.Threading;
using System.Threading.Tasks;
namespace Duplicati.Library.Snapshots.MacOS;
[SupportedOSPlatform("macOS")]
internal sealed class MacOSPhotosNativeStream : Stream
{
private readonly MacOSPhotosNative.SafeAssetHandle handle;
private readonly long length;
private long position;
private bool disposed;
public MacOSPhotosNativeStream(MacOSPhotosNative.SafeAssetHandle handle, long length)
{
this.handle = handle ?? throw new ArgumentNullException(nameof(handle));
this.length = length;
this.position = 0;
}
public override bool CanRead => !disposed;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => disposed ? throw new ObjectDisposedException(nameof(MacOSPhotosNativeStream)) : length;
public override long Position
{
get => disposed ? throw new ObjectDisposedException(nameof(MacOSPhotosNativeStream)) : position;
set => throw new NotSupportedException();
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count)
{
ValidateReadArguments(buffer, offset, count);
if (disposed)
throw new ObjectDisposedException(nameof(MacOSPhotosNativeStream));
if (count == 0)
return 0;
var bytesRead = MacOSPhotosNative.ReadAsset(handle, new Span<byte>(buffer, offset, count));
position += bytesRead;
return bytesRead;
}
public override int Read(Span<byte> buffer)
{
if (disposed)
throw new ObjectDisposedException(nameof(MacOSPhotosNativeStream));
var bytesRead = MacOSPhotosNative.ReadAsset(handle, buffer);
position += bytesRead;
return bytesRead;
}
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (disposed)
throw new ObjectDisposedException(nameof(MacOSPhotosNativeStream));
var bytesRead = MacOSPhotosNative.ReadAsset(handle, buffer.Span);
position += bytesRead;
return ValueTask.FromResult(bytesRead);
}
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
ValidateReadArguments(buffer, offset, count);
cancellationToken.ThrowIfCancellationRequested();
if (disposed)
throw new ObjectDisposedException(nameof(MacOSPhotosNativeStream));
if (count == 0)
return Task.FromResult(0);
var bytesRead = MacOSPhotosNative.ReadAsset(handle, new Span<byte>(buffer, offset, count));
position += bytesRead;
return Task.FromResult(bytesRead);
}
public override long Seek(long offset, SeekOrigin origin)
=> throw new NotSupportedException();
public override void SetLength(long value)
=> throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count)
=> throw new NotSupportedException();
public override void Write(ReadOnlySpan<byte> buffer)
=> throw new NotSupportedException();
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
=> throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (!disposed)
{
if (disposing)
handle.Dispose();
disposed = true;
}
base.Dispose(disposing);
}
private static void ValidateReadArguments(byte[] buffer, int offset, int count)
{
if (buffer is null)
throw new ArgumentNullException(nameof(buffer));
if ((uint)offset > buffer.Length)
throw new ArgumentOutOfRangeException(nameof(offset));
if ((uint)count > buffer.Length - offset)
throw new ArgumentOutOfRangeException(nameof(count));
}
}
@@ -35,7 +35,7 @@ namespace Duplicati.Library.Snapshots
/// </summary>
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macOS")]
public sealed class NoSnapshotLinux : SnapshotBase
public class NoSnapshotLinux : SnapshotBase
{
/// <summary>
/// PInvoke methods
@@ -0,0 +1,48 @@
// 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.
#nullable enable
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Versioning;
using Duplicati.Library.Interface;
using Duplicati.Library.Snapshots.MacOS;
namespace Duplicati.Library.Snapshots
{
/// <summary>
/// Handler for providing a snapshot like access to files and folders
/// </summary>
/// <param name="sources">The list of source paths</param>
/// <param name="ignoreAdvisoryLocks">Whether to ignore advisory locks</param
/// <param name="followSymlinks">Whether to follow symlinks</param>
/// <param name="photosLibraryPath">The user specified MacOS Photos library path</param>
[SupportedOSPlatform("macOS")]
public sealed class NoSnapshotMacOS(IEnumerable<string> sources, bool ignoreAdvisoryLocks, bool followSymlinks, MacOSPhotosHandling macOSPhotosHandling, string? photosLibraryPath)
: NoSnapshotLinux(sources, ignoreAdvisoryLocks, followSymlinks)
{
/// <inheritdoc/>
public override IEnumerable<ISourceProviderEntry> EnumerateFilesystemEntries(ISourceProviderEntry source)
=> base.EnumerateFilesystemEntries(source).Select(b => MacOS.MacOSPhotosLibrary.TryWrap(b, macOSPhotosHandling, photosLibraryPath));
}
}
@@ -26,6 +26,7 @@ using System.IO;
using System.Runtime.Versioning;
using Duplicati.Library.Common.IO;
using Duplicati.Library.Interface;
using Duplicati.Library.Snapshots.MacOS;
namespace Duplicati.Library.Snapshots
{
@@ -65,11 +66,17 @@ namespace Duplicati.Library.Snapshots
/// <param name="ignoreAdvisoryLocking">Flag to ignore advisory locking</param>
/// <param name="followSymlinks">Whether to follow symlinks</param>
/// <param name="useSeBackup">Whether to use SeBackupPrivilege on Windows</param>
/// <param name="macOSPhotosHandling">Whether to handle MacOS Photos libraries specially</param>
/// <param name="photosLibraryPath">The user specified MacOS Photos library path</param>
/// <returns>The ISnapshotService implementation</returns>
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
public static ISnapshotService CreateNoSnapshot(IEnumerable<string> paths, bool ignoreAdvisoryLocking, bool followSymlinks, bool useSeBackup)
public static ISnapshotService CreateNoSnapshot(IEnumerable<string> paths, bool ignoreAdvisoryLocking, bool followSymlinks, bool useSeBackup, MacOSPhotosHandling macOSPhotosHandling, string photosLibraryPath)
{
if (OperatingSystem.IsMacOS() || OperatingSystem.IsLinux())
// MacOS implementation only handles photo libraries specially if requested
// Otherwise, it behaves like the Linux implementation
if (OperatingSystem.IsMacOS())
return new NoSnapshotMacOS(paths, ignoreAdvisoryLocking, followSymlinks, macOSPhotosHandling, photosLibraryPath);
else if (OperatingSystem.IsLinux())
return new NoSnapshotLinux(paths, ignoreAdvisoryLocking, followSymlinks);
else if (OperatingSystem.IsWindows())
return new NoSnapshotWindows(paths, followSymlinks, useSeBackup);

Some files were not shown because too many files have changed in this diff Show More