copyfiles/copyfiles.ps1
2019-01-30 14:24:46 +01:00

465 lines
17 KiB
PowerShell
Executable File

#===============================================================================
#
# DIRECTORY:
# (variable)
#
# FILE:
# copyfiles.ps1 (or copyfiles.exe)
#
# USAGE:
# Step 1 (cmd.exe):
# C:\>powershell -ExecutionPolicy Unrestricted
# Step 2:
# PS C:\> E:\copyfiles.ps1 <Sourcepath> <Destinationpath> [Options]
# or
# PS C:\> E:\copyfiles.ps1 -s[ourcepath] <Path> -d[estinationpath] <Path> [Options]
#
# OPTIONS:
# -l[ogfilepath] <Path> : relative or absolute path to the logfile
# -i[nfofilepath] <Path> : relative or absolute path to the infofile
# -v[erify] : turn on output of success
# -q[uiet] : turn off all output
# -h[elp] : show a short help
#
# EXIT STATES:
# True (0) = success
# False (1) = sourcepath is empty
# False (2) = sourcepath has to exist
# False (3) = destinationpath is empty
# False (4) = cannot create destinationpath there
# False (5) = destinationpath already exist
# False (6) = logfilepath and infofilepath are equal
# False (7) = logfilepath already exist
# False (8) = cannot create logfile there
# False (9) = unsupported extension for logfile
# False (10) = infofilepath already exist
# False (11) = cannot create infofile there
# False (12) = unsupported extension for infofile
#
# DESCRIPTION:
# Copy a file or directory (incl. files) from a given sourcepath
# to a given destinationpath and verify that the content of the
# file(s) has not changed.
# Optionally output success in addition to fails.
# Optionally write metadata to an infofile and/or each step
# into a logfile.
# Optionally suppress all output.
#
# REQUIREMENTS:
# Powershell 4.0 (first Version with Get-FileHash commandlet)
#
# BUGS:
# ---
#
# NOTES:
# Syntax checked with PSScriptAnalyzer:
# PS C:\> Invoke-ScriptAnalyzer E:\copyfiles.ps1
#
# Tested on
# - Microsoft Windows 10 (1703) Pro (x64)
# with
# - PowerShell Version 5.1.15063.608 ($PSVersionTable.PSVersion)
#
# AUTHOR:
# Patrick Neumann, patrick@neumannsland.de
#
# COMPANY:
# (privately)
#
# VERSION:
# 0.9.0 (beta)
#
# LINK TO THE MOST CURRENT VERSION:
# https://vcs.neumannsland.de/casualscripter/copyfiles
#
# CREATED:
# 2017-09-29
#
# COPYRIGHT (C):
# 2017 - Patrick Neumann
#
# LICENSE:
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# WARRANTY:
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# TODO:
# - More testing (Powershell on macOS and/or ArchLinux)
#
# HISTORY:
# 0.9.0 - Patrick Neumann - Initial (for the peer reviewer eyes only) release
#
#===============================================================================
#=== CONFIGURATION (user) ======================================================
# Has to be at the top of the code (excl. comments)!
[CmdletBinding()]
Param(
[Parameter(Position=1)] # "Mandatory=$True" vs. easy -help
[string]$sourcepath,
[Parameter(Position=2)] # "Mandatory=$True" vs. easy -help
[string]$destinationpath,
[string]$logfilepath, # like log= support of dc3dd
[string]$infofilepath, # like hlog= support of dc3dd
[switch]$verify, # like --check support in GNU md5sum/sha1sum
[switch]$quiet,
[switch]$help
)
#=== CONFIGURATION (dynamic) ===================================================
# Date of start
Get-Date -Format d | New-Variable -Name CURDATE -Option constant
# Time of start
Get-Date -Format T | New-Variable -Name CURTIME -Option constant
# Timezone of start (as difference from UTC because CEST is not supported)
Get-Date -UFormat "UTC%Z" | New-Variable -Name CURTZ -Option constant
#=== CONFIGURATION (static) ====================================================
# Filename of the script
New-Variable -Name MYNAME -Value $MyInvocation.MyCommand.Name -Option constant
# Version of the script
New-Variable -Name VERSION -Value "0.9.0" -Option constant
# Creation date of the script
New-Variable -Name CREATED -Value "2017-09-29" -Option constant
#-------------------------------------------------------------------------------
# First output.
#-------------------------------------------------------------------------------
# nmap like output:
# Starting Nmap 7.40 ( https://nmap.org ) at 2017-09-26 21:13 CEST
$scriptinfo = "Starting $MYNAME $VERSION ($CREATED) at $CURDATE $CURTIME $CURTZ"
Write-Output "$scriptinfo`n"
#-------------------------------------------------------------------------------
# Help.
#-------------------------------------------------------------------------------
$helpstring = @"
NAME
copyfiles.ps1 (or copyfiles.exe)
SYNOPSIS
Copy a file or directory from sourcepath to destinationpath.
SYNTAX
PS C:\> E:\copyfiles.ps1 <Sourcepath> <Destinationpath> [Options]
or
PS C:\> E:\copyfiles.ps1 -s[ourcepath] <Path> -d[estinationpath] <Path> [Options]
DESCRIPTION
Copy a file or directory (incl. files) from a given sourcepath
to a given destinationpath and verify that the content of the
file(s) has not changed.
Optionally output success in addition to fails.
Optionally write metadata to an infofile and/or each step
into a logfile.
Optionally suppress all output.
REQUIREMENTS
Powershell 4.0 (first Version with Get-FileHash commandlet).
You have to open the Powershell with unrestricted execution policy or
use "a" .exe version!
PARAMETERS
-l[ogfilepath] <Path>
Relative or absolute path to the logfile.
-i[nfofilepath] <Path>
Relative or absolute path to the infofile.
-v[erify]
Turn on output of success (in addition to fails)
-q[uiet]
Turn off all output.
-h[elp]
Show this help.
"@
If($help){
Write-Output $helpstring
Exit $True
}
#=== FUNCTION ==================================================================
# NAME: Write-Error-and-Exit-PN
# DESCRIPTION: Write red text to the console and exit with individual exitcode.
# PARAMETER 1: string
# PARAMETER 2: integer
#===============================================================================
Function Write-Error-and-Exit-PN
{
[CmdletBinding()]
Param(
[Parameter(Mandatory=$True, Position=1)]
[string]$message,
[Parameter(Mandatory=$True, Position=2)]
[string]$exitcode
)
# If you want color you have to use Write-Host!
# (But be aware: Write-Host will not be available on "every host"!)
Write-Host " ERROR: $message ... EXIT!`n" -ForegroundColor Red
Write-Output $helpstring
Exit $exitcode
}
#-------------------------------------------------------------------------------
# Checks for sourcepath.
#-------------------------------------------------------------------------------
if(-Not $sourcepath){
Write-Error-and-Exit-PN "sourcepath is empty" 1
}
if(-Not [System.IO.Path]::IsPathRooted($sourcepath)){
# convert relative into absolut path
$sourcepath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($sourcepath)
}
if(-Not (Test-Path $sourcepath)){
Write-Error-and-Exit-PN "sourcepath has to exist" 2
}
#$sourcepathitem = Get-Item $sourcepath
## if a file, just copy it
#$sourcepathitem -is [System.IO.FileInfo]
## after copy a symlink is a file!
## if a folder, create it and copy the childitems into it
#$sourcepathitem -is [System.IO.DirectoryInfo]
## or only copy content to the destinationpath (tailing backslash)
#$sourcepath.EndsWith([IO.Path]::DirectorySeparatorChar)
## other items are not supported
#-------------------------------------------------------------------------------
# Checks for destinationpath.
#-------------------------------------------------------------------------------
if(-Not $destinationpath){
Write-Error-and-Exit-PN "destinationpath is empty" 3
}
if(-Not [System.IO.Path]::IsPathRooted($destinationpath)){
# convert relative into absolut path
$destinationpath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($destinationpath)
}
if(-Not (Test-Path (Split-Path $destinationpath))){
Write-Error-and-Exit-PN "cannot create destinationpath there" 4
}
# It is not a good idea to use an existing destination because of the
# possiblity of mixing traces from different cases!
If(Test-Path $destinationpath){
Write-Error-and-Exit-PN "destinationpath already exist" 5
}
#-------------------------------------------------------------------------------
# Like dc3dd you have to give "log" or "hlog" to activate "log" or "info".
#-------------------------------------------------------------------------------
# Checks for logfilepath != infofilepath.
#-------------------------------------------------------------------------------
if($logfilepath -and $infofilepath){
if($logfilepath -ceq $infofilepath){
Write-Error-and-Exit-PN "logfilepath and infofilepath are equal" 6
}
}
#-------------------------------------------------------------------------------
# Checks for logfilepath.
#-------------------------------------------------------------------------------
# TODO:
# Set "logfile.txt" in the destinationpath if a path is missing after "-l".
#-------------------------------------------------------------------------------
$logfileextensions = @(".log", ".txt")
if($logfilepath){
# It is not a good idea to overwrite an existing logfile!
if(Test-Path $logfilepath){
Write-Error-and-Exit-PN "logfilepath already exist" 7
}
if(-Not [System.IO.Path]::IsPathRooted($logfilepath)){
# convert relative into absolut path
$logfilepath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($logfilepath)
}
if(-Not (Test-Path (Split-Path $logfilepath))){
Write-Error-and-Exit-PN "cannot create logfile there" 8
}
# Check extension for logfile (.log or .txt)
if($logfileextensions -notcontains [System.IO.Path]::GetExtension($logfilepath)){
Write-Error-and-Exit-PN "unsupported extension for logfile" 9
}
}
#-------------------------------------------------------------------------------
# Checks for infofilepath.
#-------------------------------------------------------------------------------
# TODO:
# Set "infofile.csv" in the destinationpath if a path is missing after "-i".
#-------------------------------------------------------------------------------
$infofileextensions = @(".csv", ".txt")
if($infofilepath){
# It is not a good idea to overwrite an existing infofile!
if(Test-Path $infofilepath){
Write-Error-and-Exit-PN "infofilepath already exist" 10
}
if(-Not [System.IO.Path]::IsPathRooted($infofilepath)){
# convert relative into absolut path
$infofilepath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($infofilepath)
}
if(-Not (Test-Path (Split-Path $infofilepath))){
Write-Error-and-Exit-PN "cannot create infofile there" 11
}
# Check extension for infofile (.csv or .txt)
if($infofileextensions -notcontains [System.IO.Path]::GetExtension($infofilepath)){
Write-Error-and-Exit-PN "unsupported extension for infofile" 12
}
}
#-------------------------------------------------------------------------------
# Start write to logfile if "-l" is given.
#-------------------------------------------------------------------------------
if($logfilepath){
"$scriptinfo`n" | Out-File $logfilepath
}
#-------------------------------------------------------------------------------
# Start write to infofile if "-i" is given.
#-------------------------------------------------------------------------------
if($infofilepath){
"# $scriptinfo" | Out-File $infofilepath
$csvtablehead = '"FullName"'
$csvtablehead += ',"Length"'
$csvtablehead += ',"Mode"'
$csvtablehead += ',"Attributes"'
$csvtablehead += ',"SymbolicLinkTarget"'
$csvtablehead += ',"LastWriteTime"'
$csvtablehead += ',"LastAccessTime"'
$csvtablehead += ',"CreationTime"'
$csvtablehead += ',"Owner"'
$csvtablehead += ',"Group"'
$csvtablehead += ',"MD5"'
$csvtablehead += ',"SHA1"'
$csvtablehead | Out-File $infofilepath -Append
}
#-------------------------------------------------------------------------------
# We want as much as possible (and skip only devices and don't want errors).
#-------------------------------------------------------------------------------
$sourceroot = Get-Item -Path $sourcepath 2>$null
if($sourceroot -Is [System.IO.FileInfo]){
# copy only one file
}
elseif($sourceroot -Is [System.IO.DirectoryInfo]){
$sourcefiles = Get-ChildItem -Path $sourcepath `
-Recurse `
-Attributes ReadOnly,
Hidden,
System,
Directory,
Archive,
Normal,
Temporary,
SparseFile,
ReparsePoint,
Compressed,
Offline,
NotContentIndexed,
Encrypted,
IntegrityStream,
NoScrubData 2>$null
}
else{
# type of source not supported
}
#-------------------------------------------------------------------------------
# Main.
#-------------------------------------------------------------------------------
New-Item $destinationpath -ItemType "Directory" | Out-Null
if($logfilepath){
"Destination path successfully created.`n" | Out-File $logfilepath -Append
}
ForEach($sourcefile in $sourcefiles){
# md5 and sha1 secure like sha256...
# Tee-Object w/o process substitution...
if($sourcefile -Is [System.IO.FileInfo]){
$md5ofsource = (Get-FileHash -Path $sourcefile.FullName -Algorithm MD5).hash
$sha1ofsource = (Get-FileHash -Path $sourcefile.FullName -Algorithm SHA1).hash
}
if($infofilepath){
$line = new-object PSObject
$line | add-member -membertype NoteProperty -name "FullName" -value $sourcefile.FullName
$line | add-member -membertype NoteProperty -name "Mode" -value $sourcefile.Mode
$line | add-member -membertype NoteProperty -name "Attributes" -value $sourcefile.Attributes
if($sourcefile.LinkType -eq "SymbolicLink"){
$line | add-member -membertype NoteProperty -name "SymbolicLinkTarget" -value $sourcefile.Target
}
$line | add-member -membertype NoteProperty -name "LastWriteTime" -value $sourcefile.LastWriteTime
$line | add-member -membertype NoteProperty -name "LastAccessTime" -value $sourcefile.LastAccessTime
$line | add-member -membertype NoteProperty -name "CreationTime" -value $sourcefile.CreationTime
$line | add-member -membertype NoteProperty -name "Owner" -value (Get-Acl $sourcefile.FullName).Owner
$line | add-member -membertype NoteProperty -name "Group" -value (Get-Acl $sourcefile.FullName).Group
if($sourcefile -Is [System.IO.FileInfo]){
$line | add-member -membertype NoteProperty -name "Length" -value $sourcefile.Length
$line | add-member -membertype NoteProperty -name "MD5" -value $md5ofsource
$line | add-member -membertype NoteProperty -name "SHA1" -value $sha1ofsource
}
$line | Export-Csv $infofilepath -Delimiter "," -Append -Encoding UTF8 -NoTypeInformation -Force
$line = $null
}
# os independent path directory separator
# and
# -Replace = RegExp und .Replace() = Strings
$destinationfile = [System.IO.FileInfo](($destinationpath, [IO.Path]::DirectorySeparatorChar, $sourceroot.Name, [IO.Path]::DirectorySeparatorChar, $sourcefile.FullName.Replace($sourcepath, "")) -join "")
# Verify (= Verbose) und Quiet funktioniert so nicht wirklich einfach!?
# Erst msg bauen und dann nix, ausgabe oder in datei mit if!?
Write-Host "$($destinationfile.FullName): " -NoNewline
# Am besten gleich zusammen mit:
# Logfile support: "Out-File -Append"
Copy-Item $sourcefile.FullName $destinationfile.FullName | Out-Null
if($destinationfile.Exists){
#$destinationfile.Mode = $sourcefile.Mode # is ReadOnly :-(
$destinationfile.Attributes = $sourcefile.Attributes
$destinationfile.LastWriteTime = $sourcefile.LastWriteTime
$destinationfile.LastAccessTime = $sourcefile.LastAccessTime
$destinationfile.CreationTime = $sourcefile.CreationTime
Set-Acl $destinationfile.FullName -AclObject (Get-Acl $sourcefile.FullName)
if($destinationfile -Is [System.IO.FileInfo]){
$md5ofdestination = (Get-FileHash -Path $destinationfile.FullName -Algorithm MD5).hash
$sha1ofdestination = (Get-FileHash -Path $destinationfile.FullName -Algorithm SHA1).hash
if(($md5ofdestination -eq $md5ofsource) -and ($sha1ofdestination -eq $sha1ofsource)){
Write-Host "OK" -ForegroundColor Green
} else{
Write-Host "Failed (hash)" -ForegroundColor Red
}
}
if($destinationfile -Is [System.IO.DirectoryInfo]){
Write-Host "OK" -ForegroundColor Green
}
} else{
Write-Host "Failed (copy)" -ForegroundColor Red
}
}
exit $True