From bb919e3889ceb8e8b89378811e44f6b18a546670 Mon Sep 17 00:00:00 2001 From: Nurlan Garash <68768916+nurlangarash@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:25:16 +0400 Subject: [PATCH] fix: create the destination directory when it is missing (#3223) --- .github/workflows/ci.yml | 6 ++ .../lib/src/task/server/file_saver.dart | 12 +-- packages/localsend_isolates/pubspec.lock | 77 +++++++++++++++++++ packages/localsend_isolates/pubspec.yaml | 2 + .../test/task/server/file_saver_test.dart | 59 ++++++++++++++ 5 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 packages/localsend_isolates/test/task/server/file_saver_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b7ebccd..2b55fce0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,12 @@ jobs: - name: Test (app) working-directory: app run: flutter test + - name: Dependencies (localsend_isolates) + working-directory: packages/localsend_isolates + run: flutter pub get + - name: Test (localsend_isolates) + working-directory: packages/localsend_isolates + run: flutter test packaging: runs-on: ubuntu-latest diff --git a/packages/localsend_isolates/lib/src/task/server/file_saver.dart b/packages/localsend_isolates/lib/src/task/server/file_saver.dart index d307b837..8ce28897 100644 --- a/packages/localsend_isolates/lib/src/task/server/file_saver.dart +++ b/packages/localsend_isolates/lib/src/task/server/file_saver.dart @@ -215,14 +215,14 @@ Future<(String, String?, String)> digestFilePathAndPrepareDirectory({ if (!p.isWithin(parentDirectory, dir)) { throw 'Path traversal detected'; } - - try { - Directory(dir).createSync(recursive: true); - } catch (e) { - _logger.warning('Could not create directory', e); - } } + // The destination directory may not exist anymore, e.g. because it was deleted + // or is on a drive that is no longer mounted. This also creates the + // sub-directories of a folder transfer. Errors are propagated so that the + // caller fails the upload instead of writing to a path that cannot be opened. + Directory(dir).createSync(recursive: true); + String destinationPath; int counter = 1; do { diff --git a/packages/localsend_isolates/pubspec.lock b/packages/localsend_isolates/pubspec.lock index 01fe5f68..83ec11de 100644 --- a/packages/localsend_isolates/pubspec.lock +++ b/packages/localsend_isolates/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" build: dependency: transitive description: @@ -113,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" collection: dependency: "direct main" description: @@ -161,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.7" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" ffi: dependency: transitive description: @@ -214,6 +238,11 @@ packages: url: "https://pub.dev" source: hosted version: "2.12.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" flutter_web_plugins: dependency: transitive description: flutter @@ -291,6 +320,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" legalize: dependency: "direct main" description: @@ -315,6 +368,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -575,6 +636,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" type_plus: dependency: transitive description: @@ -614,6 +683,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" watcher: dependency: transitive description: diff --git a/packages/localsend_isolates/pubspec.yaml b/packages/localsend_isolates/pubspec.yaml index 151b9297..7777d840 100644 --- a/packages/localsend_isolates/pubspec.yaml +++ b/packages/localsend_isolates/pubspec.yaml @@ -33,4 +33,6 @@ dev_dependencies: build_runner: 2.15.1 dart_mappable_builder: 4.8.0 flutter_lints: 6.0.0 + flutter_test: + sdk: flutter freezed: 3.2.5 diff --git a/packages/localsend_isolates/test/task/server/file_saver_test.dart b/packages/localsend_isolates/test/task/server/file_saver_test.dart new file mode 100644 index 00000000..bfd468b8 --- /dev/null +++ b/packages/localsend_isolates/test/task/server/file_saver_test.dart @@ -0,0 +1,59 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:localsend_isolates/src/task/server/file_saver.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('file_saver_test'); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + Future digest(String parentDirectory, String fileName) async { + final (path, _, _) = await digestFilePathAndPrepareDirectory( + parentDirectory: parentDirectory, + fileName: fileName, + createdDirectories: {}, + ); + return path; + } + + test('creates the destination directory when it does not exist', () async { + final destination = p.join(tempDir.path, 'gone'); + final path = await digest(destination, 'file.txt'); + + expect(Directory(destination).existsSync(), isTrue); + expect(path, p.join(destination, 'file.txt')); + }); + + test('creates the sub-directories of a folder transfer', () async { + final path = await digest(tempDir.path, p.join('outer', 'inner', 'file.txt')); + + expect(Directory(p.join(tempDir.path, 'outer', 'inner')).existsSync(), isTrue); + expect(path, p.join(tempDir.path, 'outer', 'inner', 'file.txt')); + }); + + test('keeps an existing directory and its content', () async { + File(p.join(tempDir.path, 'file.txt')).writeAsStringSync('hello'); + + final path = await digest(tempDir.path, 'file.txt'); + + expect(path, p.join(tempDir.path, 'file (2).txt')); + expect(File(p.join(tempDir.path, 'file.txt')).readAsStringSync(), 'hello'); + }); + + test('still rejects path traversal', () async { + await expectLater( + digest(tempDir.path, p.join('..', 'escaped', 'file.txt')), + throwsA('Path traversal detected'), + ); + }); +}