<commit_before>import * as shellQuote from 'shell-quote'
import socksv5 from '@luminati-io/socksv5'
import { Duplex } from 'stream'
import { Injectable } from '@angular/core'
import { spawn } from 'child_process'
import { ChildProcess } from 'node:child_process'
import { Subject, Observable } from 'rxjs'
import { ConfigService, HostAppService, Platform, PlatformService } from 'tabby-core'
import { SSHSession } from '../session/ssh'
import { SSHProfile } from '../api'
import { PasswordStorageService } from './passwordStorage.service'

@Injectable({ providedIn: 'root' })
export class SSHService {
    private detectedWinSCPPath: string | null

    private constructor (
        private passwordStorage: PasswordStorageService,
        private config: ConfigService,
        hostApp: HostAppService,
        private platform: PlatformService,
    ) {
        if (hostApp.platform === Platform.Windows) {
            this.detectedWinSCPPath = platform.getWinSCPPath()
        }
    }

    getWinSCPPath (): string|undefined {
        return this.detectedWinSCPPath ?? this.config.store.ssh.winSCPPath
    }

    async getWinSCPURI (profile: SSHProfile, cwd?: string, username?: string): Promise<string> {
        let uri = `scp://${username ?? profile.options.user}`
        const password = await this.passwordStorage.loadPassword(profile)
        if (password) {
            uri += ':' + encodeURIComponent(password)
        }
        uri += `@${profile.options.host}:${profile.options.port}${cwd ?? '/'}`
        return uri
    }

    async launchWinSCP (session: SSHSession): Promise<void> {
        const path = this.getWinSCPPath()
        if (!path) {
            return
        }
        const args = [await this.getWinSCPURI(session.profile, undefined, session.authUsername ?? undefined)]
        if (session.activePrivateKey) {
            args.push('/privatekey')
            args.push(session.activePrivateKey)
        }
        this.platform.exec(path, args)
    }
}

export class SocksProxyStream extends Duplex {
    private client: Duplex|null
    private header: Buffer|null

    constructor (private profile: SSHProfile) {
        super({
            allowHalfOpen: false,
        })
    }

    async start (): Promise<void> {
        this.client = await new Promise((resolve, reject) => {
            const connector = socksv5.connect({
                host: this.profile.options.host,
                port: this.profile.options.port,
                proxyHost: this.profile.options.socksProxyHost ?? '127.0.0.1',
                proxyPort: this.profile.options.socksProxyPort ?? 5000,
                auths: [socksv5.auth.None()],
            }, s => {
                resolve(s)
                this.header = s.read()
                this.push(this.header)
            })
            connector.on('error', (err) => {
                reject(err)
                this.destroy(err)
            })
        })
        this.client?.on('data', data => {
            if (data !== this.header) {<commit_msg>ssh: fixed socks proxy connection race condition - fixes #5093, fixes #5060, fixes #5632<commit_after>import * as shellQuote from 'shell-quote'
import socksv5 from '@luminati-io/socksv5'
import { Duplex } from 'stream'
import { Injectable } from '@angular/core'
import { spawn } from 'child_process'
import { ChildProcess } from 'node:child_process'
import { Subject, Observable } from 'rxjs'
import { ConfigService, HostAppService, Platform, PlatformService } from 'tabby-core'
import { SSHSession } from '../session/ssh'
import { SSHProfile } from '../api'
import { PasswordStorageService } from './passwordStorage.service'

@Injectable({ providedIn: 'root' })
export class SSHService {
    private detectedWinSCPPath: string | null

    private constructor (
        private passwordStorage: PasswordStorageService,
        private config: ConfigService,
        hostApp: HostAppService,
        private platform: PlatformService,
    ) {
        if (hostApp.platform === Platform.Windows) {
            this.detectedWinSCPPath = platform.getWinSCPPath()
        }
    }

    getWinSCPPath (): string|undefined {
        return this.detectedWinSCPPath ?? this.config.store.ssh.winSCPPath
    }

    async getWinSCPURI (profile: SSHProfile, cwd?: string, username?: string): Promise<string> {
        let uri = `scp://${username ?? profile.options.user}`
        const password = await this.passwordStorage.loadPassword(profile)
        if (password) {
            uri += ':' + encodeURIComponent(password)
        }
        uri += `@${profile.options.host}:${profile.options.port}${cwd ?? '/'}`
        return uri
    }

    async launchWinSCP (session: SSHSession): Promise<void> {
        const path = this.getWinSCPPath()
        if (!path) {
            return
        }
        const args = [await this.getWinSCPURI(session.profile, undefined, session.authUsername ?? undefined)]
        if (session.activePrivateKey) {
            args.push('/privatekey')
            args.push(session.activePrivateKey)
        }
        this.platform.exec(path, args)
    }
}

export class SocksProxyStream extends Duplex {
    private client: Duplex|null
    private header: Buffer|null

    constructor (private profile: SSHProfile) {
        super({
            allowHalfOpen: false,
        })
    }

    async start (): Promise<void> {
        this.client = await new Promise((resolve, reject) => {
            const connector = socksv5.connect({
                host: this.profile.options.host,
                port: this.profile.options.port,
                proxyHost: this.profile.options.socksProxyHost ?? '127.0.0.1',
                proxyPort: this.profile.options.socksProxyPort ?? 5000,
                auths: [socksv5.auth.None()],
            }, s => {
                resolve(s)
                this.header = s.read()
                if (this.header) {
                    this.push(this.header)
                }
            })
            connector.on('error', (err) => {
                reject(err)
                this.destroy(err)
            })
        })
        this.client?.on('data', data => {
            if (!this.header || data !== this.header) {
{'fraction_non_alphanumeric': 0.11886304909560723, 'fraction_numerical': 0.006459948320413436, 'mean_word_length': 2.2854111405835544, 'pattern_counts': {'":': 0, '<': 9, '<?xml version=': 0, '>': 17, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 2, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '7542236', 'n_tokens_mistral': 1771, 'n_tokens_neox': 1694, 'n_words': 387}