File size: 22,606 Bytes
c0e2219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
"""

Вспомогательные утилиты: процессы, архивы, сеть, чтение потоков.

"""
import os
import re
import threading
import time
from os import name as os_name
from pathlib import Path
from shutil import move as shutil_move
from subprocess import (
    PIPE, Popen, STDOUT, TimeoutExpired,
    check_output, CalledProcessError,
)
from sys import stdout as sys_stdout
from typing import TypedDict
from urllib.parse import unquote, urlparse

from requests import get as get_url, head as get_head
from requests.structures import CaseInsensitiveDict

from ._config import WORK_FOLDER, HEADERS
from ._logger import logger


# ---------------------------------------------------------------------------
# Типы
# ---------------------------------------------------------------------------

class RunResult(TypedDict):
    status_code: int
    output: str


# ---------------------------------------------------------------------------
# Процессы
# ---------------------------------------------------------------------------

def run(

    command: str,

    cwd: str | Path | None = None,

    live_output: bool = False,

    timeout: float | None = None,

) -> RunResult:
    """

    Выполняет shell-команду, возвращает код выхода и вывод.



    Args:

        command:     Shell-команда.

        cwd:         Рабочая директория (None — текущая).

        live_output: Выводить прогресс в реальном времени.

        timeout:     Таймаут в секундах (None — без ограничений).



    Returns:

        RunResult: {'status_code': int, 'output': str}.

        При таймауте status_code == -1.

    """
    _encodings = ['utf-8', 'iso-8859-5', 'windows-1251', 'cp866', 'koi8-r', 'mac_cyrillic']
    _progress_re = re.compile(r'\d+%|\d+/\d+')

    def _decode(data: bytes) -> str:
        for enc in _encodings:
            try:
                return data.decode(enc)
            except UnicodeDecodeError:
                continue
        return data.decode('utf-8', errors='replace')

    process = Popen(command, shell=True, cwd=cwd, stdout=PIPE, stderr=STDOUT)

    # Режим без живого вывода: communicate() с поддержкой таймаута
    if not live_output:
        try:
            stdout_data, _ = process.communicate(timeout=timeout)
        except TimeoutExpired:
            process.kill()
            process.communicate()
            msg = f'Таймаут ({timeout}с): {command}'
            logger.warning(msg)
            return RunResult(status_code=-1, output=msg)
        lines = [ln.strip() for ln in _decode(stdout_data).splitlines() if ln.strip()]
        return RunResult(status_code=process.returncode, output='\n'.join(lines))

    # Режим живого вывода (для ручного использования, таймаут приблизительный)
    final_output: list[str] = []
    last_progress = ''
    deadline = time.time() + timeout if timeout else None

    for raw_line in iter(process.stdout.readline, b''):  # type: ignore[union-attr]
        if deadline and time.time() > deadline:
            process.kill()
            process.wait()
            final_output.append(f'[ТАЙМАУТ после {timeout}с]')
            break
        line = _decode(raw_line.strip())
        if _progress_re.search(line):
            last_progress = line
            sys_stdout.write('\r' + line)
            sys_stdout.flush()
        else:
            final_output.append(line)
            sys_stdout.write(line + '\n')
            sys_stdout.flush()

    try:
        process.wait(timeout=5)
    except TimeoutExpired:
        process.kill()
        process.wait()

    if last_progress:
        final_output.append(last_progress)

    return RunResult(status_code=process.returncode, output='\n'.join(final_output))


def kill_process_by_name(name: str) -> None:
    """

    Завершает процессы текущего пользователя по подстроке имени.

    Ограничение по UID предотвращает случайное завершение чужих процессов.

    """
    run(f'pkill -TERM -u "$(id -u)" -f "{name}" 2>/dev/null; true', timeout=5)


def is_process_running(process_name: str | Path) -> bool:
    try:
        output = check_output(['pgrep', '-f', str(process_name)], text=True)
        return bool(output.strip())
    except CalledProcessError:
        return False


def terminate_process(process_name: str, process_obj: Popen) -> None:
    """

    Корректно завершает процесс туннеля.

    Сначала SIGTERM + wait(5с), при зависании — SIGKILL.

    """
    for stream in (process_obj.stdout, process_obj.stderr, process_obj.stdin):
        if stream:
            try:
                stream.close()
            except Exception:
                pass

    try:
        process_obj.terminate()
        process_obj.wait(timeout=5)
    except TimeoutExpired:
        process_obj.kill()
        process_obj.wait()
    except Exception:
        pass

    kill_process_by_name(process_name)


