<# .SYNOPSIS Find-CMSharePointPathIssues.ps1 .DESCRIPTION Scans a file share for items that SharePoint in Microsoft 365 and OneDrive will reject: - Paths longer than the limit (default 400 characters). Microsoft states the entire decoded file path, including the file name, can't contain more than 400 characters. The script measures the path relative to -Path and adds the length of -TargetPrefix. - Characters not allowed in names: " * : < > ? / \ | - Names not allowed: .lock, CON, PRN, AUX, NUL, COM0-COM9, LPT0-LPT9, desktop.ini, names starting with ~$, _vti_ anywhere in a name, "forms" at the root of a library, and folder names starting with the characters U+309B or U+1027. - Leading or trailing spaces in names. - Files over the upload limit (default 250 GB). Read-only: it only reads folder listings and file sizes. It changes nothing. Limits are from Microsoft's "SharePoint limits" and "Restrictions and limitations in OneDrive and SharePoint" pages. Check them before you rely on the defaults. Provided by cloud-migrations.au as is; test before use. Read the script before you run it and try it on a small folder first. .NOTES Requirements - PowerShell 7 (recommended) or Windows PowerShell 5.1. No modules. - Read access to every folder under -Path Commands used (built-in, documented by Microsoft): Get-ChildItem, Test-Path, Write-Progress, Export-Csv .EXAMPLE .\Find-CMSharePointPathIssues.ps1 -Path \\fs01\Finance -TargetPrefix "sites/Finance/Shared Documents" .EXAMPLE .\Find-CMSharePointPathIssues.ps1 -Path D:\Shares\Projects -MaxPathLength 300 -OutputPath C:\Reports\projects.csv #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Path, [string]$TargetPrefix = '', [int]$MaxPathLength = 400, [double]$MaxFileSizeGB = 250, [string]$OutputPath = (Join-Path -Path (Get-Location) -ChildPath ('PathIssues_{0}.csv' -f (Get-Date -Format 'yyyyMMdd_HHmm'))) ) if (-not (Test-Path -LiteralPath $Path)) { Write-Error "Path not found or not readable: $Path" return } $root = (Get-Item -LiteralPath $Path).FullName.TrimEnd('\', '/') $prefix = $TargetPrefix.Trim('/', '\') $prefixLength = if ($prefix) { $prefix.Length + 1 } else { 0 } # +1 for the slash between prefix and path $maxBytes = [int64]($MaxFileSizeGB * 1GB) $invalidChars = [char[]]@('"', '*', ':', '<', '>', '?', '/', '\', '|') $blockedNames = @('.lock', 'CON', 'PRN', 'AUX', 'NUL', 'desktop.ini') + (0..9 | ForEach-Object { "COM$_" }) + (0..9 | ForEach-Object { "LPT$_" }) $badFolderStart = @([char]0x309B, [char]0x1027) $issues = New-Object System.Collections.Generic.List[object] function Add-CMIssue { param($Issue, $Item, $Length, $Detail) $isFolder = $Item.PSIsContainer $issues.Add([pscustomobject][ordered]@{ Issue = $Issue ItemType = $(if ($isFolder) { 'Folder' } else { 'File' }) FullName = $Item.FullName Name = $Item.Name ProjectedPathLength = $Length SizeGB = $(if ($isFolder) { '' } else { [math]::Round($Item.Length / 1GB, 3) }) LastWriteTime = $Item.LastWriteTime Detail = $Detail }) } Write-Host "Scanning $root ..." $scanErrors = $null $count = 0 Get-ChildItem -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue -ErrorVariable +scanErrors | ForEach-Object { $item = $_ $count++ if ($count % 500 -eq 0) { Write-Progress -Activity "Scanning $root" -Status ("{0} items checked, {1} issues found" -f $count, $issues.Count) -CurrentOperation $item.FullName } # Path relative to the scan root, with forward slashes, plus the target prefix $relative = $item.FullName.Substring($root.Length).TrimStart('\', '/') -replace '\\', '/' $projected = $prefixLength + $relative.Length $name = $item.Name $baseName = [System.IO.Path]::GetFileNameWithoutExtension($name) if ($projected -gt $MaxPathLength) { Add-CMIssue 'PathTooLong' $item $projected ("Over the {0} character limit by {1}" -f $MaxPathLength, ($projected - $MaxPathLength)) } if ($name.IndexOfAny($invalidChars) -ge 0) { Add-CMIssue 'InvalidCharacter' $item $projected 'Name contains one of " * : < > ? / \ |' } if ($blockedNames -contains $name -or $blockedNames -contains $baseName) { Add-CMIssue 'BlockedName' $item $projected "Reserved name: $name" } if ($name.StartsWith('~$')) { Add-CMIssue 'BlockedName' $item $projected 'Names starting with ~$ are not allowed' } if ($name -like '*_vti_*') { Add-CMIssue 'BlockedName' $item $projected '_vti_ is not allowed anywhere in a name' } if ($name -eq 'forms' -and -not $relative.Contains('/')) { Add-CMIssue 'BlockedName' $item $projected '"forms" is not allowed at the root of a library' } if ($item.PSIsContainer -and $name.Length -gt 0 -and $badFolderStart -contains $name[0]) { Add-CMIssue 'BlockedName' $item $projected 'Folder name starts with a character Microsoft does not allow' } if ($name -ne $name.Trim(' ')) { Add-CMIssue 'LeadingOrTrailingSpace' $item $projected 'Leading or trailing space in the name' } if (-not $item.PSIsContainer -and $item.Length -gt $maxBytes) { Add-CMIssue 'FileTooLarge' $item $projected ("Larger than {0} GB" -f $MaxFileSizeGB) } } Write-Progress -Activity "Scanning $root" -Completed # Folders the scan could not read foreach ($e in @($scanErrors)) { if ($null -eq $e) { continue } $issues.Add([pscustomobject][ordered]@{ Issue = 'AccessError'; ItemType = ''; FullName = "$($e.TargetObject)"; Name = '' ProjectedPathLength = ''; SizeGB = ''; LastWriteTime = ''; Detail = $e.Exception.Message }) } try { $issues | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 Write-Host ("Done. {0} items checked, {1} issue rows written." -f $count, $issues.Count) $issues | Group-Object -Property Issue | Sort-Object -Property Count -Descending | ForEach-Object { Write-Host (" {0}: {1}" -f $_.Name, $_.Count) } Write-Host "Report saved to: $((Resolve-Path -Path $OutputPath).Path)" } catch { Write-Error "Could not write the CSV to $OutputPath : $($_.Exception.Message)" }