Private/Validation.ps1

<#
    Règles de validation pré-vol.

    C'est la fonctionnalité la plus rentable de l'outil : elle transforme des
    échecs en cours de migration (coûteux, partiels, difficiles à reprendre) en
    anomalies connues AVANT le lancement. ShareGate et SPMT refusent de démarrer
    sans cette étape ; la version précédente de cet outil ne la faisait pas du tout.

    Les fonctions ici sont volontairement pures (aucun appel réseau) : elles sont
    testables unitairement et réutilisables pour valider un plan hors connexion.
#>


# Caractères interdits dans un nom de fichier ou de dossier SharePoint Online.
$script:SPMInvalidNameChars = @('"', '*', ':', '<', '>', '?', '/', '\', '|')

# Noms réservés (héritage Windows) refusés par SharePoint.
$script:SPMReservedNames = @(
    'CON', 'PRN', 'AUX', 'NUL',
    'COM0', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
    'LPT0', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
    '_vti_', 'desktop.ini'
)

# Limites SharePoint Online (documentées par Microsoft, valeurs 2026).
$script:SPMLimits = @{
    MaxServerRelativeUrlLength = 400          # URL complète décodée
    MaxSegmentLength = 255          # un segment de chemin
    MaxFileSizeBytes = 250GB
    ListViewThreshold = 5000
}

function Test-SPMItemName {
    <#
    .SYNOPSIS
        Valide le nom d'un fichier ou d'un dossier au regard des règles SharePoint.

    .OUTPUTS
        Liste de chaînes décrivant les problèmes. Vide = nom valide.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Name,
        [switch]$IsFolder
    )

    $problems = [System.Collections.Generic.List[string]]::new()

    if ([string]::IsNullOrWhiteSpace($Name)) {
        $problems.Add('Nom vide')
        return $problems.ToArray()
    }

    $found = $script:SPMInvalidNameChars | Where-Object { $Name.Contains($_) }
    if ($found) {
        $problems.Add("Caractères interdits : $($found -join ' ')")
    }

    if ($Name.StartsWith('.') -or $Name.EndsWith('.')) {
        $problems.Add("Ne peut ni commencer ni finir par un point")
    }
    if ($Name -ne $Name.Trim()) {
        $problems.Add('Espaces en début ou fin de nom')
    }
    if ($Name.Contains('..')) {
        $problems.Add("Séquence '..' interdite")
    }
    if ($Name.Length -gt $script:SPMLimits.MaxSegmentLength) {
        $problems.Add("Segment trop long ($($Name.Length) > $($script:SPMLimits.MaxSegmentLength) caractères)")
    }

    $bare = [System.IO.Path]::GetFileNameWithoutExtension($Name)
    if ($script:SPMReservedNames -contains $bare.ToUpperInvariant() -or
        $script:SPMReservedNames -contains $Name.ToUpperInvariant()) {
        $problems.Add("Nom réservé : $Name")
    }
    if ($Name.StartsWith('~$')) {
        $problems.Add("Fichier temporaire Office (~$) : à exclure de la migration")
    }

    return $problems.ToArray()
}

function Test-SPMUrlLength {
    <#
    .SYNOPSIS
        Vérifie qu'une URL serveur-relative reste sous la limite SharePoint.

    .DESCRIPTION
        Cause d'échec numéro un des migrations depuis un partage de fichiers :
        l'arborescence source est profonde et la destination ajoute un préfixe.
        On valide donc l'URL de destination réellement projetée.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$ServerRelativeUrl,
        [int]$MaxLength = 0
    )

    if ($MaxLength -le 0) { $MaxLength = $script:SPMLimits.MaxServerRelativeUrlLength }

    $decoded = [System.Uri]::UnescapeDataString($ServerRelativeUrl)
    $tooLongSegments = @($decoded.Split('/') | Where-Object { $_.Length -gt $script:SPMLimits.MaxSegmentLength })

    [pscustomobject]@{
        Url = $ServerRelativeUrl
        Length = $decoded.Length
        MaxLength = $MaxLength
        Ok = ($decoded.Length -le $MaxLength -and $tooLongSegments.Count -eq 0)
        Margin = $MaxLength - $decoded.Length
        LongSegments = $tooLongSegments
        RecommendedFix = if ($decoded.Length -gt $MaxLength) {
            "Raccourcir de $($decoded.Length - $MaxLength) caractères, ou migrer vers une bibliothèque au chemin plus court"
        }
        else { $null }
    }
}

function Test-SPMFileConstraint {
    <#
    .SYNOPSIS
        Valide un fichier candidat (taille, extension, état d'extraction).
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)][string]$Name,
        [long]$SizeBytes = -1,
        [string[]]$BlockedExtensions = @(),
        [bool]$IsCheckedOut = $false,
        [switch]$AllowEmptyFile
    )

    $problems = [System.Collections.Generic.List[string]]::new()
    $nameProblems = @(Test-SPMItemName -Name $Name)
    if ($nameProblems.Count -gt 0) { $problems.AddRange([string[]]$nameProblems) }

    if ($SizeBytes -ge 0) {
        if ($SizeBytes -gt $script:SPMLimits.MaxFileSizeBytes) {
            $problems.Add("Fichier de $([math]::Round($SizeBytes / 1GB, 2)) Go : au-delà de la limite SharePoint de 250 Go")
        }
        if ($SizeBytes -eq 0 -and -not $AllowEmptyFile) {
            $problems.Add('Fichier vide (0 octet) : rejeté par la Migration API')
        }
    }

    if ($BlockedExtensions.Count -gt 0) {
        $ext = [System.IO.Path]::GetExtension($Name)
        if ($ext -and ($BlockedExtensions -contains $ext.ToLowerInvariant())) {
            $problems.Add("Extension bloquée par la configuration : $ext")
        }
    }

    if ($IsCheckedOut) {
        $problems.Add('Fichier extrait (checked out) : le contenu non archivé ne sera pas migré')
    }

    return $problems.ToArray()
}

function New-SPMValidationFinding {
    <#
    .SYNOPSIS
        Crée un constat de validation normalisé.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][ValidateSet('Error', 'Warning', 'Info')][string]$Severity,
        [Parameter(Mandatory)][string]$Rule,
        [Parameter(Mandatory)][string]$Message,
        [string]$Path,
        [string]$Remediation
    )

    [pscustomobject]@{
        Severity = $Severity
        Rule = $Rule
        Message = $Message
        Path = $Path
        Remediation = $Remediation
    }
}