def _drain_raw(raw_stream: object) -> None:
    """Сливает данные из сырого потока в никуда, предотвращая блокировку пайпа."""
    try:
        os.set_blocking(raw_stream.fileno(), True)  # type: ignore[attr-defined]
    except Exception:
        pass
    try:
        while True:
            data = raw_stream.read(4096)  # type: ignore[attr-defined]
            if not data:
                break
    except Exception:
        pass


def drain_process_output(process: Popen) -> None:
    """

    Запускает daemon-потоки для слива stdout/stderr долгоживущего процесса.



    Без этого туннельный процесс заблокируется, когда системный буфер

    пайпа (обычно 64 КБ) заполнится непрочитанными логами.

    """
    for stream in (process.stdout, process.stderr):
        if stream:
            raw = getattr(stream, 'raw', stream)
            t = threading.Thread(target=_drain_raw, args=(raw,), daemon=True)
            t.start()


def read_until_pattern(

    process: Popen,

    url_pattern: str,

    timeout: float = 20.0,

    read_from_stderr: bool = False,

    read_both_streams: bool = False,

) -> tuple[str, str]:
    """

    Читает вывод процесса с таймаутом, ища URL-паттерн.



    Использует блокирующие read() в daemon-потоках вместо select() +

    неблокирующих read(). select()-подход даёт гонку при быстрых коротких

    записях (< 1мс): select сигнализирует «readable», но к моменту вызова

    read() данные ещё не попали в буфер пайпа — read возвращает пусто.

    Блокирующий read в потоке этой гонки не имеет: он просто ждёт данных.



    После нахождения URL потоки автоматически переходят в режим дренирования:

    продолжают читать и отбрасывать данные, предотвращая переполнение

    системного буфера пайпа (обычно 64 КБ) за время жизни туннеля.



    Args:

        process:           Запущенный Popen с открытыми потоками PIPE.

        url_pattern:       Regex для поиска URL.

        timeout:           Максимальное время ожидания (сек).

        read_from_stderr:  Читать из stderr (иначе stdout).

        read_both_streams: Читать из обоих потоков одновременно (для SSH).



    Returns:

        Кортеж (matched_url, full_output_so_far).



    Raises:

        RuntimeError: Паттерн не найден за timeout секунд.

    """
    import queue as _queue

    if read_both_streams:
        active = [s for s in (process.stdout, process.stderr) if s]
    elif read_from_stderr:
        active = [process.stderr] if process.stderr else []
    else:
        active = [process.stdout] if process.stdout else []

    if not active:
        raise RuntimeError('Потоки вывода процесса недоступны.')

    q: _queue.Queue[bytes | None] = _queue.Queue()
    drain_mode = threading.Event()

    def _reader(stream) -> None:
        """

        Блокирующий читатель в daemon-потоке.

        Пока URL не найден — пушит чанки в очередь.

        После установки drain_mode — читает и отбрасывает (дренирование).

        """
        raw = getattr(stream, 'raw', stream)
        try:
            # Явно возвращаем в блокирующий режим на случай,
            # если где-то раньше FD был переведён в неблокирующий
            os.set_blocking(raw.fileno(), True)
        except Exception:
            pass
        try:
            while True:
                chunk = raw.read(4096)
                if not chunk:       # EOF — процесс закрыл поток
                    break
                if drain_mode.is_set():
                    pass            # Дренирование: читаем и отбрасываем
                else:
                    q.put(chunk)    # Ищем URL: передаём в очередь
        except Exception:
            pass
        finally:
            # Сигнал EOF только если мы всё ещё ищем URL
            if not drain_mode.is_set():
                q.put(None)

    for stream in active:
        t = threading.Thread(target=_reader, args=(stream,), daemon=True)
        t.start()

    buffer = b''
    deadline = time.time() + timeout
    n_sentinels = 0

    while time.time() < deadline:
        remaining = max(0.01, deadline - time.time())
        try:
            item = q.get(timeout=min(remaining, 0.5))
        except _queue.Empty:
            if process.poll() is not None:
                # Процесс завершился — дочитываем остатки из очереди
                while True:
                    try:
                        item = q.get_nowait()
                    except _queue.Empty:
                        break
                    if item is None:
                        n_sentinels += 1
                    else:
                        buffer += item
                break
            continue

        if item is None:
            n_sentinels += 1
            if n_sentinels >= len(active):
                break       # Все читатели достигли EOF
            continue

        buffer += item
        text = buffer.decode('utf-8', errors='ignore')
        match = re.search(url_pattern, text)
        if match:
            drain_mode.set()    # Переключаем потоки в режим дренирования
            return match.group(), text

    raise RuntimeError(
        f'URL не найден за {timeout} сек. Вывод процесса:\n'
        + buffer.decode('utf-8', errors='ignore')
    )



