src/Engine/CloudFiles.ps1

# CloudFiles.ps1 — OneDrive / Files On-Demand awareness + PSModulePath info.
#
# Why this exists: on Windows, pwsh derives its FIRST PSModulePath entry (the
# CurrentUser location) from the Documents known folder
# (about_PSModulePath; ModuleIntrinsics.cs GetPersonalModulePath). OneDrive
# Known Folder Move — including the admin policy "Silently move Windows known
# folders to OneDrive", i.e. zero user action — redirects Documents into
# OneDrive (learn.microsoft.com/sharepoint/redirect-known-folders). With
# Files On-Demand, module files can then be cloud-only placeholders whose
# every read is a remote fetch; when the fetch stalls or is denied, module
# discovery and Import-Module fail in confusing ways (e.g. PSResourceGet #300
# "Access to the cloud file is denied").
#
# Attribute values verified against the Win32 File Attribute Constants doc
# and [MS-FSCC] 2.6:
# FILE_ATTRIBUTE_OFFLINE 0x00001000
# FILE_ATTRIBUTE_RECALL_ON_OPEN 0x00040000 (item is virtual)
# FILE_ATTRIBUTE_UNPINNED 0x00100000 (may be dehydrated)
# FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS 0x00400000 (content not fully local;
# reading fetches it from the cloud)
# Reading a placeholder hydrates it — that is the documented design of
# RECALL_ON_DATA_ACCESS — so hydration here is simply a full sequential read.

$script:PSMM_AttrRecallOnDataAccess = 0x00400000
$script:PSMM_AttrRecallOnOpen       = 0x00040000

# Content is NOT fully on disk when either recall attribute is set.
function Test-PSMMCloudOnlyAttribute {
    [CmdletBinding()]
    param([Parameter(Mandatory)][int]$Attributes)
    (($Attributes -band $script:PSMM_AttrRecallOnDataAccess) -ne 0) -or
    (($Attributes -band $script:PSMM_AttrRecallOnOpen) -ne 0)
}

# Is the path under a OneDrive root? (personal or business - the sync client
# publishes its roots as environment variables)
function Test-PSMMOneDrivePath {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)
    foreach ($v in 'OneDrive', 'OneDriveCommercial', 'OneDriveConsumer') {
        $root = [Environment]::GetEnvironmentVariable($v)
        if ($root -and $Path.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
    }
    $false
}

# All cloud-only placeholder files under a path (empty on non-Windows and for
# missing paths).
function Get-PSMMCloudOnlyFile {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)
    if (-not $IsWindows) { return @() }
    if (-not (Test-Path -LiteralPath $Path -ErrorAction Ignore)) { return @() }
    @(Get-ChildItem -LiteralPath $Path -File -Recurse -Force -ErrorAction SilentlyContinue |
        Where-Object { Test-PSMMCloudOnlyAttribute -Attributes ([int]$_.Attributes) })
}

# Cloud-only files across every installed base of one module. Only OneDrive
# bases are scanned - everything else can't be a placeholder, and this runs
# in load paths where speed matters.
function Get-PSMMModuleCloudOnlyFile {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Name)
    if (-not $IsWindows) { return @() }
    $bases = @(Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue |
        Select-Object -ExpandProperty ModuleBase -Unique |
        Where-Object { Test-PSMMOneDrivePath -Path $_ })
    @(foreach ($b in $bases) { Get-PSMMCloudOnlyFile -Path $b })
}

# Hydrate placeholders by reading them end-to-end (the documented
# RECALL_ON_DATA_ACCESS behaviour: a read fetches the content from the cloud).
# $OnProgress, when given, is called as &$OnProgress $index $total $file
# before each file so callers can render progress - downloads can be slow.
function Invoke-PSMMFileHydration {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()] $Files,
        [scriptblock]$OnProgress
    )
    $ok = 0; $failed = 0; $errors = [System.Collections.Generic.List[string]]::new()
    $i = 0
    foreach ($f in @($Files)) {
        $i++
        if ($OnProgress) { & $OnProgress $i @($Files).Count $f }
        try {
            $fs = [System.IO.File]::OpenRead($f.FullName)
            try { $fs.CopyTo([System.IO.Stream]::Null) } finally { $fs.Dispose() }
            $ok++
        } catch {
            $failed++
            $errors.Add("$($f.Name): $($_.Exception.Message)")
        }
    }
    [pscustomobject]@{ Ok = $ok; Failed = $failed; Errors = $errors }
}

# The default CurrentUser module location pwsh derives from the Documents
# known folder (the value OneDrive KFM moves).
function Get-PSMMUserDefaultModulePath {
    [CmdletBinding()] param()
    if (-not $IsWindows) { return $null }
    $docs = [Environment]::GetFolderPath('MyDocuments')
    if ([string]::IsNullOrWhiteSpace($docs)) { return $null }
    Join-Path (Join-Path $docs 'PowerShell') 'Modules'
}

