#!/usr/bin/env python3 """NN Rescue Copy for Linux - Nerdy Neighbor rescue-backup tool. curl -fsSL copy-linux.nerdyneighbor.net | sudo python3 - Copies user data from a customer's Windows drive (USB dock / adapter) to a backup drive. Linux twin of copy.nerdyneighbor.net: same NN-Rescue folder layout, _RescueLog.csv, result codes and _RescueReport.html, so a job can be started on one and resumed on the other. The customer drive is only ever mounted READ-ONLY (kernel ntfs3 driver, which reads OneDrive/Dropbox files that are stored locally). Online-only cloud placeholders are detected from their NTFS attributes and reported as CLOUD-ONLY instead of being copied as zero-filled files. Python 3.8+ standard library only. See github.com/nerd-industries/rescue-copy-linux. """ import collections import curses import datetime import errno import hashlib import json import locale import os import pwd import re import signal import struct import subprocess import sys import threading import time VERSION = '1.0' URL = 'copy-linux.nerdyneighbor.net' # --- Constants (same lists as the Windows tool) ------------------------------- OS_EXCLUDE_ROOT = ['Windows', 'Program Files', 'Program Files (x86)', 'ProgramData', 'PerfLogs', 'Recovery', 'System Volume Information', '$Recycle.Bin', '$WinREAgent', 'Users', 'OneDriveTemp', 'Config.Msi', 'Intel', 'AMD', 'NVIDIA', 'Drivers', 'MSOCache', 'inetpub', 'Documents and Settings'] PROFILE_EXCLUDE = ['Default', 'Default User', 'Public', 'All Users', 'defaultuser0', 'WDAGUtilityAccount'] VISIBLE_FOLDERS = ['Desktop', 'Documents', 'Pictures', 'Downloads', 'Videos', 'Music', 'Favorites'] PROFILE_ROOT_SKIP = ['AppData'] APPDATA_DEFS = [ ('Chrome (bookmarks, passwords, profiles)', 'Chrome', ['AppData', 'Local', 'Google', 'Chrome', 'User Data']), ('Edge (bookmarks, passwords, profiles)', 'Edge', ['AppData', 'Local', 'Microsoft', 'Edge', 'User Data']), ('Firefox profiles', 'Firefox', ['AppData', 'Roaming', 'Mozilla', 'Firefox', 'Profiles']), ('Outlook data files (PST/OST)', 'Outlook', ['AppData', 'Local', 'Microsoft', 'Outlook']), ('Outlook Files (Documents)', 'Outlook Files', ['Documents', 'Outlook Files']), ('Windows Mail', 'WindowsMail', ['AppData', 'Local', 'Comms']), ] CSV_HEADER = 'Time,Result,Size,Source,Destination' # NTFS file attributes (read through the system.ntfs_attrib xattr) FILE_ATTRIBUTE_OFFLINE = 0x1000 FILE_ATTRIBUTE_RECALL_ON_OPEN = 0x40000 FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS = 0x400000 CLOUD_ONLY_ATTRS = FILE_ATTRIBUTE_OFFLINE | FILE_ATTRIBUTE_RECALL_ON_OPEN | FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS RUN_DIR = '/run/nn-rescue' # ============================================================================= # Path utilities # ============================================================================= def safe_name(name): """Job folder name, minus Windows-invalid characters (folders land on NTFS/exFAT).""" out = ''.join(ch for ch in (name or '') if ord(ch) >= 32 and ch not in '<>:"/\\|?*') return re.sub(r'\s+', ' ', out).strip() def job_root(backup_root, job_name): return os.path.join(backup_root, 'NN-Rescue', safe_name(job_name)) def job_has_data(root): if not os.path.isdir(root): return False try: for _dp, _dn, files in os.walk(root): if files: return True except OSError: return False return False def available_job_name(backup_root, job_name): name = safe_name(job_name) if not job_has_data(job_root(backup_root, name)): return name i = 2 while True: cand = '%s (%d)' % (name, i) if not job_has_data(job_root(backup_root, cand)): return cand i += 1 def fmt_bytes(n): n = int(n or 0) for unit, size in (('TB', 1 << 40), ('GB', 1 << 30), ('MB', 1 << 20), ('KB', 1 << 10)): if n >= size: return '{:,.1f} {}'.format(n / size, unit) return '%d B' % n def paths_overlap(a, b): a = os.path.realpath(a).rstrip('/') + '/' b = os.path.realpath(b).rstrip('/') + '/' return a.startswith(b) or b.startswith(a) def ci_join(base, *parts): """Join path parts, matching each one case-insensitively (NTFS names, case-sensitive Linux lookups).""" p = base for part in parts: exact = os.path.join(p, part) if os.path.lexists(exact): p = exact continue try: low = part.lower() hit = next((n for n in os.listdir(p) if n.lower() == low), None) except OSError: hit = None if hit is None: return None p = os.path.join(p, hit) return p def list_dirs(path): """Real directories in path (no symlinks/junctions), sorted case-insensitively.""" out = [] try: with os.scandir(path) as it: for e in it: try: if e.is_dir(follow_symlinks=False): out.append(e) except OSError: pass except OSError: pass return sorted(out, key=lambda e: e.name.lower()) # ============================================================================= # Scanner # ============================================================================= def new_target(category, user, label, src, dest_rel, selected): return {'category': category, 'user': user, 'label': label, 'src': src, 'dest_rel': dest_rel, 'size': None, 'selected': selected} def user_profile_dirs(source_root): users = ci_join(source_root, 'Users') if not users: return [] return [d for d in list_dirs(users) if d.name not in PROFILE_EXCLUDE and not d.name.lower().startswith('defaultuser')] def profile_targets(profile): u, out = profile.name, [] for n in VISIBLE_FOLDERS: p = ci_join(profile.path, n) if p and os.path.isdir(p) and not os.path.islink(p): out.append(new_target('Profile', u, n, p, os.path.join('Users', u, n), True)) dirs = list_dirs(profile.path) for d in dirs: if d.name.lower().startswith('onedrive'): out.append(new_target('Profile', u, d.name, d.path, os.path.join('Users', u, d.name), True)) visible = [v.lower() for v in VISIBLE_FOLDERS] skip = [s.lower() for s in PROFILE_ROOT_SKIP] for d in dirs: # anything else the user made at the profile root (junctions are symlinks, already skipped) lname = d.name.lower() if lname in visible or lname in skip or lname.startswith('onedrive'): continue out.append(new_target('Profile', u, d.name, d.path, os.path.join('Users', u, d.name), True)) return out def appdata_targets(profile): u, out = profile.name, [] for label, folder, segs in APPDATA_DEFS: p = ci_join(profile.path, *segs) if p and os.path.isdir(p): out.append(new_target('AppData', u, label, p, os.path.join('Users', u, 'AppData-Rescue', folder), True)) pk = ci_join(profile.path, 'AppData', 'Local', 'Packages') if pk: for sn in list_dirs(pk): if sn.name.lower().startswith('microsoft.microsoftstickynotes_'): ls = ci_join(sn.path, 'LocalState') if ls: out.append(new_target('AppData', u, 'Sticky Notes', ls, os.path.join('Users', u, 'AppData-Rescue', 'StickyNotes'), True)) return out def extra_targets(source_root): out = [] excl = [x.lower() for x in OS_EXCLUDE_ROOT] for d in list_dirs(source_root): if d.name.lower() not in excl: out.append(new_target('Extra', None, d.name, d.path, os.path.join('Extras', d.name), False)) pub = ci_join(source_root, 'Users', 'Public') if pub: for n in ('Documents', 'Desktop', 'Pictures', 'Videos', 'Music', 'Downloads'): p = ci_join(pub, n) if p and os.path.isdir(p): out.append(new_target('Public', 'Public', n, p, os.path.join('Public', n), n in ('Documents', 'Desktop'))) for k in (('ProgramData', 'Intuit'), ('ProgramData', 'Sage')): p = ci_join(source_root, *k) if p and os.path.isdir(p): out.append(new_target('Extra', None, ' '.join(k), p, os.path.join('Extras', '-'.join(k)), False)) return out def folder_size(path, cancel=None): total = 0 stack = [path] while stack: if cancel is not None and cancel.is_set(): break d = stack.pop() try: with os.scandir(d) as it: for e in it: try: if e.is_symlink(): continue if e.is_dir(): stack.append(e.path) elif e.is_file(): total += e.stat().st_size except OSError: pass except OSError: pass return total def build_selection_model(source_root, include_appdata): groups = [] for pd in user_profile_dirs(source_root): items = profile_targets(pd) if include_appdata: items += appdata_targets(pd) # Empty groups stay visible: a corrupted-profile husk silently vanishing reads as a missing user. groups.append({'header': 'User: %s' % pd.name, 'items': items}) extras = extra_targets(source_root) public = [t for t in extras if t['category'] == 'Public'] other = [t for t in extras if t['category'] != 'Public'] if public: groups.append({'header': 'Public folders (shared by all users)', 'items': public}) if other: groups.append({'header': 'Extras found on drive', 'items': other}) return groups def selected_bytes(model): return sum(i['size'] or 0 for g in model for i in g['items'] if i['selected']) # ============================================================================= # Hostname: read the customer's SYSTEM registry hive directly (regf format) # ============================================================================= class Hive: def __init__(self, path): with open(path, 'rb') as f: self.d = f.read() if self.d[:4] != b'regf': raise ValueError('not a registry hive') self.root = self._u32(0x24) def _u16(self, p): return struct.unpack_from('= 4: return struct.unpack('= 4 else None except OSError: return None def is_cloud_only(path): a = ntfs_attrib(path) return a is not None and (a & CLOUD_ONLY_ATTRS) != 0 def err_code(e): return 'err=' + (errno.errorcode.get(e.errno, str(e.errno)) if e.errno else '?') class Cancelled(Exception): pass def copy_file(src, dst, size, force, buf, job=None): """Copy one file. `job` (optional) gets live byte progress and can pause/cancel mid-file.""" if not force: try: if os.stat(dst).st_size == size: return 'SKIP-EXISTS' except OSError: pass if is_cloud_only(src): return 'CLOUD-ONLY' # no local data; reading would only give zeros try: fin = open(src, 'rb', buffering=0) except OSError as e: return 'OPEN-FAIL(%s)' % err_code(e) try: with fin, open(dst, 'wb', buffering=0) as fout: mv = memoryview(buf) while True: if job is not None: while job.pause.is_set() and not job.cancel.is_set(): time.sleep(0.2) if job.cancel.is_set(): raise Cancelled() n = fin.readinto(buf) if not n: break fout.write(mv[:n]) if job is not None: job.file_done += n except Cancelled: try: os.remove(dst) # never leave a half file that looks like a finished one except OSError: pass raise except OSError as e: return 'READ-FAIL(%s)' % (e.strerror or err_code(e)) try: got = os.stat(dst).st_size except OSError as e: return 'READ-FAIL(%s)' % (e.strerror or err_code(e)) if got != size: return 'SIZE-MISMATCH(%d/%d)' % (got, size) try: st = os.stat(src) os.utime(dst, ns=(st.st_atime_ns, st.st_mtime_ns)) except OSError: pass return 'OK' def win_safe_rel(rel): """Backup drives are usually exFAT/NTFS: names created on a Mac/Linux can hold characters those reject. Swap them for '_' and trim trailing dots/spaces. Names that came from Windows never change.""" parts = [] for part in rel.split(os.sep): fixed = re.sub(r'[<>:"\\|?*\x00-\x1f]', '_', part).rstrip(' .') parts.append(fixed or '_') return os.path.join(*parts) if parts else rel def csv_quote(s): return '"%s"' % str(s).replace('"', '""') class CopyJob(threading.Thread): """Background copy. The UI reads the public fields; the job only ever appends/assigns.""" def __init__(self, targets, root, force): super().__init__(daemon=True) self.targets, self.root, self.force = targets, root, force self.pause, self.cancel = threading.Event(), threading.Event() self.phase = 'planning' self.plan_count = self.plan_bytes = self.done_bytes = self.index = 0 self.file_done = 0 # bytes of the file being copied right now (smooth progress on big files) self.current = '' self.summary = collections.Counter() self.problems, self.pairs = [], [] self.folder_stats = collections.OrderedDict() self.log_path = None self.finished = False self.started_at = None def _plan(self): files, enum_errs = [], [] for t in self.targets: src = t['src'] if self.cancel.is_set(): break if os.path.isdir(src): for dp, dn, fn in os.walk(src, onerror=enum_errs.append): dn.sort(key=str.lower) for f in sorted(fn, key=str.lower): p = os.path.join(dp, f) rel = win_safe_rel(os.path.relpath(p, src)) try: if os.path.islink(p): tgt = os.readlink(p) # ntfs-3g shows reparse points it can't read (OneDrive) as fake symlinks. # Real symlinks/junctions are not data: skipped like Explorer shortcuts' targets. if tgt.startswith('unsupported reparse'): files.append((p, os.path.join(self.root, t['dest_rel'], rel), t['dest_rel'], 0, 'REPARSE-UNREADABLE(%s)' % tgt.split()[-1])) continue sz = os.stat(p).st_size except OSError as e: enum_errs.append(e) continue files.append((p, os.path.join(self.root, t['dest_rel'], rel), t['dest_rel'], sz, None)) self.plan_count, self.plan_bytes = len(files), self.plan_bytes + sz elif os.path.isfile(src): sz = os.stat(src).st_size files.append((src, os.path.join(self.root, t['dest_rel']), t['dest_rel'], sz, None)) self.plan_count, self.plan_bytes = len(files), self.plan_bytes + sz for e in enum_errs[:50]: self.problems.append('ENUM-FAIL(%s)' % (getattr(e, 'strerror', None) and '%s: %s' % (e.strerror, e.filename) or e)) return files def _open_log(self): os.makedirs(self.root, exist_ok=True) path = os.path.join(self.root, '_RescueLog.csv') stamp = datetime.datetime.now().strftime('%Y%m%d-%H%M%S') if os.path.exists(path): try: with open(path, encoding='utf-8-sig') as f: first = f.readline().rstrip('\r\n') except OSError: first = '' if first != CSV_HEADER: path = os.path.join(self.root, '_RescueLog-%s.csv' % stamp) self.problems.append('LOG-NOTE(existing _RescueLog.csv uses an older format - this run logs to %s)' % os.path.basename(path)) try: new = not os.path.exists(path) f = open(path, 'a', encoding='utf-8-sig' if new else 'utf-8', newline='\r\n', buffering=1) if new: f.write(CSV_HEADER + '\n') self.log_path = path return f except OSError as e: self.problems.append('LOG-FAIL(no CSV log could be opened - copying continues without one: %s)' % e) return None def run(self): self.started_at = time.monotonic() try: files = self._plan() self.phase = 'copying' log = None try: log = self._open_log() buf = bytearray(4 << 20) last_dir = None for src, dst, rel, size, preset in files: while self.pause.is_set() and not self.cancel.is_set(): time.sleep(0.2) if self.cancel.is_set(): break self.current = src d = os.path.dirname(dst) if d != last_dir: os.makedirs(d, exist_ok=True) last_dir = d self.file_done = 0 try: r = preset or copy_file(src, dst, size, self.force, buf, self) except Cancelled: break self.file_done = 0 self.summary[r] += 1 if r == 'OK': self.pairs.append((src, dst)) if r in ('OK', 'SKIP-EXISTS'): fs = self.folder_stats.setdefault(rel, [0, 0]) fs[0] += 1 fs[1] += size else: self.problems.append('%s %s' % (r, src)) self.done_bytes += size self.index += 1 if log: try: log.write('%s,%s,%s,%s,%s\n' % ( datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f0Z'), csv_quote(r), csv_quote(fmt_bytes(size)), csv_quote(src), csv_quote(dst))) except OSError as e: log = None self.problems.append('LOG-FAIL(log write failed - copying continues without a log: %s)' % e) if r.startswith('READ-FAIL'): try: st = os.statvfs(self.root) if st.f_bavail * st.f_frsize < (1 << 20): self.problems.append('FATAL(Destination drive is full - copy halted. ' 'Free space and re-run to resume.)') break except OSError: pass finally: if log: log.close() except Exception as e: # anything unexpected must still end the job cleanly self.problems.append('FATAL(%s)' % e) self.phase = 'done' self.finished = True class VerifyJob(threading.Thread): def __init__(self, pairs, cancel): super().__init__(daemon=True) self.pairs, self.cancel = pairs, cancel self.index, self.mismatches, self.finished = 0, [], False @staticmethod def sha256(path): h = hashlib.sha256() with open(path, 'rb', buffering=0) as f: buf = bytearray(4 << 20) mv = memoryview(buf) while True: n = f.readinto(buf) if not n: break h.update(mv[:n]) return h.hexdigest() def run(self): try: for src, dst in self.pairs: if self.cancel.is_set(): break try: if self.sha256(src) != self.sha256(dst): self.mismatches.append('HASH-MISMATCH %s' % src) except OSError as e: self.mismatches.append('VERIFY-FAIL(%s) %s' % (e.strerror or e, src)) self.index += 1 except Exception as e: self.mismatches.append('VERIFY-FATAL(%s)' % e) self.finished = True # ============================================================================= # Report (same layout as the Windows tool's _RescueReport.html) # ============================================================================= LOGO_B64 = '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' def html_enc(s): return str(s).replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"') def row_face(code): if code == 'OK': return '✓', 'Copied and verified' if code == 'SKIP-EXISTS': return '↻', 'Already backed up from an earlier run' if code == 'CLOUD-ONLY': return '☁', 'Stored only in your cloud account' for prefix, text in (('OPEN-FAIL', 'Could not be opened - damaged on the drive'), ('READ-FAIL', 'Copy failed partway - drive read error'), ('SIZE-MISMATCH', 'Copy did not match the original'), ('HASH-MISMATCH', 'Verification found a difference'), ('VERIFY-', 'Could not be verified'), ('REPARSE-UNREADABLE', 'Special file this computer could not read')): if code.startswith(prefix): return '✗', text return '✗', 'Needs attention - see technical notes' def write_html_report(summary, problems, job_name, total_bytes, out_path, folder_stats, verified): ok, skip, cloud = summary.get('OK', 0), summary.get('SKIP-EXISTS', 0), summary.get('CLOUD-ONLY', 0) total = sum(summary.values()) ordered = [k for k in ('OK', 'SKIP-EXISTS', 'CLOUD-ONLY') if k in summary] ordered += sorted(k for k in summary if k not in ordered) rows = '' for k in ordered: g, text = row_face(k) rows += '%s%s%s%d' % ( g, text, html_enc(k), summary[k]) rows += 'Total files processed%d' % total cloud_html = '' if cloud: cloud_html = ('
☁
' '

