CH-J SM Mobilespráva serverů přes SSH
Menu
Vydané zdroje

CH-J SM Mobile

Procházejte adresáře a soubory konkrétního vydání aplikace.

Stáhnout zdroje ZIP
CH-J / SM Mobile 175 souborů
sm-mobile-20261016T185235Z-8e93ecc1b59a Přidáno: · Sestaveno:
sftp_service.dart Přidáno: Datum není evidováno 13,8 KB · 474 řádků
Kód Stáhnout soubor
1import 'dart:async';
2import 'dart:io';
3import 'dart:typed_data';
4import 'package:cryptography/cryptography.dart';
5import 'package:dartssh2/dartssh2.dart';
6import '../core/failure.dart';
7import '../models/server_profile.dart';
9String safeName(String name) {
10 if (name.isEmpty ||
11 name == '.' ||
12 name == '..' ||
13 name.length > 255 ||
14 RegExp(r'[/\\\x00-\x1f\x7f]').hasMatch(name)) {
15 throw const Failure('unsafePath');
16 }
17 return name;
20String safePath(String path) {
21 if (!path.startsWith('/') ||
22 RegExp(r'[\\\x00-\x1f\x7f]').hasMatch(path) ||
23 path.split('/').any((s) => s == '.' || s == '..')) {
24 throw const Failure('unsafePath');
25 }
26 return path == '/' ? '/' : path.replaceAll(RegExp(r'/+$'), '');
29String childPath(String parent, String name) =>
30 '${safePath(parent) == '/' ? '' : safePath(parent)}/${safeName(name)}';
31String parentPath(String path) {
32 safePath(path);
33 final index = path.lastIndexOf('/');
34 return index <= 0 ? '/' : path.substring(0, index);
37class TransferToken {
38 bool cancelled = false;
39 void Function()? abort;
40 void cancel() {
41 cancelled = true;
42 abort?.call();
43 }
45 void check() {
46 if (cancelled) throw const Failure('cancelled');
47 }
50class SftpService {
51 final SftpClient client;
52 SftpService(this.client);
53 Future<String> home() async => safePath(await client.absolute('.'));
54 Future<void> guard(String path, {bool missingLeaf = false}) async {
55 safePath(path);
56 var current = '';
57 final parts = path.split('/').where((p) => p.isNotEmpty).toList();
58 for (var i = 0; i < parts.length; i++) {
59 current += '/${safeName(parts[i])}';
60 try {
61 final attrs = await client.stat(current, followLink: false);
62 if (attrs.isSymbolicLink ||
63 (i < parts.length - 1 && !attrs.isDirectory)) {
64 throw const Failure('unsafePath');
65 }
66 } on SftpStatusError catch (e) {
67 if (missingLeaf && i == parts.length - 1 && e.code == 2) return;
68 rethrow;
69 }
70 }
71 }
73 Future<List<SftpName>> list(String path) async {
74 await guard(path);
75 final entries = await client.listdir(path);
76 final filtered = entries
77 .where((e) => e.filename != '.' && e.filename != '..')
78 .toList();
79 for (final entry in filtered) {
80 safeName(entry.filename);
81 }
82 filtered.sort(
83 (a, b) => a.attr.isDirectory == b.attr.isDirectory
84 ? a.filename.toLowerCase().compareTo(b.filename.toLowerCase())
85 : a.attr.isDirectory
86 ? -1
87 : 1,
88 );
89 return filtered;
90 }
92 Future<void> mkdir(String path) async {
93 await guard(path, missingLeaf: true);
94 await client.mkdir(path);
95 }
97 Future<void> rename(String oldPath, String newPath) async {
98 await guard(oldPath);
99 await guard(newPath, missingLeaf: true);
100 await _absent(newPath);
101 await client.rename(oldPath, newPath);
102 }
104 Future<void> remove(String path) async {
105 if (path == '/') throw const Failure('unsafePath');
106 await guard(path);
107 final attrs = await client.stat(path, followLink: false);
108 if (attrs.isDirectory) {
109 await client.rmdir(path);
110 } else {
111 await client.remove(path);
112 }
113 }
115 Future<void> _absent(String path) async {
116 try {
117 await client.stat(path, followLink: false);
118 } on SftpStatusError catch (e) {
119 if (e.code == 2) return;
120 rethrow;
121 }
122 throw const Failure('fileExists');
123 }
125 Future<void> upload(
126 Stream<Uint8List> stream,
127 int size,
128 String remote,
129 TransferToken token,
130 void Function(int, int) progress,
131 ) async {
132 await guard(remote, missingLeaf: true);
133 await _absent(remote);
134 token.check();
135 final temp = childPath(parentPath(remote), '.sm-${newId()}.part');
136 SftpFile? file;
137 var created = false;
138 try {
139 file = await client.open(
140 temp,
141 mode:
142 SftpFileOpenMode.write |
143 SftpFileOpenMode.create |
144 SftpFileOpenMode.exclusive,
145 );
146 created = true;
147 final writer = file.write(
148 stream,
149 onProgress: (n) {
150 progress(n, size);
151 },
152 );
153 token.abort = () {
154 unawaited(writer.abort().catchError((Object _) {}));
155 };
156 if (token.cancelled) token.abort!();
157 await writer.done;
158 token.check();
159 await file.close();
160 file = null;
161 await guard(remote, missingLeaf: true);
162 await _absent(remote);
163 await client.rename(temp, remote);
164 } catch (_) {
165 try {
166 await file?.close();
167 if (created) await client.remove(temp);
168 } catch (_) {
169 /* Session may already be closed. */
170 }
171 rethrow;
172 } finally {
173 token.abort = null;
174 }
175 }
177 Future<void> download(
178 String remote,
179 File local,
180 TransferToken token,
181 void Function(int, int) progress,
182 ) async {
183 await guard(remote);
184 token.check();
185 final attrs = await client.stat(remote, followLink: false);
186 if (!attrs.isFile) throw const Failure('unsafePath');
187 final file = await client.open(remote);
188 final sink = local.openWrite();
189 token.abort = () {
190 unawaited(file.close().catchError((Object _) {}));
191 };
192 try {
193 await sink.addStream(
194 file
195 .read(
196 onProgress: (n) {
197 progress(n, attrs.size ?? 0);
198 },
199 )
200 .map((c) {
201 token.check();
202 return c;
203 }),
204 );
205 token.check();
206 await sink.flush();
207 await sink.close();
208 } catch (_) {
209 try {
210 await sink.close();
211 } catch (_) {
212 /* Preserve the original transfer failure. */
213 }
214 if (await local.exists()) await local.delete();
215 rethrow;
216 } finally {
217 token.abort = null;
218 try {
219 await sink.close();
220 } catch (_) {
221 /* Already handled above. */
222 }
223 try {
224 await file.close();
225 } catch (_) {
226 /* Cancellation closes the handle. */
227 }
228 }
229 }
231 /// Reads a whole regular file of at most [maxBytes].
232 Future<(Uint8List, SftpFileAttrs)> readFile(
233 String path, {
234 required int maxBytes,
235 TransferToken? token,
236 void Function(int, int)? progress,
237 }) async {
238 await guard(path);
239 final attrs = await client.stat(path, followLink: false);
240 if (!attrs.isFile) throw const Failure('unsafePath');
241 if ((attrs.size ?? 0) > maxBytes) throw const Failure('fileTooLarge');
242 final file = await client.open(path);
243 token?.abort = () {
244 unawaited(file.close().catchError((Object _) {}));
245 };
246 try {
247 final bytes = BytesBuilder(copy: false);
248 await for (final chunk in file.read(
249 onProgress: (n) => progress?.call(n, attrs.size ?? 0),
250 )) {
251 token?.check();
252 bytes.add(chunk);
253 if (bytes.length > maxBytes) throw const Failure('fileTooLarge');
254 }
255 return (bytes.takeBytes(), attrs);
256 } finally {
257 if (token != null) token.abort = null;
258 try {
259 await file.close();
260 } catch (_) {
261 /* Closed by cancellation. */
262 }
263 }
264 }
266 /// The file's attributes without following a symbolic link; null when it
267 /// does not exist.
268 Future<SftpFileAttrs?> statOrNull(String path) async {
269 await guard(path, missingLeaf: true);
270 try {
271 return await client.stat(path, followLink: false);
272 } on SftpStatusError catch (e) {
273 if (e.code == 2) return null;
274 rethrow;
275 }
276 }
278 static Future<List<int>> sha256Of(List<int> bytes) async =>
279 (await Sha256().hash(bytes)).bytes;
281 static bool _same(List<int> a, List<int> b) {
282 if (a.length != b.length) return false;
283 for (var i = 0; i < a.length; i++) {
284 if (a[i] != b[i]) return false;
285 }
286 return true;
287 }
289 /// Files up to this size are compared by content before overwriting, so a
290 /// change within the same second with the same size is still noticed.
291 static const contentCheckLimit = 4 * 1024 * 1024;
293 /// Throws 'remoteChanged' when the file is no longer what was loaded:
294 /// different size, modification time or (for small files) content.
295 Future<SftpFileAttrs> checkUnchanged(
296 String path,
297 SftpFileAttrs expected,
298 List<int>? expectedHash,
299 ) async {
300 final current = await statOrNull(path);
301 if (current == null) throw const Failure('remoteDeleted');
302 if (!current.isFile) throw const Failure('unsafePath');
303 if (current.size != expected.size ||
304 current.modifyTime != expected.modifyTime) {
305 throw const Failure('remoteChanged');
306 }
307 if (expectedHash != null && (current.size ?? 0) <= contentCheckLimit) {
308 final (bytes, _) = await readFile(path, maxBytes: contentCheckLimit);
309 if (!_same(await sha256Of(bytes), expectedHash)) {
310 throw const Failure('remoteChanged');
311 }
312 }
313 return current;
314 }
316 /// Saves [bytes] to the existing file [path] without risking it:
317 ///
318 /// 1. unless [force], verifies the file was not changed since [expected];
319 /// 2. writes a temporary file next to it and gives it the original
320 /// permission bits (and owner/group where the server allows it);
321 /// 3. replaces the original by renaming (atomic with OpenSSH's
322 /// posix-rename; otherwise via a backup that is restored on failure).
323 ///
324 /// A failed save leaves the original untouched. When a temporary file
325 /// cannot be created there ('atomicNotPossible') or the owner would
326 /// change ('ownerWouldChange'), nothing is written; the caller may ask
327 /// the user and retry with [inPlace], which overwrites the file directly
328 /// (keeps owner and links, but is not atomic).
329 Future<SftpFileAttrs> saveFile(
330 String path,
331 Uint8List bytes, {
332 SftpFileAttrs? expected,
333 List<int>? expectedHash,
334 bool force = false,
335 bool inPlace = false,
336 }) async {
337 await guard(path, missingLeaf: true);
338 var current = await statOrNull(path);
339 if (current == null) {
340 if (expected != null && !force) throw const Failure('remoteDeleted');
341 return _createNew(path, bytes);
342 }
343 if (!current.isFile) throw const Failure('unsafePath');
344 if (expected != null && !force) {
345 current = await checkUnchanged(path, expected, expectedHash);
346 }
347 // Replacing via rename needs only folder permission; the user's write
348 // permission on the file itself is checked first, without changing it.
349 try {
350 final probe = await client.open(path, mode: SftpFileOpenMode.write);
351 await probe.close();
352 } on SftpStatusError catch (e) {
353 if (e.code == 3) throw const Failure('permissionDenied');
354 rethrow;
355 }
356 if (inPlace) {
357 final file = await client.open(
358 path,
359 mode: SftpFileOpenMode.write | SftpFileOpenMode.truncate,
360 );
361 try {
362 await file.writeBytes(bytes);
363 } finally {
364 await file.close();
365 }
366 return client.stat(path, followLink: false);
367 }
368 final name = path.substring(path.lastIndexOf('/') + 1);
369 final stem = name.length > 200 ? name.substring(0, 200) : name;
370 final temp = childPath(parentPath(path), '.$stem.sm-${newId()}.tmp');
371 SftpFile? file;
372 var created = false;
373 try {
374 try {
375 file = await client.open(
376 temp,
377 mode:
378 SftpFileOpenMode.write |
379 SftpFileOpenMode.create |
380 SftpFileOpenMode.exclusive,
381 );
382 } on SftpStatusError catch (e) {
383 if (e.code == 3) throw const Failure('atomicNotPossible');
384 rethrow;
385 }
386 created = true;
387 await file.writeBytes(bytes);
388 await file.close();
389 file = null;
390 final mode = current.mode?.value;
391 if (mode != null) {
392 await client.setStat(
393 temp,
394 SftpFileAttrs(mode: SftpFileMode.value(mode & 0xfff)),
395 );
396 }
397 final made = await client.stat(temp, followLink: false);
398 if (current.userID != null &&
399 (made.userID != current.userID || made.groupID != current.groupID)) {
400 try {
401 await client.setStat(
402 temp,
403 SftpFileAttrs(userID: current.userID, groupID: current.groupID),
404 );
405 } catch (_) {
406 throw const Failure('ownerWouldChange');
407 }
408 }
409 // The file may have changed while the copy was written.
410 if (expected != null && !force) {
411 await checkUnchanged(path, current, null);
412 }
413 try {
414 await client.rename(temp, path);
415 } on SftpStatusError {
416 // No posix-rename: move the original aside, then put the new file
417 // in place; restore the original if that fails.
418 final backup = childPath(parentPath(path), '.$stem.sm-${newId()}.bak');
419 await client.rename(path, backup);
420 try {
421 await client.rename(temp, path);
422 } catch (_) {
423 await client.rename(backup, path);
424 rethrow;
425 }
426 try {
427 await client.remove(backup);
428 } catch (_) {
429 /* The new file is in place; a stray backup is harmless. */
430 }
431 }
432 created = false;
433 return await client.stat(path, followLink: false);
434 } finally {
435 if (file != null) {
436 try {
437 await file.close();
438 } catch (_) {
439 /* Session may be gone. */
440 }
441 }
442 if (created) {
443 try {
444 await client.remove(temp);
445 } catch (_) {
446 /* Session may be gone; the original is untouched. */
447 }
448 }
449 }
450 }
452 Future<SftpFileAttrs> _createNew(String path, Uint8List bytes) async {
453 final file = await client.open(
454 path,
455 mode:
456 SftpFileOpenMode.write |
457 SftpFileOpenMode.create |
458 SftpFileOpenMode.exclusive,
459 );
460 try {
461 await file.writeBytes(bytes);
462 } catch (_) {
463 try {
464 await file.close();
465 await client.remove(path);
466 } catch (_) {
467 /* Best effort. */
468 }
469 rethrow;
470 }
471 await file.close();
472 return client.stat(path, followLink: false);
473 }
SHA-256a36aa268aa18f169cf10c9076ff9951ee2f03ce4f582bf7ab9ba0284141c3093

CH-J Proprietary Software License 1.14 · Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.

SHA-256 archivu46e427a43e87af091004f36145590bda8fe90086dfb0f631afeb5029fb0b5655
Soubory