diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..5e90de1
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,145 @@
+name: PSADTree Workflow
+on:
+ push:
+ branches:
+ - main
+
+ pull_request:
+ branches:
+ - main
+
+ release:
+ types:
+ - published
+
+env:
+ DOTNET_CLI_TELEMETRY_OPTOUT: 1
+ POWERSHELL_TELEMETRY_OPTOUT: 1
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
+ DOTNET_NOLOGO: true
+ BUILD_CONFIGURATION: ${{ fromJSON('["Debug", "Release"]')[startsWith(github.ref, 'refs/tags/v')] }}
+
+jobs:
+ build:
+ name: build
+ runs-on: windows-latest
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v3
+
+ - name: Build module - Debug
+ shell: pwsh
+ run: ./build.ps1 -Configuration $env:BUILD_CONFIGURATION -Task Build
+ if: ${{ env.BUILD_CONFIGURATION == 'Debug' }}
+
+ - name: Build module - Publish
+ shell: pwsh
+ run: ./build.ps1 -Configuration $env:BUILD_CONFIGURATION -Task Build
+ if: ${{ env.BUILD_CONFIGURATION == 'Release' }}
+
+ - name: Capture PowerShell Module
+ uses: actions/upload-artifact@v3
+ with:
+ name: PSModule
+ path: output/*.nupkg
+
+ test:
+ name: test
+ needs:
+ - build
+ runs-on: ${{ matrix.info.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ info:
+ - name: PS-5.1
+ psversion: '5.1'
+ os: windows-latest
+ - name: PS-7-Windows
+ psversion: '7'
+ os: windows-latest
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Restore Built PowerShell Module
+ uses: actions/download-artifact@v3
+ with:
+ name: PSModule
+ path: output
+
+ - name: Install Built PowerShell Module
+ shell: pwsh
+ run: |
+ $manifestItem = Get-Item ([IO.Path]::Combine('module', '*.psd1'))
+ $moduleName = $manifestItem.BaseName
+ $manifest = Test-ModuleManifest -Path $manifestItem.FullName -ErrorAction SilentlyContinue -WarningAction Ignore
+
+ $destPath = [IO.Path]::Combine('output', $moduleName, $manifest.Version)
+ if (-not (Test-Path -LiteralPath $destPath)) {
+ New-Item -Path $destPath -ItemType Directory | Out-Null
+ }
+
+ Get-ChildItem output/*.nupkg | Rename-Item -NewName { $_.Name -replace '.nupkg', '.zip' }
+
+ Expand-Archive -Path output/*.zip -DestinationPath $destPath -Force -ErrorAction Stop
+
+ - name: Run Tests - Windows PowerShell
+ if: ${{ matrix.info.psversion == '5.1' }}
+ shell: pwsh
+ run: |
+ powershell.exe -NoProfile -File ./build.ps1 -Configuration $env:BUILD_CONFIGURATION -Task Test
+ exit $LASTEXITCODE
+
+ - name: Run Tests - PowerShell
+ if: ${{ matrix.info.psversion != '5.1' }}
+ shell: pwsh
+ run: |
+ pwsh -NoProfile -File ./build.ps1 -Configuration $env:BUILD_CONFIGURATION -Task Test
+ exit $LASTEXITCODE
+
+ - name: Upload Test Results
+ if: always()
+ uses: actions/upload-artifact@v3
+ with:
+ name: Unit Test Results (${{ matrix.info.name }})
+ path: ./output/TestResults/Pester.xml
+
+ - name: Upload Coverage Results
+ if: always() && !startsWith(github.ref, 'refs/tags/v')
+ uses: actions/upload-artifact@v3
+ with:
+ name: Coverage Results (${{ matrix.info.name }})
+ path: ./output/TestResults/Coverage.xml
+
+ - name: Upload Coverage to codecov
+ if: always() && !startsWith(github.ref, 'refs/tags/v')
+ uses: codecov/codecov-action@v3
+ with:
+ files: ./output/TestResults/Coverage.xml
+ flags: ${{ matrix.info.name }}
+
+ publish:
+ name: publish
+ if: startsWith(github.ref, 'refs/tags/v')
+ needs:
+ - build
+ - test
+ runs-on: windows-latest
+ steps:
+ - name: Restore Built PowerShell Module
+ uses: actions/download-artifact@v3
+ with:
+ name: PSModule
+ path: ./
+
+ - name: Publish to Gallery
+ if: github.event_name == 'release'
+ shell: pwsh
+ run: >-
+ dotnet nuget push '*.nupkg'
+ --api-key $env:PSGALLERY_TOKEN
+ --source 'https://www.powershellgallery.com/api/v2/package'
+ --no-symbols
+ env:
+ PSGALLERY_TOKEN: ${{ secrets.PSGALLERY_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 0dab36a..80a49e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,272 @@
-*.zip
-**/GHacn/*
-**/old/*
\ No newline at end of file
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+
+benchmarks/
+BenchmarkDotNet.Artifacts/
+tools/dotnet
+
+# User-specific files
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+
+# Visual Studio 2015 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUNIT
+*.VisualState.xml
+TestResult.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# DNX
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+*_i.c
+*_p.c
+*_i.h
+*.ilk
+*.meta
+*.obj
+*.pch
+*.pdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# JustCode is a .NET coding add-in
+.JustCode
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# TODO: Comment the next line if you want to checkin your web deploy settings
+# but database connection strings (with potential passwords) will be unencrypted
+#*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# The packages folder can be ignored because of Package Restore
+**/packages/*
+# except build/, which is used as an MSBuild target.
+!**/packages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/packages/repositories.config
+# NuGet v3's project.json files produces more ignoreable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+node_modules/
+orleans.codegen.cs
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+
+# SQL Server files
+*.mdf
+*.ldf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# JetBrains Rider
+.idea/
+*.sln.iml
+
+# CodeRush
+.cr/
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+### Custom entries ###
+output/
+tools/Modules
+test.settings.json
+tests/integration/.vagrant
+tests/integration/cert_setup
diff --git a/.markdownlint.json b/.markdownlint.json
new file mode 100644
index 0000000..0fb951d
--- /dev/null
+++ b/.markdownlint.json
@@ -0,0 +1,7 @@
+{
+ "default": true,
+ "no-hard-tabs": true,
+ "no-duplicate-heading": false,
+ "line-length": false,
+ "no-inline-html": false
+}
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..4dafc5c
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,9 @@
+{
+ // See http://go.microsoft.com/fwlink/?LinkId=827846
+ // for the documentation about the extensions.json format
+ "recommendations": [
+ "formulahendry.dotnet-test-explorer",
+ "ms-dotnettools.csharp",
+ "ms-vscode.powershell",
+ ],
+}
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..2a6b085
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,44 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "PowerShell launch",
+ "type": "coreclr",
+ "request": "launch",
+ "program": "pwsh",
+ "args": [
+ "-NoExit",
+ "-NoProfile",
+ "-Command",
+ "Import-Module ./output/PSADTree"
+ ],
+ "cwd": "${workspaceFolder}",
+ "stopAtEntry": false,
+ "console": "externalTerminal",
+ },
+ {
+ "name": "PowerShell Launch Current File",
+ "type": "PowerShell",
+ "request": "launch",
+ "script": "${file}",
+ "cwd": "${workspaceFolder}"
+ },
+ {
+ "name": ".NET FullCLR Attach",
+ "type": "clr",
+ "request": "attach",
+ "processId": "${command:pickProcess}",
+ "justMyCode": true,
+ },
+ {
+ "name": ".NET CoreCLR Attach",
+ "type": "coreclr",
+ "request": "attach",
+ "processId": "${command:pickProcess}",
+ "justMyCode": true,
+ },
+ ],
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..2931fe0
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,29 @@
+{
+ //-------- Files configuration --------
+ // When enabled, will trim trailing whitespace when you save a file.
+ "files.trimTrailingWhitespace": true,
+ // When enabled, insert a final new line at the end of the file when saving it.
+ "files.insertFinalNewline": true,
+ "search.exclude": {
+ "Release": true,
+ "tools/ResGen": true,
+ "tools/dotnet": true,
+ },
+ "json.schemas": [
+ {
+ "fileMatch": [
+ "/test.settings.json"
+ ],
+ "url": "./tests/settings.schema.json"
+ }
+ ],
+ "dotnet-test-explorer.testProjectPath": "tests/units/*.csproj",
+ "editor.rulers": [
+ 120,
+ ],
+ //-------- PowerShell configuration --------
+ // Binary modules cannot be unloaded so running in separate processes solves that problem
+ //"powershell.debugging.createTemporaryIntegratedConsole": true,
+ // We use Pester v5 so we don't need the legacy code lens
+ "powershell.pester.useLegacyCodeLens": false,
+}
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..922c210
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,49 @@
+{
+ // See https://go.microsoft.com/fwlink/?LinkId=733558
+ // for the documentation about the tasks.json format
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "build",
+ "command": "pwsh",
+ "type": "shell",
+ "args": [
+ "-File",
+ "${workspaceFolder}/build.ps1"
+ ],
+ "group": {
+ "kind": "build",
+ "isDefault": true
+ },
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "update docs",
+ "command": "pwsh",
+ "type": "shell",
+ "args": [
+ "-Command",
+ "Import-Module ${workspaceFolder}/output/PSADTree; Import-Module ${workspaceFolder}/tools/Modules/platyPS; Update-MarkdownHelpModule ${workspaceFolder}/docs/en-US -AlphabeticParamsOrder -RefreshModulePage -UpdateInputOutput"
+ ],
+ "problemMatcher": [],
+ "dependsOn": [
+ "build"
+ ]
+ },
+ {
+ "label": "test",
+ "command": "pwsh",
+ "type": "shell",
+ "args": [
+ "-File",
+ "${workspaceFolder}/build.ps1",
+ "-Task",
+ "Test"
+ ],
+ "problemMatcher": [],
+ "dependsOn": [
+ "build"
+ ]
+ }
+ ]
+}
diff --git a/Examples/1.png b/Examples/1.png
deleted file mode 100644
index 7321da8..0000000
Binary files a/Examples/1.png and /dev/null differ
diff --git a/Examples/2.png b/Examples/2.png
deleted file mode 100644
index 41d513c..0000000
Binary files a/Examples/2.png and /dev/null differ
diff --git a/Examples/3.png b/Examples/3.png
deleted file mode 100644
index 58140f3..0000000
Binary files a/Examples/3.png and /dev/null differ
diff --git a/Get-Hierarchy.ps1 b/Get-Hierarchy.ps1
deleted file mode 100644
index 71b0d99..0000000
--- a/Get-Hierarchy.ps1
+++ /dev/null
@@ -1,285 +0,0 @@
-function Get-Hierarchy {
- <#
- .SYNOPSIS
- Gets group membership or parentship and draws it's hierarchy.
-
- .PARAMETER Name
- Objects of class User or Group.
-
- .OUTPUTS
- Object[] // System.Array. Returns with properties:
- - InputParameter: The input user or group.
- - Index: The object on each level of recursion.
- - Recursion: The level of recursion or nesting.
- - Class: The objectClass.
- - SubClass: The subClass of the group (DistributionList or SecurityGroup) or user (EID, AppID, etc).
- - Hierarchy: The hierarchy map of the input paremeter.
-
- .EXAMPLE
- C:\PS> Get-Hierarchy ExampleGroup
-
- .EXAMPLE
- C:\PS> gh ExampleGroup -RecursionProperty MemberOf
-
- .EXAMPLE
- C:\PS> Get-ADUser ExampleUser | Get-Hierarchy -RecursionProperty MemberOf
-
- .EXAMPLE
- C:\PS> Get-ADGroup ExampleGroup | Get-Hierarchy -RecursionProperty MemberOf
-
- .EXAMPLE
- C:\PS> Get-ADGroup ExampleGroup | gh
-
- .EXAMPLE
- C:\PS> Get-ADGroup -Filter {Name -like 'ExampleGroups*'} | Get-Hierarchy
-
- .EXAMPLE
- C:\PS> 'Group1,Group2,Group3'.split(',') | Get-Hierarchy -RecursionProperty MemberOf
-
- .NOTES
- Author: Santiago Squarzon.
- #>
-
- [cmdletbinding()]
- [alias('gh')]
- param(
- [parameter(Mandatory, ValueFromPipeline)]
- [string]$Name,
- [string]$Server,
- [validateset('MemberOf', 'Member')]
- [string]$RecursionProperty = 'Member'
- )
-
- begin {
- function GetObject {
- param(
- [parameter(Mandatory)]
- [string]$Name,
- [string]$Server
- )
-
- try {
- if ($PSBoundParameters.ContainsKey('Server')) {
- $Entry = [adsi] "LDAP://$Server"
- }
- }
- catch {
- $PSCmdlet.ThrowTerminatingError($_)
- }
-
- $Searcher = [adsisearcher]::new(
- $Entry,
- "(|(name=$name)(samAccountName=$Name)(distinguishedName=$Name))")
-
- $Object = $Searcher.FindOne()
-
- if (-not $Object) {
- $PSCmdlet.ThrowTerminatingError(
- [System.Management.Automation.ErrorRecord]::new(
- [System.ArgumentException] ("Cannot find an object: '{0}' under: '{1}'." -f $Name, $Searcher.SearchRoot.distinguishedName.ToString()),
- 'ObjectNotFound',
- [System.Management.Automation.ErrorCategory]::ObjectNotFound,
- $Name))
- }
-
- $Object
- }
-
- function RecHierarchy {
- param(
- [parameter(mandatory)]
- [String]$DistinguishedName,
- [Int]$Recursion = 0,
- [validateset('MemberOf', 'Member')]
- [string]$RecursionProperty = 'Member'
- )
-
- $ErrorMessage = {
- "`nGroup Name: {0}`nMember: {1}`nError Message: $_`n" -f $Group.Name, $Member.Split(',')[0].Replace('CN=', '')
- }
-
- $queryObjectSplat = @{
- DistinguishedName = $DistinguishedName
- RecursionProperty = $RecursionProperty
- }
-
- $thisObject = QueryObject @queryObjectSplat
-
- $Hierarchy = & {
- foreach ($object in $thisObject.Property) {
- try {
- QueryObject -DistinguishedName $object
- }
- catch {
- Write-Warning (& $errorMessage)
- }
- }
- } | Sort-Object -Descending ObjectClass
-
- $thisInput = if ($Index[0].Index) {
- $Index[0].Index
- }
- else {
- $thisObject.Name
- }
-
- $Index.Add(
- [pscustomobject]@{
- InputParameter = $thisInput
- Index = $thisObject.Name
- Class = $thisObject.ObjectClass
- Recursion = $Recursion
- Domain = $thisObject.Domain
- Hierarchy = [Tree]::Indent($thisObject.Name, $Recursion)
- })
-
- $Recursion++
-
- foreach ($object in $Hierarchy) {
- $class = $object.ObjectClass
-
- if (($object.Name -in $Index.Index -and ($object.Domain -in $Index.Domain))) {
- [int]$i = $Recursion
- do {
- $i--
- $z = $index.where({ $_.Recursion -eq $i }).Index | Select-Object -Last 1
- if ($object.Name -eq $z) {
- $layer = $true
- }
- } until($i -eq 0 -or $layer -eq $true)
-
- if ($layer) {
- $string = switch ($object.ObjectClass) {
- default {
- $object.Name
- }
- 'Group' {
- '{0} <=> Circular Nested Group' -f $object.Name
- }
- }
- }
- else {
- $string = switch ($object.ObjectClass) {
- default {
- $object.Name
- }
- 'Group' {
- '{0} <=> Skipping // Processed' -f $object.Name
- }
- }
- }
-
- $Index.Add(
- [pscustomobject]@{
- InputParameter = $thisInput
- Index = $object.Name
- Class = $class
- Recursion = $Recursion
- Domain = $object.Domain
- Hierarchy = [Tree]::Indent($string, $Recursion)
- })
- }
- else {
- $recHierarchySplat = @{
- DistinguishedName = $object.DistinguishedName
- Recursion = $Recursion
- RecursionProperty = $RecursionProperty
- }
-
- RecHierarchy @recHierarchySplat
- }
- }
- }
-
- function QueryObject {
- [cmdletbinding()]
- param(
- [string]$DistinguishedName,
- [validateset('MemberOf', 'Member')]
- [string]$RecursionProperty
- )
-
- $Object = [adsi] "LDAP://$DistinguishedName"
-
- $Properties = [ordered]@{
- Name = $Object.name.ToString()
- UserPrincipalName = $Object.userPrincipalName.ToString()
- DistinguishedName = $Object.distinguishedName.ToString()
- Domain = ($Object.distinguishedName.ToString() -isplit ",DC=")[1].ToUpper()
- ObjectClass = $Object.SchemaClassName.ToString()
- }
-
- if ($RecursionProperty) {
- $Properties['Property'] = $Object.$RecursionProperty
- }
-
- return [pscustomobject] $Properties
- }
-
- class Tree {
- hidden static [regex] $s_re = [regex]::new(
- '└|\S',
- [System.Text.RegularExpressions.RegexOptions]::Compiled)
-
- static [string] Indent([string] $inputString, [int] $indentation) {
- if ($indentation -eq 0) {
- return $inputString
- }
-
- return [string]::new(' ', (4 * $indentation) - 4) + '└── ' + $inputString
- }
-
- static [object[]] ConvertToTree([object[]] $inputObject) {
- for ($i = 0; $i -lt $inputObject.Length; $i++) {
- $index = $inputObject[$i].Hierarchy.IndexOf('└')
-
- if ($index -lt 0) {
- continue
- }
-
- $z = $i - 1
-
- while (-not [Tree]::s_re.IsMatch($inputObject[$z].Hierarchy[$index].ToString())) {
- $replace = $inputObject[$z].Hierarchy.ToCharArray()
- $replace[$index] = '│'
- $inputObject[$z].Hierarchy = [string]::new($replace)
- $z--
- }
-
- if ($inputObject[$z].Hierarchy[$index] -eq '└') {
- $replace = $inputObject[$z].Hierarchy.ToCharArray()
- $replace[$index] = '├'
- $inputObject[$z].Hierarchy = [string]::new($replace)
- }
- }
-
- return $inputObject
- }
- }
- }
-
- process {
- try {
- $getObjectSplat = @{
- Name = $Name
- }
-
- if ($PSBoundParameters.ContainsKey('Server')) {
- $getObjectSplat['Server'] = $Server
- }
- $Index = [System.Collections.Generic.List[object]]::new()
- $Object = GetObject @getObjectSplat
-
- $recHierarchySplat = @{
- DistinguishedName = $Object.Properties.distinguishedname
- RecursionProperty = $RecursionProperty
- }
-
- RecHierarchy @recHierarchySplat
- [Tree]::ConvertToTree($Index.ToArray())
- }
- catch {
- $PSCmdlet.ThrowTerminatingError($_)
- }
- }
-}
diff --git a/PSADTree.build.ps1 b/PSADTree.build.ps1
new file mode 100644
index 0000000..9719668
--- /dev/null
+++ b/PSADTree.build.ps1
@@ -0,0 +1,252 @@
+# I might've also stolen this from jborean93 ¯\_(ツ)_/¯
+[CmdletBinding()]
+param(
+ [ValidateSet('Debug', 'Release')]
+ [string]
+ $Configuration = 'Debug'
+)
+
+$modulePath = [IO.Path]::Combine($PSScriptRoot, 'module')
+$manifestItem = Get-Item ([IO.Path]::Combine($modulePath, '*.psd1'))
+$ModuleName = $manifestItem.BaseName
+
+$testModuleManifestSplat = @{
+ Path = $manifestItem.FullName
+ ErrorAction = 'Ignore'
+ WarningAction = 'Ignore'
+}
+$Manifest = Test-ModuleManifest @testModuleManifestSplat
+$Version = $Manifest.Version
+$BuildPath = [IO.Path]::Combine($PSScriptRoot, 'output')
+$PowerShellPath = [IO.Path]::Combine($PSScriptRoot, 'module')
+$CSharpPath = [IO.Path]::Combine($PSScriptRoot, 'src', $ModuleName)
+$ReleasePath = [IO.Path]::Combine($BuildPath, $ModuleName, $Version)
+$IsUnix = $PSEdition -eq 'Core' -and -not $IsWindows
+$UseNativeArguments = $PSVersionTable.PSVersion -gt '7.0'
+($csharpProjectInfo = [xml]::new()).Load((Get-Item ([IO.Path]::Combine($CSharpPath, '*.csproj'))).FullName)
+$TargetFrameworks = @(@($csharpProjectInfo.Project.PropertyGroup)[0].
+ TargetFrameworks.Split(';', [StringSplitOptions]::RemoveEmptyEntries))
+
+$PSFramework = $TargetFrameworks[0]
+
+[xml] $csharpProjectInfo = Get-Content ([IO.Path]::Combine($CSharpPath, '*.csproj'))
+$TargetFrameworks = @(@($csharpProjectInfo.Project.PropertyGroup)[0].TargetFrameworks.Split(
+ ';', [StringSplitOptions]::RemoveEmptyEntries))
+$PSFramework = $TargetFrameworks[0]
+
+task Clean {
+ if (Test-Path $ReleasePath) {
+ Remove-Item $ReleasePath -Recurse -Force
+ }
+
+ New-Item -ItemType Directory $ReleasePath | Out-Null
+}
+
+task BuildDocs {
+ $helpParams = @{
+ Path = [IO.Path]::Combine($PSScriptRoot, 'docs', 'en-US')
+ OutputPath = [IO.Path]::Combine($ReleasePath, 'en-US')
+ }
+ New-ExternalHelp @helpParams | Out-Null
+}
+
+task BuildManaged {
+ $arguments = @(
+ 'publish'
+ '--configuration', $Configuration
+ '--verbosity', 'q'
+ '-nologo'
+ "-p:Version=$Version"
+ )
+
+ Push-Location -LiteralPath $CSharpPath
+ try {
+ foreach ($framework in $TargetFrameworks) {
+ Write-Host "Compiling for $framework"
+ dotnet @arguments --framework $framework
+
+ if ($LASTEXITCODE) {
+ throw "Failed to compiled code for $framework"
+ }
+ }
+ }
+ finally {
+ Pop-Location
+ }
+}
+
+task CopyToRelease {
+ $copyParams = @{
+ Path = [IO.Path]::Combine($PowerShellPath, '*')
+ Destination = $ReleasePath
+ Recurse = $true
+ Force = $true
+ }
+ Copy-Item @copyParams
+
+ foreach ($framework in $TargetFrameworks) {
+ $buildFolder = [IO.Path]::Combine($CSharpPath, 'bin', $Configuration, $framework, 'publish')
+ $binFolder = [IO.Path]::Combine($ReleasePath, 'bin', $framework, $_.Name)
+ if (-not (Test-Path -LiteralPath $binFolder)) {
+ New-Item -Path $binFolder -ItemType Directory | Out-Null
+ }
+ Copy-Item ([IO.Path]::Combine($buildFolder, '*')) -Destination $binFolder -Recurse
+ }
+}
+
+task Package {
+ $nupkgPath = [IO.Path]::Combine($BuildPath, "$ModuleName.$Version*.nupkg")
+ if (Test-Path $nupkgPath) {
+ Remove-Item $nupkgPath -Force
+ }
+
+ $repoParams = @{
+ Name = 'LocalRepo'
+ SourceLocation = $BuildPath
+ PublishLocation = $BuildPath
+ InstallationPolicy = 'Trusted'
+ }
+ if (Get-PSRepository -Name $repoParams.Name -ErrorAction SilentlyContinue) {
+ Unregister-PSRepository -Name $repoParams.Name
+ }
+
+ Register-PSRepository @repoParams
+ try {
+ Publish-Module -Path $ReleasePath -Repository $repoParams.Name
+ }
+ finally {
+ Unregister-PSRepository -Name $repoParams.Name
+ }
+}
+
+task Analyze {
+ $pssaSplat = @{
+ Path = $ReleasePath
+ Settings = [IO.Path]::Combine($PSScriptRoot, 'ScriptAnalyzerSettings.psd1')
+ Recurse = $true
+ ErrorAction = 'SilentlyContinue'
+ }
+ $results = Invoke-ScriptAnalyzer @pssaSplat
+ if ($null -ne $results) {
+ $results | Out-String
+ throw 'Failed PsScriptAnalyzer tests, build failed'
+ }
+}
+
+task DoUnitTest {
+ $testsPath = [IO.Path]::Combine($PSScriptRoot, 'tests', 'units')
+ if (-not (Test-Path -LiteralPath $testsPath)) {
+ Write-Host 'No unit tests found, skipping'
+ return
+ }
+
+ $resultsPath = [IO.Path]::Combine($BuildPath, 'TestResults')
+ if (-not (Test-Path -LiteralPath $resultsPath)) {
+ New-Item $resultsPath -ItemType Directory -ErrorAction Stop | Out-Null
+ }
+
+ $tempResultsPath = [IO.Path]::Combine($resultsPath, 'TempUnit')
+ if (Test-Path -LiteralPath $tempResultsPath) {
+ Remove-Item -LiteralPath $tempResultsPath -Force -Recurse
+ }
+ New-Item -Path $tempResultsPath -ItemType Directory | Out-Null
+
+ try {
+ $runSettingsPrefix = 'DataCollectionRunSettings.DataCollectors.DataCollector.Configuration'
+ $arguments = @(
+ 'test'
+ $testsPath
+ '--results-directory', $tempResultsPath
+ if ($Configuration -eq 'Debug') {
+ '--collect:"XPlat Code Coverage"'
+ '--'
+ "$runSettingsPrefix.Format=json"
+ if ($UseNativeArguments) {
+ "$runSettingsPrefix.IncludeDirectory=`"$CSharpPath`""
+ }
+ else {
+ "$runSettingsPrefix.IncludeDirectory=\`"$CSharpPath\`""
+ }
+ }
+ )
+
+ Write-Host 'Running unit tests'
+ dotnet @arguments
+
+ if ($LASTEXITCODE) {
+ throw 'Unit tests failed'
+ }
+
+ if ($Configuration -eq 'Debug') {
+ Move-Item -Path $tempResultsPath/*/*.json -Destination $resultsPath/UnitCoverage.json -Force
+ }
+ }
+ finally {
+ Remove-Item -LiteralPath $tempResultsPath -Force -Recurse
+ }
+}
+
+task DoTest {
+ $pesterScript = [IO.Path]::Combine($PSScriptRoot, 'tools', 'PesterTest.ps1')
+ if (-not (Test-Path $pesterScript)) {
+ Write-Host 'No Pester tests found, skipping'
+ return
+ }
+
+ $resultsPath = [IO.Path]::Combine($BuildPath, 'TestResults')
+ if (-not (Test-Path $resultsPath)) {
+ New-Item $resultsPath -ItemType Directory -ErrorAction Stop | Out-Null
+ }
+
+ $resultsFile = [IO.Path]::Combine($resultsPath, 'Pester.xml')
+ if (Test-Path $resultsFile) {
+ Remove-Item $resultsFile -ErrorAction Stop -Force
+ }
+
+ $pwsh = [Environment]::GetCommandLineArgs()[0] -replace '\.dll$', ''
+ $arguments = @(
+ '-NoProfile'
+ '-NonInteractive'
+ if (-not $IsUnix) {
+ '-ExecutionPolicy', 'Bypass'
+ }
+ '-File', $pesterScript
+ '-TestPath', ([IO.Path]::Combine($PSScriptRoot, 'tests'))
+ '-OutputFile', $resultsFile
+ )
+
+ if ($Configuration -eq 'Debug') {
+ $unitCoveragePath = [IO.Path]::Combine($resultsPath, 'UnitCoverage.json')
+ $targetArgs = '"' + ($arguments -join '" "') + '"'
+
+ if ($UseNativeArguments) {
+ $watchFolder = [IO.Path]::Combine($ReleasePath, 'bin', $PSFramework)
+ }
+ else {
+ $targetArgs = '"' + ($targetArgs -replace '"', '\"') + '"'
+ $watchFolder = '"{0}"' -f ([IO.Path]::Combine($ReleasePath, 'bin', $PSFramework))
+ }
+
+ $arguments = @(
+ $watchFolder
+ '--target', $pwsh
+ '--targetargs', $targetArgs
+ '--output', ([IO.Path]::Combine($resultsPath, 'Coverage.xml'))
+ '--format', 'cobertura'
+ if (Test-Path -LiteralPath $unitCoveragePath) {
+ '--merge-with', $unitCoveragePath
+ }
+ )
+ $pwsh = 'coverlet'
+ }
+
+ & $pwsh $arguments
+
+ if ($LASTEXITCODE) {
+ throw 'Pester failed tests'
+ }
+}
+
+task Build -Jobs Clean, BuildManaged, CopyToRelease, BuildDocs, Package
+task Test -Jobs BuildManaged, Analyze, DoUnitTest, DoTest
+task . Build
diff --git a/README.md b/README.md
index 38c2e17..2827e03 100644
--- a/README.md
+++ b/README.md
@@ -1,44 +1,213 @@
-# Get-Hierarchy
+
PSADTree
-### DESCRIPTION
-Gets Group's and User's membership or Group parentship and draws it's hierarchy.
-Helps identifying Circular Nested Groups .
+
+ Tree like cmdlets for Active Directory Principals!
+
-### PARAMETER
+[![build](https://github.com/santisq/PSADTree/actions/workflows/ci.yml/badge.svg)](https://github.com/santisq/PSADTree/actions/workflows/ci.yml)
+[![PowerShell Gallery](https://img.shields.io/powershellgallery/v/PSADTree?label=gallery)](https://www.powershellgallery.com/packages/PSADTree)
+[![LICENSE](https://img.shields.io/github/license/santisq/PSADTree)](https://github.com/santisq/PSADTree/blob/main/LICENSE)
-| Parameter Name | Description |
-| --- | --- |
-| `-Name ` | __Name, SamAccountName or DistinguishedName__ of an AD Object of the class __User__ or __Group__ |
-| `[-RecursionProperty ]` | Set AD attribute for recursion. __Valid Values__: `Member` / `MemberOf`. __Default Value__: `Member` |
-| `[-Server ]` | __FQDN or NetBIOS name__ for the instance to connect to. |
-| `[]` | See [`about_CommonParameters`](https://go.microsoft.com/fwlink/?LinkID=113216) |
+
-### OUTPUTS
-``
+PSADTree is a PowerShell Module with cmdlets that emulate the [`tree` command](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/tree) for Active Directory Principals.
+This Module currently includes two cmdlets:
-- `InputParameter` The input user or group
-- `Index` The object on each level of recursion
-- `Recursion` The level of recursion or nesting
-- `Class` The objectClass
-- `Hierarchy` The hierarchy map of the input paremeter
+- [Get-ADTreeGroupMember](Get-ADTreeGroupMember.md) for AD Group Members.
+- [Get-ADTreePrincipalGroupMembership](Get-ADTreePrincipalGroupMembership.md) for AD Principal Group Membership.
-### REQUIREMENTS
+__Both cmdlets help with discovery of Circular Nested Groups.__
-- PowerShell v5.1
+## Documentation
+Check out [__the docs__](./docs/en-US/PSADTree.md) for information about how to use this Module.
-### USAGE EXAMPLES
+## Installation
+### Gallery
+
+The module is available through the [PowerShell Gallery](https://www.powershellgallery.com/packages/PSADTree):
+
+```powershell
+Install-Module PSADTree -Scope CurrentUser
+```
+
+### Source
+
+```powershell
+git clone 'https://github.com/santisq/PSADTree.git'
+Set-Location ./PSADTree
+./build.ps1
```
-PS C:\> Get-Hierarchy ExampleGroup
-PS C:\> gh ExampleGroup -RecursionProperty MemberOf
-PS C:\> Get-ADGroup ExampleGroup | Get-Hierarchy -RecursionProperty MemberOf
-PS C:\> Get-ADGroup ExampleGroup | gh
-PS C:\> Get-ADGroup -Filter "Name -like 'ExampleGroups*'" | Get-Hierarchy
-PS C:\> 'Group1,Group2,Group3'.split(',') | Get-Hierarchy -RecursionProperty MemberOf
+
+## Requirements
+
+This Module uses the [`System.DirectoryServices.AccountManagement` Namespace](https://learn.microsoft.com/en-us/dotnet/api/system.directoryservices.accountmanagement?view=dotnet-plat-ext-7.0) to query Active Directory, its System Requirement is __Windows OS__ and is compatible with __Windows PowerShell v5.1__ or [__PowerShell 7+__](https://github.com/PowerShell/PowerShell).
+
+## Usage
+
+These are some examples of what the cmdlets from this Module allow you to do. For more examples check out the docs.
+
+### Get the members of a group
+
+```powershell
+PS ..\PSADTree> Get-ADTreeGroupMember TestGroup007
+
+ Source: CN=TestGroup007,OU=Operations,DC=ChildDomain,DC=ParentDomain,DC=myDomain,DC=xyz
+
+Domain ObjectClass Hierarchy
+------ ----------- ---------
+ChildDomain group TestGroup007
+ChildDomain msDS-ManagedServiceAccount ├── testMSA$
+ChildDomain user ├── TestUser013
+ChildDomain user ├── TestUser010
+ChildDomain user ├── TestUser007
+ChildDomain group ├── TestGroup001
+ChildDomain user │ ├── TestUser015
+ChildDomain user │ ├── TestUser013
+ChildDomain user │ ├── TestUser010
+ChildDomain user │ ├── TestUser007
+ChildDomain user │ ├── TestUser002
+ChildDomain group │ ├── TestGroup005
+ParentDomain group │ │ ├── TestGroup001
+ParentDomain group │ │ └── TestGroup002
+ChildDomain group │ ├── TestGroup006
+ChildDomain computer │ │ ├── TestComputer0000004$
+ChildDomain computer │ │ ├── TestComputer0000003$
+ChildDomain computer │ │ ├── TestComputer0000002$
+ChildDomain computer │ │ └── TestComputer0000001$
+ChildDomain group │ └── TestGroup007 ↔ Circular Reference
+ChildDomain group ├── TestGroup005 ↔ Processed Group
+ChildDomain group └── TestGroup006 ↔ Processed Group
+```
+
+### Control the grade of recursion with the `-Depth` parameter
+
+The default value for `-Depth` is 3.
+
+```powershell
+PS ..\PSADTree> Get-ADTreeGroupMember TestGroup007 -Depth 2
+
+ Source: CN=TestGroup007,OU=Operations,DC=ChildDomain,DC=ParentDomain,DC=myDomain,DC=xyz
+
+Domain ObjectClass Hierarchy
+------ ----------- ---------
+ChildDomain group TestGroup007
+ChildDomain msDS-ManagedServiceAccount ├── testMSA$
+ChildDomain user ├── TestUser013
+ChildDomain user ├── TestUser010
+ChildDomain user ├── TestUser007
+ChildDomain group ├── TestGroup001
+ChildDomain user │ ├── TestUser015
+ChildDomain user │ ├── TestUser013
+ChildDomain user │ ├── TestUser010
+ChildDomain user │ ├── TestUser007
+ChildDomain user │ ├── TestUser002
+ChildDomain group │ ├── TestGroup005
+ChildDomain group │ ├── TestGroup006
+ChildDomain group │ └── TestGroup007 ↔ Circular Reference
+ChildDomain group ├── TestGroup005 ↔ Processed Group
+ChildDomain group └── TestGroup006 ↔ Processed Group
+```
+
+### Get group members recursively, include only groups and display all processed groups
+
+The `-Recursive` switch indicates that the cmdlet should traverse all the group hierarchy.
+The `-Group` switch limits the members tree view to nested groups only.
+By default, previously processed groups will be marked as _"Processed Group"_ and their hierarchy will not be displayed.
+The `-ShowAll` switch indicates that the cmdlet should display the hierarchy of all previously processed groups.
+
+```powershell
+PS ..\PSADTree> Get-ADTreeGroupMember TestGroup007 -Recursive -Group -ShowAll
+
+ Source: CN=TestGroup007,OU=Operations,DC=ChildDomain,DC=ParentDomain,DC=myDomain,DC=xyz
+
+Domain ObjectClass Hierarchy
+------ ----------- ---------
+ChildDomain group TestGroup007
+ChildDomain group ├── TestGroup001
+ChildDomain group │ ├── TestGroup005
+ParentDomain group │ │ ├── TestGroup001
+ParentDomain group │ │ │ └── TestGroup002
+ParentDomain group │ │ └── TestGroup002
+ChildDomain group │ ├── TestGroup006
+ChildDomain group │ └── TestGroup007 ↔ Circular Reference
+ChildDomain group ├── TestGroup005
+ParentDomain group │ ├── TestGroup001
+ParentDomain group │ │ └── TestGroup002
+ParentDomain group │ └── TestGroup002
+ChildDomain group └── TestGroup006
+```
+
+### Get group memberships for a user
+
+```powershell
+PS ..\PSADTree> Get-ADTreePrincipalGroupMembership TestUser002
+
+ Source: CN=TestUser002,OU=Operations,DC=ChildDomain,DC=ParentDomain,DC=myDomain,DC=xyz
+
+Domain ObjectClass Hierarchy
+------ ----------- ---------
+ChildDomain user TestUser002
+ChildDomain group ├── TestGroup003
+ChildDomain group │ └── TestGroup000
+ChildDomain group ├── TestGroup001
+ChildDomain group │ ├── TestGroup007
+ChildDomain group │ │ ├── TestGroup004
+ChildDomain group │ │ ├── TestGroup002
+ChildDomain group │ │ └── TestGroup001 ↔ Circular Reference
+ChildDomain group │ └── TestGroup000 ↔ Processed Group
+ChildDomain group ├── Terminal Server License Servers
+ChildDomain group └── Domain Users
+ChildDomain group └── Users
+```
+
+### Control the grade of recursion with the `-Depth` parameter
+
+Same as `Get-ADTreeGroupMember`, the default depth to display the principal memberships is 2.
+
+```powershell
+PS ..\PSADTree> Get-ADTreePrincipalGroupMembership TestUser002 -Depth 2
+
+ Source: CN=TestUser002,OU=Operations,DC=ChildDomain,DC=ParentDomain,DC=myDomain,DC=xyz
+
+Domain ObjectClass Hierarchy
+------ ----------- ---------
+ChildDomain user TestUser002
+ChildDomain group ├── TestGroup003
+ChildDomain group │ └── TestGroup000
+ChildDomain group ├── TestGroup001
+ChildDomain group │ ├── TestGroup007
+ChildDomain group │ └── TestGroup000 ↔ Processed Group
+ChildDomain group ├── Terminal Server License Servers
+ChildDomain group └── Domain Users
+ChildDomain group └── Users
+```
+
+### Get the user principal membership recursively and display all processed groups
+
+```powershell
+PS ..\PSADTree> Get-ADTreePrincipalGroupMembership TestUser002 -Recursive -ShowAll
+
+ Source: CN=TestUser002,OU=Operations,DC=ChildDomain,DC=ParentDomain,DC=myDomain,DC=xyz
+
+Domain ObjectClass Hierarchy
+------ ----------- ---------
+ChildDomain user TestUser002
+ChildDomain group ├── TestGroup003
+ChildDomain group │ └── TestGroup000
+ChildDomain group ├── TestGroup001
+ChildDomain group │ ├── TestGroup007
+ChildDomain group │ │ ├── TestGroup004
+ChildDomain group │ │ ├── TestGroup002
+ChildDomain group │ │ │ └── TestGroup000
+ChildDomain group │ │ └── TestGroup001 ↔ Circular Reference
+ChildDomain group │ └── TestGroup000
+ChildDomain group ├── Terminal Server License Servers
+ChildDomain group └── Domain Users
+ChildDomain group └── Users
```
+## Contributing
-![Alt text](/Examples/1.png?raw=true)
-![Alt text](/Examples/2.png?raw=true)
-![Alt text](/Examples/3.png?raw=true)
+Contributions are more than welcome, if you wish to contribute, fork this repository and submit a pull request with the changes.
diff --git a/ScriptAnalyzerSettings.psd1 b/ScriptAnalyzerSettings.psd1
new file mode 100644
index 0000000..882e626
--- /dev/null
+++ b/ScriptAnalyzerSettings.psd1
@@ -0,0 +1,29 @@
+# The PowerShell Script Analyzer will generate a warning
+# diagnostic record for this file due to a bug -
+# https://github.com/PowerShell/PSScriptAnalyzer/issues/472
+@{
+ # Only diagnostic records of the specified severity will be generated.
+ # Uncomment the following line if you only want Errors and Warnings but
+ # not Information diagnostic records.
+
+ # Severity = @('Error','Warning')
+
+ # Analyze **only** the following rules. Use IncludeRules when you want
+ # to invoke only a small subset of the defualt rules.
+
+ # IncludeRules = @('PSAvoidDefaultValueSwitchParameter',
+ # 'PSMissingModuleManifestField',
+ # 'PSReservedCmdletChar',
+ # 'PSReservedParams',
+ # 'PSShouldProcess',
+ # 'PSUseApprovedVerbs',
+ # 'PSUseDeclaredVarsMoreThanAssigments')
+
+ # Do not analyze the following rules. Use ExcludeRules when you have
+ # commented out the IncludeRules settings above and want to include all
+ # the default rules except for those you exclude below.
+ # Note: if a rule is in both IncludeRules and ExcludeRules, the rule
+ # will be excluded.
+
+ # ExcludeRules = @('')
+}
diff --git a/build.ps1 b/build.ps1
new file mode 100644
index 0000000..26e2b0b
--- /dev/null
+++ b/build.ps1
@@ -0,0 +1,70 @@
+# I may've totally stolen this from jborean93 :D
+[CmdletBinding()]
+param(
+ [Parameter()]
+ [ValidateSet('Debug', 'Release')]
+ [string]
+ $Configuration = 'Debug',
+
+ [Parameter()]
+ [string[]]
+ $Task = 'Build'
+)
+
+end {
+ if ($PSEdition -eq 'Desktop') {
+ [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 'Tls12'
+ }
+
+ $modulePath = [IO.Path]::Combine($PSScriptRoot, 'tools', 'Modules')
+ $requirements = Import-PowerShellDataFile ([IO.Path]::Combine($PSScriptRoot, 'tools', 'requiredModules.psd1'))
+
+ foreach ($req in $requirements.GetEnumerator()) {
+ $targetPath = [IO.Path]::Combine($modulePath, $req.Key)
+
+ if (Test-Path -LiteralPath $targetPath) {
+ Import-Module -Name $targetPath -Force -ErrorAction Stop
+ continue
+ }
+
+ Write-Host "Installing build pre-req $($req.Key) as it is not installed"
+ $null = New-Item -Path $targetPath -ItemType Directory -Force
+
+ $webParams = @{
+ Uri = "https://www.powershellgallery.com/api/v2/package/$($req.Key)/$($req.Value)"
+ OutFile = [IO.Path]::Combine($modulePath, "$($req.Key).zip")
+ UseBasicParsing = $true
+ }
+
+ if ('Authentication' -in (Get-Command -Name Invoke-WebRequest).Parameters.Keys) {
+ $webParams.Authentication = 'None'
+ }
+
+ $oldProgress = $ProgressPreference
+ $ProgressPreference = 'SilentlyContinue'
+
+ try {
+ Invoke-WebRequest @webParams
+ Expand-Archive -Path $webParams.OutFile -DestinationPath $targetPath -Force
+ Remove-Item -LiteralPath $webParams.OutFile -Force
+ }
+ finally {
+ $ProgressPreference = $oldProgress
+ }
+
+ Import-Module -Name $targetPath -Force -ErrorAction Stop
+ }
+
+ $dotnetTools = @(dotnet tool list --global) -join "`n"
+ if (-not $dotnetTools.Contains('coverlet.console')) {
+ Write-Host 'Installing dotnet tool coverlet.console'
+ dotnet tool install --global coverlet.console
+ }
+
+ $invokeBuildSplat = @{
+ Task = $Task
+ File = (Get-Item ([IO.Path]::Combine($PSScriptRoot, '*.build.ps1'))).FullName
+ Configuration = $Configuration
+ }
+ Invoke-Build @invokeBuildSplat
+}
diff --git a/docs/en-US/Get-ADTreeGroupMember.md b/docs/en-US/Get-ADTreeGroupMember.md
new file mode 100644
index 0000000..e085ac4
--- /dev/null
+++ b/docs/en-US/Get-ADTreeGroupMember.md
@@ -0,0 +1,231 @@
+---
+external help file: PSADTree.dll-Help.xml
+Module Name: PSADTree
+online version:
+schema: 2.0.0
+---
+
+# Get-ADTreeGroupMember
+
+## SYNOPSIS
+
+`tree` like cmdlet for Active Directory group members.
+
+## SYNTAX
+
+### Depth (Default)
+
+```powershell
+Get-ADTreeGroupMember [-Group] [-Identity] [-Server ] [-Depth ] [-ShowAll]
+ []
+```
+
+### Recursive
+
+```powershell
+Get-ADTreeGroupMember [-Group] [-Identity] [-Server ] [-Recursive] [-ShowAll]
+ []
+```
+
+## DESCRIPTION
+
+The `Get-ADTreeGroupMember` cmdlet gets the Active Directory members of a specified group and displays them in a tree like structure. The members of a group can be users, groups, computers and service accounts. This cmdlet also helps identifying Circular Nested Groups.
+
+## EXAMPLES
+
+### Example 1: Get the members of a group
+
+```powershell
+PS ..\PSADTree\> Get-ADTreeGroupMember TestGroup001
+```
+
+By default, this cmdlet uses `-Depth` with a default value of `3`.
+
+### Example 2: Get the members of a group recursively
+
+```powershell
+PS ..\PSADTree\> Get-ADTreeGroupMember TestGroup001 -Recursive
+```
+
+### Example 3: Get the members of all groups under an Organizational Unit
+
+```powershell
+PS ..\PSADTree\> Get-ADGroup -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=com' |
+ Get-ADTreeGroupMember
+```
+
+You can pipe strings containing an identity to this cmdlet. [__`ADGroup`__](https://learn.microsoft.com/en-us/dotnet/api/microsoft.activedirectory.management.adgroup?view=activedirectory-management-10.0) instances piped to this cmdlet are also supported.
+
+### Example 4: Find any Circular Nested Groups from previous example
+
+```powershell
+PS ..\PSADTree\> Get-ADComputer -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=com' |
+ Get-ADTreeGroupMember -Recursive -Group |
+ Where-Object IsCircular
+```
+
+The `-Group` switch limits the members tree view to nested groups only.
+
+### Example 5: Get group members in a different Domain
+
+```powershell
+PS ..\PSADTree\> Get-ADTreeGroupMember TestGroup001 -Server otherDomain
+```
+
+### Example 6: Get group members including processed groups
+
+```powershell
+PS ..\PSADTree\> Get-ADTreeGroupMember TestGroup001 -ShowAll
+```
+
+By default, previously processed groups will be marked as _"Processed Group"_ and their hierarchy will not be displayed.
+The `-ShowAll` switch indicates that the cmdlet should display the hierarchy of all previously processed groups.
+
+__NOTE:__ The use of this switch should not infer in a great performance cost, for more details see the parameter details.
+
+## PARAMETERS
+
+### -Depth
+
+Determines the number of nested groups and their members included in the recursion.
+By default, only 3 levels of recursion are included.
+
+```yaml
+Type: UInt32
+Parameter Sets: Depth
+Aliases:
+
+Required: False
+Position: Named
+Default value: 3
+Accept pipeline input: False
+Accept wildcard characters: False
+```
+
+### -Group
+
+The `-Group` switch indicates that the cmdlet should display nested group members only. Essentially, a built-in filter where [`ObjectClass`](https://learn.microsoft.com/en-us/windows/win32/adschema/a-objectclass) is `group`.
+
+```yaml
+Type: SwitchParameter
+Parameter Sets: (All)
+Aliases:
+
+Required: False
+Position: Named
+Default value: None
+Accept pipeline input: False
+Accept wildcard characters: False
+```
+
+### -Identity
+
+Specifies an Active Directory group by providing one of the following property values:
+
+- A DistinguishedName
+- A GUID
+- A SID (Security Identifier)
+- A sAMAccountName
+- A UserPrincipalName
+
+See [`IdentityType` Enum](https://learn.microsoft.com/en-us/dotnet/api/system.directoryservices.accountmanagement.identitytype?view=dotnet-plat-ext-7.0) for more information.
+
+```yaml
+Type: String
+Parameter Sets: (All)
+Aliases: DistinguishedName
+
+Required: True
+Position: 0
+Default value: None
+Accept pipeline input: True (ByPropertyName, ByValue)
+Accept wildcard characters: False
+```
+
+### -Recursive
+
+Specifies that the cmdlet should get all group members of the specified group.
+
+```yaml
+Type: SwitchParameter
+Parameter Sets: Recursive
+Aliases:
+
+Required: False
+Position: Named
+Default value: None
+Accept pipeline input: False
+Accept wildcard characters: False
+```
+
+### -Server
+
+Specifies the AD DS instance to connect to by providing one of the following values for a corresponding domain name or directory server.
+
+Domain name values:
+
+- Fully qualified domain name
+- NetBIOS name
+
+Directory server values:
+
+- Fully qualified directory server name
+- NetBIOS name
+- Fully qualified directory server name and port
+
+```yaml
+Type: String
+Parameter Sets: (All)
+Aliases:
+
+Required: False
+Position: Named
+Default value: None
+Accept pipeline input: False
+Accept wildcard characters: False
+```
+
+### -ShowAll
+
+By default, previously processed groups will be marked as _"Processed Group"_ and their hierarchy will not be displayed.
+This switch forces the cmdlet to display the full hierarchy including previously processed groups.
+
+> __NOTE:__ This cmdlet uses a caching mechanism to ensure that Active Directory Groups are only queried once per Identity.
+This caching mechanism is also used to reconstruct the pre-processed group's hierarchy when the `-ShowAll` switch is used, thus not incurring a performance cost.
+The intent behind this switch is to not clutter the cmdlet's output by default.
+
+```yaml
+Type: SwitchParameter
+Parameter Sets: (All)
+Aliases:
+
+Required: False
+Position: Named
+Default value: None
+Accept pipeline input: False
+Accept wildcard characters: False
+```
+
+### CommonParameters
+
+This cmdlet supports the common parameters. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216).
+
+## INPUTS
+
+### System.String
+
+You can pipe strings containing an identity to this cmdlet. [__`ADGroup`__](https://learn.microsoft.com/en-us/dotnet/api/microsoft.activedirectory.management.adgroup?view=activedirectory-management-10.0) instances piped to this cmdlet are also supported.
+
+## OUTPUTS
+
+### PSADTree.TreeGroup
+
+### PSADTree.TreeUser
+
+### PSADTree.TreeComputer
+
+## NOTES
+
+`treegroupmember` is the alias for this cmdlet.
+
+## RELATED LINKS
diff --git a/docs/en-US/Get-ADTreePrincipalGroupMembership.md b/docs/en-US/Get-ADTreePrincipalGroupMembership.md
new file mode 100644
index 0000000..93ffdac
--- /dev/null
+++ b/docs/en-US/Get-ADTreePrincipalGroupMembership.md
@@ -0,0 +1,213 @@
+---
+external help file: PSADTree.dll-Help.xml
+Module Name: PSADTree
+online version:
+schema: 2.0.0
+---
+
+# Get-ADTreePrincipalGroupMembership
+
+## SYNOPSIS
+
+`tree` like cmdlet for Active Directory Principals Group Membership.
+
+## SYNTAX
+
+### Depth (Default)
+
+```powershell
+Get-ADTreePrincipalGroupMembership [-Identity] [-Server ] [-Depth ] [-ShowAll]
+ []
+```
+
+### Recursive
+
+```powershell
+Get-ADTreePrincipalGroupMembership [-Identity] [-Server ] [-Recursive] [-ShowAll]
+ []
+```
+
+## DESCRIPTION
+
+The `Get-ADTreePrincipalGroupMembership` cmdlet gets the Active Directory groups that have a specified user, computer, group, or service account as a member and displays them in a tree like structure. This cmdlet also helps identifying Circular Nested Groups.
+
+## EXAMPLES
+
+### Example 1: Get group memberships for a user
+
+```powershell
+PS ..\PSADTree\> Get-ADTreePrincipalGroupMembership john.doe
+```
+
+By default, this cmdlet uses `-Depth` with a default value of `3`.
+
+### Example 2: Get the recursive group memberships for a user
+
+```powershell
+PS ..\PSADTree\> Get-ADTreePrincipalGroupMembership john.doe -Recursive
+```
+
+### Example 3: Get group memberships for all computers under an Organizational Unit
+
+```powershell
+PS ..\PSADTree\> Get-ADComputer -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=com' |
+ Get-ADTreePrincipalGroupMembership
+```
+
+You can pipe strings containing an identity to this cmdlet. [__`ADObject`__](https://learn.microsoft.com/en-us/dotnet/api/microsoft.activedirectory.management.adobject?view=activedirectory-management-10.0) instances piped to this cmdlet are also supported.
+
+### Example 4: Find any Circular Nested Groups from previous example
+
+```powershell
+PS ..\PSADTree\> Get-ADComputer -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=com' |
+ Get-ADTreePrincipalGroupMembership -Recursive |
+ Where-Object IsCircular
+```
+
+### Example 5: Get group memberships for a user in a different Domain
+
+```powershell
+PS ..\PSADTree\> Get-ADTreePrincipalGroupMembership john.doe -Server otherDomain
+```
+
+### Example 6: Get group memberships for a user, including processed groups
+
+```powershell
+PS ..\PSADTree\> Get-ADTreePrincipalGroupMembership john.doe -ShowAll
+```
+
+By default, previously processed groups will be marked as _"Processed Group"_ and their hierarchy will not be displayed.
+The `-ShowAll` switch indicates that the cmdlet should display the hierarchy of all previously processed groups.
+
+__NOTE:__ The use of this switch should not infer in a great performance cost, for more details see the parameter details.
+
+## PARAMETERS
+
+### -Depth
+
+Determines the number of nested group memberships included in the recursion.
+By default, only 3 levels of recursion are included.
+
+```yaml
+Type: UInt32
+Parameter Sets: Depth
+Aliases:
+
+Required: False
+Position: Named
+Default value: 3
+Accept pipeline input: False
+Accept wildcard characters: False
+```
+
+### -Identity
+
+Specifies an Active Directory principal by providing one of the following property values:
+
+- A DistinguishedName
+- A GUID
+- A SID (Security Identifier)
+- A sAMAccountName
+- A UserPrincipalName
+
+See [`IdentityType` Enum](https://learn.microsoft.com/en-us/dotnet/api/system.directoryservices.accountmanagement.identitytype?view=dotnet-plat-ext-7.0) for more information.
+
+```yaml
+Type: String
+Parameter Sets: (All)
+Aliases: DistinguishedName
+
+Required: True
+Position: 0
+Default value: None
+Accept pipeline input: True (ByPropertyName, ByValue)
+Accept wildcard characters: False
+```
+
+### -Recursive
+
+Specifies that the cmdlet should get all group membership of the specified principal.
+
+```yaml
+Type: SwitchParameter
+Parameter Sets: Recursive
+Aliases:
+
+Required: False
+Position: Named
+Default value: None
+Accept pipeline input: False
+Accept wildcard characters: False
+```
+
+### -Server
+
+Specifies the AD DS instance to connect to by providing one of the following values for a corresponding domain name or directory server.
+
+Domain name values:
+
+- Fully qualified domain name
+- NetBIOS name
+
+Directory server values:
+
+- Fully qualified directory server name
+- NetBIOS name
+- Fully qualified directory server name and port
+
+```yaml
+Type: String
+Parameter Sets: (All)
+Aliases:
+
+Required: False
+Position: Named
+Default value: None
+Accept pipeline input: False
+Accept wildcard characters: False
+```
+
+### -ShowAll
+
+By default, previously processed groups will be marked as _"Processed Group"_ and their hierarchy will not be displayed.
+This switch forces the cmdlet to display the full hierarchy including previously processed groups.
+
+> __NOTE:__ This cmdlet uses a caching mechanism to ensure that Active Directory Groups are only queried once per Identity.
+This caching mechanism is also used to reconstruct the pre-processed group's hierarchy when the `-ShowAll` switch is used, thus not incurring a performance cost.
+The intent behind this switch is to not clutter the cmdlet's output by default.
+
+```yaml
+Type: SwitchParameter
+Parameter Sets: (All)
+Aliases:
+
+Required: False
+Position: Named
+Default value: None
+Accept pipeline input: False
+Accept wildcard characters: False
+```
+
+### CommonParameters
+
+This cmdlet supports the common parameters. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216).
+
+## INPUTS
+
+### System.String
+
+You can pipe strings containing an identity to this cmdlet. [`ADObject`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.activedirectory.management.adobject?view=activedirectory-management-10.0) instances piped to this cmdlet are also supported.
+
+## OUTPUTS
+
+### PSADTree.TreeGroup
+
+### PSADTree.TreeUser
+
+### PSADTree.TreeComputer
+
+## NOTES
+
+`treeprincipalmembership` is the alias for this cmdlet.
+
+## RELATED LINKS
diff --git a/docs/en-US/PSADTree.md b/docs/en-US/PSADTree.md
new file mode 100644
index 0000000..82e6f4b
--- /dev/null
+++ b/docs/en-US/PSADTree.md
@@ -0,0 +1,23 @@
+---
+Module Name: PSADTree
+Module Guid: e49013dc-4106-4a95-aebc-b2669cbadeab
+Download Help Link:
+Help Version: 1.0.0.0
+Locale: en-US
+---
+
+# PSADTree Module
+
+## Description
+
+PSADTree is a PowerShell Module with cmdlets that emulate the [`tree` command](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/tree) for Active Directory Principals.
+
+## PSADTree Cmdlets
+
+### [Get-ADTreeGroupMember](Get-ADTreeGroupMember.md)
+
+The `Get-ADTreeGroupMember` cmdlet displays members of an Active Directory Group in a tree structure.
+
+### [Get-ADTreePrincipalGroupMembership](Get-ADTreePrincipalGroupMembership.md)
+
+The `Get-ADTreePrincipalGroupMembership` cmdlet displays an Active Directory Principal group membership in a tree structure.
diff --git a/module/PSADTree.Format.ps1xml b/module/PSADTree.Format.ps1xml
new file mode 100644
index 0000000..9eef827
--- /dev/null
+++ b/module/PSADTree.Format.ps1xml
@@ -0,0 +1,56 @@
+
+
+
+
+ treeview
+
+ PSADTree.TreeObjectBase
+
+
+ [PSADTree.Internal._FormattingInternals]::GetSource($_)
+ Source
+
+
+
+
+
+ Domain
+
+ Left
+
+
+ ObjectClass
+
+ Right
+
+
+ Hierarchy
+ Left
+
+
+
+
+
+
+
+ $_.Domain -replace '^DC=|(?<!\\),.+'
+
+
+ ObjectClass
+
+
+ Hierarchy
+
+
+
+
+
+
+
+
diff --git a/module/PSADTree.psd1 b/module/PSADTree.psd1
new file mode 100644
index 0000000..3755427
--- /dev/null
+++ b/module/PSADTree.psd1
@@ -0,0 +1,143 @@
+#
+# Module manifest for module 'PSADTree'
+#
+# Generated by: Santiago Squarzon
+#
+# Generated on: 8/14/2023
+#
+
+@{
+
+ # Script module or binary module file associated with this manifest.
+ RootModule = if ($PSEdition -eq 'Core') {
+ 'bin/net6.0/PSADTree.dll'
+ }
+ else {
+ 'bin/net472/PSADTree.dll'
+ }
+
+ # Version number of this module.
+ ModuleVersion = '1.1.0'
+
+ # Supported PSEditions
+ # CompatiblePSEditions = @()
+
+ # ID used to uniquely identify this module
+ GUID = 'e49013dc-4106-4a95-aebc-b2669cbadeab'
+
+ # Author of this module
+ Author = 'Santiago Squarzon'
+
+ # Company or vendor of this module
+ CompanyName = 'Unknown'
+
+ # Copyright statement for this module
+ Copyright = '(c) Santiago Squarzon. All rights reserved.'
+
+ # Description of the functionality provided by this module
+ Description = 'tree commands for Active Directory'
+
+ # Minimum version of the PowerShell engine required by this module
+ PowerShellVersion = '5.1'
+
+ # Name of the PowerShell host required by this module
+ # PowerShellHostName = ''
+
+ # Minimum version of the PowerShell host required by this module
+ # PowerShellHostVersion = ''
+
+ # Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
+ # DotNetFrameworkVersion = ''
+
+ # Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
+ # ClrVersion = ''
+
+ # Processor architecture (None, X86, Amd64) required by this module
+ # ProcessorArchitecture = ''
+
+ # Modules that must be imported into the global environment prior to importing this module
+ # RequiredModules = @()
+
+ # Assemblies that must be loaded prior to importing this module
+ RequiredAssemblies = @('System.DirectoryServices.AccountManagement')
+
+ # Script files (.ps1) that are run in the caller's environment prior to importing this module.
+ # ScriptsToProcess = @()
+
+ # Type files (.ps1xml) to be loaded when importing this module
+ # TypesToProcess = @()
+
+ # Format files (.ps1xml) to be loaded when importing this module
+ FormatsToProcess = @('PSADTree.Format.ps1xml')
+
+ # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
+ # NestedModules = @()
+
+ # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
+ FunctionsToExport = @()
+
+ # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export.
+ CmdletsToExport = @(
+ 'Get-ADTreeGroupMember'
+ 'Get-ADTreePrincipalGroupMembership'
+ )
+
+ # Variables to export from this module
+ VariablesToExport = @()
+
+ # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export.
+ AliasesToExport = @(
+ 'treegroupmember'
+ 'treeprincipalmembership'
+ )
+
+ # DSC resources to export from this module
+ # DscResourcesToExport = @()
+
+ # List of all modules packaged with this module
+ # ModuleList = @()
+
+ # List of all files packaged with this module
+ # FileList = @()
+
+ # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell.
+ PrivateData = @{
+
+ PSData = @{
+
+ # Tags applied to this module. These help with module discovery in online galleries.
+ # Tags = @()
+
+ # A URL to the license for this module.
+ LicenseUri = 'https://github.com/santisq/PSADTree/blob/main/LICENSE'
+
+ # A URL to the main website for this project.
+ ProjectUri = 'https://github.com/santisq/PSADTree'
+
+ # A URL to an icon representing this module.
+ # IconUri = ''
+
+ # ReleaseNotes of this module
+ # ReleaseNotes = ''
+
+ # Prerelease string of this module
+ # Prerelease = ''
+
+ # Flag to indicate whether the module requires explicit user acceptance for install/update/save
+ # RequireLicenseAcceptance = $false
+
+ # External dependent modules of this module
+ # ExternalModuleDependencies = @()
+
+ } # End of PSData hashtable
+
+ } # End of PrivateData hashtable
+
+ # HelpInfo URI of this module
+ # HelpInfoURI = ''
+
+ # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix.
+ # DefaultCommandPrefix = ''
+
+}
+
diff --git a/src/PSADTree/Commands/GetADTreeGroupMemberCommand.cs b/src/PSADTree/Commands/GetADTreeGroupMemberCommand.cs
new file mode 100644
index 0000000..ab2ffca
--- /dev/null
+++ b/src/PSADTree/Commands/GetADTreeGroupMemberCommand.cs
@@ -0,0 +1,225 @@
+using System;
+using System.DirectoryServices.AccountManagement;
+using System.Management.Automation;
+
+namespace PSADTree;
+
+[Cmdlet(
+ VerbsCommon.Get, "ADTreeGroupMember",
+ DefaultParameterSetName = DepthParameterSet)]
+[Alias("treegroupmember")]
+[OutputType(
+ typeof(TreeGroup),
+ typeof(TreeUser),
+ typeof(TreeComputer))]
+public sealed class GetADTreeGroupMemberCommand : PSADTreeCmdletBase
+{
+ [Parameter]
+ public SwitchParameter Group { get; set; }
+
+ protected override void ProcessRecord()
+ {
+ Dbg.Assert(Identity is not null);
+ Dbg.Assert(_context is not null);
+
+ try
+ {
+ using GroupPrincipal? group = GroupPrincipal.FindByIdentity(_context, Identity);
+ if (group is null)
+ {
+ WriteError(ErrorHelper.IdentityNotFound(Identity));
+ return;
+ }
+
+ WriteObject(
+ sendToPipeline: Traverse(
+ groupPrincipal: group,
+ source: group.DistinguishedName),
+ enumerateCollection: true);
+ }
+ catch (Exception e) when (e is PipelineStoppedException or FlowControlException)
+ {
+ throw;
+ }
+ catch (MultipleMatchesException e)
+ {
+ WriteError(ErrorHelper.AmbiguousIdentity(Identity, e));
+ }
+ catch (Exception e)
+ {
+ WriteError(ErrorHelper.Unspecified(Identity, e));
+ }
+ }
+
+ private TreeObjectBase[] Traverse(
+ GroupPrincipal groupPrincipal,
+ string source)
+ {
+ int depth;
+ Clear();
+ Push(groupPrincipal, new TreeGroup(source, groupPrincipal));
+
+ while (_stack.Count > 0)
+ {
+ (GroupPrincipal? current, TreeGroup treeGroup) = _stack.Pop();
+
+ try
+ {
+ depth = treeGroup.Depth + 1;
+
+ // if this node has been already processed
+ if (!_cache.TryAdd(treeGroup))
+ {
+ current?.Dispose();
+ treeGroup.Hook(_cache);
+ _index.Add(treeGroup);
+
+ // if it's a circular reference, go next
+ if (TreeCache.IsCircular(treeGroup))
+ {
+ treeGroup.SetCircularNested();
+ continue;
+ }
+
+ // else, if we want to show all nodes
+ if (ShowAll.IsPresent)
+ {
+ // reconstruct the output without querying AD again
+ EnumerateMembers(treeGroup, depth);
+ continue;
+ }
+
+ // else, just skip this reference and go next
+ treeGroup.SetProcessed();
+ continue;
+ }
+
+ using PrincipalSearchResult? search = current?.GetMembers();
+
+ if (search is not null)
+ {
+ EnumerateMembers(treeGroup, search, source, depth);
+ }
+
+ _index.Add(treeGroup);
+ _index.TryAddPrincipals();
+ current?.Dispose();
+ }
+ catch (Exception e) when (e is PipelineStoppedException or FlowControlException)
+ {
+ throw;
+ }
+ catch (Exception e)
+ {
+ WriteError(ErrorHelper.EnumerationFailure(current, e));
+ }
+ }
+
+ return _index.GetTree();
+ }
+
+ private void EnumerateMembers(
+ TreeGroup parent,
+ PrincipalSearchResult searchResult,
+ string source,
+ int depth)
+ {
+ foreach (Principal member in searchResult)
+ {
+ IDisposable? disposable = null;
+ try
+ {
+ if (member is { DistinguishedName: null })
+ {
+ disposable = member;
+ continue;
+ }
+
+ if (member is not GroupPrincipal)
+ {
+ disposable = member;
+
+ if (Group.IsPresent)
+ {
+ continue;
+ }
+ }
+
+ TreeObjectBase treeObject = ProcessPrincipal(
+ principal: member,
+ parent: parent,
+ source: source,
+ depth: depth);
+
+ if (ShowAll.IsPresent)
+ {
+ parent.AddChild(treeObject);
+ }
+ }
+ finally
+ {
+ disposable?.Dispose();
+ }
+ }
+ }
+
+ private TreeObjectBase ProcessPrincipal(
+ Principal principal,
+ TreeGroup parent,
+ string source,
+ int depth)
+ {
+ return principal switch
+ {
+ UserPrincipal user => AddTreeObject(new TreeUser(source, parent, user, depth)),
+ ComputerPrincipal computer => AddTreeObject(new TreeComputer(source, parent, computer, depth)),
+ GroupPrincipal group => HandleGroup(parent, group, source, depth),
+ _ => throw new ArgumentOutOfRangeException(nameof(principal)),
+ };
+
+ TreeObjectBase AddTreeObject(TreeObjectBase obj)
+ {
+ if (Recursive.IsPresent || depth <= Depth)
+ {
+ _index.AddPrincipal(obj);
+ }
+
+ return obj;
+ }
+
+ TreeObjectBase HandleGroup(
+ TreeGroup parent,
+ GroupPrincipal group,
+ string source,
+ int depth)
+ {
+ if (_cache.TryGet(group.DistinguishedName, out TreeGroup? treeGroup))
+ {
+ Push(group, (TreeGroup)treeGroup.Clone(parent, depth));
+ return treeGroup;
+ }
+
+ treeGroup = new(source, parent, group, depth);
+ Push(group, treeGroup);
+ return treeGroup;
+ }
+ }
+
+ private void EnumerateMembers(TreeGroup parent, int depth)
+ {
+ bool shouldProcess = Recursive.IsPresent || depth <= Depth;
+ foreach (TreeObjectBase member in parent.Childs)
+ {
+ if (member is TreeGroup treeGroup)
+ {
+ Push(null, (TreeGroup)treeGroup.Clone(parent, depth));
+ continue;
+ }
+
+ if (shouldProcess)
+ {
+ _index.Add(member.Clone(parent, depth));
+ }
+ }
+ }
+}
diff --git a/src/PSADTree/Commands/GetADTreePrincipalGroupMembershipCommand.cs b/src/PSADTree/Commands/GetADTreePrincipalGroupMembershipCommand.cs
new file mode 100644
index 0000000..799e703
--- /dev/null
+++ b/src/PSADTree/Commands/GetADTreePrincipalGroupMembershipCommand.cs
@@ -0,0 +1,200 @@
+using System;
+using System.DirectoryServices.AccountManagement;
+using System.Linq;
+using System.Management.Automation;
+
+namespace PSADTree;
+
+[Cmdlet(
+ VerbsCommon.Get, "ADTreePrincipalGroupMembership",
+ DefaultParameterSetName = DepthParameterSet)]
+[Alias("treeprincipalmembership")]
+[OutputType(
+ typeof(TreeGroup),
+ typeof(TreeUser),
+ typeof(TreeComputer))]
+public sealed class GetADTreePrincipalGroupMembershipCommand : PSADTreeCmdletBase
+{
+ protected override void ProcessRecord()
+ {
+ Dbg.Assert(Identity is not null);
+ Dbg.Assert(_context is not null);
+ Principal? principal;
+ Clear();
+
+ try
+ {
+ principal = Principal.FindByIdentity(_context, Identity);
+ }
+ catch (Exception e) when (e is PipelineStoppedException or FlowControlException)
+ {
+ throw;
+ }
+ catch (MultipleMatchesException e)
+ {
+ WriteError(ErrorHelper.AmbiguousIdentity(Identity, e));
+ return;
+ }
+ catch (Exception e)
+ {
+ WriteError(ErrorHelper.Unspecified(Identity, e));
+ return;
+ }
+
+ if (principal is null)
+ {
+ WriteError(ErrorHelper.IdentityNotFound(Identity));
+ return;
+ }
+
+ string source = principal.DistinguishedName;
+ switch (principal)
+ {
+ case UserPrincipal user:
+ _index.Add(new TreeUser(source, user));
+ break;
+
+ case ComputerPrincipal computer:
+ _index.Add(new TreeComputer(source, computer));
+ break;
+
+ case GroupPrincipal group:
+ TreeGroup treeGroup = new(source, group);
+ _index.Add(treeGroup);
+ _cache.Add(treeGroup);
+ break;
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(principal));
+ }
+
+ try
+ {
+ using PrincipalSearchResult search = principal.GetGroups();
+ foreach (GroupPrincipal parent in search.Cast())
+ {
+ TreeGroup treeGroup = new(source, null, parent, 1);
+ Push(parent, treeGroup);
+ }
+ }
+ catch (Exception e) when (e is PipelineStoppedException or FlowControlException)
+ {
+ throw;
+ }
+ catch (Exception e)
+ {
+ WriteError(ErrorHelper.EnumerationFailure(null, e));
+ }
+ finally
+ {
+ principal?.Dispose();
+ }
+
+ WriteObject(
+ sendToPipeline: Traverse(source),
+ enumerateCollection: true);
+ }
+
+ private TreeObjectBase[] Traverse(string source)
+ {
+ int depth;
+ while (_stack.Count > 0)
+ {
+ (GroupPrincipal? current, TreeGroup treeGroup) = _stack.Pop();
+
+ try
+ {
+ depth = treeGroup.Depth + 1;
+
+ // if this node has been already processed
+ if (!_cache.TryAdd(treeGroup))
+ {
+ current?.Dispose();
+ treeGroup.Hook(_cache);
+ _index.Add(treeGroup);
+
+ // if it's a circular reference, go next
+ if (TreeCache.IsCircular(treeGroup))
+ {
+ treeGroup.SetCircularNested();
+ continue;
+ }
+
+ // else, if we want to show all nodes
+ if (ShowAll.IsPresent)
+ {
+ // reconstruct the output without querying AD again
+ EnumerateMembership(treeGroup, depth);
+ continue;
+ }
+
+ // else, just skip this reference and go next
+ treeGroup.SetProcessed();
+ continue;
+ }
+
+ using PrincipalSearchResult? search = current?.GetGroups();
+
+ if (search is not null)
+ {
+ EnumerateMembership(treeGroup, search, source, depth);
+ }
+
+ _index.Add(treeGroup);
+ current?.Dispose();
+ }
+ catch (Exception e) when (e is PipelineStoppedException or FlowControlException)
+ {
+ throw;
+ }
+ catch (Exception e)
+ {
+ WriteError(ErrorHelper.EnumerationFailure(current, e));
+ }
+ }
+
+ return _index.GetTree();
+ }
+
+ private void EnumerateMembership(
+ TreeGroup parent,
+ PrincipalSearchResult searchResult,
+ string source,
+ int depth)
+ {
+ foreach (GroupPrincipal group in searchResult.Cast())
+ {
+ TreeGroup treeGroup = ProcessGroup(group);
+ if (ShowAll.IsPresent)
+ {
+ parent.AddChild(treeGroup);
+ }
+ }
+
+ TreeGroup ProcessGroup(GroupPrincipal group)
+ {
+ if (_cache.TryGet(group.DistinguishedName, out TreeGroup? treeGroup))
+ {
+ Push(group, (TreeGroup)treeGroup.Clone(parent, depth));
+ return treeGroup;
+ }
+
+ treeGroup = new(source, parent, group, depth);
+ Push(group, treeGroup);
+ return treeGroup;
+ }
+ }
+
+ private void EnumerateMembership(TreeGroup parent, int depth)
+ {
+ if (!Recursive.IsPresent && depth > Depth)
+ {
+ return;
+ }
+
+ foreach (TreeGroup group in parent.Childs.Cast())
+ {
+ Push(null, (TreeGroup)group.Clone(parent, depth));
+ }
+ }
+}
diff --git a/src/PSADTree/Dbg.cs b/src/PSADTree/Dbg.cs
new file mode 100644
index 0000000..1dee2c7
--- /dev/null
+++ b/src/PSADTree/Dbg.cs
@@ -0,0 +1,11 @@
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+
+namespace PSADTree;
+
+internal static class Dbg
+{
+ [Conditional("DEBUG")]
+ public static void Assert([DoesNotReturnIf(false)] bool condition) =>
+ Debug.Assert(condition);
+}
diff --git a/src/PSADTree/ErrorHelper.cs b/src/PSADTree/ErrorHelper.cs
new file mode 100644
index 0000000..dddb695
--- /dev/null
+++ b/src/PSADTree/ErrorHelper.cs
@@ -0,0 +1,26 @@
+using System;
+using System.DirectoryServices.AccountManagement;
+using System.Management.Automation;
+
+namespace PSADTree;
+
+internal static class ErrorHelper
+{
+ internal static ErrorRecord IdentityNotFound(string? identity) =>
+ new(
+ new NoMatchingPrincipalException($"Cannot find an object with identity: '{identity}'."),
+ "IdentityNotFound",
+ ErrorCategory.ObjectNotFound,
+ identity);
+
+ internal static ErrorRecord AmbiguousIdentity(string? identity, Exception exception) =>
+ new(exception, "AmbiguousIdentity", ErrorCategory.InvalidResult, identity);
+
+ internal static ErrorRecord Unspecified(string? identity, Exception exception) =>
+ new(exception, "Unspecified", ErrorCategory.NotSpecified, identity);
+
+ internal static ErrorRecord EnumerationFailure(
+ GroupPrincipal? groupPrincipal,
+ Exception exception) =>
+ new(exception, "EnumerationFailure", ErrorCategory.NotSpecified, groupPrincipal);
+}
diff --git a/src/PSADTree/Internal/_FormattingInternals.cs b/src/PSADTree/Internal/_FormattingInternals.cs
new file mode 100644
index 0000000..4cb3171
--- /dev/null
+++ b/src/PSADTree/Internal/_FormattingInternals.cs
@@ -0,0 +1,14 @@
+using System.ComponentModel;
+using System.Management.Automation;
+
+namespace PSADTree.Internal;
+
+#pragma warning disable IDE1006
+
+[EditorBrowsable(EditorBrowsableState.Never)]
+public static class _FormattingInternals
+{
+ [Hidden, EditorBrowsable(EditorBrowsableState.Never)]
+ public static string GetSource(TreeObjectBase treeObject) =>
+ treeObject.Source;
+}
diff --git a/src/PSADTree/Nullable.cs b/src/PSADTree/Nullable.cs
new file mode 100644
index 0000000..97c2603
--- /dev/null
+++ b/src/PSADTree/Nullable.cs
@@ -0,0 +1,140 @@
+#if !NETCOREAPP
+
+namespace System.Diagnostics.CodeAnalysis
+{
+ /// Specifies that null is allowed as an input even if the corresponding type disallows it.
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)]
+ internal sealed class AllowNullAttribute : Attribute { }
+
+ /// Specifies that null is disallowed as an input even if the corresponding type allows it.
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)]
+ internal sealed class DisallowNullAttribute : Attribute { }
+
+ /// Specifies that an output may be null even if the corresponding type disallows it.
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)]
+ internal sealed class MaybeNullAttribute : Attribute { }
+
+ /// Specifies that an output will not be null even if the corresponding type allows it. Specifies that an input argument was not null when the call returns.
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)]
+ internal sealed class NotNullAttribute : Attribute { }
+
+ /// Specifies that when a method returns , the parameter may be null even if the corresponding type disallows it.
+ [AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
+ internal sealed class MaybeNullWhenAttribute : Attribute
+ {
+ /// Initializes the attribute with the specified return value condition.
+ ///
+ /// The return value condition. If the method returns this value, the associated parameter may be null.
+ ///
+ public MaybeNullWhenAttribute(bool returnValue) => ReturnValue = returnValue;
+
+ /// Gets the return value condition.
+ public bool ReturnValue { get; }
+ }
+
+ /// Specifies that when a method returns , the parameter will not be null even if the corresponding type allows it.
+ [AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
+ internal sealed class NotNullWhenAttribute : Attribute
+ {
+ /// Initializes the attribute with the specified return value condition.
+ ///
+ /// The return value condition. If the method returns this value, the associated parameter will not be null.
+ ///
+ public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue;
+
+ /// Gets the return value condition.
+ public bool ReturnValue { get; }
+ }
+
+ /// Specifies that the output will be non-null if the named parameter is non-null.
+ [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, AllowMultiple = true, Inherited = false)]
+ internal sealed class NotNullIfNotNullAttribute : Attribute
+ {
+ /// Initializes the attribute with the associated parameter name.
+ ///
+ /// The associated parameter name. The output will be non-null if the argument to the parameter specified is non-null.
+ ///
+ public NotNullIfNotNullAttribute(string parameterName) => ParameterName = parameterName;
+
+ /// Gets the associated parameter name.
+ public string ParameterName { get; }
+ }
+
+ /// Applied to a method that will never return under any circumstance.
+ [AttributeUsage(AttributeTargets.Method, Inherited = false)]
+ internal sealed class DoesNotReturnAttribute : Attribute { }
+
+ /// Specifies that the method will not return if the associated Boolean parameter is passed the specified value.
+ [AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
+ internal sealed class DoesNotReturnIfAttribute : Attribute
+ {
+ /// Initializes the attribute with the specified parameter value.
+ ///
+ /// The condition parameter value. Code after the method will be considered unreachable by diagnostics if the argument to
+ /// the associated parameter matches this value.
+ ///
+ public DoesNotReturnIfAttribute(bool parameterValue) => ParameterValue = parameterValue;
+
+ /// Gets the condition parameter value.
+ public bool ParameterValue { get; }
+ }
+
+ /// Specifies that the method or property will ensure that the listed field and property members have not-null values.
+ [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)]
+ internal sealed class MemberNotNullAttribute : Attribute
+ {
+ /// Initializes the attribute with a field or property member.
+ ///
+ /// The field or property member that is promised to be not-null.
+ ///
+ public MemberNotNullAttribute(string member) => Members = new[] { member };
+
+ /// Initializes the attribute with the list of field and property members.
+ ///
+ /// The list of field and property members that are promised to be not-null.
+ ///
+ public MemberNotNullAttribute(params string[] members) => Members = members;
+
+ /// Gets field or property member names.
+ public string[] Members { get; }
+ }
+
+ /// Specifies that the method or property will ensure that the listed field and property members have not-null values when returning with the specified return value condition.
+ [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)]
+ internal sealed class MemberNotNullWhenAttribute : Attribute
+ {
+ /// Initializes the attribute with the specified return value condition and a field or property member.
+ ///
+ /// The return value condition. If the method returns this value, the associated parameter will not be null.
+ ///
+ ///
+ /// The field or property member that is promised to be not-null.
+ ///
+ public MemberNotNullWhenAttribute(bool returnValue, string member)
+ {
+ ReturnValue = returnValue;
+ Members = new[] { member };
+ }
+
+ /// Initializes the attribute with the specified return value condition and list of field and property members.
+ ///
+ /// The return value condition. If the method returns this value, the associated parameter will not be null.
+ ///
+ ///
+ /// The list of field and property members that are promised to be not-null.
+ ///
+ public MemberNotNullWhenAttribute(bool returnValue, params string[] members)
+ {
+ ReturnValue = returnValue;
+ Members = members;
+ }
+
+ /// Gets the return value condition.
+ public bool ReturnValue { get; }
+
+ /// Gets field or property member names.
+ public string[] Members { get; }
+ }
+}
+
+#endif
diff --git a/src/PSADTree/PSADTree.csproj b/src/PSADTree/PSADTree.csproj
new file mode 100644
index 0000000..55fd80f
--- /dev/null
+++ b/src/PSADTree/PSADTree.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net6.0;net472
+ enable
+ true
+ PSADTree
+ latest
+ CA1416
+
+
+
+ $(DefineConstants);CORE
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PSADTree/PSADTreeCmdletBase.cs b/src/PSADTree/PSADTreeCmdletBase.cs
new file mode 100644
index 0000000..1c801c1
--- /dev/null
+++ b/src/PSADTree/PSADTreeCmdletBase.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Collections.Generic;
+using System.DirectoryServices.AccountManagement;
+using System.Management.Automation;
+
+namespace PSADTree;
+
+public abstract class PSADTreeCmdletBase : PSCmdlet, IDisposable
+{
+ protected const string DepthParameterSet = "Depth";
+
+ protected const string RecursiveParameterSet = "Recursive";
+
+ protected PrincipalContext? _context;
+
+ private bool _disposed;
+
+ protected readonly Stack<(GroupPrincipal? group, TreeGroup treeGroup)> _stack = new();
+
+ internal readonly TreeCache _cache = new();
+
+ internal readonly TreeIndex _index = new();
+
+ [Parameter(
+ Position = 0,
+ Mandatory = true,
+ ValueFromPipeline = true,
+ ValueFromPipelineByPropertyName = true)]
+ [Alias("DistinguishedName")]
+ public string? Identity { get; set; }
+
+ [Parameter]
+ public string? Server { get; set; }
+
+ [Parameter(ParameterSetName = DepthParameterSet)]
+ public uint Depth { get; set; } = 3;
+
+ [Parameter(ParameterSetName = RecursiveParameterSet)]
+ public SwitchParameter Recursive { get; set; }
+
+ [Parameter]
+ public SwitchParameter ShowAll { get; set; }
+
+ protected override void BeginProcessing()
+ {
+ try
+ {
+ if (Server is null)
+ {
+ _context = new PrincipalContext(ContextType.Domain);
+ return;
+ }
+
+ _context = new PrincipalContext(ContextType.Domain, Server);
+ }
+ catch (Exception e)
+ {
+ ThrowTerminatingError(new ErrorRecord(
+ e, "SetPrincipalContext", ErrorCategory.ConnectionError, null));
+ }
+ }
+
+ protected void Push(GroupPrincipal? groupPrincipal, TreeGroup treeGroup)
+ {
+ if (Recursive.IsPresent || treeGroup.Depth <= Depth)
+ {
+ _stack.Push((groupPrincipal, treeGroup));
+ }
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing && !_disposed)
+ {
+ _context?.Dispose();
+ _disposed = true;
+ }
+ }
+
+ protected void Clear()
+ {
+ _index.Clear();
+ _cache.Clear();
+ }
+
+ public void Dispose()
+ {
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/src/PSADTree/TreeCache.cs b/src/PSADTree/TreeCache.cs
new file mode 100644
index 0000000..f045052
--- /dev/null
+++ b/src/PSADTree/TreeCache.cs
@@ -0,0 +1,56 @@
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+
+namespace PSADTree;
+
+internal sealed class TreeCache
+{
+ private readonly Dictionary _cache;
+
+ internal TreeGroup this[string distinguishedName] =>
+ _cache[distinguishedName];
+
+ internal TreeCache() => _cache = new();
+
+ internal void Add(TreeGroup treeGroup) =>
+ _cache.Add(treeGroup.DistinguishedName, treeGroup);
+
+ internal bool TryAdd(TreeGroup group)
+ {
+ if (_cache.ContainsKey(group.DistinguishedName))
+ {
+ return false;
+ }
+
+ _cache.Add(group.DistinguishedName, group);
+ return true;
+ }
+
+ internal bool TryGet(
+ string distinguishedName,
+ [NotNullWhen(true)] out TreeGroup? principal) =>
+ _cache.TryGetValue(distinguishedName, out principal);
+
+ internal static bool IsCircular(TreeGroup node)
+ {
+ if (node.Parent is null)
+ {
+ return false;
+ }
+
+ TreeGroup? current = node.Parent;
+ while (current is not null)
+ {
+ if (node.DistinguishedName == current.DistinguishedName)
+ {
+ return true;
+ }
+
+ current = current.Parent;
+ }
+
+ return false;
+ }
+
+ internal void Clear() => _cache.Clear();
+}
diff --git a/src/PSADTree/TreeComputer.cs b/src/PSADTree/TreeComputer.cs
new file mode 100644
index 0000000..e06906a
--- /dev/null
+++ b/src/PSADTree/TreeComputer.cs
@@ -0,0 +1,30 @@
+using System.DirectoryServices.AccountManagement;
+
+namespace PSADTree;
+
+public sealed class TreeComputer : TreeObjectBase
+{
+ private TreeComputer(
+ TreeComputer computer,
+ TreeGroup parent,
+ int depth)
+ : base(computer, parent, depth)
+ { }
+
+ internal TreeComputer(
+ string source,
+ TreeGroup? parent,
+ ComputerPrincipal computer,
+ int depth)
+ : base(source, parent, computer, depth)
+ { }
+
+ internal TreeComputer(
+ string source,
+ ComputerPrincipal computer)
+ : base(source, computer)
+ { }
+
+ internal override TreeObjectBase Clone(TreeGroup parent, int depth) =>
+ new TreeComputer(this, parent, depth);
+}
diff --git a/src/PSADTree/TreeExtensions.cs b/src/PSADTree/TreeExtensions.cs
new file mode 100644
index 0000000..d489690
--- /dev/null
+++ b/src/PSADTree/TreeExtensions.cs
@@ -0,0 +1,70 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace PSADTree;
+
+internal static class TreeExtensions
+{
+ private static readonly Regex s_reDefaultNamingContext = new(
+ "(?<=,)DC=.+$",
+ RegexOptions.Compiled);
+
+ private static readonly StringBuilder s_sb = new();
+
+ internal static string Indent(this string inputString, int indentation)
+ {
+ s_sb.Clear();
+
+ return s_sb
+ .Append(' ', (4 * indentation) - 4)
+ .Append("└── ")
+ .Append(inputString)
+ .ToString();
+ }
+
+ internal static string GetDefaultNamingContext(this string distinguishedName) =>
+ s_reDefaultNamingContext.Match(distinguishedName).Value;
+
+ internal static TreeObjectBase[] ConvertToTree(
+ this TreeObjectBase[] inputObject)
+ {
+ int index;
+ TreeObjectBase current;
+ for (int i = 0; i < inputObject.Length; i++)
+ {
+ current = inputObject[i];
+ if ((index = current.Hierarchy.IndexOf('└')) == -1)
+ {
+ continue;
+ }
+
+ int z;
+ char[] replace;
+ for (z = i - 1; z >= 0; z--)
+ {
+ current = inputObject[z];
+ if (!char.IsWhiteSpace(current.Hierarchy[index]))
+ {
+ UpdateCorner(index, current);
+ break;
+ }
+
+ replace = current.Hierarchy.ToCharArray();
+ replace[index] = '│';
+ current.Hierarchy = new string(replace);
+ }
+ }
+
+ return inputObject;
+ }
+
+ private static void UpdateCorner(int index, TreeObjectBase current)
+ {
+ if (current.Hierarchy[index] == '└')
+ {
+ char[] replace = current.Hierarchy.ToCharArray();
+ replace[index] = '├';
+ current.Hierarchy = new string(replace);
+ }
+ }
+}
diff --git a/src/PSADTree/TreeGroup.cs b/src/PSADTree/TreeGroup.cs
new file mode 100644
index 0000000..54696c6
--- /dev/null
+++ b/src/PSADTree/TreeGroup.cs
@@ -0,0 +1,71 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.DirectoryServices.AccountManagement;
+
+namespace PSADTree;
+
+public sealed class TreeGroup : TreeObjectBase
+{
+ private const string _isCircular = " ↔ Circular Reference";
+
+ private const string _isProcessed = " ↔ Processed Group";
+
+ private const string _vtBrightRed = "\x1B[91m";
+
+ private const string _vtReset = "\x1B[0m";
+
+ private List? _childs;
+
+ public ReadOnlyCollection Childs => new(_childs ??= new());
+
+ public bool IsCircular { get; private set; }
+
+ private TreeGroup(
+ TreeGroup group,
+ TreeGroup parent,
+ int depth)
+ : base(group, parent, depth)
+ {
+ _childs = group._childs;
+ }
+
+ internal TreeGroup(
+ string source,
+ GroupPrincipal group)
+ : base(source, group)
+ { }
+
+ internal TreeGroup(
+ string source,
+ TreeGroup? parent,
+ GroupPrincipal group,
+ int depth)
+ : base(source, parent, group, depth)
+ { }
+
+ internal void SetCircularNested()
+ {
+ IsCircular = true;
+ Hierarchy = string.Concat(
+ Hierarchy.Insert(
+ Hierarchy.IndexOf("─ ") + 2,
+ _vtBrightRed),
+ _isCircular,
+ _vtReset);
+ }
+
+ internal void SetProcessed() =>
+ Hierarchy = string.Concat(Hierarchy, _isProcessed);
+
+ internal void Hook(TreeCache cache) =>
+ _childs ??= cache[DistinguishedName]._childs;
+
+ internal void AddChild(TreeObjectBase child)
+ {
+ _childs ??= new();
+ _childs.Add(child);
+ }
+
+ internal override TreeObjectBase Clone(TreeGroup parent, int depth) =>
+ new TreeGroup(this, parent, depth);
+}
diff --git a/src/PSADTree/TreeIndex.cs b/src/PSADTree/TreeIndex.cs
new file mode 100644
index 0000000..8df3a0c
--- /dev/null
+++ b/src/PSADTree/TreeIndex.cs
@@ -0,0 +1,40 @@
+using System.Collections.Generic;
+
+namespace PSADTree;
+
+internal sealed class TreeIndex
+{
+ private readonly List _principals;
+
+ private readonly List _output;
+
+ internal TreeIndex()
+ {
+ _principals = new();
+ _output = new();
+ }
+
+ internal void AddPrincipal(TreeObjectBase principal) =>
+ _principals.Add(principal);
+
+ internal void Add(TreeObjectBase principal) =>
+ _output.Add(principal);
+
+ internal void TryAddPrincipals()
+ {
+ if (_principals.Count > 0)
+ {
+ _output.AddRange(_principals.ToArray());
+ _principals.Clear();
+ }
+ }
+
+ internal TreeObjectBase[] GetTree() =>
+ _output.ToArray().ConvertToTree();
+
+ internal void Clear()
+ {
+ _output.Clear();
+ _principals.Clear();
+ }
+}
diff --git a/src/PSADTree/TreeObjectBase.cs b/src/PSADTree/TreeObjectBase.cs
new file mode 100644
index 0000000..ed515a0
--- /dev/null
+++ b/src/PSADTree/TreeObjectBase.cs
@@ -0,0 +1,87 @@
+using System;
+using System.DirectoryServices.AccountManagement;
+using System.Security.Principal;
+
+namespace PSADTree;
+
+public abstract class TreeObjectBase
+{
+ internal int Depth { get; set; }
+
+ internal string Source { get; }
+
+ public TreeGroup? Parent { get; }
+
+ public string Domain { get; }
+
+ public string SamAccountName { get; }
+
+ public string ObjectClass { get; }
+
+ public string Hierarchy { get; internal set; }
+
+ public string DistinguishedName { get; }
+
+ public Guid? ObjectGuid { get; }
+
+ public string UserPrincipalName { get; }
+
+ public string Description { get; }
+
+ public string DisplayName { get; }
+
+ public SecurityIdentifier ObjectSid { get; }
+
+ protected TreeObjectBase(
+ TreeObjectBase treeObject,
+ TreeGroup? parent,
+ int depth)
+ {
+ Depth = depth;
+ Source = treeObject.Source;
+ SamAccountName = treeObject.SamAccountName;
+ Domain = treeObject.Domain;
+ ObjectClass = treeObject.ObjectClass;
+ DistinguishedName = treeObject.DistinguishedName;
+ ObjectGuid = treeObject.ObjectGuid;
+ ObjectSid = treeObject.ObjectSid;
+ Hierarchy = treeObject.SamAccountName.Indent(depth);
+ Parent = parent;
+ UserPrincipalName = treeObject.UserPrincipalName;
+ Description = treeObject.Description;
+ DisplayName = treeObject.DisplayName;
+ }
+
+ protected TreeObjectBase(
+ string source,
+ Principal principal)
+ {
+ Source = source;
+ SamAccountName = principal.SamAccountName;
+ Domain = principal.DistinguishedName.GetDefaultNamingContext();
+ ObjectClass = principal.StructuralObjectClass;
+ DistinguishedName = principal.DistinguishedName;
+ ObjectGuid = principal.Guid;
+ ObjectSid = principal.Sid;
+ Hierarchy = SamAccountName;
+ UserPrincipalName = principal.UserPrincipalName;
+ Description = principal.Description;
+ DisplayName = principal.DisplayName;
+ }
+
+ protected TreeObjectBase(
+ string source,
+ TreeGroup? parent,
+ Principal principal,
+ int depth)
+ : this(source, principal)
+ {
+ Depth = depth;
+ Hierarchy = principal.SamAccountName.Indent(depth);
+ Parent = parent;
+ }
+
+ public override string ToString() => DistinguishedName;
+
+ internal abstract TreeObjectBase Clone(TreeGroup parent, int depth);
+}
diff --git a/src/PSADTree/TreeUser.cs b/src/PSADTree/TreeUser.cs
new file mode 100644
index 0000000..2497243
--- /dev/null
+++ b/src/PSADTree/TreeUser.cs
@@ -0,0 +1,30 @@
+using System.DirectoryServices.AccountManagement;
+
+namespace PSADTree;
+
+public sealed class TreeUser : TreeObjectBase
+{
+ private TreeUser(
+ TreeUser user,
+ TreeGroup parent,
+ int depth)
+ : base(user, parent, depth)
+ { }
+
+ internal TreeUser(
+ string source,
+ TreeGroup? parent,
+ UserPrincipal user,
+ int depth)
+ : base(source, parent, user, depth)
+ { }
+
+ internal TreeUser(
+ string source,
+ UserPrincipal user)
+ : base(source, user)
+ { }
+
+ internal override TreeObjectBase Clone(TreeGroup parent, int depth) =>
+ new TreeUser(this, parent, depth);
+}
diff --git a/tests/PSADTree.tests.ps1 b/tests/PSADTree.tests.ps1
new file mode 100644
index 0000000..5d1eb4d
--- /dev/null
+++ b/tests/PSADTree.tests.ps1
@@ -0,0 +1,11 @@
+# I know, this is a shame but setting up a Domain for tests is too much trouble :D
+$ErrorActionPreference = 'Stop'
+
+Describe 'PSADTreeModule' {
+ It 'Should not throw on import' {
+ $moduleName = (Get-Item ([IO.Path]::Combine($PSScriptRoot, '..', 'module', '*.psd1'))).BaseName
+ $manifestPath = [IO.Path]::Combine($PSScriptRoot, '..', 'output', $moduleName)
+
+ { Import-Module $manifestPath } | Should -Not -Throw
+ }
+}
diff --git a/tools/PesterTest.ps1 b/tools/PesterTest.ps1
new file mode 100644
index 0000000..92c4a15
--- /dev/null
+++ b/tools/PesterTest.ps1
@@ -0,0 +1,63 @@
+<#
+.SYNOPSIS
+Run Pester test
+
+.PARAMETER TestPath
+The path to the tests to run
+
+.PARAMETER OutputFile
+The path to write the Pester test results to.
+#>
+[CmdletBinding()]
+param (
+ [Parameter(Mandatory)]
+ [String]
+ $TestPath,
+
+ [Parameter(Mandatory)]
+ [String]
+ $OutputFile
+)
+
+$ErrorActionPreference = 'Stop'
+$requirements = Import-PowerShellDataFile ([IO.Path]::Combine($PSScriptRoot, 'requiredModules.psd1'))
+foreach ($req in $requirements.GetEnumerator()) {
+ $importModuleSplat = @{
+ Name = ([IO.Path]::Combine($PSScriptRoot, 'Modules', $req.Key))
+ Force = $true
+ DisableNameChecking = $true
+ }
+
+ Write-Host "Importing: $($importModuleSplat['Name'])"
+ Import-Module @importModuleSplat
+}
+
+[PSCustomObject] $PSVersionTable |
+ Select-Object -Property *, @{
+ Name = 'Architecture'
+ Expression = {
+ switch ([IntPtr]::Size) {
+ 4 {
+ 'x86'
+ }
+ 8 {
+ 'x64'
+ }
+ default {
+ 'Unknown'
+ }
+ }
+ }
+ } |
+ Format-List |
+ Out-Host
+
+$configuration = [PesterConfiguration]::Default
+$configuration.Output.Verbosity = 'Detailed'
+$configuration.Run.Exit = $true
+$configuration.Run.Path = $TestPath
+$configuration.TestResult.Enabled = $true
+$configuration.TestResult.OutputPath = $OutputFile
+$configuration.TestResult.OutputFormat = 'NUnitXml'
+
+Invoke-Pester -Configuration $configuration -WarningAction Ignore
diff --git a/tools/requiredModules.psd1 b/tools/requiredModules.psd1
new file mode 100644
index 0000000..b40540a
--- /dev/null
+++ b/tools/requiredModules.psd1
@@ -0,0 +1,6 @@
+@{
+ InvokeBuild = '5.10.3'
+ platyPS = '0.14.2'
+ PSScriptAnalyzer = '1.21.0'
+ Pester = '5.4.1'
+}