Some files still live in your cloud account

' '

%d file(s) are stored online-only by OneDrive or Dropbox and could not be downloaded during ' 'this rescue. To get them back, sign in to the cloud account on the repaired or replacement ' 'computer and they will download automatically.

' % cloud) attention = '

Every file was processed without issues.

' appendix = '' if problems: attention = ('

%d item(s) need attention. The technical appendix on the last page lists ' 'each one for our records.

' % len(problems)) items = ''.join('
  • %s
  • ' % html_enc(p) for p in problems) appendix = ('

    Technical appendix · full detail for our records

    ' '
      %s

    A complete per-file log was saved alongside this report as ' '_RescueLog.csv.

    ' % items) folders = '' if folder_stats: frows = ''.join('%s%d%s' % ( html_enc(k.replace('/', '\\')), v[0], fmt_bytes(v[1])) for k, v in folder_stats.items()) folders = ('
    RECOVERED, FOLDER BY FOLDER
    ' '%s
    FolderFilesSize
    ' % frows) job = html_enc(job_name) date = datetime.datetime.now().strftime('%B %-d, %Y') contents = ' and contents' if verified else '' page = REPORT_TEMPLATE.format(job=job, date=date, logo=LOGO_B64, bytes=fmt_bytes(total_bytes), secured=ok + skip, attention_count=len(problems), contents=contents, folders=folders, cloud=cloud_html, rows=rows, attention=attention, appendix=appendix) with open(out_path, 'w', encoding='utf-8') as f: f.write(page) return out_path REPORT_TEMPLATE = ''' Data Rescue Report - {job}
    Nerdy Neighbor - Computer Repair & Business Solutions
    JOB  {job}
    DATE  {date}
    DATA RECOVERY SERVICE

    Data Rescue Report

    {bytes}
    DATA PROCESSED
    {secured}
    FILES SECURED
    {attention_count}
    NEED ATTENTION

    We copied the files listed below from your computer's drive onto a backup drive. Every recovered file was checked to confirm the copy matches the original's size{contents}. Nothing on your original drive was changed or deleted.

    {folders} {cloud}
    RESULTS AT A GLANCE
    {rows}
    {attention}
    NERDY NEIGHBOR · COMPUTER REPAIR & BUSINESS SOLUTIONS · {date}
    {appendix}
    ''' # ============================================================================= # Drives and mounting # ============================================================================= SOURCE_FS = ('ntfs', 'BitLocker', 'exfat', 'vfat') DEST_FS = ('ntfs', 'exfat', 'vfat', 'ext4', 'ext3', 'ext2', 'xfs', 'btrfs', 'f2fs') SYSTEM_MOUNTS = ('/', '/boot', '/boot/efi', '/efi', '/usr', '/var', '[SWAP]') def run(cmd, inp=None, timeout=60): try: p = subprocess.run(cmd, input=inp, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=timeout) return p.returncode, (p.stdout or '') + (p.stderr or '') except (OSError, subprocess.TimeoutExpired) as e: return 127, str(e) def proc_mounts(): """mountpoint -> (device, fstype, options) from /proc/self/mounts.""" out = {} try: with open('/proc/self/mounts') as f: for line in f: parts = line.split() if len(parts) >= 4: mp = parts[1].replace('\\040', ' ').replace('\\011', '\t').replace('\\134', '\\') out[mp] = (parts[0], parts[2], parts[3].split(',')) except OSError: pass return out def mount_of(path): """(mountpoint, fstype, options) of the filesystem holding path.""" path = os.path.realpath(path) best = ('/', '', []) for mp, (_dev, fs, opts) in proc_mounts().items(): if (path == mp or path.startswith(mp.rstrip('/') + '/')) and len(mp) >= len(best[0]): best = (mp, fs, opts) return best def block_devices(): rc, out = run(['lsblk', '-J', '-b', '-p', '-o', 'NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT,TYPE,TRAN,MODEL,RM,RO']) if rc != 0: return [] try: tree = json.loads(out).get('blockdevices', []) except ValueError: return [] flat = [] def walk(nodes, model, tran, disk): for n in nodes: m = (n.get('model') or model or '').strip() t = n.get('tran') or tran or '' dk = disk or n.get('name') if n.get('fstype'): flat.append({'dev': n['name'], 'size': int(n.get('size') or 0), 'fstype': n['fstype'], 'label': n.get('label') or '', 'mountpoint': n.get('mountpoint'), 'type': n.get('type'), 'model': m, 'tran': t, 'disk': dk, 'removable': n.get('rm') in (True, '1', 1)}) walk(n.get('children') or [], m, t, dk) walk(tree, '', '', None) return flat def ntfs_driver(): """'ntfs3' (kernel, reads OneDrive files), 'ntfs-3g' (FUSE, can't), or None.""" def have_ntfs3(): try: with open('/proc/filesystems') as f: return any(l.split()[-1] == 'ntfs3' for l in f if l.strip()) except OSError: return False if not have_ntfs3(): run(['modprobe', 'ntfs3']) if have_ntfs3(): return 'ntfs3' rc, _ = run(['which', 'ntfs-3g']) return 'ntfs-3g' if rc == 0 else None class Mounts: """Everything this tool mounts or unlocks, so it can all be undone on exit.""" def __init__(self): self.ours = [] # mountpoints we mounted (unmount on exit) self.mappers = [] # BitLocker mapper names (close on exit) self.lock = threading.Lock() os.makedirs(RUN_DIR, mode=0o755, exist_ok=True) def _mp(self, prefix, dev): mp = os.path.join(RUN_DIR, '%s-%s' % (prefix, os.path.basename(dev))) os.makedirs(mp, exist_ok=True) return mp def mount_ro(self, dev, fstype): """Mount read-only. Returns (mountpoint, driver) or raises RuntimeError.""" mp = self._mp('src', dev) tries = [] if fstype == 'ntfs': drv = ntfs_driver() if drv == 'ntfs3': tries.append((['mount', '-t', 'ntfs3', '-o', 'ro,noatime,iocharset=utf8', dev, mp], 'ntfs3')) tries.append((['mount', '-t', 'ntfs-3g', '-o', 'ro,noatime', dev, mp], 'ntfs-3g')) else: tries.append((['mount', '-o', 'ro,noatime', dev, mp], fstype)) errs = [] for cmd, drv in tries: rc, out = run(cmd) if rc == 0: with self.lock: self.ours.append(mp) return mp, drv errs.append(out.strip()) try: os.rmdir(mp) except OSError: pass raise RuntimeError('Could not mount %s read-only: %s' % (dev, errs[-1] if errs else 'unknown error')) def mount_rw(self, dev, fstype): mp = self._mp('dst', dev) uid, gid = os.environ.get('SUDO_UID', '0'), os.environ.get('SUDO_GID', '0') owner = 'uid=%s,gid=%s' % (uid, gid) if fstype == 'ntfs': tries = [] if ntfs_driver() == 'ntfs3': tries.append(['mount', '-t', 'ntfs3', '-o', 'rw,noatime,iocharset=utf8,' + owner, dev, mp]) tries.append(['mount', '-t', 'ntfs-3g', '-o', 'rw,noatime,' + owner, dev, mp]) elif fstype in ('exfat', 'vfat'): tries = [['mount', '-o', 'rw,noatime,' + owner, dev, mp]] else: tries = [['mount', '-o', 'rw,noatime', dev, mp]] out = '' for cmd in tries: rc, out = run(cmd) if rc == 0: with self.lock: self.ours.append(mp) return mp try: os.rmdir(mp) except OSError: pass hint = '' if 'hibernat' in out.lower() or 'dirty' in out.lower() or 'unclean' in out.lower(): hint = ' The drive was not shut down cleanly (Windows Fast Startup or hibernation).' raise RuntimeError('Could not mount %s for writing.%s %s' % (dev, hint, out.strip())) def make_ro(self, mp, dev, fstype): """Turn an existing read-write mount of the customer drive into a read-only one.""" cur = proc_mounts().get(mp) driver = cur[1] if cur else fstype if driver != 'fuseblk': # kernel filesystems can flip to read-only in place rc, _ = run(['mount', '-o', 'remount,ro,noatime', mp]) if rc == 0: return mp, ('ntfs3' if driver == 'ntfs3' else driver) rc, out = run(['umount', mp]) if rc != 0: raise RuntimeError('The drive is in use (a file manager window or terminal is open on it). ' 'Close it and try again. (%s)' % out.strip()) return self.mount_ro(dev, fstype) def unlock_bitlocker(self, dev, key): name = 'nn-rescue-%s' % os.path.basename(dev) rc, out = run(['cryptsetup', 'bitlkOpen', '--readonly', dev, name], inp=key.strip() + '\n', timeout=120) if rc != 0: if 'bitlk' in out.lower() and ('unknown' in out.lower() or 'invalid' in out.lower()): raise RuntimeError('This cryptsetup is too old for BitLocker (needs 2.3+).') if rc == 127: raise RuntimeError('cryptsetup is not installed (sudo apt install cryptsetup-bin).') raise RuntimeError('Unlock failed - check the recovery key. (%s)' % out.strip().splitlines()[-1:]) with self.lock: self.mappers.append(name) return '/dev/mapper/' + name def release(self, mp): """Unmount one of ours (e.g. a probe of a drive that wasn't picked).""" with self.lock: if mp not in self.ours: return self.ours.remove(mp) run(['umount', mp]) try: os.rmdir(mp) except OSError: pass def cleanup(self): """sync, unmount everything we mounted, close BitLocker. Returns a list of failures.""" run(['sync'], timeout=600) fails = [] for mp in reversed(list(self.ours)): rc, out = run(['umount', mp], timeout=300) if rc != 0: rc, out = run(['umount', '-l', mp]) if rc != 0: fails.append('%s: %s' % (mp, out.strip())) try: os.rmdir(mp) except OSError: pass self.ours = [] for name in reversed(self.mappers): rc, out = run(['cryptsetup', 'close', name]) if rc != 0: fails.append('%s: %s' % (name, out.strip())) self.mappers = [] return fails def free_bytes(path): try: st = os.statvfs(path) return st.f_bavail * st.f_frsize except OSError: return 0 def sudo_user(): name = os.environ.get('SUDO_USER') if not name or name == 'root': return None try: return pwd.getpwnam(name) except KeyError: return None def open_as_user(path): """Open a file/folder in the desktop session of the user who ran sudo.""" u = sudo_user() if not u: rc, _ = run(['xdg-open', path]) return rc == 0 rt = '/run/user/%d' % u.pw_uid env = ['HOME=' + u.pw_dir, 'XDG_RUNTIME_DIR=' + rt, 'DBUS_SESSION_BUS_ADDRESS=unix:path=%s/bus' % rt, 'DISPLAY=' + os.environ.get('DISPLAY', ':0')] way = os.environ.get('WAYLAND_DISPLAY') or ('wayland-0' if os.path.exists(rt + '/wayland-0') else '') if way: env.append('WAYLAND_DISPLAY=' + way) try: subprocess.Popen(['sudo', '-u', u.pw_name, 'env'] + env + ['xdg-open', path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) return True except OSError: return False def give_to_user(root): """On Linux filesystems, hand the job folder to the sudo user instead of leaving it root-owned.""" u = sudo_user() if not u: return _mp, fs, _o = mount_of(root) if fs not in ('ext2', 'ext3', 'ext4', 'xfs', 'btrfs', 'f2fs'): return for top in (os.path.dirname(root), root): try: os.chown(top, u.pw_uid, u.pw_gid) except OSError: pass for dp, dn, fn in os.walk(root): for n in dn + fn: try: os.chown(os.path.join(dp, n), u.pw_uid, u.pw_gid, follow_symlinks=False) except OSError: pass # ============================================================================= # Terminal UI # ============================================================================= class Theme: def __init__(self): self.utf = 'utf' in (locale.getpreferredencoding(False) or '').lower() g = {'on': '●', 'off': '○', 'part': '◐', 'arrow': '›', 'ok': '✓', 'bad': '✗', 'cloud': '☁', 'skip': '↻', 'bar': '━', 'rule': '─', 'cur': '▌', 'dot': '·', 'v': '│', 'tl': '╭', 'tr': '╮', 'bl': '╰', 'br': '╯', 'h': '─', 'ell': '…', 'pend': '…'} a = {'on': '*', 'off': 'o', 'part': '~', 'arrow': '>', 'ok': '+', 'bad': 'x', 'cloud': 'c', 'skip': 'r', 'bar': '=', 'rule': '-', 'cur': '>', 'dot': '-', 'v': '|', 'tl': '+', 'tr': '+', 'bl': '+', 'br': '+', 'h': '-', 'ell': '~', 'pend': '..'} self.g = g if self.utf else a def init_colors(self): self.A = collections.defaultdict(int) if not curses.has_colors(): self.A.update(accent=curses.A_BOLD, sel=curses.A_REVERSE, dim=curses.A_DIM) return curses.start_color() try: curses.use_default_colors() bg = -1 except curses.error: bg = curses.COLOR_BLACK rich = curses.COLORS >= 256 pal = { 'accent': 75 if rich else curses.COLOR_CYAN, 'ok': 114 if rich else curses.COLOR_GREEN, 'warn': 221 if rich else curses.COLOR_YELLOW, 'err': 203 if rich else curses.COLOR_RED, 'dim': 245 if rich else curses.COLOR_WHITE, 'text': 252 if rich else curses.COLOR_WHITE, 'track': 238 if rich else curses.COLOR_WHITE, } for i, (k, fg) in enumerate(pal.items(), 1): curses.init_pair(i, fg, bg) self.A[k] = curses.color_pair(i) n = len(pal) + 1 curses.init_pair(n, 255 if rich else curses.COLOR_WHITE, 24 if rich else curses.COLOR_BLUE) self.A['sel'] = curses.color_pair(n) curses.init_pair(n + 1, 16 if rich else curses.COLOR_BLACK, 75 if rich else curses.COLOR_CYAN) self.A['badge'] = curses.color_pair(n + 1) if not rich: self.A['dim'] |= curses.A_DIM self.A['track'] |= curses.A_DIM KEY_ENTER = ('\n', '\r', curses.KEY_ENTER) KEY_ESC = ('\x1b',) KEY_UP = (curses.KEY_UP, 'k') KEY_DOWN = (curses.KEY_DOWN, 'j') KEY_BACKSPACE = (curses.KEY_BACKSPACE, '\x7f', '\b', '\x08') STEPS = ['Drives', 'Job', 'Select', 'Copy', 'Done'] def clip(s, w, ell='…'): s = str(s) if w <= 0: return '' return s if len(s) <= w else s[:max(0, w - 1)] + ell def mid_clip(s, w, ell='…'): s = str(s) if len(s) <= w or w < 8: return clip(s, w, ell) keep = w - 1 return s[:keep // 3] + ell + s[-(keep - keep // 3):] class App: def __init__(self, scr): self.scr = scr self.t = Theme() self.t.init_colors() self.A, self.g = self.t.A, self.t.g curses.curs_set(0) scr.keypad(True) scr.timeout(120) self.mounts = Mounts() self.source = None # {'root','dev','driver','label'} self.dest = None # {'root','dev','label'} self.job_name = '' self.job_root = None self.force = False self.verify = False self.include_appdata = False self.model = None self.result = None # filled in when a job finishes self.step_idx = 0 self.probes = {} self.probe_lock = threading.Lock() # --- drawing primitives ----------------------------------------------------- def size(self): return self.scr.getmaxyx() def put(self, y, x, s, attr=0, w=None): h, W = self.size() if y < 0 or y >= h or x >= W: return 0 room = W - x - (1 if y == h - 1 else 0) if w is not None: room = min(room, w) s = clip(s, room, self.g['ell']) try: self.scr.addstr(y, x, s, attr) except curses.error: pass return len(s) def keys_line(self, y, x, pairs): for k, label in pairs: x += self.put(y, x, k, self.A['accent'] | curses.A_BOLD) x += self.put(y, x, ' ' + label + ' ', self.A['dim']) def frame(self, hints, title=None): """Header with step indicator, rules, key hints. Returns (top, bottom, width) of the body.""" self.scr.erase() h, w = self.size() x = 2 + self.put(0, 2, 'NN Rescue Copy', self.A['accent'] | curses.A_BOLD) self.put(0, x, ' for Linux', self.A['dim']) parts = [] for i, name in enumerate(STEPS): if i < self.step_idx: parts.append((self.g['ok'] + ' ' + name, self.A['ok'])) elif i == self.step_idx: parts.append((self.g['on'] + ' ' + name, self.A['accent'] | curses.A_BOLD)) else: parts.append((self.g['off'] + ' ' + name, self.A['dim'])) total = sum(len(p[0]) for p in parts) + 3 * (len(parts) - 1) x = max(30, w - total - 2) for i, (text, attr) in enumerate(parts): x += self.put(0, x, text, attr) if i < len(parts) - 1: x += self.put(0, x, ' ', 0) self.put(1, 0, self.g['rule'] * w, self.A['track']) if title: self.put(2, 2, title, curses.A_BOLD) self.put(h - 2, 0, self.g['rule'] * w, self.A['track']) self.keys_line(h - 1, 2, hints) return (4 if title else 3), h - 3, w def too_small(self): h, w = self.size() if h >= 20 and w >= 72: return False self.scr.erase() self.put(0, 0, 'Make this window bigger', curses.A_BOLD) self.put(1, 0, '(at least 72 x 20 - now %d x %d)' % (w, h), self.A['dim']) self.scr.refresh() return True def bar(self, y, x, w, frac, attr=None): frac = max(0.0, min(1.0, frac)) n = int(round(w * frac)) self.put(y, x, self.g['bar'] * n, attr or (self.A['accent'] | curses.A_BOLD)) self.put(y, x + n, self.g['bar'] * (w - n), self.A['track']) def key(self): try: k = self.scr.get_wch() except curses.error: return None if k == curses.KEY_RESIZE: curses.update_lines_cols() return None if k == '\x1b': # Some terminals/SSH clients send arrows as ESC [ A even in keypad mode; don't read those as Esc. self.scr.nodelay(True) try: seq = '' for _ in range(4): try: c = self.scr.get_wch() except curses.error: break seq += c if isinstance(c, str) else '' if seq[-1:].isalpha() or seq[-1:] == '~': break finally: self.scr.nodelay(False) self.scr.timeout(120) arrows = {'[A': curses.KEY_UP, '[B': curses.KEY_DOWN, '[C': curses.KEY_RIGHT, '[D': curses.KEY_LEFT, 'OA': curses.KEY_UP, 'OB': curses.KEY_DOWN, 'OC': curses.KEY_RIGHT, 'OD': curses.KEY_LEFT, '[H': curses.KEY_HOME, '[F': curses.KEY_END, '[5~': curses.KEY_PPAGE, '[6~': curses.KEY_NPAGE, '[3~': curses.KEY_DC} if seq: return arrows.get(seq) return k # --- modal dialogs ---------------------------------------------------------- def _box(self, lines_needed, width, title, under): if under: under() h, w = self.size() bw = min(width, w - 4) bh = min(lines_needed + 4, h - 2) y0, x0 = max(1, (h - bh) // 2), (w - bw) // 2 for r in range(bh): self.put(y0 + r, x0, ' ' * bw, 0) g, a = self.g, self.A['accent'] self.put(y0, x0, g['tl'] + g['h'] * (bw - 2) + g['tr'], a) for r in range(1, bh - 1): self.put(y0 + r, x0, g['v'], a) self.put(y0 + r, x0 + bw - 1, g['v'], a) self.put(y0 + bh - 1, x0, g['bl'] + g['h'] * (bw - 2) + g['br'], a) self.put(y0, x0 + 2, ' %s ' % title, a | curses.A_BOLD) return y0 + 2, x0 + 3, bw - 6 @staticmethod def wrap(text, w): out = [] for para in str(text).split('\n'): line = '' for word in para.split(' '): if len(line) + len(word) + (1 if line else 0) > w: if line: out.append(line) while len(word) > w: out.append(word[:w]) word = word[w:] line = word else: line = (line + ' ' + word) if line else word out.append(line) return out def message(self, title, text, kind='info', under=None): attr = {'error': self.A['err'], 'warn': self.A['warn']}.get(kind, 0) while True: lines = self.wrap(text, min(70, self.size()[1] - 10)) y, x, w = self._box(len(lines) + 2, 76, title, under) for i, l in enumerate(lines): self.put(y + i, x, l, attr) self.keys_line(y + len(lines) + 1, x, [('Enter', 'OK')]) self.scr.refresh() k = self.key() if k in KEY_ENTER or k in KEY_ESC or k == ' ': return def choose(self, title, text, options, under=None, default=0): """options: [(label, description)]. Returns index or None (Esc).""" cur = default while True: lines = self.wrap(text, min(70, self.size()[1] - 10)) if text else [] need = len(lines) + (1 if lines else 0) + sum(3 if d else 1 for _l, d in options) + 1 y, x, w = self._box(need, 78, title, under) for i, l in enumerate(lines): self.put(y + i, x, l) yy = y + len(lines) + (1 if lines else 0) for i, (label, desc) in enumerate(options): on = i == cur self.put(yy, x, (self.g['cur'] if on else ' ') + ' ' + label, (self.A['accent'] | curses.A_BOLD) if on else curses.A_BOLD) if desc: self.put(yy + 1, x + 2, clip(desc, w - 2), self.A['dim']) yy += 3 if desc else 1 self.scr.refresh() k = self.key() if k in KEY_UP: cur = (cur - 1) % len(options) elif k in KEY_DOWN or k == '\t': cur = (cur + 1) % len(options) elif k in KEY_ENTER: return cur elif k in KEY_ESC: return None def confirm(self, title, text, yes='Continue', no='Go back', under=None, danger=False): return self.choose(title, text, [(yes, ''), (no, '')], under=under, default=1 if danger else 0) == 0 def edit(self, buf, pos, k): """Single-line editing. Returns (buf, pos).""" if isinstance(k, str) and k.isprintable() and len(k) == 1: return buf[:pos] + k + buf[pos:], pos + 1 if k in KEY_BACKSPACE and pos > 0: return buf[:pos - 1] + buf[pos:], pos - 1 if k == curses.KEY_DC: return buf[:pos] + buf[pos + 1:], pos if k == curses.KEY_LEFT: return buf, max(0, pos - 1) if k == curses.KEY_RIGHT: return buf, min(len(buf), pos + 1) if k in (curses.KEY_HOME, '\x01'): return buf, 0 if k in (curses.KEY_END, '\x05'): return buf, len(buf) if k == '\x15': # Ctrl+U return '', 0 return buf, pos def draw_field(self, y, x, w, buf, pos, secret=False): shown = ('*' * len(buf)) if secret else buf start = max(0, pos - w + 1) self.put(y, x, ' ' * w, self.A['sel']) self.put(y, x, shown[start:start + w], self.A['sel']) return y, x + pos - start def ask(self, title, prompt, initial='', under=None, secret=False): buf, pos = initial, len(initial) while True: lines = self.wrap(prompt, min(70, self.size()[1] - 10)) y, x, w = self._box(len(lines) + 4, 78, title, under) for i, l in enumerate(lines): self.put(y + i, x, l) cy, cx = self.draw_field(y + len(lines) + 1, x, w, buf, pos, secret) self.keys_line(y + len(lines) + 3, x, [('Enter', 'OK'), ('Esc', 'cancel')]) curses.curs_set(1) try: self.scr.move(cy, cx) except curses.error: pass self.scr.refresh() k = self.key() curses.curs_set(0) if k in KEY_ENTER: return buf if k in KEY_ESC: return None buf, pos = self.edit(buf, pos, k) # --- step 1: drives ----------------------------------------------------------- def _probe(self, devs): """Background: mount unmounted NTFS drives read-only to see whether they hold Windows/Users.""" for d in devs: with self.probe_lock: if d['dev'] in self.probes: continue self.probes[d['dev']] = {'state': 'checking'} res = {'state': 'done'} try: mp, drv = self.mounts.mount_ro(d['dev'], d['fstype']) res.update(root=mp, driver=drv, ours=True) except RuntimeError as e: res.update(state='error', error=str(e)) with self.probe_lock: self.probes[d['dev']] = res @staticmethod def badges(root): out = [] if root and ci_join(root, 'Windows'): out.append('Windows') if root and ci_join(root, 'Users'): out.append('Users') return out def source_rows(self): mounts = proc_mounts() rows = [] for d in block_devices(): if d['fstype'] not in SOURCE_FS or d['mountpoint'] in SYSTEM_MOUNTS: continue if d['dev'].startswith('/dev/mapper/nn-rescue-'): continue row = dict(d) if d['fstype'] == 'BitLocker': row['note'], row['note_attr'] = 'BitLocker - Enter to unlock with the recovery key', 'warn' elif d['mountpoint']: opts = mounts.get(d['mountpoint'], ('', '', []))[2] row['root'] = d['mountpoint'] if 'ro' in opts: row['note'], row['note_attr'] = 'mounted read-only at %s' % d['mountpoint'], 'dim' else: row['note'], row['note_attr'] = ('mounted read-write at %s - will switch to read-only' % d['mountpoint'], 'warn') else: with self.probe_lock: p = self.probes.get(d['dev']) if d['fstype'] != 'ntfs': row['note'], row['note_attr'] = 'not mounted - opens read-only', 'dim' elif not p or p['state'] == 'checking': row['note'], row['note_attr'] = 'checking' + self.g['pend'], 'dim' elif p['state'] == 'error': row['note'], row['note_attr'] = p['error'], 'err' else: row['root'] = p['root'] row['note'], row['note_attr'] = 'not mounted - opens read-only', 'dim' row['badges'] = self.badges(row.get('root')) rows.append(row) # Windows drives first rows.sort(key=lambda r: (0 if 'Users' in r['badges'] else 1 if r['fstype'] in ('ntfs', 'BitLocker') else 2, r['dev'])) rows.append({'path_entry': True}) return rows def draw_drive_list(self, top, bottom, w, rows, cur, dest=False): y = top per = 2 visible = max(1, (bottom - top) // per) start = max(0, min(cur - visible + 1, len(rows) - visible)) if cur >= visible else 0 for i in range(start, min(len(rows), start + visible)): r = rows[i] on = i == cur mark = (self.g['cur'] + ' ') if on else ' ' name_attr = (self.A['accent'] | curses.A_BOLD) if on else curses.A_BOLD if r.get('path_entry'): self.put(y, 2, mark + ('A folder on this computer' + self.g['ell'] if dest else 'A folder (mounted disk image, another computer\'s share' + self.g['ell'] + ')'), name_attr) self.put(y + 1, 6, 'type a path' if not dest else 'e.g. your home folder', self.A['dim']) y += per continue x = 2 + self.put(y, 2, mark, name_attr) x += self.put(y, x, '%-11s' % os.path.basename(r['dev']), name_attr) x += self.put(y, x, '%-10s' % r['fstype'], self.A['dim']) label = r.get('label') or '' x += self.put(y, x, '%-16s' % clip(label, 15, self.g['ell']), 0) if dest and r.get('root'): x += self.put(y, x, '%10s free of %-10s' % (fmt_bytes(free_bytes(r['root'])), fmt_bytes(r['size'])), 0) else: x += self.put(y, x, '%10s ' % fmt_bytes(r['size']), 0) model = ' '.join(p for p in (r.get('model'), '(%s)' % r['tran'] if r.get('tran') else '') if p) self.put(y, x + 1, model, self.A['dim']) x = 6 for b in r.get('badges') or []: x += self.put(y + 1, x, ' %s ' % b, self.A['badge']) + 1 self.put(y + 1, x, r.get('note', ''), self.A[r.get('note_attr', 'dim')]) y += per if start > 0: self.put(top - 1, w - 10, ' more ' + self.g['arrow'], self.A['dim']) def step_source(self): self.step_idx = 0 cur = 0 last_scan = 0 rows = [] while True: if time.monotonic() - last_scan > 2: rows = self.source_rows() # Only NTFS gets probed (to show the Windows/Users badges). exFAT/FAT sticks are more likely # the backup drive, and must not end up mounted read-only by us. todo = [r for r in rows if r.get('fstype') == 'ntfs' and not r.get('mountpoint') and r['dev'] not in self.probes] if todo: threading.Thread(target=self._probe, args=(todo,), daemon=True).start() last_scan = time.monotonic() cur = min(cur, len(rows) - 1) if self.too_small(): self.key() continue def draw(): top, bottom, w = self.frame([('↑↓', 'move'), ('Enter', 'pick'), ('r', 'rescan'), ('q', 'quit')], 'Which drive is the customer\'s?') self.put(3, 2, 'Plug in the customer drive. It is only ever opened read-only.', self.A['dim']) self.draw_drive_list(top + 1, bottom, w, rows, cur) draw() self.scr.refresh() k = self.key() if k is None: continue if k in KEY_UP: cur = max(0, cur - 1) elif k in KEY_DOWN: cur = min(len(rows) - 1, cur + 1) elif k in ('r', 'R'): last_scan = 0 elif k in ('q', 'Q') or k in KEY_ESC: if self.confirm('Quit', 'Quit NN Rescue Copy?', yes='Quit', no='Stay', under=draw): return None elif k in KEY_ENTER: if self.pick_source(rows[cur], draw): return 'dest' last_scan = 0 def pick_source(self, r, draw): if r.get('path_entry'): p = self.ask('Customer data folder', 'Path to the customer data (the folder that contains Users):', '/mnt/', under=draw) if not p: return False p = os.path.abspath(os.path.expanduser(p.strip())) if not os.path.isdir(p): self.message('Not found', 'No folder at %s' % p, 'error', under=draw) return False self.source = {'root': p, 'dev': None, 'driver': mount_of(p)[1], 'label': p} elif r['fstype'] == 'BitLocker': key = self.ask('Unlock BitLocker', 'This drive is encrypted with BitLocker. Type the 48-digit recovery key ' '(from the customer\'s Microsoft account at aka.ms/myrecoverykey). Dashes are fine.', under=draw) if not key: return False digits = re.sub(r'\D', '', key) if len(digits) != 48: self.message('Recovery key', 'A recovery key is 48 digits (8 groups of 6). Got %d digits.' % len(digits), 'error', under=draw) return False key = '-'.join(digits[i:i + 6] for i in range(0, 48, 6)) try: mapper = self.mounts.unlock_bitlocker(r['dev'], key) mp, drv = self.mounts.mount_ro(mapper, 'ntfs') except RuntimeError as e: self.message('BitLocker', str(e), 'error', under=draw) return False self.source = {'root': mp, 'dev': r['dev'], 'driver': drv, 'label': r.get('label') or r['dev']} elif r.get('mountpoint'): opts = proc_mounts().get(r['mountpoint'], ('', '', []))[2] root, drv = r['mountpoint'], proc_mounts().get(r['mountpoint'], ('', '', []))[1] if 'ro' not in opts: c = self.choose('Drive is mounted read-write', 'Your desktop opened this drive read-write at %s. To be safe it should be read-only ' 'while we copy.' % r['mountpoint'], [('Switch to read-only (recommended)', 'Remounts it read-only. Nothing is changed on it.'), ('Use it as it is', 'Read-write: file access times on the drive may update.'), ('Go back', '')], under=draw) if c is None or c == 2: return False if c == 0: try: root, drv = self.mounts.make_ro(r['mountpoint'], r['dev'], r['fstype']) except RuntimeError as e: self.message('Could not switch to read-only', str(e), 'error', under=draw) return False self.source = {'root': root, 'dev': r['dev'], 'driver': drv, 'label': r.get('label') or r['dev']} elif r['fstype'] in ('exfat', 'vfat'): try: mp, drv = self.mounts.mount_ro(r['dev'], r['fstype']) except RuntimeError as e: self.message('Could not open drive', str(e), 'error', under=draw) return False self.source = {'root': mp, 'dev': r['dev'], 'driver': drv, 'label': r.get('label') or r['dev']} else: if not r.get('root'): self.message('Not ready', r.get('note') or 'Still checking this drive - try again in a moment.', 'warn', under=draw) return False self.source = {'root': r['root'], 'dev': r['dev'], 'driver': self.probes[r['dev']]['driver'], 'label': r.get('label') or r['dev']} if self.source['driver'] in ('fuseblk', 'ntfs-3g'): self.message('OneDrive files will be skipped', 'This drive is open with ntfs-3g, which cannot read OneDrive/Dropbox files - they will be ' 'listed as REPARSE-UNREADABLE. Linux kernel 5.15 or newer (ntfs3 driver) reads them. ' 'Everything else copies normally.', 'warn', under=draw) if not ci_join(self.source['root'], 'Users'): if not self.confirm('No Users folder', 'There is no Users folder on %s - it may not be the Windows drive. ' 'Use it anyway?' % self.source['root'], under=draw): return False # Let go of probe mounts of drives that weren't picked for dev, p in list(self.probes.items()): if p.get('root') and p['root'] != self.source['root']: self.mounts.release(p['root']) del self.probes[dev] return True def dest_rows(self): rows = [] src_root = self.source['root'] for d in block_devices(): if d['fstype'] not in DEST_FS or d['mountpoint'] in SYSTEM_MOUNTS: continue if self.source.get('dev') and d['dev'] == self.source['dev']: continue if d['dev'].startswith('/dev/mapper/nn-rescue-'): continue mp = d['mountpoint'] if mp and paths_overlap(mp, src_root): continue row = dict(d) row['badges'] = [] if mp: row['root'] = mp opts = proc_mounts().get(mp, ('', '', []))[2] row['note'], row['note_attr'] = (('mounted read-only - can\'t write here', 'err') if 'ro' in opts else ('mounted at %s' % mp, 'dim')) row['writable'] = 'ro' not in opts else: row['note'], row['note_attr'] = 'not mounted - will open for writing', 'dim' row['writable'] = True if mp and job_has_data(os.path.join(mp, 'NN-Rescue')): row['badges'] = ['NN-Rescue'] rows.append(row) rows.sort(key=lambda r: (not r.get('removable'), r['dev'])) rows.append({'path_entry': True}) return rows def step_dest(self): self.step_idx = 0 cur = 0 last = 0 rows = [] while True: if time.monotonic() - last > 2: rows = self.dest_rows() last = time.monotonic() cur = min(cur, len(rows) - 1) if self.too_small(): self.key() continue def draw(): top, bottom, w = self.frame([('↑↓', 'move'), ('Enter', 'pick'), ('r', 'rescan'), ('Esc', 'back')], 'Where should the backup go?') x = 2 + self.put(3, 2, 'Customer drive: ', self.A['dim']) self.put(3, x, '%s %s' % (self.source['label'], self.source['root']), self.A['ok']) self.draw_drive_list(top + 1, bottom, w, rows, cur, dest=True) draw() self.scr.refresh() k = self.key() if k is None: continue if k in KEY_UP: cur = max(0, cur - 1) elif k in KEY_DOWN: cur = min(len(rows) - 1, cur + 1) elif k in ('r', 'R'): last = 0 elif k in KEY_ESC: return 'source' elif k in KEY_ENTER: r = rows[cur] root = None if r.get('path_entry'): u = sudo_user() p = self.ask('Backup folder', 'Folder to back up into (NN-Rescue/ is created inside it):', u.pw_dir if u else '/root', under=draw) if not p: continue root = os.path.abspath(os.path.expanduser(p.strip())) if not os.path.isdir(root): if not self.confirm('Create folder', '%s does not exist. Create it?' % root, yes='Create', under=draw): continue try: os.makedirs(root) except OSError as e: self.message('Could not create', str(e), 'error', under=draw) continue label = root elif not r.get('writable'): self.message('Read-only', 'That drive is mounted read-only. Pick another, or remount it.', 'error', under=draw) continue elif r.get('root'): root, label = r['root'], r.get('label') or r['dev'] else: try: root = self.mounts.mount_rw(r['dev'], r['fstype']) except RuntimeError as e: self.message('Could not open drive', str(e), 'error', under=draw) continue label = r.get('label') or r['dev'] if paths_overlap(root, self.source['root']): self.message('Same drive', 'The backup location overlaps the customer drive. Pick another.', 'error', under=draw) continue try: # prove we can actually write there os.makedirs(os.path.join(root, 'NN-Rescue'), exist_ok=True) probe = os.path.join(root, 'NN-Rescue', '.nn-write-test') with open(probe, 'w') as f: f.write('ok') os.remove(probe) except OSError as e: self.message('Can\'t write there', '%s: %s' % (root, e.strerror or e), 'error', under=draw) continue self.dest = {'root': root, 'dev': r.get('dev'), 'label': label, 'ours': root in self.mounts.ours} return 'job' # --- step 2: job name ----------------------------------------------------------- def step_job(self): self.step_idx = 1 hint = '' if not self.job_name: hn = source_hostname(self.source['root']) if hn: self.job_name, hint = hn, 'Computer name read from the drive.' else: hint = 'Could not read the drive\'s registry - type a name.' buf, pos = self.job_name, len(self.job_name) while True: if self.too_small(): self.key() continue def draw(): top, bottom, w = self.frame([('Enter', 'next'), ('Esc', 'back')], 'Name this job') self.put(top, 2, 'Customer or computer name', self.A['dim']) cy, cx = self.draw_field(top + 1, 2, min(50, w - 4), buf, pos) self.put(top + 2, 2, hint, self.A['dim']) self.put(top + 4, 2, 'Backup folder', self.A['dim']) self.put(top + 5, 2, job_root(self.dest['root'], buf) if safe_name(buf) else '', self.A['ok']) self.put(top + 7, 2, 'From', self.A['dim']) self.put(top + 8, 2, '%s (%s, read-only)' % (self.source['root'], self.source['driver']), 0) return cy, cx cy, cx = draw() curses.curs_set(1) try: self.scr.move(cy, cx) except curses.error: pass self.scr.refresh() k = self.key() curses.curs_set(0) if k is None: continue if k in KEY_ESC: self.job_name = buf return 'dest' if k in KEY_ENTER: name = safe_name(buf) if not name: self.message('Job name', 'Enter a customer or job name.', 'error', under=draw) continue self.job_name = name self.job_root = job_root(self.dest['root'], name) self.force = False if job_has_data(self.job_root): nxt = available_job_name(self.dest['root'], name) c = self.choose('This job already has data on the backup drive', self.job_root, [('Resume this job', 'Skip files already copied - only what is missing gets copied.'), ('Keep both', 'Leave the existing backup alone and back up into "%s" instead.' % nxt), ('Start over', 'Re-copy everything into the same folder, replacing earlier ' 'copies. Nothing is deleted first.'), ('Go back', '')], under=draw) if c is None or c == 3: continue if c == 1: self.job_name = nxt self.job_root = job_root(self.dest['root'], nxt) if c == 2: self.force = True return 'select' buf, pos = self.edit(buf, pos, k) # --- step 3: select ------------------------------------------------------------- def start_scan(self): self.scan_gen = getattr(self, 'scan_gen', 0) + 1 gen = self.scan_gen self.model, self.scan_state = None, 'Scanning' + self.g['pend'] if getattr(self, 'scan_cancel', None): self.scan_cancel.set() cancel = self.scan_cancel = threading.Event() def work(): try: model = build_selection_model(self.source['root'], self.include_appdata) except Exception as e: if gen == self.scan_gen: self.scan_state = 'Scan failed: %s' % e return if gen != self.scan_gen: return self.model, self.scan_state = model, 'Sizing folders' + self.g['pend'] for g in model: for i in g['items']: if cancel.is_set(): return sz = folder_size(i['src'], cancel) if cancel.is_set(): return i['size'] = sz if sz == 0: i['selected'] = False if gen == self.scan_gen: self.scan_state = 'Scan complete.' threading.Thread(target=work, daemon=True).start() def flat_rows(self): rows = [] for gi, g in enumerate(self.model or []): rows.append(('group', gi, None)) if not g['items']: rows.append(('empty', gi, None)) for ii in range(len(g['items'])): rows.append(('item', gi, ii)) return rows def step_select(self): self.step_idx = 2 self.start_scan() cur, top_row = 0, 0 free = free_bytes(self.dest['root']) # Resuming: what's already in the job folder won't be copied again, so it doesn't need free space. already = 0 if self.force else folder_size(self.job_root) if os.path.isdir(self.job_root) else 0 while True: rows = self.flat_rows() if rows: cur = max(0, min(cur, len(rows) - 1)) if self.too_small(): self.key() continue def draw(): nonlocal top_row t, bottom, w = self.frame([('↑↓', 'move'), ('Space', 'tick'), ('a', 'AppData'), ('v', 'verify'), ('Enter', 'start copy'), ('Esc', 'back')], 'What should be copied?') self.put(3, 2, self.scan_state, self.A['dim']) list_bottom = bottom - 4 vis = max(1, list_bottom - t) if cur < top_row: top_row = cur if cur >= top_row + vis: top_row = cur - vis + 1 sizew = 12 for n, (kind, gi, ii) in enumerate(rows[top_row:top_row + vis]): y = t + n idx = top_row + n on = idx == cur g = self.model[gi] if on: self.put(y, 0, ' ' * w, self.A['sel']) base = self.A['sel'] if on else 0 if kind == 'group': items = g['items'] nsel = sum(1 for i in items if i['selected']) mark = self.g['off'] if not nsel else self.g['on'] if nsel == len(items) else self.g['part'] self.put(y, 2, mark + ' ' + g['header'], base | self.A['accent'] | curses.A_BOLD if not on else base | curses.A_BOLD) gs = sum(i['size'] or 0 for i in items if i['selected']) self.put(y, w - sizew - 2, '%*s' % (sizew, fmt_bytes(gs) if items else ''), base | curses.A_BOLD) elif kind == 'empty': self.put(y, 6, 'nothing recoverable in this profile', base | self.A['dim'] | getattr(curses, 'A_ITALIC', 0)) else: i = g['items'][ii] mark = self.g['on'] if i['selected'] else self.g['off'] attr = base | (self.A['ok'] if i['selected'] and not on else 0) self.put(y, 5, mark, attr) self.put(y, 7, clip(i['label'], w - sizew - 12, self.g['ell']), base | (0 if i['selected'] else self.A['dim'])) if i['size'] is None: sz, sa = self.g['pend'], self.A['dim'] elif i['size'] == 0: sz, sa = 'empty', self.A['dim'] else: sz, sa = fmt_bytes(i['size']), 0 self.put(y, w - sizew - 2, '%*s' % (sizew, sz), base | sa) # details of the highlighted row if rows: kind, gi, ii = rows[cur] if kind == 'item': i = self.model[gi]['items'][ii] self.put(bottom - 3, 2, mid_clip(i['src'], w - 4, self.g['ell']), self.A['dim']) self.put(bottom - 2, 2, mid_clip(self.g['arrow'] + ' ' + os.path.join(self.job_root, i['dest_rel']), w - 4, self.g['ell']), self.A['dim']) sel = selected_bytes(self.model or []) over = sel - already > free x = 2 + self.put(bottom, 2, 'Selected ', self.A['dim']) x += self.put(bottom, x, fmt_bytes(sel), (self.A['err'] if over else self.A['ok']) | curses.A_BOLD) if already: x += self.put(bottom, x, ' (%s already backed up)' % fmt_bytes(already), self.A['dim']) x += self.put(bottom, x, ' Free on backup ', self.A['dim']) x += self.put(bottom, x, fmt_bytes(free), curses.A_BOLD) opts = [('AppData', self.include_appdata), ('Verify', self.verify), ('Start over', self.force)] ow = sum(len(n) + 6 for n, _ in opts) oy = bottom if x + 2 < w - 2 - ow else bottom - 1 # narrow window: toggles go up a line x = w - 2 - ow for n, v in opts: x += self.put(oy, x, (self.g['on'] if v else self.g['off']) + ' ' + n + ' ', self.A['accent'] if v else self.A['dim']) draw() self.scr.refresh() k = self.key() if k is None: continue if k in KEY_UP: cur = max(0, cur - 1) elif k in KEY_DOWN: cur = min(max(0, len(rows) - 1), cur + 1) elif k == curses.KEY_PPAGE: cur = max(0, cur - 10) elif k == curses.KEY_NPAGE: cur = min(max(0, len(rows) - 1), cur + 10) elif k == curses.KEY_HOME: cur = 0 elif k == curses.KEY_END: cur = max(0, len(rows) - 1) elif k == ' ' and rows: kind, gi, ii = rows[cur] g = self.model[gi] if kind == 'group' and g['items']: val = not all(i['selected'] for i in g['items']) for i in g['items']: i['selected'] = val elif kind == 'item': g['items'][ii]['selected'] = not g['items'][ii]['selected'] if kind == 'item': cur = min(len(rows) - 1, cur + 1) elif k in ('a', 'A'): self.include_appdata = not self.include_appdata self.start_scan() cur = 0 elif k in ('v', 'V'): self.verify = not self.verify elif k in ('f', 'F'): self.force = not self.force elif k in KEY_ESC: if getattr(self, 'scan_cancel', None): self.scan_cancel.set() return 'job' elif k in KEY_ENTER: targets = [i for g in (self.model or []) for i in g['items'] if i['selected']] if not targets: self.message('Nothing selected', 'Tick at least one folder (Space).', 'warn', under=draw) continue sel = selected_bytes(self.model) if sel - already > free and not self.confirm( 'Not enough space', 'The selection (%s%s) is bigger than the free space on the backup (%s). ' 'Continue anyway?' % (fmt_bytes(sel), ', %s already backed up' % fmt_bytes(already) if already else '', fmt_bytes(free)), under=draw, danger=True): continue if not self.confirm('Start copying', 'Copy %s into\n%s%s%s' % ( fmt_bytes(sel), self.job_root, '\n\nStart over is on: existing copies are replaced.' if self.force else '', '\nA SHA-256 verify pass runs afterwards.' if self.verify else ''), yes='Start copy', no='Go back', under=draw): continue if getattr(self, 'scan_cancel', None): self.scan_cancel.set() self.targets = targets return 'copy' # --- step 4: copy ----------------------------------------------------------------- def step_copy(self): self.step_idx = 3 job = CopyJob(self.targets, self.job_root, self.force) job.start() verify = None samples = collections.deque(maxlen=40) while True: now = time.monotonic() done_b = job.done_bytes + job.file_done samples.append((now, done_b)) if job.finished and verify is None: if self.verify and job.pairs and not job.cancel.is_set(): verify = VerifyJob(job.pairs, job.cancel) verify.start() else: return self.finish(job, None) if verify is not None and verify.finished: return self.finish(job, verify) if self.too_small(): self.key() continue def draw(): t, bottom, w = self.frame([('p', 'pause/resume'), ('c', 'cancel')]) bw = w - 4 if verify is not None: title, attr = 'Verifying copies (SHA-256)' + self.g['ell'], self.A['accent'] frac = verify.index / max(1, len(verify.pairs)) line = '%s of %s files checked' % ('{:,}'.format(verify.index), '{:,}'.format(len(verify.pairs))) elif job.phase == 'planning': title, attr = 'Finding files' + self.g['ell'], self.A['accent'] frac = 0 line = '%s files, %s so far' % ('{:,}'.format(job.plan_count), fmt_bytes(job.plan_bytes)) else: paused = job.pause.is_set() title = 'Paused' if paused else 'Copying' + self.g['ell'] attr = self.A['warn'] if paused else self.A['accent'] frac = done_b / max(1, job.plan_bytes) if job.plan_bytes else job.index / max(1, job.plan_count) old = next((s for s in samples if now - s[0] <= 5), samples[0]) rate = (done_b - old[1]) / max(0.001, now - old[0]) if now - old[0] > 0.5 else 0 elapsed = now - (job.started_at or now) eta = (job.plan_bytes - done_b) / rate if rate > 0 else None line = '%s of %s %s %s / %s files %s elapsed%s' % ( fmt_bytes(done_b), fmt_bytes(job.plan_bytes), (fmt_bytes(rate) + '/s') if rate else '', '{:,}'.format(job.index), '{:,}'.format(job.plan_count), hms(elapsed), (' ~%s left' % hms(eta)) if eta is not None and not paused else '') self.put(t, 2, title, attr | curses.A_BOLD) self.put(t, w - 8, '%5.1f%%' % (frac * 100), curses.A_BOLD) self.bar(t + 2, 2, bw, frac, attr | curses.A_BOLD) self.put(t + 4, 2, line, 0) cur_file = job.current if verify is None else '' self.put(t + 5, 2, mid_clip(cur_file, bw, self.g['ell']), self.A['dim']) tally = job.summary probs = sum(v for k2, v in tally.items() if k2 not in ('OK', 'SKIP-EXISTS', 'CLOUD-ONLY')) x = 2 for glyph, label, n, a in ((self.g['ok'], 'Copied', tally.get('OK', 0), self.A['ok']), (self.g['skip'], 'Already there', tally.get('SKIP-EXISTS', 0), self.A['dim']), (self.g['cloud'], 'Cloud-only', tally.get('CLOUD-ONLY', 0), self.A['warn']), (self.g['bad'], 'Problems', probs, self.A['err'] if probs else self.A['dim'])): x += self.put(t + 7, x, glyph + ' ', a | curses.A_BOLD) x += self.put(t + 7, x, '{:,}'.format(n), curses.A_BOLD) x += self.put(t + 7, x, ' ' + label + ' ', self.A['dim']) plist = list(job.problems[-max(0, bottom - t - 10):]) if bottom - t > 10 else [] if plist: self.put(t + 9, 2, 'Problems', self.A['err'] | curses.A_BOLD) for n, p in enumerate(plist): self.put(t + 10 + n, 4, mid_clip(p, bw - 2, self.g['ell']), self.A['err']) draw() self.scr.refresh() k = self.key() if k in ('p', 'P', ' ') and verify is None: if job.pause.is_set(): job.pause.clear() else: job.pause.set() elif k in ('c', 'C') or k in KEY_ESC: if self.confirm('Cancel', 'Stop now? Everything copied so far is kept - run the same job again to ' 'resume.', yes='Stop', no='Keep going', under=draw, danger=True): job.cancel.set() job.pause.clear() def finish(self, job, verify): self.frame([]) self.put(4, 2, 'Writing the report' + self.g['ell'], self.A['accent'] | curses.A_BOLD) self.scr.refresh() summary = collections.Counter(job.summary) problems = list(job.problems) if verify is not None: problems += verify.mismatches h = sum(1 for m in verify.mismatches if m.startswith('HASH-MISMATCH')) f = len(verify.mismatches) - h if h: summary['HASH-MISMATCH'] = h if f: summary['VERIFY-FAIL'] = f report = None try: report = write_html_report(summary, problems, self.job_name, job.done_bytes, os.path.join(self.job_root, '_RescueReport.html'), job.folder_stats, verified=verify is not None and verify.index == len(verify.pairs)) except OSError as e: problems.append('REPORT-FAIL(%s)' % e) give_to_user(self.job_root) self.result = {'summary': summary, 'problems': problems, 'report': report, 'log': job.log_path, 'cancelled': job.cancel.is_set(), 'bytes': job.done_bytes, 'fatal': any(p.startswith('FATAL') for p in problems), 'verified': None if verify is None else (verify.index, len(verify.pairs), len(verify.mismatches))} return 'done' # --- step 5: done ------------------------------------------------------------------- def step_done(self): self.step_idx = 4 r = self.result s = r['summary'] scroll = 0 flash = '' while True: if self.too_small(): self.key() continue t, bottom, w = self.frame([('o', 'open report'), ('f', 'open folder'), ('↑↓', 'scroll problems'), ('q', 'quit')]) n_prob = len(r['problems']) if r['fatal']: title, attr = 'Stopped - see problems below', self.A['err'] elif r['cancelled']: title, attr = 'Cancelled - run the same job again to resume', self.A['warn'] elif n_prob: title, attr = 'Finished - %d item(s) need attention' % n_prob, self.A['warn'] else: title, attr = 'Rescue complete', self.A['ok'] self.put(t, 2, title, attr | curses.A_BOLD) secured = s.get('OK', 0) + s.get('SKIP-EXISTS', 0) self.put(t + 1, 2, '%s files secured %s processed' % ('{:,}'.format(secured), fmt_bytes(r['bytes'])), self.A['dim']) y = t + 3 for k in sorted(s, key=lambda c: (c not in ('OK', 'SKIP-EXISTS', 'CLOUD-ONLY'), c)): good = k in ('OK', 'SKIP-EXISTS') a = self.A['ok'] if good else self.A['warn'] if k == 'CLOUD-ONLY' else self.A['err'] self.put(y, 4, '%-24s' % k, a) self.put(y, 30, '{:>9,}'.format(s[k]), curses.A_BOLD) y += 1 if r['verified']: checked, total_v, bad = r['verified'] y += 1 if bad: self.put(y, 4, '%s SHA-256 check: %d of %d copies differ' % (self.g['bad'], bad, checked), self.A['err'] | curses.A_BOLD) else: self.put(y, 4, '%s SHA-256 check: all %s copies match the originals' % ( self.g['ok'], '{:,}'.format(checked)), self.A['ok']) y += 1 if s.get('CLOUD-ONLY'): y += 1 for l in self.wrap('%d file(s) live only in the customer\'s cloud account (OneDrive/Dropbox). Sign in ' 'to the account on a working machine to download them.' % s['CLOUD-ONLY'], w - 8): self.put(y, 4, l, self.A['warn']) y += 1 y += 1 for label, val in (('Backup', self.job_root), ('Report', r['report'] or '(could not be written)'), ('Log', r['log'] or '(no log)')): self.put(y, 4, label, self.A['dim']) self.put(y, 12, mid_clip(val, w - 16, self.g['ell']), 0) y += 1 if flash: self.put(y, 4, flash, self.A['accent']) y += 2 if r['problems'] and y < bottom: self.put(y, 2, 'Problems', self.A['err'] | curses.A_BOLD) room = bottom - y - 1 scroll = max(0, min(scroll, len(r['problems']) - room)) for n, p in enumerate(r['problems'][scroll:scroll + room]): self.put(y + 1 + n, 4, mid_clip(p, w - 6, self.g['ell']), self.A['err']) self.scr.refresh() k = self.key() if k in KEY_UP: scroll = max(0, scroll - 1) elif k in KEY_DOWN: scroll += 1 elif k in ('o', 'O') and r['report']: # copy it out of the backup drive so it stays readable after the drive is unmounted u = sudo_user() tmp = '/tmp/NN-Rescue-%s-report.html' % safe_name(self.job_name).replace(' ', '_') try: with open(r['report'], 'rb') as a, open(tmp, 'wb') as b: b.write(a.read()) if u: os.chown(tmp, u.pw_uid, u.pw_gid) flash = 'Opening the report in your browser' + self.g['ell'] if open_as_user(tmp) \ else 'Could not open a browser - report is at %s' % tmp except OSError as e: flash = 'Could not open the report: %s' % e elif k in ('f', 'F'): flash = 'Opening the backup folder' + self.g['ell'] if open_as_user(self.job_root) \ else 'Could not open a file manager.' elif k in ('q', 'Q') or k in KEY_ENTER or k in KEY_ESC: return None def run(self): step = 'source' while step: step = getattr(self, 'step_' + step)() def hms(sec): sec = int(max(0, sec or 0)) return '%d:%02d:%02d' % (sec // 3600, sec % 3600 // 60, sec % 60) if sec >= 3600 else '%d:%02d' % (sec // 60, sec % 60) # ============================================================================= # Entry # ============================================================================= def reattach_tty(): """Under `curl ... | sudo python3 -` stdin is the pipe; the UI needs the terminal.""" if not sys.stdin.isatty(): try: fd = os.open('/dev/tty', os.O_RDWR) except OSError: return False os.dup2(fd, 0) os.close(fd) sys.stdin = open(0, encoding='utf-8', errors='replace', closefd=False) return sys.stdout.isatty() def main(): if sys.version_info < (3, 6): print('NN Rescue Copy needs Python 3.6 or newer.') return 1 if not sys.platform.startswith('linux'): print('This is the Linux version. On Windows run: irm copy.nerdyneighbor.net | iex') return 1 if os.geteuid() != 0: print('\n NN Rescue Copy needs root to open drives read-only. Run it with sudo:\n') print(' curl -fsSL %s | sudo python3 -\n' % URL) return 1 if not reattach_tty(): print('NN Rescue Copy needs an interactive terminal.') return 1 locale.setlocale(locale.LC_ALL, '') os.environ.setdefault('ESCDELAY', '25') app_ref = {} def on_term(*_): raise KeyboardInterrupt() signal.signal(signal.SIGTERM, on_term) def ui(scr): app = App(scr) app_ref['app'] = app app.run() err = None import io import warnings warnings.simplefilter('ignore') real_stderr, sys.stderr = sys.stderr, io.StringIO() try: curses.wrapper(ui) except KeyboardInterrupt: err = 'Interrupted. Anything copied so far is kept - run the same job again to resume.' except Exception: # leave the terminal clean and still unmount drives import traceback err = 'NN Rescue Copy hit an error:\n' + traceback.format_exc() finally: sys.stderr = real_stderr app = app_ref.get('app') print() print(' NN Rescue Copy') if app and app.result: r = app.result s = r['summary'] print(' %s files secured, %d need attention.' % ('{:,}'.format(s.get('OK', 0) + s.get('SKIP-EXISTS', 0)), len(r['problems']))) print(' Backup: %s' % app.job_root) if r['report']: print(' Report: %s' % r['report']) if err: print(' ' + err) if app: print(' Syncing and unmounting drives...', end='', flush=True) fails = app.mounts.cleanup() print(' done.' if not fails else ' some could not be unmounted:') for f in fails: print(' ' + f) if not fails and app.dest and app.dest.get('ours'): print(' The backup drive is safe to unplug.') elif not fails and app.dest and app.dest.get('dev'): print(' Eject the backup drive from your file manager before unplugging it.') print() return 1 if err else 0 if __name__ == '__main__': sys.exit(main())