# One record per $env:PSModulePath entry, annotated for the paths screen.
function Get-PSMMModulePathInfo {
    [CmdletBinding()] param()
    $userDefault = Get-PSMMUserDefaultModulePath
    $sep = [System.IO.Path]::PathSeparator
    $i = 0
    @(foreach ($p in ($env:PSModulePath -split $sep | Where-Object { $_ })) {
        [pscustomobject]@{
            Order       = $i
            Path        = $p
            First       = ($i -eq 0)
            # -ErrorAction Ignore: an entry we cannot even stat (e.g. under
            # /root on CI) must count as not-ours, not crash the listing
            Exists      = (Test-Path -LiteralPath $p -ErrorAction Ignore)
            OneDrive    = (Test-PSMMOneDrivePath -Path $p)
            UserDefault = ($userDefault -and ($p.TrimEnd('\', '/') -eq $userDefault.TrimEnd('\', '/')))
        }
        $i++
    })
}

# Path of the CurrentUser powershell.config.json (about_PowerShell_Config:
# "The user configuration directory can be found across platforms with the
# command Split-Path $PROFILE.CurrentUserCurrentHost").
function Get-PSMMUserConfigJsonPath {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '',
        Justification = 'Reading $global:PROFILE is the point: the user config dir is defined by it.')]
    [CmdletBinding()] param()
    $profilePath = $global:PROFILE
    if ($profilePath -and $profilePath.PSObject.Properties['CurrentUserCurrentHost']) {
        $profilePath = $profilePath.CurrentUserCurrentHost
    }
    if ([string]::IsNullOrWhiteSpace("$profilePath")) { return $null }
    Join-Path (Split-Path -Parent "$profilePath") 'powershell.config.json'
}

# Set (or with -Clear remove) the CurrentUser module path override in
# powershell.config.json ("PSModulePath" key, about_PowerShell_Config).
# Preserves every other key. A corrupt config file stops pwsh from starting
# interactive sessions, so: refuse to touch a file we cannot parse, and write
# a .bak of the previous content first. Throws on failure.
# NOTE (documented caveat): this changes where pwsh LOOKS for CurrentUser
# modules; Install-Module/Install-PSResource keep installing to the DEFAULT
# Documents-derived location.
function Set-PSMMUserModulePath {
    [CmdletBinding()]
    param(
        [string]$Path,
        [switch]$Clear,
        [string]$ConfigPath   # test seam; defaults to the real user config
    )
    if (-not $Clear -and [string]::IsNullOrWhiteSpace($Path)) { throw 'a path is required (or -Clear)' }
    $cfgPath = if ($ConfigPath) { $ConfigPath } else { Get-PSMMUserConfigJsonPath }
    if (-not $cfgPath) { throw 'cannot locate the user powershell.config.json (no $PROFILE in this host)' }
    $obj = [ordered]@{}
    if (Test-Path -LiteralPath $cfgPath) {
        $raw = Get-Content -LiteralPath $cfgPath -Raw
        try { $parsed = $raw | ConvertFrom-Json -AsHashtable -ErrorAction Stop }
        catch { throw "refusing to modify $cfgPath - it does not parse as JSON ($($_.Exception.Message))" }
        foreach ($k in $parsed.Keys) { $obj[$k] = $parsed[$k] }
        Set-Content -LiteralPath "$cfgPath.bak" -Value $raw -Encoding utf8
    }
    if ($Clear) { $obj.Remove('PSModulePath') }
    else { $obj['PSModulePath'] = $Path }
    $dir = Split-Path -Parent $cfgPath
    if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
    ([pscustomobject]$obj | ConvertTo-Json -Depth 10) | Set-Content -LiteralPath $cfgPath -Encoding utf8
    $cfgPath
}

# Tell the OneDrive sync client to keep a folder tree permanently local
# ("always available"): attrib +p, the Microsoft-documented pin
# (learn.microsoft.com/sharepoint/files-on-demand-windows - "Pinning an
# online-only file makes the sync app download the file contents"). The
# download itself happens in the background via the sync client.
function Invoke-PSMMPinPath {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)
    if (-not $IsWindows) { throw 'pinning is a Windows / OneDrive feature' }
    if (-not (Test-Path -LiteralPath $Path -ErrorAction Ignore)) { throw "path not found: $Path" }
    & "$env:SystemRoot\System32\attrib.exe" +p -u "$Path\*" /s /d
    if ($LASTEXITCODE -ne 0) { throw "attrib.exe failed with exit code $LASTEXITCODE" }
}