diff --git a/lib/ansible/modules/windows/win_copy.ps1 b/lib/ansible/modules/windows/win_copy.ps1 index 2f521f7da5..26b4c76a39 100644 --- a/lib/ansible/modules/windows/win_copy.ps1 +++ b/lib/ansible/modules/windows/win_copy.ps1 @@ -158,10 +158,90 @@ Function Get-FileSize($path) { $size } +Function Extract-Zip($src, $dest) { + $archive = [System.IO.Compression.ZipFile]::Open($src, [System.IO.Compression.ZipArchiveMode]::Read, [System.Text.Encoding]::UTF8) + foreach ($entry in $archive.Entries) { + $archive_name = $entry.FullName + + # FullName may be appended with / or \, determine if it is padded and remove it + $padding_length = $archive_name.Length % 4 + if ($padding_length -eq 0) { + $is_dir = $false + $base64_name = $archive_name + } elseif ($padding_length -eq 1) { + $is_dir = $true + if ($archive_name.EndsWith("/") -or $archive_name.EndsWith("`\")) { + $base64_name = $archive_name.Substring(0, $archive_name.Length - 1) + } else { + throw "invalid base64 archive name $archive_name" + } + } else { + throw "invalid base64 length $archive_name" + } + + # to handle unicode character, win_copy action plugin has encoded the filename + $decoded_archive_name = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($base64_name)) + # re-add the / to the entry full name if it was a directory + if ($is_dir) { + $decoded_archive_name = "$decoded_archive_name/" + } + $entry_target_path = [System.IO.Path]::Combine($dest, $decoded_archive_name) + $entry_dir = [System.IO.Path]::GetDirectoryName($entry_target_path) + + if (-not (Test-Path -Path $entry_dir)) { + New-Item -Path $entry_dir -ItemType Directory -WhatIf:$check_mode | Out-Null + } + + if ($is_dir -eq $false) { + if (-not $check_mode) { + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $entry_target_path, $true) + } + } + } +} + +Function Extract-ZipLegacy($src, $dest) { + if (-not (Test-Path -Path $dest)) { + New-Item -Path $dest -ItemType Directory -WhatIf:$check_mode | Out-Null + } + $shell = New-Object -ComObject Shell.Application + $zip = $shell.NameSpace($src) + $dest_path = $shell.NameSpace($dest) + + foreach ($entry in $zip.Items()) { + $is_dir = $entry.IsFolder + $encoded_archive_entry = $entry.Name + # to handle unicode character, win_copy action plugin has encoded the filename + $decoded_archive_entry = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($encoded_archive_entry)) + if ($is_dir) { + $decoded_archive_entry = "$decoded_archive_entry/" + } + + $entry_target_path = [System.IO.Path]::Combine($dest, $decoded_archive_entry) + $entry_dir = [System.IO.Path]::GetDirectoryName($entry_target_path) + + if (-not (Test-Path -Path $entry_dir)) { + New-Item -Path $entry_dir -ItemType Directory -WhatIf:$check_mode | Out-Null + } + + if ($is_dir -eq $false -and (-not $check_mode)) { + # https://msdn.microsoft.com/en-us/library/windows/desktop/bb787866.aspx + # From Folder.CopyHere documentation, 1044 means: + # - 1024: do not display a user interface if an error occurs + # - 16: respond with "yes to all" for any dialog box that is displayed + # - 4: do not display a progress dialog box + $dest_path.CopyHere($entry, 1044) + + # once file is extraced, we need to rename it with non base64 name + $combined_encoded_path = [System.IO.Path]::Combine($dest, $encoded_archive_entry) + Move-Item -Path $combined_encoded_path -Destination $entry_target_path -Force | Out-Null + } + } +} + if ($mode -eq "query") { # we only return a list of files/directories that need to be copied over # the source of the local file will be the key used - $will_change = $false $changed_files = @() $changed_directories = @() $changed_symlinks = @() @@ -182,7 +262,6 @@ if ($mode -eq "query") { } elseif (Test-Path -Path $filepath -PathType Container) { Fail-Json -obj $result -message "cannot copy file to dest $($filepath): object at path is already a directory" } else { - $will_change = $true $changed_files += $file } } @@ -198,24 +277,12 @@ if ($mode -eq "query") { if (Test-Path -Path $dirpath -PathType Leaf) { Fail-Json -obj $result -message "cannot copy folder to dest $($dirpath): object at path is already a file" } elseif (-not (Test-Path -Path $dirpath -PathType Container)) { - $will_change = $true $changed_directories += $directory } } # TODO: Handle symlinks - # Detect if the PS zip assemblies are available, this will control whether - # the win_copy plugin will use explode as the mode or single - try { - Add-Type -Assembly System.IO.Compression.FileSystem | Out-Null - Add-Type -Assembly System.IO.Compression | Out-Null - $result.zip_available = $true - } catch { - $result.zip_available = $false - } - - $result.will_change = $will_change $result.files = $changed_files $result.directories = $changed_directories $result.symlinks = $changed_symlinks @@ -227,23 +294,18 @@ if ($mode -eq "query") { Fail-Json -obj $result -message "Cannot expand src zip file file: $src as it does not exist" } - Add-Type -Assembly System.IO.Compression.FileSystem | Out-Null - Add-Type -Assembly System.IO.Compression | Out-Null - - $archive = [System.IO.Compression.ZipFile]::Open($src, [System.IO.Compression.ZipArchiveMode]::Read, [System.Text.Encoding]::UTF8) - foreach ($entry in $archive.Entries) { - $entry_target_path = [System.IO.Path]::Combine($dest, $entry.FullName) - $entry_dir = [System.IO.Path]::GetDirectoryName($entry_target_path) - - if (-not (Test-Path -Path $entry_dir)) { - New-Item -Path $entry_dir -ItemType Directory -WhatIf:$check_mode | Out-Null - } - - if (-not ($entry_target_path.EndsWith("`\") -or $entry_target_path.EndsWith("/"))) { - if (-not $check_mode) { - [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $entry_target_path, $true) - } - } + # Detect if the PS zip assemblies are available or whether to use Shell + $use_legacy = $false + try { + Add-Type -AssemblyName System.IO.Compression.FileSystem | Out-Null + Add-Type -AssemblyName System.IO.Compression | Out-Null + } catch { + $use_legacy = $true + } + if ($use_legacy) { + Extract-ZipLegacy -src $src -dest $dest + } else { + Extract-Zip -src $src -dest $dest } $result.changed = $true diff --git a/lib/ansible/plugins/action/win_copy.py b/lib/ansible/plugins/action/win_copy.py index e753d278aa..d299c4471a 100644 --- a/lib/ansible/plugins/action/win_copy.py +++ b/lib/ansible/plugins/action/win_copy.py @@ -7,6 +7,7 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type +import base64 import json import os import os.path @@ -231,17 +232,22 @@ class ActionModule(ActionBase): zip_file_path = os.path.join(tmpdir, "win_copy.zip") zip_file = zipfile.ZipFile(zip_file_path, "w") - # need to write in byte string with utf-8 encoding to support unicode - # characters in the filename. + # encoding the file/dir name with base64 so Windows can unzip a unicode + # filename and get the right name, Windows doesn't handle unicode names + # very well for directory in directories: directory_path = to_bytes(directory['src'], errors='surrogate_or_strict') archive_path = to_bytes(directory['dest'], errors='surrogate_or_strict') - zip_file.write(directory_path, archive_path, zipfile.ZIP_DEFLATED) + + encoded_path = to_text(base64.b64encode(archive_path), errors='surrogate_or_strict') + zip_file.write(directory_path, encoded_path, zipfile.ZIP_DEFLATED) for file in files: file_path = to_bytes(file['src'], errors='surrogate_or_strict') archive_path = to_bytes(file['dest'], errors='surrogate_or_strict') - zip_file.write(file_path, archive_path, zipfile.ZIP_DEFLATED) + + encoded_path = to_text(base64.b64encode(archive_path), errors='surrogate_or_strict') + zip_file.write(file_path, encoded_path, zipfile.ZIP_DEFLATED) return zip_file_path @@ -249,20 +255,6 @@ class ActionModule(ActionBase): if content is not None: os.remove(content_tempfile) - def _create_directory(self, dest, source_rel, task_vars): - dest_path = self._connection._shell.join_path(dest, source_rel) - file_args = self._task.args.copy() - file_args.update( - dict( - path=dest_path, - state="directory" - ) - ) - file_args.pop('content', None) - - file_result = self._execute_module(module_name='file', module_args=file_args, task_vars=task_vars) - return file_result - def _copy_single_file(self, local_file, dest, source_rel, task_vars): if self._play_context.check_mode: module_return = dict(changed=True) @@ -311,9 +303,9 @@ class ActionModule(ActionBase): os.removedirs(os.path.dirname(zip_path)) return module_return - # send zip file to remote + # send zip file to remote, file must end in .zip so Com Shell.Application works tmp_path = self._make_tmp_path() - tmp_src = self._connection._shell.join_path(tmp_path, 'source') + tmp_src = self._connection._shell.join_path(tmp_path, 'source.zip') self._transfer_file(zip_path, tmp_src) # run the explode operation of win_copy on remote @@ -475,47 +467,30 @@ class ActionModule(ActionBase): query_args.pop('content', None) query_return = self._execute_module(module_args=query_args, task_vars=task_vars) - if query_return.get('failed', False) is True: + if query_return.get('failed') is True: result.update(query_return) return result - if query_return.get('will_change') is False: - # no changes need to occur - result['failed'] = False - result['changed'] = False - return result + if len(query_return['files']) == 1 and len(query_return['directories']) == 0: + # we only need to copy 1 file, don't mess around with zips + file_src = query_return['files'][0]['src'] + file_dest = query_return['files'][0]['dest'] + copy_result = self._copy_single_file(file_src, dest, file_dest, task_vars) - if query_return.get('zip_available') is True and result['operation'] != 'file_copy': - # if the PS zip utils are available and we need to copy more than a - # single file/folder, create a local zip file of all the changed - # files and send that to the server to be expanded + result['changed'] = True + if copy_result.get('failed') is True: + result['failed'] = True + result['msg'] = "failed to copy file %s: %s" % (file_src, copy_result['msg']) + elif len(query_return['files']) > 0 or len(query_return['directories']) > 0: + # either multiple files or directories need to be copied, compress + # to a zip and 'explode' the zip on the server # TODO: handle symlinks result.update(self._copy_zip_file(dest, source_files['files'], source_files['directories'], task_vars)) + result['changed'] = True else: - # the PS zip assemblies are not available or only a single file - # needs to be copied. Instead of zipping up into one task this - # will handle each file/folder as an individual task - # TODO: Handle symlinks - - for directory in query_return['directories']: - file_result = self._create_directory(dest, directory['dest'], task_vars) - - result['changed'] = file_result.get('changed', False) - if file_result.get('failed', False) is True: - self._remove_tempfile_if_content_defined(content, content_tempfile) - result['failed'] = True - result['msg'] = "failed to create directory %s" % file_result['msg'] - return result - - for file in query_return['files']: - copy_result = self._copy_single_file(file['src'], dest, file['dest'], task_vars) - - result['changed'] = copy_result.get('changed', False) - if copy_result.get('failed', False) is True: - self._remove_tempfile_if_content_defined(content, content_tempfile) - result['failed'] = True - result['msg'] = "failed to copy file %s: %s" % (file['src'], copy_result['msg']) - return result + # no operations need to occur + result['failed'] = False + result['changed'] = False # remove the content temp file if it was created self._remove_tempfile_if_content_defined(content, content_tempfile)