Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 86 additions & 12 deletions lib/features/sftp/data/dartssh2_sftp_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class DartSsh2SftpConnection implements SftpConnection {
@override
Future<void> chmod(String path, SftpPermissions permissions) async {
await _withMappedSftpErrors(() async {
final mode = int.parse(permissions.octal, radix: 8);
final mode = int.parse(permissions.normalizedOctal, radix: 8);
await _sftp.setStat(
path,
ssh.SftpFileAttrs(mode: ssh.SftpFileMode.value(mode)),
Expand Down Expand Up @@ -374,6 +374,17 @@ class DartSsh2SftpConnection implements SftpConnection {
);
transferredBytes += await file.length();
}
for (final directory in directories.reversed) {
await transfer.waitIfPaused();
final relativePath = p.relative(directory.path, from: root.path);
final remoteDirectory = relativePath == '.'
? remotePath
: p.posix.join(remotePath, p.split(relativePath).join('/'));
await _trySetRemoteModifiedTime(
remoteDirectory,
(await directory.stat()).modified,
);
}
transfer.markCompleted();
_emitTransferProgress(
controller,
Expand All @@ -397,9 +408,12 @@ class DartSsh2SftpConnection implements SftpConnection {
required StreamController<TransferProgress> controller,
}) async {
ssh.SftpFile? remoteFile;
var completed = false;
late final DateTime localModifiedAt;
try {
await transfer.waitIfPaused();
final localFile = File(localPath);
localModifiedAt = (await localFile.stat()).modified;
remoteFile = await _sftp.open(
remotePath,
mode:
Expand All @@ -425,9 +439,13 @@ class DartSsh2SftpConnection implements SftpConnection {
transfer.bindWriter(writer);
await writer.done;
transfer.throwIfCanceled();
completed = true;
} finally {
await remoteFile?.close();
}
if (completed) {
await _trySetRemoteModifiedTime(remotePath, localModifiedAt);
}
}

Future<void> _downloadFileTransfer({
Expand Down Expand Up @@ -457,6 +475,7 @@ class DartSsh2SftpConnection implements SftpConnection {
baseTransferredBytes: 0,
fileBytes: totalBytes,
aggregateTotalBytes: totalBytes,
remoteModifiedAt: _modifiedAtFromSftpSeconds(stat.modifyTime),
transfer: transfer,
controller: controller,
);
Expand Down Expand Up @@ -530,6 +549,7 @@ class DartSsh2SftpConnection implements SftpConnection {
baseTransferredBytes: transferredBytes,
fileBytes: file.size,
aggregateTotalBytes: aggregateTotalBytes,
remoteModifiedAt: file.modifiedAt,
transfer: transfer,
controller: controller,
);
Expand All @@ -555,6 +575,7 @@ class DartSsh2SftpConnection implements SftpConnection {
required int baseTransferredBytes,
required int? fileBytes,
required int? aggregateTotalBytes,
DateTime? remoteModifiedAt,
required _SftpTransferControl transfer,
required StreamController<TransferProgress> controller,
}) async {
Expand All @@ -566,6 +587,7 @@ class DartSsh2SftpConnection implements SftpConnection {
StreamSubscription<Uint8List>? subscription;
final done = Completer<void>();
var transferredBytes = 0;
var completed = false;

Future<void> fail(Object error, StackTrace stackTrace) async {
if (!done.isCompleted) {
Expand Down Expand Up @@ -630,19 +652,26 @@ class DartSsh2SftpConnection implements SftpConnection {
);
await done.future;
transfer.throwIfCanceled();
completed = true;
} finally {
await subscription?.cancel();
await sink?.close();
await remoteFile?.close();
}
if (completed) {
await _trySetLocalModifiedTime(localFile, remoteModifiedAt);
}
}

Future<_RemoteTree> _collectRemoteTree(String rootPath) async {
final rootAttrs = await _sftp.stat(rootPath);
final directories = <SftpEntry>[
SftpEntry(
name: p.posix.basename(rootPath),
path: rootPath,
type: SftpEntryType.directory,
modifiedAt: _modifiedAtFromSftpSeconds(rootAttrs.modifyTime),
permissions: _permissionsFromSftpMode(rootAttrs.mode),
),
];
final files = <SftpEntry>[];
Expand Down Expand Up @@ -684,6 +713,22 @@ class DartSsh2SftpConnection implements SftpConnection {
}
}

Future<void> _trySetRemoteModifiedTime(
String path,
DateTime modifiedAt,
) async {
try {
final seconds = _sftpSecondsFromDateTime(modifiedAt);
await _sftp.setStat(
path,
ssh.SftpFileAttrs(accessTime: seconds, modifyTime: seconds),
);
} on Object {
// Timestamp preservation is best-effort because many SFTP servers reject
// SETSTAT even when the file transfer itself succeeded.
}
}

static SftpEntry mapName({required String path, required ssh.SftpName name}) {
final attrs = name.attr;
final entryPath = _joinRemotePath(path, name.filename);
Expand All @@ -692,24 +737,53 @@ class DartSsh2SftpConnection implements SftpConnection {
path: entryPath,
type: _mapType(attrs.type),
size: attrs.size,
modifiedAt: attrs.modifyTime == null
? null
: DateTime.fromMillisecondsSinceEpoch(
attrs.modifyTime! * 1000,
isUtc: true,
),
permissions: attrs.mode == null
? null
: SftpPermissions(
(attrs.mode!.value & 0x1ff).toRadixString(8).padLeft(4, '0'),
),
modifiedAt: _modifiedAtFromSftpSeconds(attrs.modifyTime),
permissions: _permissionsFromSftpMode(attrs.mode),
owner: attrs.userID?.toString(),
group: attrs.groupID?.toString(),
isHidden: name.filename.startsWith('.'),
);
}
}

DateTime? _modifiedAtFromSftpSeconds(int? seconds) {
return seconds == null
? null
: DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true);
}

SftpPermissions? _permissionsFromSftpMode(ssh.SftpFileMode? mode) {
return mode == null
? null
: SftpPermissions.fromOctal(
(mode.value & 0xfff).toRadixString(8).padLeft(4, '0'),
);
}

int _sftpSecondsFromDateTime(DateTime value) {
return value.toUtc().millisecondsSinceEpoch ~/ 1000;
}

Future<void> _trySetLocalModifiedTime(
FileSystemEntity entity,
DateTime? modifiedAt,
) async {
if (modifiedAt == null) {
return;
}
try {
switch (entity) {
case File file:
await file.setLastModified(modifiedAt);
default:
return;
}
} on Object {
// Some platforms or target locations reject metadata writes; downloaded
// contents should remain successful when timestamp restoration fails.
}
}

class _RemoteTree {
const _RemoteTree({required this.directories, required this.files});

Expand Down
147 changes: 147 additions & 0 deletions lib/features/sftp/domain/sftp_entry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,33 @@ enum SftpEntryType { file, directory, symlink, unknown }
class SftpPermissions {
const SftpPermissions(this.octal);

factory SftpPermissions.fromOctal(String octal) {
final normalized = _normalizeOctalPermissions(octal);
if (normalized == null) {
throw FormatException('Invalid octal permissions: $octal');
}
return SftpPermissions(normalized);
}

static SftpPermissions? tryParse(String input) {
final trimmed = input.trim();
final normalizedOctal = _normalizeOctalPermissions(trimmed);
if (normalizedOctal != null) {
return SftpPermissions(normalizedOctal);
}
final symbolicOctal = _octalFromSymbolicPermissions(trimmed);
if (symbolicOctal != null) {
return SftpPermissions(symbolicOctal);
}
return null;
}

final String octal;

String get normalizedOctal => _normalizeOctalPermissions(octal) ?? octal;

String get symbolic =>
_symbolicPermissionsFromOctal(normalizedOctal) ?? octal;
}

class SftpEntry {
Expand All @@ -29,3 +55,124 @@ class SftpEntry {
final String? group;
final bool isHidden;
}

String? _normalizeOctalPermissions(String value) {
if (!RegExp(r'^[0-7]{3,4}$').hasMatch(value)) {
return null;
}
return value.length == 3 ? '0$value' : value;
}

String? _symbolicPermissionsFromOctal(String value) {
final normalized = _normalizeOctalPermissions(value);
if (normalized == null) {
return null;
}
final special = int.parse(normalized[0], radix: 8);
return [
_symbolicPermissionTriplet(
int.parse(normalized[1], radix: 8),
special: special & 4 != 0,
executableSpecial: 's',
nonExecutableSpecial: 'S',
),
_symbolicPermissionTriplet(
int.parse(normalized[2], radix: 8),
special: special & 2 != 0,
executableSpecial: 's',
nonExecutableSpecial: 'S',
),
_symbolicPermissionTriplet(
int.parse(normalized[3], radix: 8),
special: special & 1 != 0,
executableSpecial: 't',
nonExecutableSpecial: 'T',
),
].join();
}

String _symbolicPermissionTriplet(
int digit, {
required bool special,
required String executableSpecial,
required String nonExecutableSpecial,
}) {
final read = digit & 4 != 0 ? 'r' : '-';
final write = digit & 2 != 0 ? 'w' : '-';
final executable = digit & 1 != 0;
final execute = special
? (executable ? executableSpecial : nonExecutableSpecial)
: (executable ? 'x' : '-');
return '$read$write$execute';
}

String? _octalFromSymbolicPermissions(String value) {
final symbolic = value.length == 10 && _looksLikeFileType(value[0])
? value.substring(1)
: value;
if (symbolic.length != 9) {
return null;
}
final user = _octalDigitFromSymbolicTriplet(
symbolic.substring(0, 3),
specialExecutable: 's',
specialNonExecutable: 'S',
);
final group = _octalDigitFromSymbolicTriplet(
symbolic.substring(3, 6),
specialExecutable: 's',
specialNonExecutable: 'S',
);
final other = _octalDigitFromSymbolicTriplet(
symbolic.substring(6, 9),
specialExecutable: 't',
specialNonExecutable: 'T',
);
if (user == null || group == null || other == null) {
return null;
}
final special =
(user.special ? 4 : 0) +
(group.special ? 2 : 0) +
(other.special ? 1 : 0);
return '$special${user.digit}${group.digit}${other.digit}';
}

bool _looksLikeFileType(String value) {
return const {'-', 'd', 'l', 'c', 'b', 'p', 's', '?'}.contains(value);
}

({int digit, bool special})? _octalDigitFromSymbolicTriplet(
String triplet, {
required String specialExecutable,
required String specialNonExecutable,
}) {
final read = switch (triplet[0]) {
'r' => 4,
'-' => 0,
_ => null,
};
final write = switch (triplet[1]) {
'w' => 2,
'-' => 0,
_ => null,
};
if (read == null || write == null) {
return null;
}

final execute = switch (triplet[2]) {
'x' => (digit: 1, special: false),
'-' => (digit: 0, special: false),
String value when value == specialExecutable => (digit: 1, special: true),
String value when value == specialNonExecutable => (
digit: 0,
special: true,
),
_ => null,
};
if (execute == null) {
return null;
}
return (digit: read + write + execute.digit, special: execute.special);
}
1 change: 1 addition & 0 deletions lib/features/workspace/presentation/workspace_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'dart:math' as math;

import 'package:desktop_drop/desktop_drop.dart';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ String _fileName(String path) {
return parts.last;
}

String? _droppedItemName(DropItem item, String localPath) {
final itemName = item.name.trim();
final candidate = itemName.isEmpty ? _fileName(localPath) : itemName;
final name = _fileName(candidate).trim();
if (name.isEmpty || name == '.' || name == '..') {
return null;
}
return name;
}

String _parentPath(String path) {
final normalized = _joinRemotePath(path);
if (normalized == '/') {
Expand Down Expand Up @@ -49,10 +59,6 @@ bool _sameRemotePath(String left, String right) {
return _joinRemotePath(left) == _joinRemotePath(right);
}

bool _isOctalPermissions(String value) {
return RegExp(r'^[0-7]{3,4}$').hasMatch(value);
}

String _joinRemotePath(String path) {
final segments = <String>[];
for (final segment in path.split('/')) {
Expand Down
Loading