def move_path(old_path: Path | str, new_path: Path | str) -> None:
    old, new = Path(old_path), Path(new_path)
    if not old.exists():
        raise RuntimeError(f'Не найден исходный путь для перемещения: {old}')
    try:
        old.replace(new)
    except Exception:
        try:
            shutil_move(str(old), str(new))
        except Exception:
            if os_name == 'posix':
                run(f'mv "{old}" "{new}"')
            else:
                run(f'move "{old}" "{new}"')


# ---------------------------------------------------------------------------
# Файлы и архивы
# ---------------------------------------------------------------------------

def _snapshot(folder: Path) -> set[Path]:
    """Рекурсивный снимок всех файлов в директории (включая вложенные папки)."""
    if not folder.exists():
        return set()
    return {f for f in folder.rglob('*') if f.is_file()}


def determine_archive_format(filepath: str | Path) -> str | None:
    """Определяет формат архива по сигнатуре байт (magic bytes)."""
    filepath = Path(filepath)
    zip_sig = bytes([0x50, 0x4B, 0x03, 0x04])
    seven_z = bytes([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C])
    lzma_xz = bytes([0xFD, 0x37, 0x7A, 0x58, 0x5A])
    tgz     = bytes([0x1F, 0x8B])
    tbz     = bytes([0x42, 0x5A, 0x68])
    ustar   = bytes([0x75, 0x73, 0x74, 0x61, 0x72])

    with filepath.open('rb') as fh:
        header = fh.read(262)

    if header.startswith(zip_sig):   return 'zip'
    if header.startswith(seven_z):   return '7z'
    if header.startswith(lzma_xz):   return '7z'
    if header.startswith(tgz):       return 'tar.gz'
    if header.startswith(tbz):       return 'tar.bz2'
    if header[0x101:0x101 + len(ustar)] == ustar: return 'tar'
    return None


def unpack_archive(

    archive_path: str | Path,

    dest_path: str | Path,

    rm_archive: bool = True,

) -> None:
    """

    Распаковывает архив в указанную директорию.



    Поддерживает: tar, tar.gz, tar.bz2, tar.xz, gz (одиночный файл), zip, 7z.

    При неудаче основного метода делает fallback к 7z.

    Принимает URL — скачивает архив сам.

    """
    if str(archive_path).startswith(('https://', 'http://')):
        archive_path = download(str(archive_path), save_path=WORK_FOLDER, progress=False)

    archive_path = Path(archive_path)
    dest_path    = Path(dest_path)

    if not archive_path.exists():
        raise RuntimeError(f'Архив не найден: {archive_path}')

    fmt = determine_archive_format(archive_path)
    dest_path.mkdir(parents=True, exist_ok=True)

    def _rm() -> None:
        if rm_archive:
            archive_path.unlink(missing_ok=True)

    def _try_7z() -> None:
        result = run(
            f'7z -bso0 -bd -mmt4 -slp -y x "{archive_path}" -o"{dest_path}"',
            timeout=180,
        )
        if result['status_code'] != 0:
            raise RuntimeError(
                f'Не удалось распаковать {archive_path.name}.\n{result["output"]}'
            )
        _rm()

    try:
        if fmt == 'zip' or archive_path.suffix == '.zip':
            r = run(f'unzip -o "{archive_path}" -d "{dest_path}"', timeout=180)
            if r['status_code'] != 0:
                raise RuntimeError(r['output'])
            _rm()

        elif fmt in ('7z',) or archive_path.suffix == '.7z':
            r = run(
                f'7z -bso0 -bd -slp -y x "{archive_path}" -o"{dest_path}"',
                timeout=180,
            )
            if r['status_code'] != 0:
                raise RuntimeError(r['output'])
            _rm()

        elif fmt in ('tar', 'tar.gz', 'tar.bz2') or archive_path.suffix in (
            '.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tar.xz', '.txz', '.gz',
        ):
            # GNU tar определяет формат сжатия автоматически через -xpf
            r = run(f'tar -xpf "{archive_path}" -C "{dest_path}"', timeout=180)
            if r['status_code'] != 0:
                # Возможно, это одиночный gzip-файл без tar-обёртки (напр. tmole-linux.gz)
                gz_r = run(f'gzip -df "{archive_path}"', timeout=60)
                if gz_r['status_code'] != 0:
                    raise RuntimeError(
                        f'tar: {r["output"]}\ngzip: {gz_r["output"]}'
                    )
                # gzip -f уже удалил .gz и создал распакованный файл на месте
                return
            _rm()

        else:
            _try_7z()

    except RuntimeError as e:
        logger.debug(f'Основной метод распаковки не сработал ({e}), fallback к 7z.')
        _try_7z()


