forked from bscp-tool/bscp
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbscp.python2
More file actions
executable file
·1560 lines (1399 loc) · 67.3 KB
/
Copy pathbscp.python2
File metadata and controls
executable file
·1560 lines (1399 loc) · 67.3 KB
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
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright (C) 2012-2024 The Bscp Authors <https://github.com/bscp-tool/bscp/graphs/contributors>
#
# This is a fork. It builds on, and is inspired by, the original bscp:
# https://github.com/bscp-tool/bscp (canonical project, latest master)
# https://github.com/vog/bscp (original author, Volker Diels-Grabsch)
# Fork: https://github.com/groenewe/bscp
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
# Bscp copies a file or block device over SSH, transferring only changed
# blocks. Both push (local → remote) and pull (remote → local) are
# supported. The remote-side script is embedded and executed via
# "python(/2/3) -c", with a Perl fallback for hosts without Python, so
# no installation on the remote host is required.
# Features: section-based processing to bound memory use, single-pass
# scan+copy, windowed pull to avoid SSH pipe deadlock, resume on failure
# (byte offset or percentage of source), automatic retry with exponential
# back-off, and dry-run mode.
#
# Changes in this fork were made with extensive help of Claude Code, Sonnet
# and Opus models (Anthropic).
from __future__ import division
from argparse import ArgumentParser, ArgumentTypeError
import binascii
import hashlib
import math
import os
import shlex
import struct
import subprocess
import sys
from time import sleep, time
# Python 2.7 has shlex but no shlex.quote; the pipes module provides the same
# function under the same name. Python 3.3+ has shlex.quote in stdlib.
try:
from shlex import quote as _shquote
except ImportError:
from pipes import quote as _shquote
# BrokenPipeError / ConnectionResetError are Python 3.3+; on Python 2 a broken
# pipe surfaces as IOError(EPIPE) and a reset connection as a generic
# IOError/OSError. Alias them to the Py2 equivalents so the same except
# clauses work verbatim under both interpreters: a literal `except
# BrokenPipeError` would raise NameError on Py2 *while* handling a real pipe
# error and mask it. The named aliases let each handler list exactly the
# exceptions it needs (some want OSError, some EOFError, some neither) instead
# of forcing one shared tuple everywhere.
try:
_BrokenPipeError = BrokenPipeError
_ConnectionResetError = ConnectionResetError
except NameError:
_BrokenPipeError = IOError
_ConnectionResetError = OSError
_PIPE_ERRORS = (_BrokenPipeError, _ConnectionResetError, EOFError)
# Header: size(8), blocksize(8), section_size(8), start_offset(8), filename_len(8), hashname_len(8), mode(1) - 49 bytes total
HEADER_FMT = '<QQQQQQB'
HEADER_SIZE = struct.calcsize(HEADER_FMT)
MODE_PUSH, MODE_PULL = 0, 1
ALLOW_TRUNCATE = 2
# Number of diff-block requests sent per round-trip in pull phase B.
# Larger = fewer round-trips; too large risks filling SSH stdout window before
# the client drains it. 128 × 64 KiB = 8 MiB max in-flight, well within
# OpenSSH's dynamic window.
PULL_WINDOW = 128
DEFAULT_BLOCK = 64 * 1024
DEFAULT_SECTION = 10 * 1024**3
DEFAULT_RETRIES = 3
MIN_MEMORY_PUSH = 64 * DEFAULT_BLOCK
# Hash algorithms guaranteed on every remote variant: python3, python2, and
# the Perl fallback (Digest::MD5 + Digest::SHA). hashlib offers more
# locally, but only these are portable to all three remotes.
PORTABLE_ALGOS = ('md5', 'sha1', 'sha224', 'sha256', 'sha384', 'sha512')
def algorithm_help():
# Local-only extras: anything hashlib can do here that is not in the
# portable set. Drop uppercase aliases (e.g. 'SHA256') for a clean list,
# and skip XOF/variable-length functions (shake_*, digest_size 0) — they
# have no fixed digest and would break the wire protocol.
portable = set(PORTABLE_ALGOS)
extra = sorted(a for a in hashlib.algorithms_available
if a.lower() not in portable and a == a.lower()
and hashlib.new(a).digest_size > 0)
h = ('hash algorithm (default sha256). Portable to all remotes: %s.'
% ', '.join(PORTABLE_ALGOS))
if extra:
h += (' Also usable locally: %s — these need the remote to be '
'python3/python2 with the same algorithm; the Perl remote '
'supports only the portable set.' % ', '.join(extra))
return h
def parse_algorithm(name):
# Reject anything the local hashlib cannot build, and XOF/variable-length
# functions (shake_*, digest_size 0) — the wire protocol assumes a fixed
# digest size per block. Remote availability still varies (Perl supports
# only PORTABLE_ALGOS); that surfaces as a remote handshake error.
try:
if hashlib.new(name).digest_size <= 0:
raise ArgumentTypeError(
"hash algorithm %r has no fixed digest size and cannot be "
"used (variable-length/XOF functions are unsupported)" % name)
except ArgumentTypeError:
raise
except (ValueError, TypeError):
raise ArgumentTypeError("unknown hash algorithm %r" % name)
return name
REPORT_INTERVAL = 0.5 # status line should update after this many seconds have passed
last_report_len = 0 # last progress or error length message on screen, if any; used by report()
quiet = False # option -q or --quiet: suppress '\r' progress report line normally updated via stderr
batch = False # option --batch: suppress all stderr output, return only a suitable exit code (see help)
# NOTE: the three remote source literals below (remote_script,
# remote_script_mt, remote_perl) are indented with TABS, not spaces. Two of
# them are hex-encoded onto the ssh command line; a tab is one character
# where the equivalent 4-space indent is four, so tabs roughly halve the
# leading-whitespace bytes on the wire (deep nesting shrinks the most). Keep
# them tab-indented when editing. The surrounding bscp client code stays
# 4-space indented as usual — the tabs live only inside these string
# literals, so they do not affect this file's own indentation. A `ts=4`
# vim modeline at the end of the file renders the embedded tabs at width 4.
remote_script = '''
def _remote():
import hashlib, os, struct, sys # noqa: F401 (standalone on remote host)
stdin = getattr(sys.stdin, 'buffer', sys.stdin)
stdout = getattr(sys.stdout, 'buffer', sys.stdout)
# Read exactly n bytes or raise EOFError. On a blocking pipe a short
# read means EOF, i.e. the client's SSH connection is gone; signal it
# rather than acting on a truncated read (a partial block write would
# otherwise corrupt the destination silently).
def rd(n):
buf = b''
while len(buf) < n:
chunk = stdin.read(n - len(buf))
if not chunk:
raise EOFError
buf += chunk
return buf
HEADER_FMT = '<QQQQQQB'
HEADER_SIZE = struct.calcsize(HEADER_FMT)
MODE_PUSH, MODE_PULL = 0, 1
PUSH_PULL_MASK = 1
ALLOW_TRUNCATE = 2
try:
data = rd(HEADER_SIZE)
except EOFError:
sys.exit(1)
(size, blocksize, section_size, start_offset,
filename_len, hashname_len, mode) = struct.unpack(HEADER_FMT, data)
allow_truncate = mode & ALLOW_TRUNCATE
mode = mode & PUSH_PULL_MASK
try:
fn_bytes = rd(filename_len)
hashname = rd(hashname_len).decode('ascii')
filename = fn_bytes.decode('utf-8')
stdout.write(hashlib.new(hashname, fn_bytes).digest())
stdout.flush()
if rd(2) != b'go':
sys.exit(1)
except EOFError:
sys.exit(1)
try:
fobj = open(filename, 'rb+' if mode == MODE_PUSH else 'rb')
except OSError:
stdout.write(struct.pack('<Q', 0))
stdout.flush()
sys.exit(1)
with fobj as f:
f.seek(0, 2)
remote_size = f.tell()
stdout.write(struct.pack('<Q', remote_size))
stdout.flush()
# In push, remote is destination; in pull, remote is source.
# Refuse to proceed if destination is smaller, unless --allow-truncate.
dst_size = remote_size if mode == MODE_PUSH else size
src_size = size if mode == MODE_PUSH else remote_size
if dst_size < src_size and not allow_truncate:
sys.exit(1)
sync_size = min(size, remote_size)
eff_section = section_size or sync_size
if sync_size == 0:
sys.exit(0)
# If the client decides to abort after the handshake (e.g. its
# post-handshake truncate check fires) it closes its end of the
# pipe, which surfaces here as a short stdin read or a broken stdout
# write. Treat both as a clean shutdown rather than letting Python
# spew a traceback over the user's stderr.
try:
pos = start_offset
while pos < sync_size:
sec_end = min(pos + eff_section, sync_size)
p = pos
while p < sec_end:
bl = min(blocksize, sec_end - p)
f.seek(p)
stdout.write(hashlib.new(hashname, f.read(bl)).digest())
p += bl
stdout.flush()
(count,) = struct.unpack('<Q', rd(8))
if mode == MODE_PUSH:
for _ in range(count):
(wpos,) = struct.unpack('<Q', rd(8))
bl = min(blocksize, sync_size - wpos)
f.seek(wpos)
f.write(rd(bl))
else:
for _ in range(count):
(rpos,) = struct.unpack('<Q', rd(8))
bl = min(blocksize, sync_size - rpos)
f.seek(rpos)
stdout.write(f.read(bl))
stdout.flush()
pos = sec_end
except (struct.error, IOError, OSError, EOFError):
sys.exit(1)
'''
# Multi-threaded python3 remote. Functionally identical to remote_script
# on the wire — same handshake, same phase A/B contract — but phase-A
# hashing fans out across a ThreadPoolExecutor. python3-only (uses
# concurrent.futures, sys.stdin.buffer unconditionally), so build_ssh_cmd()
# dispatches it ONLY to python3 and falls back to the single-threaded
# remote_script for python2/python and to remote_perl for Perl-only hosts.
#
# Because build_ssh_cmd() hex-encodes this and runs it via
# python3 -O -B -c "exec(bytes.fromhex('...').decode())"
# the no-"/no-$/no-% constraints that apply to remote_script do NOT apply
# here — the hex alphabet is inert in every shell quoting context. The
# thread count is baked into the trailing _remote(N) call by build_ssh_cmd
# (N = client --hash-threads; 0 = let the remote auto-detect its own cores).
#
# Protocol constants are duplicated here too (see CLAUDE.md): any change to
# HEADER_FMT / the mode bits / the phase contract must be made in FOUR
# places now — client, remote_script, remote_script_mt, and remote_perl.
remote_script_mt = '''
def _remote(nthreads):
import hashlib, os, struct, sys
from collections import deque
from concurrent.futures import ThreadPoolExecutor
stdin = sys.stdin.buffer
stdout = sys.stdout.buffer
def rd(n):
buf = b""
while len(buf) < n:
chunk = stdin.read(n - len(buf))
if not chunk:
raise EOFError
buf += chunk
return buf
HEADER_FMT = "<QQQQQQB"
HEADER_SIZE = struct.calcsize(HEADER_FMT)
MODE_PUSH, MODE_PULL = 0, 1
PUSH_PULL_MASK = 1
ALLOW_TRUNCATE = 2
HASH_THREADS_CAP = 4
if nthreads <= 0:
nthreads = min(os.cpu_count() or 1, HASH_THREADS_CAP)
window = max(2, nthreads * 2)
try:
data = rd(HEADER_SIZE)
except EOFError:
sys.exit(1)
(size, blocksize, section_size, start_offset,
filename_len, hashname_len, mode) = struct.unpack(HEADER_FMT, data)
allow_truncate = mode & ALLOW_TRUNCATE
mode = mode & PUSH_PULL_MASK
try:
fn_bytes = rd(filename_len)
hashname = rd(hashname_len).decode("ascii")
filename = fn_bytes.decode("utf-8")
stdout.write(hashlib.new(hashname, fn_bytes).digest())
stdout.flush()
if rd(2) != b"go":
sys.exit(1)
except EOFError:
sys.exit(1)
try:
fobj = open(filename, "rb+" if mode == MODE_PUSH else "rb")
except OSError:
stdout.write(struct.pack("<Q", 0))
stdout.flush()
sys.exit(1)
def hashfn(block):
return hashlib.new(hashname, block).digest()
with fobj as f:
f.seek(0, 2)
remote_size = f.tell()
stdout.write(struct.pack("<Q", remote_size))
stdout.flush()
dst_size = remote_size if mode == MODE_PUSH else size
src_size = size if mode == MODE_PUSH else remote_size
if dst_size < src_size and not allow_truncate:
sys.exit(1)
sync_size = min(size, remote_size)
eff_section = section_size or sync_size
if sync_size == 0:
sys.exit(0)
ex = ThreadPoolExecutor(max_workers=nthreads)
try:
pos = start_offset
while pos < sync_size:
sec_end = min(pos + eff_section, sync_size)
# Phase A: reads stay single-threaded and sequential (fast on
# NVMe); only hashing fans out. Digests are written in block
# order, so the wire stream is identical to remote_script.
f.seek(pos)
p = pos
inflight = deque()
while p < sec_end and len(inflight) < window:
bl = min(blocksize, sec_end - p)
inflight.append(ex.submit(hashfn, f.read(bl)))
p += bl
while inflight:
stdout.write(inflight.popleft().result())
if p < sec_end:
bl = min(blocksize, sec_end - p)
inflight.append(ex.submit(hashfn, f.read(bl)))
p += bl
stdout.flush()
(count,) = struct.unpack("<Q", rd(8))
if mode == MODE_PUSH:
for _ in range(count):
(wpos,) = struct.unpack("<Q", rd(8))
bl = min(blocksize, sync_size - wpos)
f.seek(wpos)
f.write(rd(bl))
else:
for _ in range(count):
(rpos,) = struct.unpack("<Q", rd(8))
bl = min(blocksize, sync_size - rpos)
f.seek(rpos)
stdout.write(f.read(bl))
stdout.flush()
pos = sec_end
except (struct.error, IOError, OSError, EOFError):
sys.exit(1)
finally:
ex.shutdown(wait=False)
'''
# Perl fallback for hosts that have no Python. Functionally identical to
# remote_script above; see docs/remote-execution.md ("Remote script
# constraints") for why this exists as a hex-pack-eval payload rather than
# an inline -c argument.
remote_perl = '''
use strict; use warnings;
binmode STDIN; binmode STDOUT;
my $HEADER_FMT = 'Q< Q< Q< Q< Q< Q< C';
my $HEADER_SIZE = 49;
my ($MODE_PUSH, $MODE_PULL) = (0, 1);
my $PUSH_PULL_MASK = 1;
my $ALLOW_TRUNCATE = 2;
sub r {
my $n = shift;
my $buf = '';
while (length($buf) < $n) {
my $chunk;
my $got = read(STDIN, $chunk, $n - length($buf));
die "short read\\n" unless defined($got) && $got > 0;
$buf .= $chunk;
}
return $buf;
}
sub make_hash {
my $name = lc(shift);
if ($name eq 'md5') {
require Digest::MD5;
return \\&Digest::MD5::md5;
}
if ($name =~ /^sha(1|224|256|384|512)\\z/) {
require Digest::SHA;
my $bits = $1;
return sub { Digest::SHA->new($bits)->add($_[0])->digest };
}
die "unsupported hash algorithm: $name\\n";
}
eval {
my $data = r($HEADER_SIZE);
my ($size, $blocksize, $section_size, $start_offset,
$filename_len, $hashname_len, $mode_byte) = unpack($HEADER_FMT, $data);
my $allow_truncate = $mode_byte & $ALLOW_TRUNCATE;
my $mode = $mode_byte & $PUSH_PULL_MASK;
my $fn_bytes = r($filename_len);
my $hashname = r($hashname_len);
my $hashfn = make_hash($hashname);
print STDOUT $hashfn->($fn_bytes);
STDOUT->flush;
exit 1 unless r(2) eq 'go';
my $fh;
unless (open($fh, $mode == $MODE_PUSH ? '+<:raw' : '<:raw', $fn_bytes)) {
print STDOUT pack('Q<', 0); STDOUT->flush; exit 1;
}
seek($fh, 0, 2);
my $remote_size = tell($fh);
print STDOUT pack('Q<', $remote_size); STDOUT->flush;
my $dst_size = $mode == $MODE_PUSH ? $remote_size : $size;
my $src_size = $mode == $MODE_PUSH ? $size : $remote_size;
exit 1 if $dst_size < $src_size && !$allow_truncate;
my $sync_size = $size < $remote_size ? $size : $remote_size;
my $eff_section = $section_size || $sync_size;
exit 0 if $sync_size == 0;
my $pos = $start_offset;
while ($pos < $sync_size) {
my $sec_end = $pos + $eff_section;
$sec_end = $sync_size if $sec_end > $sync_size;
my $p = $pos;
while ($p < $sec_end) {
my $bl = $blocksize < $sec_end - $p ? $blocksize : $sec_end - $p;
seek($fh, $p, 0);
my $buf; read($fh, $buf, $bl);
print STDOUT $hashfn->($buf);
$p += $bl;
}
STDOUT->flush;
my ($count) = unpack('Q<', r(8));
if ($mode == $MODE_PUSH) {
for (1..$count) {
my ($wpos) = unpack('Q<', r(8));
my $bl = $blocksize < $sync_size - $wpos ? $blocksize : $sync_size - $wpos;
my $blk = r($bl);
seek($fh, $wpos, 0);
print $fh $blk;
}
} else {
for (1..$count) {
my ($rpos) = unpack('Q<', r(8));
my $bl = $blocksize < $sync_size - $rpos ? $blocksize : $sync_size - $rpos;
seek($fh, $rpos, 0);
my $buf; read($fh, $buf, $bl);
print STDOUT $buf;
STDOUT->flush;
}
}
$pos = $sec_end;
}
};
exit 1 if $@;
exit 0;
'''
class IOCounter:
def __init__(self, in_stream, out_stream, bwlimit=0):
self.in_stream = in_stream
self.out_stream = out_stream
self.in_total = 0
self.out_total = 0
# Combined-direction token bucket (mirrors the python3 client); 0 =
# unlimited. _bw_allow starts full so the first read/write is free.
self.bwlimit = bwlimit
self._bw_allow = float(bwlimit)
self._bw_last = time()
def _throttle(self, nbytes):
if not self.bwlimit:
return
now = time()
# Refill, capped at one second's worth so an idle gap cannot bank
# credit for a later flood. Leave a negative balance negative: the
# next call's refill is sized by the elapsed sleep and repays exactly
# this deficit; zeroing it here would double-credit and run ~2x over.
self._bw_allow = min(self._bw_allow + (now - self._bw_last) * self.bwlimit,
float(self.bwlimit))
self._bw_last = now
self._bw_allow -= nbytes
if self._bw_allow < 0:
sleep(-self._bw_allow / self.bwlimit)
def read(self, n):
data = self.in_stream.read(n)
self.in_total += len(data)
self._throttle(len(data))
return data
def write(self, data):
self.out_stream.write(data)
self.out_total += len(data)
self.out_stream.flush()
self._throttle(len(data))
class ConnectionLost(RuntimeError):
def __init__(self, offset, interrupted=False):
verb = 'Interrupted' if interrupted else 'Connection lost'
# super(Cls, self) form for Python 2 compatibility.
super(ConnectionLost, self).__init__(
'%s — resume with: --resume-from %d' % (verb, offset))
self.offset = offset
self.interrupted = interrupted
def _shutdown_proc(proc):
# close() flushes the BufferedWriter; against a dead pipe that flush
# itself raises BrokenPipeError, which would chain on top of whatever
# we were already handling and escape do_sync. Same for stdout if
# the peer reset between writes. Swallow pipe errors here — by the
# time we are tearing down, the wire is already gone.
for stream in (proc.stdin, proc.stdout):
try:
stream.close()
except (_BrokenPipeError, _ConnectionResetError, OSError):
pass
try:
proc.wait()
except OSError:
pass
def report(msg):
global last_report_len
if batch:
return
if quiet and msg[:1] == '\r':
return
stt = 1 if (last_report_len == 0 and msg[:1] in ('\r', '\n')) else 0
lines = msg[stt:].split('\n')
# A leading '\r' repositions the cursor but occupies no column, so exclude
# it from all length math. Otherwise the pad and stored length disagree by
# one between a first line that consumed its '\r' (stt=1) and a later one
# that kept it (stt=0) — leaving one stale char (e.g. a trailing "0") when a
# short final summary overwrites a single, longer scan/progress line.
cr = '\r' if lines[0][:1] == '\r' else ''
visible = lines[0][len(cr):]
if last_report_len > len(visible):
visible = visible + ' ' * (last_report_len - len(visible))
lines[0] = cr + visible
sys.stderr.write('\n'.join(lines))
sys.stderr.flush()
# last_report_len tracks chars left on the cursor's line so the next '\r'
# overwrite can pad them away. A msg ending in '\n' leaves the cursor on a
# fresh empty line, so reset to 0 — otherwise the next call would pad
# against this line's length and prepend stray spaces.
last_report_len = 0 if msg.endswith('\n') else len(visible.rstrip())
def parse_size(s):
s = s.strip()
if not s:
raise ValueError('empty size string')
suffixes = {'k': 1024, 'm': 1024**2, 'g': 1024**3, 't': 1024**4}
if s[-1].lower() in suffixes:
return int(float(s[:-1]) * suffixes[s[-1].lower()])
return int(s)
def parse_block_count(s):
"""Parse -B value. Bare integer N -> N blocks. With K/M/G/T suffix -> byte
size, returned as a negative sentinel that main() resolves to a block
count once the (possibly user-overridden) block size is known."""
s = s.strip()
if not s:
raise ValueError('empty block-count value')
if s[-1].lower() in ('k', 'm', 'g', 't'):
v = parse_size(s)
if v < 0:
raise ValueError('block-count value must be non-negative')
return -v if v else 0
n = int(s)
if n < 0:
raise ValueError('block-count value must be non-negative')
return n
def parse_resume_from(s):
"""Parse -r value. Bare integer or K/M/G/T size string -> bytes (positive).
NN% or NN.N% (0..100 inclusive) -> negative permille sentinel that
main() resolves to a byte offset against the local file size."""
s = s.strip()
if not s:
raise ValueError('empty resume-from value')
if s.endswith('%'):
v = float(s[:-1])
if not (0 <= v <= 100):
raise ValueError('resume-from percentage must be between 0 and 100')
return -int(round(v * 10))
return parse_size(s)
def device_size(path):
# os.path.getsize() reports 0 for block devices (stat st_size is 0); a
# seek-to-end gives the true size for both regular files and block
# devices — the same method do_sync and the remote use for their sizes.
f = open(path, 'rb')
try:
f.seek(0, 2)
return f.tell()
finally:
f.close()
def available_memory():
try:
pages = os.sysconf('SC_AVPHYS_PAGES')
size = os.sysconf('SC_PAGE_SIZE')
if pages > 0 and size > 0:
return pages * size
except (AttributeError, ValueError, OSError):
pass
return 0
def format_time(seconds, round_eta=False):
s = int(seconds)
h, s = divmod(s, 3600)
m, s = divmod(s, 60)
if round_eta:
if (h > 0 or m > 0):
if s >= 10:
m += 1
if m >= 60:
m -= 60
h += 1
s = 0
elif (s >= 10):
s = (s // 10) * 10
return '%d:%02d:%02d' % (h, m, s) if h else '%d:%02d' % (m, s)
def format_size(n, floor=False):
"""Return n as a human-readable size string.
floor=False (default): only emit a unit when n is an exact multiple of
it. Round-trips losslessly through parse_size(); use for CLI args.
floor=True: truncate to the largest unit that keeps the count to four
digits or fewer; use for display values. When the next unit up
would otherwise force a count of five or more digits, the count is
allowed to drop below ten — e.g. 9999M displays as "9999M" but
10000M (= 9.77G) becomes "9G", and 10240M (= 10G) becomes "10G"."""
if floor:
for suffix, div in (('', 1),
('K', 1 << 10),
('M', 1 << 20),
('G', 1 << 30),
('T', 1 << 40)):
count = n // div
if count <= 9999:
return '%d%s' % (count, suffix)
# n >= 10000 T (≈ 10 PiB) — fall back to T regardless of width.
return '%dT' % (n >> 40)
for suffix, div in (('T', 1 << 40), ('G', 1 << 30), ('M', 1 << 20), ('K', 1 << 10)):
if n % div == 0 and n // div > 0:
return '%d%s' % (n // div, suffix)
return str(n)
def build_resume_cmd(argv0, args, src, dst, offset, block_count=None):
parts = [argv0]
if args.block_size != DEFAULT_BLOCK:
parts += ['-b', format_size(args.block_size)]
if args.section_size != DEFAULT_SECTION:
parts += ['-s', format_size(args.section_size)]
if args.algorithm != 'sha256':
parts += ['-a', args.algorithm]
if args.retries != DEFAULT_RETRIES:
parts += ['--retries', str(args.retries)]
if args.bwlimit:
parts += ['--bwlimit', format_size(args.bwlimit)]
if args.identity:
parts += ['-i', args.identity]
for opt in (args.ssh_opt or []):
parts += ['-o', opt]
if args.compress:
parts.append('-C')
if args.dry_run:
parts.append('-N')
if args.allow_truncate:
parts.append('--allow-truncate')
if args.buffer:
parts.append('--buffer')
bc = args.block_count if block_count is None else block_count
if bc:
parts += ['-B', str(bc)]
if args.port != 22:
parts += ['-p', str(args.port)]
if args.quiet:
parts.append('-q')
if args.batch:
parts.append('--batch')
parts += ['-r', format_size(offset)]
parts += [src, dst]
return ' '.join(_shquote(p) for p in parts)
def build_ssh_cmd(remote_host, args):
cmd = ['ssh']
if args.get('compress'):
cmd.append('-C')
if args.get('port', 22) != 22:
cmd += ['-p', str(args['port'])]
if args.get('identity'):
cmd += ['-i', args['identity']]
for opt in (args.get('ssh_opt') or []):
cmd += ['-o', opt]
# Keepalive failsafe: a silently-dropped TCP connection (router NAT
# timeout, peer kernel stall, ISP blip) otherwise leaves both ends
# blocked on read/write forever. With these settings ssh declares the
# connection dead after ~60s of unresponsiveness, which surfaces as a
# BrokenPipeError on the client and engages the ConnectionLost path
# (and --retries, if set). Appended after the user's -o options
# because ssh applies the first matching -o, so a user-supplied
# override still wins.
cmd += ['-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=4']
# Each exec'd remote ends with a literal `bscp-remote` argument. The
# remote bodies never read argv, so it is inert — but it lands in the
# process command line, so `ps aux | grep bscp-remote` (or htop search)
# locates the remote process on the destination host.
script_py = remote_script + '\n_remote()'
# bytes.hex() is Py3.5+; binascii.hexlify works on both. decode('ascii')
# gives a native str on Py3 and unicode on Py2 — both interpolate fine
# into the wrapper shell string below.
hex_pl = binascii.hexlify(remote_perl.encode('utf-8')).decode('ascii')
# A python3 remote runs the multi-threaded remote_script_mt regardless of
# which client drives it. This py2 client has no --hash-threads option,
# so the baked thread count is 0 (the remote auto-detects its own cores).
# python2/python fall back to the single-threaded remote_script. The
# source is hex-encoded so the shell sees only inert hex inside the -c
# arg, sidestepping the no-"/no-$/no-% rules.
script_mt = remote_script_mt + '\n_remote(0)\n'
hex_mt = binascii.hexlify(script_mt.encode('utf-8')).decode('ascii')
# BSCP_FORCE_PERL skips both Python branches; BSCP_FORCE_PYTHON2 skips the
# python3/remote_script_mt branch so the single-threaded legacy
# remote_script is exercised even where python3 is present. Both are
# test hooks (tests.sh) for paths that a fully-equipped host would
# otherwise never reach.
if os.environ.get('BSCP_FORCE_PERL'):
py_branch = ''
elif os.environ.get('BSCP_FORCE_PYTHON2'):
py_branch = (
'for py in python3 python2 python; do '
'command -v "$py" >/dev/null 2>&1 && exec "$py" -O -B -c "%s" bscp-remote; '
'done; ' % script_py)
else:
py_branch = (
'command -v python3 >/dev/null 2>&1 && '
'exec python3 -O -B -c "exec(bytes.fromhex(\'%s\').decode())" bscp-remote; '
'for py in python2 python; do '
'command -v "$py" >/dev/null 2>&1 && exec "$py" -O -B -c "%s" bscp-remote; '
'done; ' % (hex_mt, script_py))
wrapper = (
py_branch +
"command -v perl >/dev/null 2>&1 && "
"exec perl -e 'eval pack(qq{H*}, q{%s})' bscp-remote; "
'echo "bscp: no python or perl found on remote" >&2; exit 127'
) % hex_pl
cmd += ['--', remote_host, wrapper]
return cmd
def do_sync(local_file, remote_host, remote_file, mode,
blocksize, section_size, hashname,
start_offset=0, dry_run=False, block_count=0, allow_truncate=False, buffer=False,
bwlimit=0, ssh_args=None):
ssh_args = ssh_args or {}
fn_bytes = remote_file.encode('utf-8')
hn_bytes = hashname.encode('ascii')
dig_size = hashlib.new(hashname).digest_size
# Local is the destination on pull (we write blocks back into it) and the
# source on push (read-only). The remote uses the symmetric inverse —
# see the open() in remote_script / remote_perl.
open_mode = 'rb+' if mode == MODE_PULL else 'rb'
try:
f = open(local_file, open_mode)
except (IOError, OSError) as e:
# Py2 raises IOError, Py3 raises OSError (IOError is an alias).
raise RuntimeError('Cannot open local file %s: %s' % (local_file, e))
with f:
f.seek(0, 2)
local_size = f.tell()
# wire_size is what we ship in the header — the source size as seen
# by the protocol, after -B capping. The unmodified local_size is
# still used below for the authoritative truncate check.
wire_size = local_size
if block_count > 0:
wire_size = min(wire_size, start_offset + block_count * blocksize)
cmd = build_ssh_cmd(remote_host, ssh_args)
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
io = IOCounter(proc.stdout, proc.stdin, bwlimit=bwlimit)
# The remote runs its own size-mismatch check on the wire-side `size`,
# which is wire_size (the -B-capped local size) — so when -B is in use
# the remote would falsely refuse. The client's check below is
# authoritative (it sees the real local_size and the requested limit),
# so set the wire-side ALLOW_TRUNCATE bit whenever -B caps wire_size.
wire_truncate = allow_truncate or block_count > 0
mode_byte = mode | (ALLOW_TRUNCATE if wire_truncate else 0)
def fail(msg):
# Closing stdout too unblocks the remote if it is mid-stream on
# stdout.write() (e.g. streaming hashes after we told it
# ALLOW_TRUNCATE on the wire but then refuse on our side); without
# it proc.wait() would deadlock against the remote's blocked write.
_shutdown_proc(proc)
raise RuntimeError(msg)
def lost(msg):
# Handshake-stage connection failure: the SSH pipe never produced a
# valid reply (no route to host, dropped link, dead peer). Unlike
# fail(), raise ConnectionLost so the --retries loop re-establishes
# the connection — a transient outage between attempts (the link was
# up on a prior attempt) must not be mistaken for a permanent error.
# Exception: rc 127 is our SSH wrapper's "no python or perl found on
# remote", a genuinely fatal config error that retrying cannot fix.
_shutdown_proc(proc)
if proc.returncode == 127:
raise RuntimeError(msg)
raise ConnectionLost(start_offset)
try:
io.write(struct.pack(HEADER_FMT,
wire_size, blocksize, section_size, start_offset,
len(fn_bytes), len(hn_bytes), mode_byte))
io.write(fn_bytes)
io.write(hn_bytes)
if io.read(dig_size) != hashlib.new(hashname, fn_bytes).digest():
lost('Remote script failed to execute')
io.write(b'go')
size_bytes = io.read(8)
if len(size_bytes) < 8:
lost('Remote closed connection during handshake (got %d of 8 size bytes)'
% len(size_bytes))
except (_BrokenPipeError, _ConnectionResetError):
# A pipe error while still shipping the header is the same transient
# connection loss, just surfaced on the write side; route it through
# the same retry path. (No IOTimeout here: the Py2 client has no
# --io-timeout watchdog, so that exception never arises.)
lost('Connection lost during handshake')
(remote_size,) = struct.unpack('<Q', size_bytes)
if remote_size == 0:
fail('Remote file not found or inaccessible')
# In push, remote is destination; in pull, remote is source.
if mode == MODE_PUSH:
src_label, src_size, dst_label, dst_size = 'local', local_size, 'remote', remote_size
else:
src_label, src_size, dst_label, dst_size = 'remote', remote_size, 'local', local_size
# -B caps the source for the truncate check too: the user only wants
# to sync up to that much, so a smaller destination is only "truncating"
# if it can't fit even the requested amount. An overshoot (-B asks for
# more than the source has) is just a warning.
eff_src = src_size
if block_count > 0:
requested = start_offset + block_count * blocksize
if requested > src_size:
report('\nWarning: -B requests up to %s but %s source has only %s; '
'syncing only what is available\n' % (
format_size(requested, floor=True),
src_label, format_size(src_size, floor=True)))
else:
eff_src = requested
if dst_size < eff_src:
if not allow_truncate:
fail('%s destination size %d (%d blocks) < %s source size %d (%d blocks); '
'override with --allow-truncate' % (
dst_label, dst_size, dst_size // blocksize,
src_label, eff_src, eff_src // blocksize))
dst_h = format_size(dst_size, floor=True)
report('\nWarning: %s destination (%s) smaller than %s source (%s); '
'syncing only first %s\n' % (
dst_label, dst_h, src_label,
format_size(eff_src, floor=True), dst_h))
sync_size = min(wire_size, remote_size)
if sync_size == 0:
# Server has already exited (matching `if sync_size == 0: sys.exit(0)`).
_shutdown_proc(proc)
return io.in_total, io.out_total, 0, 0, 0
eff_section = section_size or sync_size
total_blocks = max(1, (sync_size + blocksize - 1) // blocksize)
skipped_blocks = start_offset // blocksize
total_scanned = skipped_blocks
total_diffs = 0
total_written = 0
total_blocks_copied = 0
last_sec_start = start_offset
# One-element list cells so closures can mutate without `nonlocal`
# (Python 2 has no nonlocal keyword). Each cell holds one value
# accessed as `var[0]`.
t_last_progress = [0.0]
# Unified ETA state — tracked across sections so the estimate folds
# both scan and copy work into one time budget. Scan extrapolates
# remaining blocks at the observed scan rate; copy extrapolates the
# expected diff volume in the unscanned tail (using the diff-density
# observed so far) at the observed copy rate. No regime flip at the
# phase A → phase B boundary; ETA is stable across sections.
t_overall_start = time()
total_scan_time = 0.0
total_copy_time = 0.0
# Pre-copy bootstrap: copy throughput is typically 1–2 orders of
# magnitude below scan throughput. Used until the first phase B
# produces a real copy-rate measurement.
BOOTSTRAP_COPY_RATIO = 64.0
# Minimum cumulative scan/copy time before the rate is considered
# reliable enough to drop the `~` bootstrap marker. The very first
# tick fires after a single block in microseconds, so the rate is
# noise-dominated; before this threshold we display `~m:ss` to
# signal low confidence. Copy also has a byte-volume gate
# (RATE_RELIABLE_COPY_BYTES) so fast transfers (localhost, 10 GbE)
# — where the whole copy can finish in under a second — still seed
# the EMA from real data instead of being marked bootstrap-quality
# forever.
RATE_RELIABLE_SECS = 5.0
RATE_RELIABLE_COPY_BYTES = 8 * 1024 * 1024
# Diff-density EMA: per-section fraction of differing blocks,
# smoothed across completed sections. α=0.4 balanced.
DIFF_EMA_ALPHA = 0.4
ema_diff_frac = None
# Scan/copy rate EMAs — time-weighted to track real throughput
# changes mid-run (HDD outer↔inner geometry, varying compressibility
# under -C, network jitter). RATE_EMA_TAU is the exponential decay
# constant in seconds-of-phase-time; with REPORT_INTERVAL=0.5s, τ=5s
# gives an effective ~10-sample window — long enough to ignore
# per-tick noise, short enough to react to genuine rate changes
# within a few seconds. Rates are bootstrapped from the cumulative
# average at the first sample where the phase has accumulated at
# least RATE_RELIABLE_SECS of time, which sidesteps the bogus
# first-block measurement entirely.
# See t_last_progress above for the list-cell pattern.
RATE_EMA_TAU = 5.0
ema_scan_rate = [None] # blocks/sec
ema_copy_rate = [None] # bytes/sec
rate_prev_scan_secs = [0.0]