202 lines
7.3 KiB
Python
202 lines
7.3 KiB
Python
import os
|
|
import stat
|
|
import posixpath
|
|
import paramiko
|
|
from .base import AbstractFS
|
|
|
|
|
|
class SFTPfs(AbstractFS):
|
|
|
|
PLUGIN_NAME = "sftp"
|
|
PLUGIN_LABEL = "SFTP"
|
|
|
|
def __init__(self):
|
|
self._client = None # paramiko SSHClient
|
|
self._sftp = None # paramiko SFTPClient
|
|
self._root = "/"
|
|
self._connected = False
|
|
|
|
def connect(self, config: dict):
|
|
host = config['host']
|
|
port = int(config.get('port', 22))
|
|
username = config['username']
|
|
password = config.get('password') or None
|
|
key_path = config.get('key_path') or None
|
|
self._root = config.get('root_path', '/')
|
|
|
|
self._client = paramiko.SSHClient()
|
|
self._client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
|
|
connect_kwargs = dict(hostname=host, port=port, username=username, timeout=10)
|
|
if key_path:
|
|
connect_kwargs['key_filename'] = key_path
|
|
if password:
|
|
connect_kwargs['password'] = password
|
|
|
|
self._client.connect(**connect_kwargs)
|
|
self._sftp = self._client.open_sftp()
|
|
self._connected = True
|
|
|
|
def disconnect(self):
|
|
try:
|
|
if self._sftp:
|
|
self._sftp.close()
|
|
if self._client:
|
|
self._client.close()
|
|
except Exception:
|
|
pass
|
|
self._sftp = None
|
|
self._client = None
|
|
self._connected = False
|
|
|
|
def is_connected(self) -> bool:
|
|
if not self._connected or not self._sftp:
|
|
return False
|
|
try:
|
|
self._sftp.stat('.')
|
|
return True
|
|
except Exception:
|
|
self._connected = False
|
|
return False
|
|
|
|
def _reconnect_if_needed(self, config):
|
|
if not self.is_connected():
|
|
self.connect(config)
|
|
|
|
# ─── Navigation ──────────────────────────────────────────────
|
|
|
|
def list(self, path: str) -> list:
|
|
items = []
|
|
for attr in self._sftp.listdir_attr(path):
|
|
is_dir = stat.S_ISDIR(attr.st_mode)
|
|
items.append({
|
|
'name': attr.filename,
|
|
'path': posixpath.join(path, attr.filename),
|
|
'is_dir': is_dir,
|
|
'size': attr.st_size if not is_dir else 0,
|
|
'mtime': attr.st_mtime,
|
|
})
|
|
items.sort(key=lambda e: (not e['is_dir'], e['name'].lower()))
|
|
return items
|
|
|
|
def isdir(self, path: str) -> bool:
|
|
try:
|
|
return stat.S_ISDIR(self._sftp.stat(path).st_mode)
|
|
except Exception:
|
|
return False
|
|
|
|
def exists(self, path: str) -> bool:
|
|
try:
|
|
self._sftp.stat(path)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def getsize(self, path: str) -> int:
|
|
return self._sftp.stat(path).st_size
|
|
|
|
def join(self, *parts) -> str:
|
|
return posixpath.join(*parts)
|
|
|
|
def basename(self, path: str) -> str:
|
|
return posixpath.basename(path)
|
|
|
|
def dirname(self, path: str) -> str:
|
|
return posixpath.dirname(path)
|
|
|
|
def relpath(self, path: str, base: str) -> str:
|
|
# posixpath n'a pas relpath, on le simule
|
|
if path.startswith(base):
|
|
rel = path[len(base):]
|
|
return rel.lstrip('/')
|
|
return path
|
|
|
|
# ─── Opérations ──────────────────────────────────────────────
|
|
|
|
def mkdir(self, path: str):
|
|
"""Crée le dossier et tous les parents manquants."""
|
|
parts = path.split('/')
|
|
current = ''
|
|
for part in parts:
|
|
if not part:
|
|
current = '/'
|
|
continue
|
|
current = posixpath.join(current, part)
|
|
try:
|
|
self._sftp.stat(current)
|
|
except IOError:
|
|
self._sftp.mkdir(current)
|
|
|
|
def rename(self, old_path: str, new_path: str):
|
|
self._sftp.rename(old_path, new_path)
|
|
|
|
def remove(self, path: str):
|
|
if self.isdir(path):
|
|
for attr in self._sftp.listdir_attr(path):
|
|
child = posixpath.join(path, attr.filename)
|
|
if stat.S_ISDIR(attr.st_mode):
|
|
self.remove(child)
|
|
else:
|
|
self._sftp.remove(child)
|
|
self._sftp.rmdir(path)
|
|
else:
|
|
self._sftp.remove(path)
|
|
|
|
def walk(self, path: str):
|
|
"""os.walk équivalent pour SFTP."""
|
|
dirs = []
|
|
files = []
|
|
for attr in self._sftp.listdir_attr(path):
|
|
if stat.S_ISDIR(attr.st_mode):
|
|
dirs.append(attr.filename)
|
|
else:
|
|
files.append(attr.filename)
|
|
yield path, dirs, files
|
|
for d in dirs:
|
|
yield from self.walk(posixpath.join(path, d))
|
|
|
|
# ─── Transfert ───────────────────────────────────────────────
|
|
|
|
def read_chunks(self, path: str, chunk_size: int = 4 * 1024 * 1024):
|
|
# Limiter le pipelining Paramiko pour contrôler la RAM
|
|
import paramiko
|
|
old_max = paramiko.sftp_file.SFTPFile.MAX_REQUEST_SIZE
|
|
paramiko.sftp_file.SFTPFile.MAX_REQUEST_SIZE = 32768 # 32KB natif SFTP
|
|
import gc
|
|
SFTP_BLOCK = 32768
|
|
try:
|
|
with self._sftp.open(path, 'rb') as f:
|
|
accumulated = bytearray()
|
|
while True:
|
|
block = f.read(SFTP_BLOCK)
|
|
if not block:
|
|
break
|
|
accumulated += block
|
|
if len(accumulated) >= chunk_size:
|
|
data = bytes(accumulated)
|
|
accumulated = bytearray()
|
|
gc.collect() # forcer Python à rendre la mémoire
|
|
yield data
|
|
if accumulated:
|
|
yield bytes(accumulated)
|
|
finally:
|
|
paramiko.sftp_file.SFTPFile.MAX_REQUEST_SIZE = old_max
|
|
|
|
def write_chunks(self, path: str, chunks):
|
|
self.mkdir(posixpath.dirname(path))
|
|
with self._sftp.open(path, 'wb') as f:
|
|
for chunk in chunks:
|
|
f.write(chunk)
|
|
|
|
# ─── Config ──────────────────────────────────────────────────
|
|
|
|
@classmethod
|
|
def get_config_fields(cls) -> list:
|
|
return [
|
|
{'name': 'host', 'label': 'Hôte', 'type': 'text', 'required': True, 'placeholder': 'sftp.exemple.com'},
|
|
{'name': 'port', 'label': 'Port', 'type': 'number', 'required': False, 'default': 22},
|
|
{'name': 'username', 'label': 'Utilisateur', 'type': 'text', 'required': True, 'placeholder': 'user'},
|
|
{'name': 'password', 'label': 'Mot de passe', 'type': 'password', 'required': False, 'placeholder': 'Laisser vide si clé SSH'},
|
|
{'name': 'key_path', 'label': 'Clé SSH (chemin)', 'type': 'text', 'required': False, 'placeholder': '/root/.ssh/id_rsa'},
|
|
{'name': 'root_path', 'label': 'Dossier racine', 'type': 'text', 'required': False, 'default': '/', 'placeholder': '/home/user'},
|
|
] |