# ---------------------------------------------------------------------------
# Сеть
# ---------------------------------------------------------------------------

def is_ipv4(address: str) -> bool:
    pattern = re.compile(
        r'^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$'
    )
    return bool(pattern.match(address))


def is_valid_url(url: str, custom_headers: dict | None = None) -> bool:
    """

    Проверяет доступность URL (HEAD или GET запрос).



    Returns:

        True, если сервер вернул HTTP 2xx.

    """
    req_headers = HEADERS.copy()
    if custom_headers:
        req_headers.update(custom_headers)

    for _ in range(2):
        try:
            with get_url(url, headers=req_headers, allow_redirects=True, stream=True, timeout=10) as resp:
                if 200 <= resp.status_code < 300:
                    return True
        except Exception:
            try:
                resp = get_head(url, headers=req_headers, allow_redirects=True, timeout=10)
                if 200 <= resp.status_code < 300:
                    return True
            except Exception:
                pass
        else:
            break
    return False


def get_filename_from_headers(headers: CaseInsensitiveDict) -> str | None:
    cd = headers.get('content-disposition')
    if not cd:
        return headers.get('filename')
    for part in cd.split(';'):
        part = part.strip()
        if part.startswith('filename*='):
            encoding, _, encoded = part[len('filename*='):].partition("''")
            return unquote(encoded, encoding=encoding)
        if part.startswith('filename='):
            return part[len('filename='):].strip('"')
    return None


def download(

    url: str,

    filename: str | Path | None = None,

    save_path: str | Path | None = None,

    progress: bool = True,

    extra_headers: dict[str, str] | None = None,

) -> Path:
    """

    Скачивает файл по URL.



    Args:

        url:           URL файла.

        filename:      Имя для сохранения (None — из заголовков или URL).

        save_path:     Папка сохранения (None — текущая директория).

        progress:      Показывать прогресс загрузки.

        extra_headers: Дополнительные HTTP-заголовки.



    Returns:

        Path к скачанному файлу.



    Raises:

        RuntimeError: При ошибке сети или записи файла.

    """
    save_path = Path(save_path) if save_path else Path.cwd()
    save_path.mkdir(parents=True, exist_ok=True)

    req_headers = HEADERS.copy()
    if extra_headers:
        req_headers.update(extra_headers)

    try:
        resp = get_url(url, stream=True, allow_redirects=True, headers=req_headers, timeout=60)
        resp.raise_for_status()
    except Exception as e:
        raise RuntimeError(f'Не удалось скачать файл по ссылке {url}:\n{e}')

    file_size = int(resp.headers.get('content-length', 0))
    raw_name = (
        str(filename)
        if filename
        else (get_filename_from_headers(resp.headers) or Path(urlparse(resp.url).path).name)
    )
    file_name = Path(raw_name).name
    file_path = save_path / file_name
    chunk_size = max(4096, file_size // 2000) if file_size else 4096

    downloaded = 0
    start = time.time()
    try:
        with open(file_path, 'wb') as fp:
            for chunk in resp.iter_content(chunk_size=chunk_size):
                if chunk:
                    fp.write(chunk)
                    if progress and file_size:
                        downloaded += len(chunk)
                        elapsed = time.time() - start
                        print(
                            f'\rзагрузка {file_name}: '
                            f'{downloaded / file_size * 100:.2f}% | {elapsed:.2f} сек.',
                            end='',
                        )
    except Exception as e:
        raise RuntimeError(f'Не удалось сохранить файл из {url}:\n{e}')

    if progress and file_size:
        print()

    logger.debug(f'Скачан: {file_path} ({file_size} байт)')
    return file_path


def get_github_latest_release_url(repo: str, asset_pattern: str) -> str:
    """

    Возвращает URL загрузки актуального релизного ассета с GitHub.



    Args:

        repo:          Репозиторий в формате 'owner/repo'.

        asset_pattern: Регулярное выражение для имени ассета.



    Returns:

        browser_download_url для найденного ассета.



    Raises:

        RuntimeError: Если ассет не найден или GitHub API недоступен.

    """
    api_url = f'https://api.github.com/repos/{repo}/releases/latest'
    resp = get_url(api_url, timeout=10)
    resp.raise_for_status()

    assets = resp.json().get('assets', [])
    for asset in assets:
        if re.search(asset_pattern, asset['name']):
            return asset['browser_download_url']

    available = [a['name'] for a in assets]
    raise RuntimeError(
        f'Ассет "{asset_pattern}" не найден в {repo}. '
        f'Доступные: {available}'
    )