2026-04-27 21:17:53 -07:00
from __future__ import annotations
import fnmatch
import posixpath
import shlex
import stat
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any , Dict , List , Optional , Tuple
from urllib . parse import quote , unquote , urlparse
import paramiko
from scp import SCPClient
2026-05-21 16:19:17 -07:00
from PluginCore . base import Provider , SearchResult , parse_inline_query_arguments
2026-04-27 21:17:53 -07:00
def _coerce_bool ( value : Any , default : bool = False ) - > bool :
if isinstance ( value , bool ) :
return value
if value is None :
return default
text = str ( value ) . strip ( ) . lower ( )
if not text :
return default
if text in { " 1 " , " true " , " yes " , " on " } :
return True
if text in { " 0 " , " false " , " no " , " off " } :
return False
return default
def _coerce_int ( value : Any , default : int ) - > int :
try :
return int ( value )
except Exception :
return default
def _format_epoch ( raw_value : Any ) - > str :
try :
stamp = int ( raw_value )
except Exception :
return " "
try :
return datetime . fromtimestamp ( stamp ) . strftime ( " % Y- % m- %d % H: % M " )
except Exception :
return str ( raw_value or " " )
def _safe_filename ( name : Any ) - > str :
raw = str ( name or " " ) . strip ( )
if not raw :
raw = " download "
cleaned = " " . join ( ch if ch . isalnum ( ) or ch in { " - " , " _ " , " . " , " " } else " _ " for ch in raw )
cleaned = cleaned . strip ( " ._ " )
return cleaned or " download "
def _unique_path ( path : Path ) - > Path :
if not path . exists ( ) :
return path
stem = path . stem or " download "
suffix = path . suffix
counter = 1
while True :
candidate = path . with_name ( f " { stem } _ { counter } { suffix } " )
if not candidate . exists ( ) :
return candidate
counter + = 1
class SCP ( Provider ) :
PLUGIN_NAME = " scp "
URL = ( " scp:// " , " sftp:// " )
2026-05-26 19:00:04 -07:00
SUPPORTED_CMDLETS = frozenset ( { " add-file " , " download-file " , " search-file " } )
2026-04-27 21:17:53 -07:00
@property
def label ( self ) - > str :
return " SCP "
@property
def preserve_order ( self ) - > bool :
return True
@classmethod
def config_schema ( cls ) - > List [ Dict [ str , Any ] ] :
return [
{
" key " : " host " ,
" label " : " Host " ,
" default " : " " ,
" required " : True ,
" placeholder " : " ssh.example.com " ,
} ,
{
" key " : " port " ,
" label " : " Port " ,
" type " : " integer " ,
" default " : 22 ,
} ,
{
" key " : " username " ,
" label " : " Username " ,
" default " : " " ,
" required " : True ,
" placeholder " : " deploy " ,
} ,
{
" key " : " password " ,
" label " : " Password " ,
" type " : " secret " ,
" secret " : True ,
" default " : " " ,
} ,
{
" key " : " key_path " ,
" label " : " SSH Key Path " ,
" type " : " path " ,
" default " : " " ,
" placeholder " : " C:/Users/Admin/.ssh/id_ed25519 " ,
} ,
{
" key " : " base_path " ,
" label " : " Base Path " ,
" default " : " / " ,
" placeholder " : " /srv/files " ,
} ,
{
" key " : " timeout " ,
" label " : " Timeout Seconds " ,
" type " : " integer " ,
" default " : 20 ,
} ,
{
" key " : " search_depth " ,
" label " : " Default Search Depth " ,
" type " : " integer " ,
" default " : 1 ,
} ,
{
" key " : " allow_agent " ,
" label " : " Use SSH Agent " ,
" type " : " boolean " ,
" default " : True ,
} ,
{
" key " : " look_for_keys " ,
" label " : " Look For Default Keys " ,
" type " : " boolean " ,
" default " : True ,
} ,
]
def __init__ ( self , config : Optional [ Dict [ str , Any ] ] = None ) :
super ( ) . __init__ ( config )
2026-04-28 22:20:54 -07:00
_instance_name , conf = self . resolve_plugin_instance ( )
defaults = self . _settings_from_config ( conf )
self . _host = str ( defaults . get ( " host " ) or " " ) . strip ( )
self . _port = int ( defaults . get ( " port " ) or 22 )
self . _username = str ( defaults . get ( " username " ) or " " ) . strip ( )
self . _password = str ( defaults . get ( " password " ) or " " ) . strip ( )
self . _key_path = str ( defaults . get ( " key_path " ) or " " ) . strip ( )
self . _timeout = max ( 1 , int ( defaults . get ( " timeout " ) or 20 ) )
self . _search_depth = max ( 0 , int ( defaults . get ( " search_depth " ) or 1 ) )
self . _allow_agent = bool ( defaults . get ( " allow_agent " ) )
self . _look_for_keys = bool ( defaults . get ( " look_for_keys " ) )
self . _base_path = self . _normalize_remote_path ( defaults . get ( " base_path " ) or " / " , default = " / " )
def _settings_from_config ( self , conf : Optional [ Dict [ str , Any ] ] , * , instance_name : Optional [ str ] = None ) - > Dict [ str , Any ] :
entry = dict ( conf or { } )
return {
" instance " : str ( instance_name or entry . get ( " _instance_name " ) or " " ) . strip ( ) or None ,
" host " : str ( entry . get ( " host " ) or " " ) . strip ( ) ,
" port " : _coerce_int ( entry . get ( " port " ) , 22 ) ,
" username " : str ( entry . get ( " username " ) or entry . get ( " user " ) or " " ) . strip ( ) ,
" password " : str ( entry . get ( " password " ) or " " ) . strip ( ) ,
" key_path " : str ( entry . get ( " key_path " ) or entry . get ( " identity_file " ) or " " ) . strip ( ) ,
" timeout " : max ( 1 , _coerce_int ( entry . get ( " timeout " ) , 20 ) ) ,
" search_depth " : max ( 0 , _coerce_int ( entry . get ( " search_depth " ) , 1 ) ) ,
" allow_agent " : _coerce_bool ( entry . get ( " allow_agent " ) , True ) ,
" look_for_keys " : _coerce_bool ( entry . get ( " look_for_keys " ) , True ) ,
" base_path " : self . _normalize_remote_path ( entry . get ( " base_path " ) or " / " , default = " / " ) ,
}
def _resolve_settings (
self ,
* ,
filters : Optional [ Dict [ str , Any ] ] = None ,
instance_name : Optional [ str ] = None ,
require_explicit : bool = False ,
) - > Dict [ str , Any ] :
requested = self . requested_instance_name ( filters , instance = instance_name )
resolved_name , conf = self . resolve_plugin_instance (
requested ,
require_explicit = require_explicit or bool ( requested ) ,
)
settings = self . _settings_from_config ( conf , instance_name = resolved_name )
if settings . get ( " instance " ) is None and requested :
settings [ " instance " ] = requested
return settings
2026-04-27 21:17:53 -07:00
def validate ( self ) - > bool :
2026-04-28 22:20:54 -07:00
settings = self . _resolve_settings ( )
return bool ( settings . get ( " host " ) and settings . get ( " username " ) )
2026-04-27 21:17:53 -07:00
def config_helper_text ( self ) - > str :
return " Test the SSH/SCP connection before searching. You can also generate an RSA key pair from here. "
def config_actions ( self ) - > List [ Dict [ str , Any ] ] :
return [
{
" id " : " test_connection " ,
" label " : " Test connection " ,
" variant " : " primary " ,
} ,
{
" id " : " generate_ssh_key " ,
" label " : " Generate SSH key " ,
" variant " : " default " ,
} ,
]
def run_config_action ( self , action_id : str , * * _kwargs : Any ) - > Dict [ str , Any ] :
normalized = str ( action_id or " " ) . strip ( ) . lower ( )
if normalized == " test_connection " :
return self . _run_test_connection ( )
if normalized == " generate_ssh_key " :
return self . _generate_ssh_keypair ( )
return super ( ) . run_config_action ( action_id , * * _kwargs )
def extract_query_arguments ( self , query : str ) - > Tuple [ str , Dict [ str , Any ] ] :
text , inline = parse_inline_query_arguments ( query )
filters : Dict [ str , Any ] = { }
2026-04-28 22:20:54 -07:00
instance_name = str ( inline . get ( " instance " ) or inline . get ( " store " ) or " " ) . strip ( )
if instance_name :
filters [ " instance " ] = instance_name
2026-04-27 21:17:53 -07:00
if inline . get ( " path " ) :
filters [ " path " ] = inline . get ( " path " )
if inline . get ( " depth " ) :
filters [ " depth " ] = max ( 0 , _coerce_int ( inline . get ( " depth " ) , self . _search_depth ) )
if inline . get ( " type " ) :
filters [ " type " ] = str ( inline . get ( " type " ) or " " ) . strip ( ) . lower ( )
return text , filters
def get_table_title ( self , query : str , filters : Optional [ Dict [ str , Any ] ] = None ) - > str :
2026-04-28 22:20:54 -07:00
settings = self . _resolve_settings ( filters = filters )
active_path = self . _normalize_remote_path ( ( filters or { } ) . get ( " path " ) or settings . get ( " base_path " ) or " / " , default = str ( settings . get ( " base_path " ) or " / " ) )
instance_name = str ( settings . get ( " instance " ) or " " ) . strip ( )
2026-04-27 21:17:53 -07:00
text = str ( query or " " ) . strip ( )
if not text or text == " * " :
2026-04-28 22:20:54 -07:00
return f " SCP { f ' [ { instance_name } ] ' if instance_name else ' ' } : { active_path } "
return f " SCP { f ' [ { instance_name } ] ' if instance_name else ' ' } : { text } @ { active_path } "
2026-04-27 21:17:53 -07:00
def get_table_metadata ( self , query : str , filters : Optional [ Dict [ str , Any ] ] = None ) - > Dict [ str , Any ] :
2026-04-28 22:20:54 -07:00
settings = self . _resolve_settings ( filters = filters )
2026-04-27 21:17:53 -07:00
return {
" plugin " : self . name ,
2026-04-28 22:20:54 -07:00
" instance " : settings . get ( " instance " ) ,
" host " : settings . get ( " host " ) ,
" path " : self . _normalize_remote_path ( ( filters or { } ) . get ( " path " ) or settings . get ( " base_path " ) or " / " , default = str ( settings . get ( " base_path " ) or " / " ) ) ,
2026-04-27 21:17:53 -07:00
" query " : str ( query or " " ) . strip ( ) ,
}
def search (
self ,
query : str ,
limit : int = 50 ,
filters : Optional [ Dict [ str , Any ] ] = None ,
* * kwargs : Any ,
) - > List [ SearchResult ] :
_ = kwargs
active_filters = dict ( filters or { } )
2026-04-28 22:20:54 -07:00
settings = self . _resolve_settings ( filters = active_filters , require_explicit = True )
if not settings . get ( " host " ) or not settings . get ( " username " ) :
requested = self . requested_instance_name ( active_filters )
if requested :
raise RuntimeError ( f " SCP instance ' { requested } ' is unavailable " )
return [ ]
start_path = self . _normalize_remote_path ( active_filters . get ( " path " ) or settings . get ( " base_path " ) or " / " , default = str ( settings . get ( " base_path " ) or " / " ) )
search_depth = max ( 0 , _coerce_int ( active_filters . get ( " depth " ) , int ( settings . get ( " search_depth " ) or self . _search_depth ) ) )
2026-04-27 21:17:53 -07:00
type_filter = str ( active_filters . get ( " type " ) or " any " ) . strip ( ) . lower ( )
needle = str ( query or " " ) . strip ( )
max_results = max ( 0 , int ( limit or 0 ) )
if max_results < = 0 :
return [ ]
2026-04-28 22:20:54 -07:00
ssh = self . _connect_ssh ( settings )
2026-04-27 21:17:53 -07:00
sftp = None
try :
try :
sftp = self . _open_sftp ( ssh )
except Exception as exc :
if not self . _is_sftp_negotiation_error ( exc ) :
raise
return self . _search_directory_via_ssh (
ssh ,
start_path ,
needle = needle ,
limit = max_results ,
search_depth = search_depth ,
type_filter = type_filter ,
2026-04-28 22:20:54 -07:00
settings = settings ,
2026-04-27 21:17:53 -07:00
)
return self . _search_directory (
sftp ,
start_path ,
needle = needle ,
limit = max_results ,
search_depth = search_depth ,
type_filter = type_filter ,
2026-04-28 22:20:54 -07:00
settings = settings ,
2026-04-27 21:17:53 -07:00
)
finally :
self . _close_client ( sftp )
self . _close_client ( ssh )
def selector (
self ,
selected_items : List [ Any ] ,
* ,
ctx : Any ,
stage_is_last : bool = True ,
* * _kwargs : Any ,
) - > bool :
if not stage_is_last :
return False
target_path = " "
target_title = " "
2026-04-28 22:20:54 -07:00
instance_name = " "
2026-04-27 21:17:53 -07:00
for item in selected_items or [ ] :
metadata = self . _item_metadata ( item )
if not metadata . get ( " is_dir " ) :
continue
2026-04-28 22:20:54 -07:00
settings = self . _resolve_settings ( instance_name = str ( metadata . get ( " instance " ) or " " ) . strip ( ) or None , require_explicit = bool ( metadata . get ( " instance " ) ) )
target_path = self . _normalize_remote_path ( metadata . get ( " scp_path " ) or metadata . get ( " selection_path " ) , default = str ( settings . get ( " base_path " ) or " / " ) )
2026-04-27 21:17:53 -07:00
target_title = str ( metadata . get ( " title " ) or metadata . get ( " name " ) or " " ) . strip ( )
2026-04-28 22:20:54 -07:00
instance_name = str ( settings . get ( " instance " ) or metadata . get ( " instance " ) or " " ) . strip ( )
2026-04-27 21:17:53 -07:00
if target_path :
break
if not target_path :
return False
2026-04-28 22:20:54 -07:00
settings = self . _resolve_settings ( instance_name = instance_name or None , require_explicit = bool ( instance_name ) )
ssh = self . _connect_ssh ( settings )
2026-04-27 21:17:53 -07:00
sftp = None
try :
try :
sftp = self . _open_sftp ( ssh )
except Exception as exc :
if not self . _is_sftp_negotiation_error ( exc ) :
raise
rows = self . _search_directory_via_ssh (
ssh ,
target_path ,
needle = " * " ,
limit = 500 ,
search_depth = 0 ,
type_filter = " any " ,
2026-04-28 22:20:54 -07:00
settings = settings ,
2026-04-27 21:17:53 -07:00
)
else :
rows = self . _search_directory (
sftp ,
target_path ,
needle = " * " ,
limit = 500 ,
search_depth = 0 ,
type_filter = " any " ,
2026-04-28 22:20:54 -07:00
settings = settings ,
2026-04-27 21:17:53 -07:00
)
finally :
self . _close_client ( sftp )
self . _close_client ( ssh )
try :
from SYS . result_table import Table
from SYS . rich_display import stdout_console
except Exception :
return True
title = target_title or target_path
2026-04-28 22:20:54 -07:00
table = Table ( f " SCP { f ' [ { instance_name } ] ' if instance_name else ' ' } : { title } " ) . _perseverance ( True )
2026-04-27 21:17:53 -07:00
table . set_table ( " scp " )
try :
table . set_table_metadata ( {
2026-05-26 15:32:01 -07:00
" plugin " : " scp " ,
2026-04-28 22:20:54 -07:00
" instance " : instance_name or None ,
" host " : settings . get ( " host " ) ,
2026-04-27 21:17:53 -07:00
" path " : target_path ,
" view " : " directory " ,
} )
except Exception :
pass
2026-04-28 22:20:54 -07:00
source_args = [ " -plugin " , " scp " ]
if instance_name :
source_args . extend ( [ " -instance " , instance_name ] )
source_args . extend ( [ f " path: { target_path } " , " * " ] )
table . set_source_command ( " search-file " , source_args )
2026-04-27 21:17:53 -07:00
payloads : List [ Dict [ str , Any ] ] = [ ]
for row in rows :
table . add_result ( row )
payloads . append ( row . to_dict ( ) )
try :
2026-04-28 22:20:54 -07:00
ctx . set_last_result_table ( table , payloads , subject = { " plugin " : " scp " , " instance " : instance_name or None , " path " : target_path } )
2026-04-27 21:17:53 -07:00
ctx . set_current_stage_table ( table )
except Exception :
pass
try :
stdout_console ( ) . print ( )
stdout_console ( ) . print ( table )
except Exception :
pass
return True
2026-04-28 22:20:54 -07:00
def show_selection_details (
self ,
selected_items : List [ Any ] ,
* ,
ctx : Any ,
stage_is_last : bool = True ,
source_command : str = " " ,
table_type : str = " " ,
table_metadata : Optional [ Dict [ str , Any ] ] = None ,
* * _kwargs : Any ,
) - > bool :
_ = table_type
item , _payload , _meta = self . resolve_selection_detail_subject (
selected_items ,
stage_is_last = stage_is_last ,
source_command = source_command ,
require_media_kind = " file " ,
)
if item is None :
return False
metadata = self . _item_metadata ( item )
if bool ( metadata . get ( " is_dir " ) ) :
return False
title = str ( metadata . get ( " title " ) or metadata . get ( " name " ) or metadata . get ( " path " ) or " " ) . strip ( ) or " SCP Item "
instance_name = str ( metadata . get ( " instance " ) or ( table_metadata or { } ) . get ( " instance " ) or " " ) . strip ( )
scp_url = str ( metadata . get ( " scp_url " ) or metadata . get ( " selection_url " ) or metadata . get ( " path " ) or " " ) . strip ( )
remote_path = str ( metadata . get ( " scp_path " ) or " " ) . strip ( )
host = str ( metadata . get ( " host " ) or " " ) . strip ( )
modified = str ( metadata . get ( " modified " ) or " " ) . strip ( )
try :
from SYS . detail_view_helpers import prepare_detail_metadata , render_selection_detail_view
except Exception :
return super ( ) . show_selection_details (
selected_items ,
ctx = ctx ,
stage_is_last = stage_is_last ,
source_command = source_command ,
table_type = table_type ,
table_metadata = table_metadata ,
)
detail_metadata = prepare_detail_metadata (
item ,
title = title ,
store = instance_name or self . name ,
path = scp_url or remote_path or None ,
tags = metadata . get ( " tag " ) or metadata . get ( " tags " ) ,
extra_fields = {
" Plugin " : self . name ,
" Host " : host or None ,
" Instance " : instance_name or None ,
" Remote Path " : remote_path or None ,
" Directory " : str ( metadata . get ( " detail " ) or " " ) . strip ( ) or None ,
" Modified " : modified or None ,
" Scp Url " : scp_url or None ,
} ,
)
return render_selection_detail_view (
ctx = ctx ,
item = item ,
title = f " SCP Item: { title } " ,
metadata = detail_metadata ,
table_name = self . name ,
2026-05-04 15:08:18 -07:00
detail_order = [ " Title " , " Instance " , " Host " , " Remote Path " , " Directory " , " Modified " , " Path " , " Ext " , " SCP URL " , " Plugin " ] ,
2026-04-28 22:20:54 -07:00
value_case = " preserve " ,
)
2026-04-27 21:17:53 -07:00
def download ( self , result : SearchResult , output_dir : Path ) - > Optional [ Path ] :
metadata = getattr ( result , " full_metadata " , None )
if isinstance ( metadata , dict ) and metadata . get ( " is_dir " ) :
return None
target = str ( getattr ( result , " path " , " " ) or " " ) . strip ( )
if not target :
return None
2026-04-28 22:20:54 -07:00
instance_name = str ( metadata . get ( " instance " ) or " " ) . strip ( ) if isinstance ( metadata , dict ) else " "
return self . download_url ( target , output_dir , title = getattr ( result , " title " , None ) , instance = instance_name or None )
2026-04-27 21:17:53 -07:00
def download_url ( self , url : str , output_dir : Path , * * kwargs : Any ) - > Optional [ Path ] :
2026-04-28 22:20:54 -07:00
parsed = kwargs . get ( " parsed " ) if isinstance ( kwargs . get ( " parsed " ) , dict ) else { }
settings = self . _connection_settings_for_url (
url ,
instance_name = str ( kwargs . get ( " instance " ) or parsed . get ( " instance " ) or " " ) . strip ( ) or None ,
)
2026-04-27 21:17:53 -07:00
remote_path = settings [ " path " ]
if not remote_path or remote_path == " / " :
return None
filename_hint = str ( kwargs . get ( " title " ) or " " ) . strip ( )
parsed_name = posixpath . basename ( remote_path . rstrip ( " / " ) )
filename = _safe_filename ( filename_hint or unquote ( parsed_name ) or " download " )
destination_dir = Path ( output_dir )
destination_dir . mkdir ( parents = True , exist_ok = True )
destination = _unique_path ( destination_dir / filename )
ssh = self . _connect_ssh ( settings )
scp_client = None
try :
scp_client = self . _open_scp ( ssh )
scp_client . get ( remote_path , local_path = str ( destination ) )
return destination
except Exception :
try :
destination . unlink ( missing_ok = True )
except Exception :
pass
return None
finally :
self . _close_client ( scp_client )
self . _close_client ( ssh )
def resolve_pipe_result_download (
self ,
result : Any ,
pipe_obj : Any ,
) - > Tuple [ Optional [ Path ] , Optional [ str ] , Optional [ Path ] ] :
metadata = self . _item_metadata ( result , pipe_obj = pipe_obj )
if metadata . get ( " is_dir " ) :
return None , None , None
download_url = str (
metadata . get ( " selection_url " )
or metadata . get ( " scp_url " )
or metadata . get ( " path " )
or " "
) . strip ( )
if not download_url . startswith ( ( " scp:// " , " sftp:// " ) ) :
return None , None , None
temp_dir = Path ( tempfile . mkdtemp ( prefix = " scp-add-file- " ) )
2026-04-28 22:20:54 -07:00
downloaded = self . download_url (
download_url ,
temp_dir ,
title = metadata . get ( " title " ) ,
instance = metadata . get ( " instance " ) ,
)
2026-04-27 21:17:53 -07:00
if downloaded is None :
try :
temp_dir . rmdir ( )
except Exception :
pass
return None , None , None
try :
if pipe_obj is not None :
pipe_obj . is_temp = True
except Exception :
pass
return downloaded , None , temp_dir
def upload ( self , file_path : str , * * kwargs : Any ) - > str :
local_path = Path ( str ( file_path or " " ) ) . expanduser ( )
if not local_path . exists ( ) or not local_path . is_file ( ) :
raise FileNotFoundError ( f " File not found: { local_path } " )
2026-05-04 18:41:01 -07:00
pipe_obj = kwargs . get ( " pipe_obj " )
2026-04-28 22:20:54 -07:00
settings = self . _resolve_settings (
instance_name = str ( kwargs . get ( " instance " ) or kwargs . get ( " store " ) or " " ) . strip ( ) or None ,
require_explicit = bool ( kwargs . get ( " instance " ) or kwargs . get ( " store " ) ) ,
)
if not settings . get ( " host " ) or not settings . get ( " username " ) :
requested = str ( kwargs . get ( " instance " ) or kwargs . get ( " store " ) or " " ) . strip ( )
if requested :
raise RuntimeError ( f " SCP instance ' { requested } ' is unavailable " )
raise RuntimeError ( " No configured SCP instance is available " )
remote_dir = self . _normalize_remote_path (
kwargs . get ( " remote_path " ) or kwargs . get ( " path " ) or settings . get ( " base_path " ) or " / " ,
default = str ( settings . get ( " base_path " ) or " / " ) ,
)
2026-04-27 21:17:53 -07:00
remote_name = posixpath . basename ( str ( kwargs . get ( " remote_name " ) or local_path . name ) . replace ( " \\ " , " / " ) ) or local_path . name
remote_path = self . _join_remote_path ( remote_dir , remote_name )
2026-04-28 22:20:54 -07:00
ssh = self . _connect_ssh ( settings )
2026-04-27 21:17:53 -07:00
sftp = None
scp_client = None
try :
try :
sftp = self . _open_sftp ( ssh )
except Exception as exc :
if not self . _is_sftp_negotiation_error ( exc ) :
raise
self . _ensure_directory_via_ssh ( ssh , remote_dir )
2026-05-04 18:41:01 -07:00
if self . _remote_filename_exists_via_ssh ( ssh , remote_path ) :
try :
if pipe_obj is not None :
if not isinstance ( getattr ( pipe_obj , " extra " , None ) , dict ) :
pipe_obj . extra = { }
pipe_obj . extra [ " upload_duplicate " ] = True
pipe_obj . extra [ " upload_duplicate_rule " ] = " filename "
pipe_obj . extra [ " upload_duplicate_target " ] = remote_path
except Exception :
pass
return self . _build_url ( remote_path , settings = settings )
2026-04-27 21:17:53 -07:00
else :
2026-04-28 22:20:54 -07:00
self . _ensure_directory ( sftp , remote_dir , base_path = str ( settings . get ( " base_path " ) or " / " ) )
2026-05-04 18:41:01 -07:00
if self . _remote_filename_exists ( sftp , remote_path ) :
try :
if pipe_obj is not None :
if not isinstance ( getattr ( pipe_obj , " extra " , None ) , dict ) :
pipe_obj . extra = { }
pipe_obj . extra [ " upload_duplicate " ] = True
pipe_obj . extra [ " upload_duplicate_rule " ] = " filename "
pipe_obj . extra [ " upload_duplicate_target " ] = remote_path
except Exception :
pass
return self . _build_url ( remote_path , settings = settings )
2026-04-27 21:17:53 -07:00
scp_client = self . _open_scp ( ssh )
scp_client . put ( str ( local_path ) , remote_path = remote_path )
finally :
self . _close_client ( scp_client )
self . _close_client ( sftp )
self . _close_client ( ssh )
2026-04-28 22:20:54 -07:00
return self . _build_url ( remote_path , settings = settings )
2026-04-27 21:17:53 -07:00
2026-05-04 18:41:01 -07:00
def _remote_filename_exists ( self , sftp : Any , remote_path : str ) - > bool :
try :
sftp . stat ( remote_path )
return True
except Exception :
return False
def _remote_filename_exists_via_ssh ( self , ssh : Any , remote_path : str ) - > bool :
normalized = self . _normalize_remote_path ( remote_path , default = self . _base_path )
quoted_path = shlex . quote ( normalized )
status , _ , _ = self . _run_ssh_command ( ssh , f " test -e { quoted_path } " )
return status == 0
2026-04-27 21:17:53 -07:00
def _run_test_connection ( self ) - > Dict [ str , Any ] :
2026-04-28 22:20:54 -07:00
settings = self . _resolve_settings ( )
if not settings . get ( " host " ) :
2026-04-27 21:17:53 -07:00
return { " ok " : False , " message " : " Set ' host ' before testing the SCP connection. " }
2026-04-28 22:20:54 -07:00
if not settings . get ( " username " ) :
2026-04-27 21:17:53 -07:00
return { " ok " : False , " message " : " Set ' username ' before testing the SCP connection. " }
ssh = None
sftp = None
try :
2026-04-28 22:20:54 -07:00
ssh = self . _connect_ssh ( settings )
base_path = str ( settings . get ( " base_path " ) or " / " )
2026-04-27 21:17:53 -07:00
transport_detail = " SFTP available "
try :
sftp = self . _open_sftp ( ssh )
except Exception as exc :
if not self . _is_sftp_negotiation_error ( exc ) :
raise
is_dir = self . _path_exists_via_ssh ( ssh , base_path )
transport_detail = " SFTP unavailable; using SSH command fallback "
else :
try :
attrs = sftp . stat ( base_path )
is_dir = stat . S_ISDIR ( getattr ( attrs , " st_mode " , 0 ) )
except Exception :
is_dir = False
detail = f " and confirmed { base_path } " if is_dir else " "
2026-04-28 22:20:54 -07:00
key_path = str ( settings . get ( " key_path " ) or " " ) . strip ( )
auth_mode = f " key { key_path } " if key_path else " password/agent auth "
2026-04-27 21:17:53 -07:00
return {
" ok " : True ,
2026-04-28 22:20:54 -07:00
" message " : f " Connected to SCP { settings . get ( ' host ' ) } : { settings . get ( ' port ' ) } as { settings . get ( ' username ' ) } via { auth_mode } . { transport_detail } { detail } . " ,
2026-04-27 21:17:53 -07:00
}
except Exception as exc :
return { " ok " : False , " message " : f " SCP connection failed: { exc } " }
finally :
self . _close_client ( sftp )
self . _close_client ( ssh )
def _generate_ssh_keypair ( self ) - > Dict [ str , Any ] :
2026-04-28 22:20:54 -07:00
settings = self . _resolve_settings ( )
key_path = str ( settings . get ( " key_path " ) or " " ) . strip ( )
target = Path ( key_path ) . expanduser ( ) if key_path else ( Path . home ( ) / " .ssh " / " medeia_scp_rsa " )
2026-04-27 21:17:53 -07:00
try :
target . parent . mkdir ( parents = True , exist_ok = True )
except Exception as exc :
return { " ok " : False , " message " : f " Could not create key directory: { exc } " }
public_path = target . with_name ( target . name + " .pub " )
if target . exists ( ) or public_path . exists ( ) :
return {
" ok " : False ,
" message " : f " SSH key already exists at { target } . Remove it or choose a different key_path first. " ,
}
try :
key = paramiko . RSAKey . generate ( bits = 4096 )
key . write_private_key_file ( str ( target ) )
2026-04-28 22:20:54 -07:00
comment = f " { settings . get ( ' username ' ) or ' medeia ' } @ { settings . get ( ' host ' ) or ' scp ' } "
2026-04-27 21:17:53 -07:00
public_path . write_text ( f " { key . get_name ( ) } { key . get_base64 ( ) } { comment } \n " , encoding = " utf-8 " )
try :
target . chmod ( 0o600 )
except Exception :
pass
return {
" ok " : True ,
" message " : f " Generated SSH key pair at { target } . Save the config to persist key_path. " ,
" config_updates " : { " key_path " : str ( target ) } ,
}
except Exception as exc :
try :
target . unlink ( missing_ok = True )
except Exception :
pass
try :
public_path . unlink ( missing_ok = True )
except Exception :
pass
return { " ok " : False , " message " : f " SSH key generation failed: { exc } " }
def _connect_ssh ( self , overrides : Optional [ Dict [ str , Any ] ] = None ) - > paramiko . SSHClient :
settings = dict ( overrides or { } )
client = paramiko . SSHClient ( )
client . set_missing_host_key_policy ( paramiko . AutoAddPolicy ( ) )
client . connect (
hostname = str ( settings . get ( " host " ) or self . _host ) ,
port = int ( settings . get ( " port " ) or self . _port ) ,
username = str ( settings . get ( " username " ) or self . _username ) ,
password = str ( settings . get ( " password " ) or self . _password ) or None ,
key_filename = str ( settings . get ( " key_path " ) or self . _key_path ) or None ,
timeout = self . _timeout ,
allow_agent = self . _allow_agent if " allow_agent " not in settings else bool ( settings . get ( " allow_agent " ) ) ,
look_for_keys = self . _look_for_keys if " look_for_keys " not in settings else bool ( settings . get ( " look_for_keys " ) ) ,
)
return client
def _open_sftp ( self , ssh : Any ) - > Any :
return ssh . open_sftp ( )
def _open_scp ( self , ssh : Any ) - > Any :
return SCPClient ( ssh . get_transport ( ) )
def _is_sftp_negotiation_error ( self , exc : Exception ) - > bool :
text = str ( exc or " " ) . strip ( ) . lower ( )
if isinstance ( exc , EOFError ) :
return True
return any (
marker in text
for marker in (
" eof during negotiation " ,
" open failed " ,
" channel closed " ,
" administratively prohibited " ,
" subsystem request failed " ,
)
)
def _run_ssh_command ( self , ssh : Any , command : str ) - > Tuple [ int , str , str ] :
stdin , stdout , stderr = ssh . exec_command ( command , timeout = self . _timeout )
try :
stdin . close ( )
except Exception :
pass
output = stdout . read ( ) . decode ( " utf-8 " , errors = " replace " )
error = stderr . read ( ) . decode ( " utf-8 " , errors = " replace " )
status = 0
try :
status = int ( stdout . channel . recv_exit_status ( ) )
except Exception :
status = 0
return status , output , error
def _path_exists_via_ssh ( self , ssh : Any , remote_path : str ) - > bool :
normalized = self . _normalize_remote_path ( remote_path , default = self . _base_path )
quoted_path = shlex . quote ( normalized )
status , _ , _ = self . _run_ssh_command ( ssh , f " test -d { quoted_path } " )
return status == 0
def _ensure_directory_via_ssh ( self , ssh : Any , remote_path : str ) - > None :
normalized = self . _normalize_remote_path ( remote_path , default = self . _base_path )
if normalized == " / " :
return
quoted_path = shlex . quote ( normalized )
status , _ , error = self . _run_ssh_command ( ssh , f " mkdir -p { quoted_path } " )
if status != 0 :
raise RuntimeError ( error . strip ( ) or f " mkdir -p failed for { normalized } " )
def _close_client ( self , client : Any ) - > None :
if client is None :
return
try :
client . close ( )
except Exception :
pass
def _normalize_remote_path ( self , value : Any , * , default : str ) - > str :
text = str ( value or " " ) . strip ( ) . replace ( " \\ " , " / " )
if not text :
text = default
elif text . startswith ( ( " scp:// " , " sftp:// " ) ) :
try :
text = unquote ( urlparse ( text ) . path or " / " )
except Exception :
text = default
elif not text . startswith ( " / " ) :
text = posixpath . join ( default , text )
normalized = posixpath . normpath ( text )
normalized = " / " + normalized . lstrip ( " / " )
return normalized or " / "
def _join_remote_path ( self , parent : Any , child : Any ) - > str :
left = self . _normalize_remote_path ( parent , default = self . _base_path )
right = str ( child or " " ) . strip ( ) . replace ( " \\ " , " / " )
if not right :
return left
return self . _normalize_remote_path ( posixpath . join ( left , right ) , default = " / " )
def _build_url (
self ,
remote_path : Any ,
* ,
2026-04-28 22:20:54 -07:00
settings : Optional [ Dict [ str , Any ] ] = None ,
2026-04-27 21:17:53 -07:00
host : Optional [ str ] = None ,
port : Optional [ int ] = None ,
scheme : str = " scp " ,
) - > str :
2026-04-28 22:20:54 -07:00
resolved = dict ( settings or { } )
2026-04-27 21:17:53 -07:00
path_text = self . _normalize_remote_path ( remote_path , default = " / " )
2026-04-28 22:20:54 -07:00
host_text = str ( host or resolved . get ( " host " ) or self . _host ) . strip ( )
port_value = int ( port or resolved . get ( " port " ) or self . _port )
2026-04-27 21:17:53 -07:00
port_suffix = f " : { port_value } " if port_value and port_value != 22 else " "
return f " { scheme } :// { host_text } { port_suffix } { quote ( path_text , safe = ' /-._~!$& \' ()*+,;=:@ ' ) } "
2026-04-28 22:20:54 -07:00
def _connection_settings_for_url ( self , url : str , * , instance_name : Optional [ str ] = None ) - > Dict [ str , Any ] :
settings = self . _resolve_settings ( instance_name = instance_name , require_explicit = bool ( instance_name ) )
2026-04-27 21:17:53 -07:00
parsed = urlparse ( str ( url or " " ) . strip ( ) )
scheme = ( parsed . scheme or " scp " ) . strip ( ) . lower ( )
2026-04-28 22:20:54 -07:00
host = parsed . hostname or settings . get ( " host " ) or self . _host
port = parsed . port or settings . get ( " port " ) or self . _port
username = parsed . username or settings . get ( " username " ) or self . _username
password = parsed . password or settings . get ( " password " ) or self . _password
path_text = self . _normalize_remote_path ( unquote ( parsed . path or " / " ) , default = str ( settings . get ( " base_path " ) or " / " ) )
2026-04-27 21:17:53 -07:00
return {
2026-04-28 22:20:54 -07:00
" instance " : settings . get ( " instance " ) ,
2026-04-27 21:17:53 -07:00
" scheme " : scheme ,
" host " : host ,
" port " : port ,
" username " : username ,
" password " : password ,
2026-04-28 22:20:54 -07:00
" key_path " : settings . get ( " key_path " ) or self . _key_path ,
" allow_agent " : settings . get ( " allow_agent " , self . _allow_agent ) ,
" look_for_keys " : settings . get ( " look_for_keys " , self . _look_for_keys ) ,
2026-04-27 21:17:53 -07:00
" path " : path_text ,
2026-04-28 22:20:54 -07:00
" timeout " : settings . get ( " timeout " , self . _timeout ) ,
" base_path " : settings . get ( " base_path " , self . _base_path ) ,
2026-04-27 21:17:53 -07:00
}
def _search_directory (
self ,
sftp : Any ,
start_path : str ,
* ,
needle : str ,
limit : int ,
search_depth : int ,
type_filter : str ,
2026-04-28 22:20:54 -07:00
settings : Dict [ str , Any ] ,
2026-04-27 21:17:53 -07:00
) - > List [ SearchResult ] :
results : List [ SearchResult ] = [ ]
visited : set [ str ] = set ( )
def walk ( current_path : str , depth_left : int ) - > None :
2026-04-28 22:20:54 -07:00
normalized = self . _normalize_remote_path ( current_path , default = str ( settings . get ( " base_path " ) or self . _base_path ) )
2026-04-27 21:17:53 -07:00
if normalized in visited or len ( results ) > = limit :
return
visited . add ( normalized )
for entry in self . _list_directory ( sftp , normalized ) :
if len ( results ) > = limit :
return
if self . _matches_entry ( entry , needle = needle , type_filter = type_filter ) :
2026-04-28 22:20:54 -07:00
results . append ( self . _build_result ( entry , settings = settings ) )
2026-04-27 21:17:53 -07:00
if entry . get ( " is_dir " ) and depth_left > 0 :
walk ( str ( entry . get ( " scp_path " ) or normalized ) , depth_left - 1 )
walk ( start_path , max ( 0 , search_depth ) )
return results
def _search_directory_via_ssh (
self ,
ssh : Any ,
start_path : str ,
* ,
needle : str ,
limit : int ,
search_depth : int ,
type_filter : str ,
2026-04-28 22:20:54 -07:00
settings : Dict [ str , Any ] ,
2026-04-27 21:17:53 -07:00
) - > List [ SearchResult ] :
entries = self . _list_directory_via_ssh ( ssh , start_path , depth = search_depth )
results : List [ SearchResult ] = [ ]
for entry in entries :
if len ( results ) > = limit :
break
if self . _matches_entry ( entry , needle = needle , type_filter = type_filter ) :
2026-04-28 22:20:54 -07:00
results . append ( self . _build_result ( entry , settings = settings ) )
2026-04-27 21:17:53 -07:00
return results
def _matches_entry ( self , entry : Dict [ str , Any ] , * , needle : str , type_filter : str ) - > bool :
is_dir = bool ( entry . get ( " is_dir " ) )
if type_filter in { " dir " , " dirs " , " folder " , " folders " } and not is_dir :
return False
if type_filter in { " file " , " files " } and is_dir :
return False
text = str ( needle or " " ) . strip ( ) . lower ( )
if not text or text in { " * " , " all " , " list " } :
return True
haystacks = [
str ( entry . get ( " name " ) or " " ) . lower ( ) ,
str ( entry . get ( " scp_path " ) or " " ) . lower ( ) ,
]
for token in [ part for part in text . split ( ) if part ] :
if any ( ch in token for ch in " *?[] " ) :
if not any ( fnmatch . fnmatch ( haystack , token ) for haystack in haystacks ) :
return False
elif not any ( token in haystack for haystack in haystacks ) :
return False
return True
2026-04-28 22:20:54 -07:00
def _build_result ( self , entry : Dict [ str , Any ] , * , settings : Dict [ str , Any ] ) - > SearchResult :
2026-04-27 21:17:53 -07:00
scp_path = str ( entry . get ( " scp_path " ) or " / " )
2026-04-28 22:20:54 -07:00
scp_url = self . _build_url ( scp_path , settings = settings )
2026-04-27 21:17:53 -07:00
is_dir = bool ( entry . get ( " is_dir " ) )
size_value = entry . get ( " size " )
modified = str ( entry . get ( " modified " ) or " " )
parent = posixpath . dirname ( scp_path . rstrip ( " / " ) ) or " / "
2026-04-28 22:20:54 -07:00
instance_name = str ( settings . get ( " instance " ) or " " ) . strip ( )
2026-04-27 21:17:53 -07:00
metadata = {
2026-05-26 15:32:01 -07:00
" plugin " : " scp " ,
2026-04-28 22:20:54 -07:00
" instance " : instance_name or None ,
" host " : settings . get ( " host " ) ,
2026-04-27 21:17:53 -07:00
" scp_path " : scp_path ,
" scp_url " : scp_url ,
" selection_url " : scp_url ,
" is_dir " : is_dir ,
" name " : str ( entry . get ( " name " ) or " " ) . strip ( ) ,
}
if size_value is not None :
metadata [ " size " ] = size_value
if modified :
metadata [ " modified " ] = modified
2026-04-28 22:20:54 -07:00
selection_args = [ " -url " , scp_url ]
selection_action = [ " download-file " , " -plugin " , " scp " ]
if instance_name :
selection_args = [ " -instance " , instance_name , * selection_args ]
selection_action . extend ( [ " -instance " , instance_name ] )
selection_action . extend ( [ " -url " , scp_url ] )
2026-04-27 21:17:53 -07:00
return SearchResult (
table = " scp " ,
title = str ( entry . get ( " name " ) or scp_path ) ,
path = scp_url ,
detail = parent ,
annotations = [ " folder " if is_dir else " file " ] ,
media_kind = " folder " if is_dir else " file " ,
size_bytes = int ( size_value ) if isinstance ( size_value , int ) else None ,
tag = { " scp " , " folder " if is_dir else " file " } ,
columns = [
( " Name " , str ( entry . get ( " name " ) or " " ) ) ,
( " Type " , " dir " if is_dir else " file " ) ,
( " Directory " , parent ) ,
( " Size " , " " if size_value is None else str ( size_value ) ) ,
( " Modified " , modified ) ,
] ,
2026-04-28 22:20:54 -07:00
selection_args = None if is_dir else selection_args ,
selection_action = None if is_dir else selection_action ,
2026-04-27 21:17:53 -07:00
full_metadata = metadata ,
)
def _list_directory ( self , sftp : Any , remote_path : str ) - > List [ Dict [ str , Any ] ] :
try :
attrs = sftp . listdir_attr ( remote_path )
except Exception :
return [ ]
entries : List [ Dict [ str , Any ] ] = [ ]
for attr in attrs :
name_text = str ( getattr ( attr , " filename " , " " ) or " " ) . strip ( )
if not name_text or name_text in { " . " , " .. " } :
continue
mode = getattr ( attr , " st_mode " , 0 )
is_dir = stat . S_ISDIR ( mode )
size_value = getattr ( attr , " st_size " , None )
try :
size_int = int ( size_value ) if size_value is not None else None
except Exception :
size_int = None
entries . append (
{
" name " : name_text ,
" scp_path " : self . _join_remote_path ( remote_path , name_text ) ,
" is_dir " : is_dir ,
" size " : size_int ,
" modified " : _format_epoch ( getattr ( attr , " st_mtime " , None ) ) ,
}
)
return entries
def _list_directory_via_ssh ( self , ssh : Any , remote_path : str , * , depth : int ) - > List [ Dict [ str , Any ] ] :
normalized = self . _normalize_remote_path ( remote_path , default = self . _base_path )
max_depth = max ( 1 , int ( depth ) + 1 )
quoted_path = shlex . quote ( normalized )
command = (
f " find { quoted_path } -mindepth 1 -maxdepth { max_depth } "
f " \\ ( -type d -o -type f \\ ) -exec sh -c ' for path do "
f " if [ -d \" $path \" ]; then kind=d; else kind=f; fi; "
f " name=$(basename \" $path \" ); "
f " printf \" %s \\ 0%s \\ 0%s \\ 0 \" \" $kind \" \" $path \" \" $name \" ; "
f " done ' sh {{ }} + "
)
status , output , error = self . _run_ssh_command ( ssh , command )
if status != 0 :
error_text = error . strip ( ) . lower ( )
if " no such file " in error_text or " cannot access " in error_text :
return [ ]
raise RuntimeError ( error . strip ( ) or f " SSH listing failed for { normalized } " )
chunks = [ part for part in output . split ( " \0 " ) if part ]
entries : List [ Dict [ str , Any ] ] = [ ]
for index in range ( 0 , len ( chunks ) , 3 ) :
if index + 2 > = len ( chunks ) :
break
kind = chunks [ index ]
scp_path = self . _normalize_remote_path ( chunks [ index + 1 ] , default = normalized )
name_text = str ( chunks [ index + 2 ] or " " ) . strip ( )
if not name_text or name_text in { " . " , " .. " } :
continue
entries . append (
{
" name " : name_text ,
" scp_path " : scp_path ,
" is_dir " : kind == " d " ,
" size " : None ,
" modified " : " " ,
}
)
return entries
2026-04-28 22:20:54 -07:00
def _ensure_directory ( self , sftp : Any , remote_path : str , * , base_path : str ) - > None :
normalized = self . _normalize_remote_path ( remote_path , default = base_path )
2026-04-27 21:17:53 -07:00
if normalized == " / " :
return
partial = " "
for segment in [ part for part in normalized . split ( " / " ) if part ] :
partial = f " { partial } / { segment } "
try :
attrs = sftp . stat ( partial )
if stat . S_ISDIR ( getattr ( attrs , " st_mode " , 0 ) ) :
continue
except Exception :
pass
try :
sftp . mkdir ( partial )
except Exception :
try :
attrs = sftp . stat ( partial )
if stat . S_ISDIR ( getattr ( attrs , " st_mode " , 0 ) ) :
continue
except Exception :
pass
raise
def _item_metadata ( self , item : Any , * , pipe_obj : Any = None ) - > Dict [ str , Any ] :
metadata : Dict [ str , Any ] = { }
for source in ( item , pipe_obj ) :
if isinstance ( source , dict ) :
for key in ( " title " , " path " , " url " ) :
if source . get ( key ) is not None and key not in metadata :
metadata [ key ] = source . get ( key )
nested = source . get ( " full_metadata " ) or source . get ( " metadata " )
if isinstance ( nested , dict ) :
metadata . update ( nested )
elif source is not None :
for attr in ( " title " , " path " , " url " ) :
try :
value = getattr ( source , attr , None )
except Exception :
value = None
if value is not None and attr not in metadata :
metadata [ attr ] = value
try :
nested = getattr ( source , " full_metadata " , None ) or getattr ( source , " metadata " , None )
except Exception :
nested = None
if isinstance ( nested , dict ) :
metadata . update ( nested )
scp_path = metadata . get ( " scp_path " ) or metadata . get ( " selection_path " )
if not scp_path :
path_value = metadata . get ( " path " ) or metadata . get ( " url " ) or metadata . get ( " scp_url " )
path_text = str ( path_value or " " ) . strip ( )
if path_text . startswith ( ( " scp:// " , " sftp:// " ) ) :
scp_path = self . _normalize_remote_path ( path_text , default = self . _base_path )
if scp_path :
2026-04-28 22:20:54 -07:00
base_path = str ( metadata . get ( " base_path " ) or self . _base_path )
metadata [ " scp_path " ] = self . _normalize_remote_path ( scp_path , default = base_path )
2026-04-27 21:17:53 -07:00
metadata . setdefault ( " selection_path " , metadata [ " scp_path " ] )
if metadata . get ( " scp_path " ) and not metadata . get ( " scp_url " ) :
2026-04-28 22:20:54 -07:00
metadata [ " scp_url " ] = self . _build_url (
metadata [ " scp_path " ] ,
settings = {
" host " : metadata . get ( " host " ) or self . _host ,
" instance " : metadata . get ( " instance " ) ,
} ,
)
2026-04-27 21:17:53 -07:00
if metadata . get ( " scp_url " ) and not metadata . get ( " selection_url " ) :
metadata [ " selection_url " ] = metadata [ " scp_url " ]
is_dir = metadata . get ( " is_dir " )
if is_dir is None and metadata . get ( " media_kind " ) :
is_dir = str ( metadata . get ( " media_kind " ) or " " ) . strip ( ) . lower ( ) == " folder "
metadata [ " is_dir " ] = bool ( is_dir )
return metadata