diff --git a/.github/actions/purge-m365-user-data/action.yml b/.github/actions/purge-m365-user-data/action.yml index f064bb363..b4a4b26d5 100644 --- a/.github/actions/purge-m365-user-data/action.yml +++ b/.github/actions/purge-m365-user-data/action.yml @@ -9,10 +9,9 @@ name: Purge M365 User Data # # The script focuses on the cleaning up the following: # * All folders, descending from the exchange root, of a given prefix. -# * All folders in PersonMetadata +# * All folders in PersonMetadata # * All already soft-deleted items -# * All recoverable items in Audits -# * All recoverable items in Purges +# * All folders under recoverable items inputs: user: @@ -35,20 +34,19 @@ inputs: runs: using: composite steps: - - - name: Run the folder-matrix purge script set - if: ${{ inputs.folder-prefix != '' }} + - name: Run the all purge scripts for user + if: ${{ inputs.user != '' }} shell: pwsh working-directory: ./src/cmd/purge/scripts - env: + env: AZURE_CLIENT_ID: ${{ inputs.azure-client-id }} AZURE_CLIENT_SECRET: ${{ inputs.azure-client-secret }} AZURE_TENANT_ID: ${{ inputs.azure-tenant-id }} run: | - ./foldersAndItems.ps1 -WellKnownRoot root -User ${{ inputs.user }} -FolderPrefixPurge ${{ inputs.folder-prefix }} -FolderBeforePurge ${{ inputs.older-than }} + ./foldersAndItems.ps1 -User ${{ inputs.user }} -FolderNamePurgeList PersonMetadata --FolderPrefixPurgeList ${{ inputs.folder-prefix }} -PurgeBeforeTimestamp ${{ inputs.older-than }} - name: Reset retention for all mailboxes to 0 - if: ${{ inputs.folder-prefix == '' && inputs.user == '' }} + if: ${{ inputs.user == '' }} shell: pwsh working-directory: ./src/cmd/purge/scripts env: @@ -57,25 +55,9 @@ runs: run: | ./setRetention.ps1 - - name: Run the static purge script set - if: ${{ inputs.folder-prefix == '' && inputs.user != '' }} - shell: pwsh - working-directory: ./src/cmd/purge/scripts - env: - AZURE_CLIENT_ID: ${{ inputs.azure-client-id }} - AZURE_CLIENT_SECRET: ${{ inputs.azure-client-secret }} - AZURE_TENANT_ID: ${{ inputs.azure-tenant-id }} - # powershell doesn't like multiline commands, each of these must be on a single line - run: | - ./foldersAndItems.ps1 -WellKnownRoot root -User ${{ inputs.user }} -FolderNamePurge PersonMetadata - ./foldersAndItems.ps1 -WellKnownRoot deleteditems -User ${{ inputs.user }} - ./foldersAndItems.ps1 -WellKnownRoot recoverableitemsroot -User ${{ inputs.user }} -FolderNamePurge Audits - ./foldersAndItems.ps1 -WellKnownRoot recoverableitemsroot -User ${{ inputs.user }} -FolderNamePurge Purges - ./foldersAndItems.ps1 -WellKnownRoot recoverableitemsroot -User ${{ inputs.user }} -FolderNamePurge Deletions - - name: Run the old purge script to clear out onedrive buildup working-directory: ./src - if: ${{ inputs.folder-prefix != '' }} + if: ${{ inputs.folder-prefix != '' && inputs.user != ''}} shell: sh env: AZURE_CLIENT_ID: ${{ inputs.azure-client-id }} diff --git a/.github/workflows/ci_test_cleanup.yml b/.github/workflows/ci_test_cleanup.yml index 795f138af..ddc7313fb 100644 --- a/.github/workflows/ci_test_cleanup.yml +++ b/.github/workflows/ci_test_cleanup.yml @@ -10,14 +10,9 @@ jobs: runs-on: ubuntu-latest continue-on-error: true strategy: - # jobs are expanded per user. Try to run 2 jobs per active user at a time - max-parallel: 6 matrix: - folder: [Corso_Restore_, TestRestore, testfolder, incrementals_ci_, Alcion_Restore_, ''] - user: [CORSO_M365_TEST_USER_ID, CORSO_SECONDARY_M365_TEST_USER_ID, EXT_SDK_TEST_USER_ID] - include: - - folder: "" - user: "" + folder: 'Corso_Restore_, TestRestore, testfolder, incrementals_ci_, Alcion_Restore_' + user: [ CORSO_M365_TEST_USER_ID, CORSO_SECONDARY_M365_TEST_USER_ID, EXT_SDK_TEST_USER_ID, '' ] steps: - uses: actions/checkout@v3 diff --git a/src/cmd/purge/scripts/foldersAndItems.ps1 b/src/cmd/purge/scripts/foldersAndItems.ps1 index 9d8926076..0233b4c88 100644 --- a/src/cmd/purge/scripts/foldersAndItems.ps1 +++ b/src/cmd/purge/scripts/foldersAndItems.ps1 @@ -4,17 +4,14 @@ Param ( [Parameter(Mandatory = $True, HelpMessage = "User for which to delete folders")] [String]$User, - [Parameter(Mandatory = $False, HelpMessage = "Well-known name of folder under which to clean")] - [String]$WellKnownRoot = "deleteditems", + [Parameter(Mandatory = $True, HelpMessage = "Purge folders or contacts before this date time (UTC)")] + [datetime]$PurgeBeforeTimestamp, - [Parameter(Mandatory = $False, HelpMessage = "Purge folders before this date time (UTC)")] - [datetime]$FolderBeforePurge, + [Parameter(Mandatory = $True, HelpMessage = "Name of specific folder to purge under root")] + [String[]]$FolderNamePurgeList, - [Parameter(Mandatory = $False, HelpMessage = "Name of specific folder to purge under root")] - [String]$FolderNamePurge, - - [Parameter(Mandatory = $False, HelpMessage = "Purge folders with this prefix")] - [String]$FolderPrefixPurge, + [Parameter(Mandatory = $True, HelpMessage = "Purge folders with this prefix")] + [String[]]$FolderPrefixPurgeList, [Parameter(Mandatory = $False, HelpMessage = "Azure TenantId")] [String]$TenantId = $ENV:AZURE_TENANT_ID, @@ -26,6 +23,10 @@ Param ( [String]$ClientSecret = $ENV:AZURE_CLIENT_SECRET ) +Set-StrictMode -Version 2.0 +# Attempt to set network timeout to 10min +[System.Net.ServicePointManager]::MaxServicePointIdleTime = 600000 + function Get-AccessToken { [CmdletBinding()] Param() @@ -34,11 +35,11 @@ function Get-AccessToken { Write-Host "`nNeed to specify TenantId, ClientId, and ClientSecret as parameters or ENVs" } - $body=@{ - client_id=$ClientId - client_secret=$ClientSecret - scope="https://outlook.office365.com/.default" - grant_type="client_credentials" + $body = @{ + client_id = $ClientId + client_secret = $ClientSecret + scope = "https://outlook.office365.com/.default" + grant_type = "client_credentials" } $res = Invoke-WebRequest -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -ContentType "application/x-www-form-urlencoded" -Body $body -Method Post @@ -102,20 +103,62 @@ function Invoke-SOAPRequest { return $xmlResponse } -function Remove-Folder { - [CmdletBinding(SupportsShouldProcess)] +function IsNameMatch { Param( - [Parameter(Mandatory = $True, HelpMessage = "OAuth token to connect to Exchange Online.")] - [Securestring]$Token, + [Parameter(Mandatory = $True, HelpMessage = "Folder name to evaluate for match against a list of targets")] + [string]$FolderName, - [Parameter(Mandatory = $True, HelpMessage = "User for which to delete folders")] - [String]$User, + [Parameter(Mandatory = $True, HelpMessage = "Folder names to evaluate for match")] + [string[]]$FolderNamePurgeList = @() + ) - [Parameter(Mandatory = $False, HelpMessage = "Well-known name of folder under which to clean")] - [String]$WellKnownRoot = "deleteditems" - ) + return ($FolderName -in $FolderNamePurgeList) +} - # SOAP message for getting the folder id +function IsPrefixAndAgeMatch { + Param( + [Parameter(Mandatory = $True, HelpMessage = "Folder name to evaluate for match against a list of targets")] + [string]$FolderName, + + [Parameter(Mandatory = $True, HelpMessage = "Folder creation times")] + [string]$FolderCreateTime, + + [Parameter(Mandatory = $True, HelpMessage = "Folder name prefixes to evaluate for match")] + [string[]]$FolderPrefixPurgeList, + + [Parameter(Mandatory = $TRUE, HelpMessage = "Purge folders before this date time (UTC)")] + [datetime]$PurgeBeforeTimestamp + ) + + if ($PurgeBeforeTimestamp -gt $folderCreateTime ) { + foreach ($prefix in $FolderPrefixPurgeList) { + if ($FolderName -like "$prefix*") { + return $true + } + } + } + + return $false +} + +function Get-FoldersToPurge { + Param( + [Parameter(Mandatory = $True, HelpMessage = "Folder under which to look for items matching removal criteria")] + [String]$WellKnownRoot, + + [Parameter(Mandatory = $False, HelpMessage = "Purge folders before this date time (UTC)")] + [datetime]$PurgeBeforeTimestamp, + + [Parameter(Mandatory = $False, HelpMessage = "Purge folders with these names")] + [string[]]$FolderNamePurgeList = @(), + + [Parameter(Mandatory = $False, HelpMessage = "Purge folders with these prefixes")] + [string[]]$FolderPrefixPurgeList = @() + ) + + $foldersToDelete = @() + + # SOAP message for getting the folders $body = @" @@ -130,78 +173,360 @@ function Remove-Folder { "@ - Write-Host "`nLooking for folders under well-known folder: $WellKnownRoot & matching folder: $FolderNamePurge$FolderNamePrefixPurge & for user: $User" + Write-Host "`nLooking for folders under well-known folder: $WellKnownRoot matching folders: $FolderNamePurgeList or prefixes: $FolderPrefixPurgeList for user: $User" $getFolderIdMsg = Initialize-SOAPMessage -User $User -Body $body $response = Invoke-SOAPRequest -Token $Token -Message $getFolderIdMsg # Get the folders from the response - $folders = $response | Select-Xml -XPath "//t:Folders/*" -Namespace @{t = "http://schemas.microsoft.com/exchange/services/2006/types"} | - Select-Object -ExpandProperty Node + $folders = $response | Select-Xml -XPath "//t:Folders/*" -Namespace @{t = "http://schemas.microsoft.com/exchange/services/2006/types" } | + Select-Object -ExpandProperty Node - $folderId = $null - $changeKey = $null - $totalCount = $null + # Are there more folders to list + $rootFolder = $response | Select-Xml -XPath "//m:RootFolder" -Namespace @{m = "http://schemas.microsoft.com/exchange/services/2006/messages" } | + Select-Object -ExpandProperty Node + $moreToList = ![System.Convert]::ToBoolean($rootFolder.IncludesLastItemInRange) # Loop through folders foreach ($folder in $folders) { - $folderId = $folder.FolderId.Id - $changeKey = $folder.FolderId.Changekey - $totalCount = $folder.TotalCount $folderName = $folder.DisplayName $folderCreateTime = $folder.ExtendedProperty - | Where-Object { $_.ExtendedFieldURI.PropertyTag -eq "0x3007" } - | Select-Object -ExpandProperty Value - | Get-Date + | Where-Object { $_.ExtendedFieldURI.PropertyTag -eq "0x3007" } + | Select-Object -ExpandProperty Value + | Get-Date - if ((![String]::IsNullOrEmpty($FolderNamePurge) -and $folderName -ne $FolderNamePurge) -or - (![String]::IsNullOrEmpty($FolderPrefixPurge) -and $folderName -notlike "$FolderPrefixPurge*") -or - (![String]::IsNullOrEmpty($FolderBeforePurge) -and $folderCreateTime -gt $FolderBeforePurge)) { + $IsNameMatchParams = @{ + 'FolderName' = $folderName; + 'FolderNamePurgeList' = $FolderNamePurgeList + } + + $IsPrefixAndAgeMatchParams = @{ + 'FolderName' = $folderName; + 'FolderCreateTime' = $folderCreateTime; + 'FolderPrefixPurgeList' = $FolderPrefixPurgeList; + 'PurgeBeforeTimestamp' = $PurgeBeforeTimestamp; + } + + if ((IsNameMatch @IsNameMatchParams) -or (IsPrefixAndAgeMatch @IsPrefixAndAgeMatchParams)) { + Write-Host "`nFound desired folder to purge: $folderName ($folderCreateTime)" + $foldersToDelete += $folder + } + } + + # powershel does not do well when returning empty arrays + return $foldersToDelete, $moreToList +} + +function Empty-Folder { + [CmdletBinding(SupportsShouldProcess)] + Param( + [Parameter(Mandatory = $False, HelpMessage = "List of well-known folders to empty ")] + [String[]]$WellKnownRootList = @(), + + [Parameter(Mandatory = $False, HelpMessage = "List of folderIds to empty ")] + [string[]]$FolderIdList = @(), + + [Parameter(Mandatory = $False, HelpMessage = "List of folder names to empty ")] + [string[]]$FolderNameList = @() + ) + + $folderIdsBody = "" + $foldersToEmptyCount = $FolderIdList.count + $WellKnownRootList.count + + foreach ($wnr in $WellKnownRootList) { + $folderIdsBody += "" + } + + foreach ($fid in $FolderIdList) { + $folderIdsBody += "" + } + + if ($PSCmdlet.ShouldProcess("Emptying $foldersToEmptyCount folders ($WellKnownRootList $FolderNameList)", "$foldersToEmptyCount folders ($WellKnownRootList $FolderNameList)", "Empty folders")) { + Write-Host "`nEmptying $foldersToEmptyCount folders ($WellKnownRootList $FolderNameList)" + + # DeleteType = HardDelete, MoveToDeletedItems, or SoftDelete + $body = @" + + + $folderIdsBody + + +"@ + + $emptyFolderMsg = Initialize-SOAPMessage -User $User -Body $body + $response = Invoke-SOAPRequest -Token $Token -Message $emptyFolderMsg + } +} + +function Delete-Folder { + [CmdletBinding(SupportsShouldProcess)] + Param( + [Parameter(Mandatory = $True, HelpMessage = "List of folderIds to remove ")] + [String[]]$FolderIdList, + + [Parameter(Mandatory = $False, HelpMessage = "List of folder names to remove ")] + [String[]]$FolderNameList = @() + ) + + $folderIdsBody = "" + $foldersToRemoveCount = $FolderIdList.count + + foreach ($fid in $FolderIdList) { + $folderIdsBody += "" + } + + if ($PSCmdlet.ShouldProcess("Removing $foldersToRemoveCount folders ($FolderNameList)", "$foldersToRemoveCount folders ($FolderNameList)", "Delete folders")) { + Write-Host "`nRemoving $foldersToRemoveCount folders ($FolderNameList)" + + # DeleteType = HardDelete, MoveToDeletedItems, or SoftDelete + $body = @" + + + $folderIdsBody + + +"@ + + $emptyFolderMsg = Initialize-SOAPMessage -User $User -Body $body + $response = Invoke-SOAPRequest -Token $Token -Message $emptyFolderMsg + } +} + +function Purge-Folders { + [CmdletBinding(SupportsShouldProcess)] + Param( + [Parameter(Mandatory = $True, HelpMessage = "Folder under which to look for items matching removal criteria")] + [String]$WellKnownRoot, + + [Parameter(Mandatory = $False, HelpMessage = "Purge folders with these names")] + [string[]]$FolderNamePurgeList = @(), + + [Parameter(Mandatory = $False, HelpMessage = "Purge folders with these prefixes")] + [string[]]$FolderPrefixPurgeList = @(), + + [Parameter(Mandatory = $False, HelpMessage = "Purge folders before this date time (UTC)")] + [datetime]$PurgeBeforeTimestamp + ) + + if (($FolderNamePurgeList.count -eq 0) -and + ($FolderPrefixPurgeList.count -eq 0 -or $PurgeBeforeTimestamp -eq $null )) { + Write-Host "Either a list of specific folders or a list of prefixes and purge timestamp is required" + Exit + } + + Write-Host "`nPurging CI-produced folders..." + Write-Host "--------------------------------" + + if ($FolderNamePurgeList.count -gt 0) { + Write-Host "Folders with names: $FolderNamePurgeList" + } + + if ($FolderPrefixPurgeList.count -gt 0 -and $PurgeBeforeTimestamp -ne $null) { + Write-Host "Folders older than $PurgeBeforeTimestamp with prefix: $FolderPrefixPurgeList" + } + + $foldersToDeleteParams = @{ + 'WellKnownRoot' = $WellKnownRoot; + 'FolderNamePurgeList' = $FolderNamePurgeList; + 'FolderPrefixPurgeList' = $FolderPrefixPurgeList; + 'PurgeBeforeTimestamp' = $PurgeBeforeTimestamp + } + + $moreToList = $True + # only get max of 1000 results so we may need to iterate over eligible folders + while ($moreToList) { + $foldersToDelete, $moreToList = Get-FoldersToPurge @foldersToDeleteParams + $foldersToDeleteCount = $foldersToDelete.count + $foldersToDeleteIds = @() + $folderNames = @() + + if ($foldersToDeleteCount -eq 0) { + Write-Host "`nNo folders to purge matching the criteria" + break + } + + foreach ($folder in $foldersToDelete) { + $foldersToDeleteIds += $folder.FolderId.Id + $folderNames += $folder.DisplayName + } + + Empty-Folder -FolderIdList $foldersToDeleteIds -FolderNameList $folderNames + Delete-Folder -FolderIdList $foldersToDeleteIds -FolderNameList $folderNames + } +} + +function Create-Contact { + $now = (Get-Date (Get-Date).ToUniversalTime() -Format "o") + #used to create a recent seed contact that will be shielded from cleanup. CI tests rely on this + $body = @" + + + + + + + Sanitago + TestContact - $now + Corso test enterprises + + sanitago@example.com + + + 4255550199 + + 2000-01-01T11:59:00Z + Tester + Plate + + + +"@ + + $createContactMsg = Initialize-SOAPMessage -User $User -Body $body + $response = Invoke-SOAPRequest -Token $Token -Message $createContactMsg +} + +function Get-ItemsToPurge { + Param( + [Parameter(Mandatory = $True, HelpMessage = "Folder under which to look for items matching removal criteria")] + [String]$WellKnownRoot, + + [Parameter(Mandatory = $True, HelpMessage = "Purge items before this date time (UTC)")] + [datetime]$PurgeBeforeTimestamp + ) + + $itemsToDelete = @() + + # SOAP message for getting the folder id + $body = @" + + + Default + + + + + + + + +"@ + + Write-Host "`nLooking for items under well-known folder: $WellKnownRoot older than $PurgeBeforeTimestamp for user: $User" + $getItemsMsg = Initialize-SOAPMessage -User $User -Body $body + $response = Invoke-SOAPRequest -Token $Token -Message $getItemsMsg + + # Get the contacts from the response + $items = $response | Select-Xml -XPath "//t:Items/*" -Namespace @{t = "http://schemas.microsoft.com/exchange/services/2006/types" } | + Select-Object -ExpandProperty Node + + # Are there more folders to list + $rootFolder = $response | Select-Xml -XPath "//m:RootFolder" -Namespace @{m = "http://schemas.microsoft.com/exchange/services/2006/messages" } | + Select-Object -ExpandProperty Node + $moreToList = ![System.Convert]::ToBoolean($rootFolder.IncludesLastItemInRange) + + foreach ($item in $items) { + $itemId = $item.ItemId.Id + $changeKey = $item.ItemId.Changekey + $itemName = $item.DisplayName + $itemCreateTime = $item.ExtendedProperty + | Where-Object { $_.ExtendedFieldURI.PropertyTag -eq "0x3007" } + | Select-Object -ExpandProperty Value + | Get-Date + + if ([String]::IsNullOrEmpty($itemId) -or [String]::IsNullOrEmpty($changeKey)) { continue } - if (![String]::IsNullOrEmpty($FolderNamePurge)) { - Write-Host "`nFound desired folder to purge: $FolderNamePurge" + if (![String]::IsNullOrEmpty($PurgeBeforeTimestamp) -and $itemCreateTime -gt $PurgeBeforeTimestamp) { + continue } - Write-Verbose "`nFolder Id and ChangeKey for ""$folderName"": $folderId, $changeKey" + Write-Verbose "Item Id and ChangeKey for ""$itemName"": $itemId, $changeKey" + $itemsToDelete += $item + } - # Empty and delete the folder if found - if (![String]::IsNullOrEmpty($folderId) -and ![String]::IsNullOrEmpty($changeKey)) { - if ($PSCmdlet.ShouldProcess("$folderName ($totalCount items) created $folderCreateTime", "Emptying folder")) { - Write-Host "Emptying folder $folderName ($totalCount items)..." + return $itemsToDelete, $moreToList +} - # DeleteType = HardDelete, MoveToDeletedItems, or SoftDelete - $body = @" - - - - - +function Purge-Contacts { + [CmdletBinding(SupportsShouldProcess)] + Param( + [Parameter(Mandatory = $True, HelpMessage = "Purge items before this date time (UTC)")] + [datetime]$PurgeBeforeTimestamp + ) + + Write-Host "`nCleaning up contacts older than $PurgeBeforeTimestamp" + Write-Host "-------------------------------------------------------" + + # Create one seed contact which will have recent create date and will not be sweapt + # This is needed since tests rely on some contact data being present + Write-Host "`nCreating seed contact" + Create-Contact + + $moreToList = $True + # only get max of 1000 results so we may need to iterate over eligible contacts + while ($moreToList) { + $itemsToDelete, $moreToList = Get-ItemsToPurge -WellKnownRoot "contacts" -PurgeBeforeTimestamp $PurgeBeforeTimestamp + $itemsToDeleteCount = $itemsToDelete.count + $itemsToDeleteBody = "" + + if ($itemsToDeleteCount -eq 0) { + Write-Host "`nNo more contacts to delete matching criteria" + break + } + + Write-Host "`nQueueing $itemsToDeleteCount items to delete" + foreach ($item in $itemsToDelete) { + $itemId = $item.ItemId.Id + $changeKey = $item.ItemId.Changekey + $itemsToDeleteBody += "`n" + } + + # Do the actual deletion in a batch request + # DeleteType = HardDelete, MoveToDeletedItems, or SoftDelete + $body = @" + + + $itemsToDeleteBody + + "@ - $emptyFolderMsg = Initialize-SOAPMessage -User $User -Body $body - $response = Invoke-SOAPRequest -Token $Token -Message $emptyFolderMsg - } - - if ($PSCmdlet.ShouldProcess($folderName, "Deleting folder")) { - Write-Host "Deleting folder $folderName..." - - # DeleteType = HardDelete, MoveToDeletedItems, or SoftDelete - $body = @" - - - - - -"@ - $deleteFolderMsg = Initialize-SOAPMessage -User $User -Body $body - $response = Invoke-SOAPRequest -Token $Token -Message $deleteFolderMsg - } - - Write-Host "Deleted folder $folderName ($totalCount items)" + + if ($PSCmdlet.ShouldProcess("Deleting $itemsToDeleteCount items...", "$itemsToDeleteCount items", "Delete items")) { + Write-Host "`nDeleting $itemsToDeleteCount items..." + + $emptyFolderMsg = Initialize-SOAPMessage -User $User -Body $body + $response = Invoke-SOAPRequest -Token $Token -Message $emptyFolderMsg + + Write-Host "`nDeleted $itemsToDeleteCount items..." + } } } -$token = Get-AccessToken | ConvertTo-SecureString -AsPlainText -Force +Write-Host 'Authenticating with Exchange Web Services ...' +$global:Token = Get-AccessToken | ConvertTo-SecureString -AsPlainText -Force -Remove-Folder -Token $token -User $User -WellKnownRoot $WellKnownRoot \ No newline at end of file +$purgeFolderParams = @{ + 'WellKnownRoot' = "root"; + 'FolderNamePurgeList' = $FolderNamePurgeList; + 'FolderPrefixPurgeList' = $FolderPrefixPurgeList; + 'PurgeBeforeTimestamp' = $PurgeBeforeTimestamp +} + +#purge older prefix folders +Purge-Folders @purgeFolderParams + +#purge older contacts +Purge-Contacts -PurgeBeforeTimestamp $PurgeBeforeTimestamp + +# Empty Deleted Items and then purge all recoverable items. Deletes the following +# -/Recoverable Items/Audits +# -/Recoverable Items/Deletion +# -/Recoverable Items/Purges +# -/Recoverable Items/Versions +# -/Recoverable Items/Calendar Logging +# -/Recoverable Items/SubstrateHolds +Write-Host "`nProcess well-known folders that are always purged" +Write-Host "---------------------------------------------------" +Empty-Folder -WellKnownRoot "deleteditems", "recoverableitemsroot" \ No newline at end of file