diff --git a/scripts/wk/clone/ddrescue.py b/scripts/wk/clone/ddrescue.py index 756d587e..c3537d19 100644 --- a/scripts/wk/clone/ddrescue.py +++ b/scripts/wk/clone/ddrescue.py @@ -1531,6 +1531,12 @@ def get_etoc() -> str: return etoc +def finalize_recovery(state: State, dry_run: bool = True) -> None: + """Show recovery finalization options.""" + zero_fill_destination(state, dry_run=dry_run) + if state.mode == 'Clone': + relocate_backup_gpt(state, dry_run=dry_run) + def get_fstype_macos(path) -> str: """Get fstype for path under macOS, returns str.""" fstype = 'UNKNOWN' @@ -1802,13 +1808,15 @@ def main() -> None: # Quit if 'Quit' in selection: total_percent = state.get_percent_recovered() - if total_percent == 100: - break - # Recovey < 100% - cli.print_warning('Recovery is less than 100%') - if cli.ask('Are you sure you want to quit?'): - break + # Confirm exit if recovery is less than 100% + if total_percent < 100: + cli.print_warning('Recovery is less than 100%') + if not cli.ask('Are you sure you want to quit?'): + continue + + finalize_recovery(state, dry_run=args['--dry-run']) + break # Save results to log LOG.info('') @@ -1889,6 +1897,39 @@ def mount_raw_image_macos(path) -> pathlib.Path: return loopback_path +def relocate_backup_gpt(state: State, dry_run: bool = True) -> None: + """Relocate backup GPT on the destination if applicable and approved.""" + cmd = ['sudo', 'sfdisk', '--relocate', 'gpt-bak-std', state.destination.path] + state.destination.update_details(skip_children=False) + + # Safety checks + ## Breakdown of below tests: + ## Only offer this option when cloning a whole, non-child device + ## where the source is smaller than the destination + ## and both the source and destination are using a GUID_Partition_Table + if not ( + len(state.block_pairs) == 1 + and str(state.destination.raw_details.get('pttype', 'Unknown')).lower() == 'gpt' + and state.source.size < state.destination.size + and not state.source.parent + and str(state.source.raw_details.get('pttype', 'Unknown')).lower() == 'gpt' + and cli.ask('Relocate backup GPT to the end of the device?') + ): + LOG.warning('Refusing to attempt a backup GPT relocation.') + return + + # Dry run + if dry_run: + cli.print_standard(f'Dry-run: Relocate GPT with command: {cmd}') + return + + # Relocate GPT data + proc = exe.run_program(cmd, check=False) + if proc.returncode: + cli.print_error('ERROR: Failed to relocate backup GPT.') + LOG.error('sfdisk result: %s, %s', proc.stdout, proc.stderr) + + def run_ddrescue(state, block_pair, pass_name, settings, dry_run=True) -> None: """Run ddrescue using passed settings.""" cmd = build_ddrescue_cmd(block_pair, pass_name, settings) @@ -2163,5 +2204,68 @@ def unmount_loopback_device(path) -> None: exe.run_program(cmd, check=False) +def zero_fill_destination(state: State, dry_run: bool = True) -> None: + """Zero-fill any gaps and space on destination beyond the source size.""" + full_disk_clone = False + larger_destination = False + percent_recovered = state.get_percent_recovered() + if state.mode == 'Clone' and len(state.block_pairs) == 1: + full_disk_clone = True + + # Bail early + if not ( + (percent_recovered < 100 + or (full_disk_clone and state.source.size < state.destination.size)) + and cli.ask('Fill gaps with zeros?')): + return + + for block_pair in state.block_pairs: + destination_size = block_pair.size + if (full_disk_clone and state.source.size < state.destination.size): + destination_size = state.destination.size + larger_destination = True + + # Prep zero-fill map file + zero_map_path = block_pair.map_path.with_stem( + f'{block_pair.map_path.name}_zero-fill', + ) + io.copy_file(block_pair.map_path, zero_map_path, overwrite=True) + if larger_destination: + with open(zero_map_path, 'a') as f: + f.write( + f'{hex(block_pair.size)} ' + f'{hex(destination_size - block_pair.size)} ?' + ) + + # Build cmd + cmd = [ + 'sudo', + 'ddrescue', + '--force', + f'--size={destination_size}', + '--binary-prefixes', + '--complete-only', + '--data-preview=5', + '--odirect', + '--retry-passes=0', + f'--sector-size={block_pair.sector_size}', + '-vvvv', + '/dev/zero', + block_pair.destination, + zero_map_path, + ] + + # Dry run + if dry_run: + cli.print_standard(f'Zero-fill with command: {cmd}') + return + + # Re-run ddrescue to zero-fill gaps + proc = exe.run_program(cmd, check=False, pipe=False) + if proc.returncode: + cli.print_error('ERROR: Failed to zero-fill: {block_pair.destination}') + LOG.error('zero-fill error: %s, %s', proc.stdout, proc.stderr) + + if __name__ == '__main__': print("This file is not meant to be called directly.")