From f8ed59f9e8bb0434741df02fe73c2b6c7ad42352 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Thu, 21 Mar 2024 06:26:29 +1000 Subject: [PATCH] Add Move-OpenADObject and Rename-OpenADObject (#77) Adds the cmdlets Move-OpenADObject and Rename-OpenADObject which can be used to move or rename an AD object respectively. --- CHANGELOG.md | 2 + docs/en-US/Move-OpenADObject.md | 281 +++++++++++ docs/en-US/PSOpenAD.md | 6 + docs/en-US/Rename-OpenADObject.md | 286 +++++++++++ module/PSOpenAD.psd1 | 2 + .../Commands/MoveOpenADObject.cs | 125 +++++ .../Commands/OpenADSessionCmdletBase.cs | 44 ++ .../Commands/RenameOpenADObject.cs | 126 +++++ src/PSOpenAD.Module/Commands/SetOpenAD.cs | 41 +- src/PSOpenAD/LDAP/DistinguishedName.cs | 459 +++++++++++++++++- src/PSOpenAD/LDAP/LDAPSession.cs | 19 + src/PSOpenAD/LDAP/Messages.cs | 93 ++++ src/PSOpenAD/Operations.cs | 45 ++ tests/Move-OpenADObject.Tests.ps1 | 119 +++++ tests/Rename-OpenADObject.Tests.ps1 | 140 ++++++ tests/units/DistinguishedNameTests.cs | 400 ++++++++++++++- 16 files changed, 2135 insertions(+), 53 deletions(-) create mode 100644 docs/en-US/Move-OpenADObject.md create mode 100644 docs/en-US/Rename-OpenADObject.md create mode 100644 src/PSOpenAD.Module/Commands/MoveOpenADObject.cs create mode 100644 src/PSOpenAD.Module/Commands/RenameOpenADObject.cs create mode 100644 tests/Move-OpenADObject.Tests.ps1 create mode 100644 tests/Rename-OpenADObject.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index dda98f9..0b3ea93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## v0.5.0 - TBD + Added the following cmdlets: + + [Move-OpenADObject](./docs/en-US/Move-OpenADObject.md): Moves an AD object to another container + + [Rename-OpenADObject](./docs/en-US/Rename-OpenADObject.md): Changes the name of an AD object + [Set-OpenADObject](./docs/en-US/Set-OpenADObject.md): Sets existing AD objects + Fix up deadlock when reading the AD schema with an auxiliary class that inherits from `top` diff --git a/docs/en-US/Move-OpenADObject.md b/docs/en-US/Move-OpenADObject.md new file mode 100644 index 0000000..d933a0d --- /dev/null +++ b/docs/en-US/Move-OpenADObject.md @@ -0,0 +1,281 @@ +--- +external help file: PSOpenAD.Module.dll-Help.xml +Module Name: PSOpenAD +online version: https://www.github.com/jborean93/PSOpenAD/blob/main/docs/en-US/Move-OpenADObject.md +schema: 2.0.0 +--- + +# Move-OpenADObject + +## SYNOPSIS +Moves an Active Directory object or a container of objects to a different container. + +## SYNTAX + +### Server (Default) +``` +Move-OpenADObject [-Identity] [-TargetPath] [-PassThru] [-Server ] + [-AuthType ] [-SessionOption ] [-StartTLS] + [-Credential ] [-ProgressAction ] [-WhatIf] [-Confirm] [] +``` + +### Session +``` +Move-OpenADObject [-Identity] [-TargetPath] [-PassThru] -Session + [-ProgressAction ] [-WhatIf] [-Confirm] [] +``` + +## DESCRIPTION +The `Move-OpenADObject` cmdlet moves an object or a container of objects from one container to another. + +The `-Identity` parameter specifies the Active Directory object or container to move. +You can identify an object or container by its `distinguishedName` or `objectGuid`, or by poviding the `OpenADObject` instance as generated by other cmdlets like [Get-OpenADUser](./Get-OpenADUser.md). + +The `-TargetPath` parameter must be specified and is the new location to move the identified object or container to. + +## EXAMPLES + +### Example 1: Move an OU to a new location +```powershell +PS C:\> Move-OpenADObject -Identity "OU=ManagedGroups,DC=Fabrikam,DC=Com" -TargetPath "OU=Managed,DC=Fabrikam,DC=Com" +``` + +This command moves the organizational unit (OU) `ManagedGroups` to a new location. +The OU ManagedGroups must not be protected from accidental deletion for the successful move. + +### Example 2: Move a user to a new location +```powershell +PS C:\> Get-OpenADUser -LDAPFilter '(physicalDeliveryOfficeName=Site1)' | + Move-OpenADObject -TargetPath "OU=NewSite,DC=Fabrikam,DC=Com" +``` + +This command moves all users under `physicalDeliveryOfficeName=Site1` to the new OU `NewSite`. + +### Example 3: Move an object specified by its objectGuid +```powershell +PS C:\> Move-OpenADObject -Identity "8d0bcc44-c826-4dd8-af5c-2c69960fbd47" -TargetPath "OU=Managed,DC=Fabrikam,DC=Com" +``` + +This command moves the object identified by `objectGuid` `8d0bcc44-c826-4dd8-af5c-2c69960fbd47` to the new OU `OU=Managed,DC=Fabrikam,DC=Com`. + +## PARAMETERS + +### -AuthType +The authentication type to use when creating the `OpenAD` session. +This is used when the cmdlet creates a new connection to the `-Server` specified`. + +```yaml +Type: AuthenticationMethod +Parameter Sets: Server +Aliases: +Accepted values: Default, Anonymous, Simple, Negotiate, Kerberos, Certificate + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Credential +The explicit credentials to use when creating the `OpenAD` session. +This is used when the cmdlet creates a new connection to the `-Server` specified. + +```yaml +Type: PSCredential +Parameter Sets: Server +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Identity +Specifies the Active Directory user object to move using one of the following formats: + ++ `DistinguishedName` + ++ `ObjectGUID` + +If the `DistinguishedName` is given directly, the cmdlet will attempt to move it as is, if the `ObjectGUID` is provided the cmdlet will lookup the DN based on that GUID. +The `-Identity` can be provided through pipeline input from cmdlets like `Get-OpenADObject`. + +```yaml +Type: ADObjectIdentity +Parameter Sets: (All) +Aliases: + +Required: True +Position: 0 +Default value: None +Accept pipeline input: True (ByPropertyName, ByValue) +Accept wildcard characters: False +``` + +### -PassThru +Returns an object representing the item that was modified. +By default this cmdlet does not general any output unless `-PassThru` was specified. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction +New common parameter introduced in PowerShell 7.4. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Server +The Active Directory server to connect to. +This can either be the name of the server or the LDAP connection uri starting with `ldap://` or `ldaps://`. +The derived URI of this value is used to find any existing connections that are available for use or will be used to create a new session if no cached session exists. +If both `-Server` and `-Session` are not specified then the default Kerberos realm is used if available otherwise it will generate an error. +This option supports tab completion based on the existing OpenADSessions that have been created. + +This option is mutually exclusive with `-Session`. + +```yaml +Type: String +Parameter Sets: Server +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Session +The `OpenAD` session to use for the query rather than trying to create a new connection or reuse a cached connection. +This session is generated by `New-OpenADSession` and can be used in situations where the global defaults should not be used. + +This option is mutually exclusive with `-Server`. + +```yaml +Type: OpenADSession +Parameter Sets: Session +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -SessionOption +Advanced session options used when creating a new session with `-Server`. +These options can be generated with `New-OpenADSessionOption`. + +```yaml +Type: OpenADSessionOptions +Parameter Sets: Server +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -StartTLS +Use `StartTLS` when creating a new session with `-Server`. + +```yaml +Type: SwitchParameter +Parameter Sets: Server +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -TargetPath +Specifies the new target location for the object. +This location must be the DistinguishedName/path to a container or organizational unit. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: True +Position: 1 +Default value: None +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: False +``` + +### -Confirm +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### PSOpenAD.ADObjectIdentity +The identity in its various forms can be piped into the cmdlet. + +## OUTPUTS + +### PSOpenAD.OpenADObject +Returns the moved Active Directory object when the `-PassThru` parameter is specified. By default, this cmdlet does not generate any output. The output object will have all the default `OpenADObject` properties set. Using `-WhatIf` and `-PassThru` will output an object but the values in the result will be blank. + +## NOTES + +## RELATED LINKS diff --git a/docs/en-US/PSOpenAD.md b/docs/en-US/PSOpenAD.md index da30599..e0c4f55 100644 --- a/docs/en-US/PSOpenAD.md +++ b/docs/en-US/PSOpenAD.md @@ -44,6 +44,9 @@ Gets one or more Active Directory users. ### [Get-OpenADWhoami](Get-OpenADWhoami.md) Performs an LDAP Whoami extended operation on the target server. +### [Move-OpenADObject](Move-OpenADObject.md) +Moves an Active Directory object or a container of objects to a different container. + ### [New-OpenADObject](New-OpenADObject.md) Creates an Active Directory object. @@ -59,6 +62,9 @@ Removes an Active Directory object. ### [Remove-OpenADSession](Remove-OpenADSession.md) Disconnects an LDAP/AD session. +### [Rename-OpenADObject](Rename-OpenADObject.md) +Changes the name of an Active Directory object. + ### [Set-OpenADObject](Set-OpenADObject.md) Modifies an Active Directory object. diff --git a/docs/en-US/Rename-OpenADObject.md b/docs/en-US/Rename-OpenADObject.md new file mode 100644 index 0000000..a788c49 --- /dev/null +++ b/docs/en-US/Rename-OpenADObject.md @@ -0,0 +1,286 @@ +--- +external help file: PSOpenAD.Module.dll-Help.xml +Module Name: PSOpenAD +online version: https://www.github.com/jborean93/PSOpenAD/blob/main/docs/en-US/Rename-OpenADObject.md +schema: 2.0.0 +--- + +# Rename-OpenADObject + +## SYNOPSIS +Changes the name of an Active Directory object. + +## SYNTAX + +### Server (Default) +``` +Rename-OpenADObject [-Identity] [-NewName] [-PassThru] [-Server ] + [-AuthType ] [-SessionOption ] [-StartTLS] + [-Credential ] [-ProgressAction ] [-WhatIf] [-Confirm] [] +``` + +### Session +``` +Rename-OpenADObject [-Identity] [-NewName] [-PassThru] -Session + [-ProgressAction ] [-WhatIf] [-Confirm] [] +``` + +## DESCRIPTION +The `Rename-OpenADObject` cmdlet renames an Active Directory object. +The cmdlet set the `name` LDAP attribute of an object. +To modify other properties like `givenName`, `surname`, etc, use the [Set-OpenADObject](./Set-OpenADObject.md) cmdlet instead. + +The `-Identity` parameter specifies the object to rename. +You can identify an object or container by its `distinguishedName` or `objectGuid`, or by poviding the `OpenADObject` instance as generated by other cmdlets like [Get-OpenADUser](./Get-OpenADUser.md). + +The `-NewName` parameter defines the new name for the object and must be specified. + +## EXAMPLES + +### Example 1: Rename a site +```powershell +PS C:\> Rename-OpenADObject -Idenitty "CN=HQ,CN=Sites,CN=Configuration,DC=FABRIKAM,DC=COM" -NewName "UnitedKingdomHQ" +``` + +This command renames the name of the existing site `HQ` to the new name `UnitedKingdomHQ`. + +### Example 2: Rename an object by GUID +```powershell +PS C:\> Rename-ADObject -Identity "4777c8e8-cd29-4699-91e8-c507705a0966" -NewName "AmsterdamHQ" +``` + +This command renamed the object identified by the `objectGuid` `4777c8e8-cd29-4699-91e8-c507705a0966` to `AmsterdamHQ`. + +### Example 3: Rename by piping in identities +```powershell +PS C:\> Get-OpenADUser -LDAPFilter '(company=DevsRUs)' | + Rename-OpenADObject -NewName { "$($_.Name)-Rockstar" } +``` + +This command gets all the AD users under the company `DevsRUs` and renames them with the suffix `-Rockstar`. +It uses a [delay-bind script block value](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_script_blocks?view=powershell-7.4#using-delay-bind-script-blocks-with-parameters) for `-NewName` allowing the name to be generated from the input object being processed. + +## PARAMETERS + +### -AuthType +The authentication type to use when creating the `OpenAD` session. +This is used when the cmdlet creates a new connection to the `-Server` specified`. + +```yaml +Type: AuthenticationMethod +Parameter Sets: Server +Aliases: +Accepted values: Default, Anonymous, Simple, Negotiate, Kerberos, Certificate + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Credential +The explicit credentials to use when creating the `OpenAD` session. +This is used when the cmdlet creates a new connection to the `-Server` specified. + +```yaml +Type: PSCredential +Parameter Sets: Server +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Identity +Specifies the Active Directory user object to rename using one of the following formats: + ++ `DistinguishedName` + ++ `ObjectGUID` + +If the `DistinguishedName` is given directly, the cmdlet will attempt to rename it as is, if the `ObjectGUID` is provided the cmdlet will lookup the DN based on that GUID. +The `-Identity` can be provided through pipeline input from cmdlets like `Get-OpenADObject`. + +```yaml +Type: ADObjectIdentity +Parameter Sets: (All) +Aliases: + +Required: True +Position: 0 +Default value: None +Accept pipeline input: True (ByPropertyName, ByValue) +Accept wildcard characters: False +``` + +### -NewName +Specifies the new name of the object. +This parameter sets the `name` property of the Active Directory object. +The cmdlet will automatically escape any values needed to set this on the LDAP attribute, for example `-NewName 'User "Nickname" Name'` will become `User \"Nickname\" Name` when being set in the LDAP attribute. + +This parameter supports [delay-bind scriptblock values](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_script_blocks?view=powershell-7.4#using-delay-bind-script-blocks-with-parameters) when piping in an identity object. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: True +Position: 1 +Default value: None +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: False +``` + +### -PassThru +Returns an object representing the item that was modified. +By default this cmdlet does not general any output unless `-PassThru` was specified. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction +New common parameter introduced in PowerShell 7.4. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Server +The Active Directory server to connect to. +This can either be the name of the server or the LDAP connection uri starting with `ldap://` or `ldaps://`. +The derived URI of this value is used to find any existing connections that are available for use or will be used to create a new session if no cached session exists. +If both `-Server` and `-Session` are not specified then the default Kerberos realm is used if available otherwise it will generate an error. +This option supports tab completion based on the existing OpenADSessions that have been created. + +This option is mutually exclusive with `-Session`. + +```yaml +Type: String +Parameter Sets: Server +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Session +The `OpenAD` session to use for the query rather than trying to create a new connection or reuse a cached connection. +This session is generated by `New-OpenADSession` and can be used in situations where the global defaults should not be used. + +This option is mutually exclusive with `-Server`. + +```yaml +Type: OpenADSession +Parameter Sets: Session +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -SessionOption +Advanced session options used when creating a new session with `-Server`. +These options can be generated with `New-OpenADSessionOption`. + +```yaml +Type: OpenADSessionOptions +Parameter Sets: Server +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -StartTLS +Use `StartTLS` when creating a new session with `-Server`. + +```yaml +Type: SwitchParameter +Parameter Sets: Server +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### PSOpenAD.ADObjectIdentity +The identity in its various forms can be piped into the cmdlet. + +## OUTPUTS + +### PSOpenAD.OpenADObject +Returns the renamed Active Directory object when the `-PassThru` parameter is specified. By default, this cmdlet does not generate any output. The output object will have all the default `OpenADObject` properties set. Using `-WhatIf` and `-PassThru` will output an object but the values in the result will be blank. + +## NOTES + +## RELATED LINKS diff --git a/module/PSOpenAD.psd1 b/module/PSOpenAD.psd1 index 6abf7cb..4e4e02f 100644 --- a/module/PSOpenAD.psd1 +++ b/module/PSOpenAD.psd1 @@ -85,8 +85,10 @@ 'New-OpenADObject' 'New-OpenADSession' 'New-OpenADSessionOption' + 'Move-OpenADObject' 'Remove-OpenADObject' 'Remove-OpenADSession' + 'Rename-OpenADObject' 'Set-OpenADObject' ) diff --git a/src/PSOpenAD.Module/Commands/MoveOpenADObject.cs b/src/PSOpenAD.Module/Commands/MoveOpenADObject.cs new file mode 100644 index 0000000..fff7a60 --- /dev/null +++ b/src/PSOpenAD.Module/Commands/MoveOpenADObject.cs @@ -0,0 +1,125 @@ +using PSOpenAD.LDAP; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; + +namespace PSOpenAD.Module.Commands; + +[Cmdlet( + VerbsCommon.Move, "OpenADObject", + DefaultParameterSetName = DefaultSessionParameterSet, + SupportsShouldProcess = true +)] +[OutputType(typeof(OpenADObject))] +public class MoveOpenADObject : OpenADSessionCmdletBase +{ + private StringComparer _caseInsensitiveComparer = StringComparer.OrdinalIgnoreCase; + + #region Move-OpenAD* Parameters + + [Parameter( + Mandatory = true, + Position = 0, + ValueFromPipeline = true, + ValueFromPipelineByPropertyName = true + )] + public ADObjectIdentity? Identity { get; set; } + + [Parameter( + Mandatory = true, + Position = 1, + ValueFromPipelineByPropertyName = true + )] + public string TargetPath { get; set; } = string.Empty; + + [Parameter] + public SwitchParameter PassThru { get; set; } + + #endregion + + protected override void ProcessRecordWithSession(OpenADSession session) + { + ArgumentNullException.ThrowIfNull(Identity); + + string? entry = Identity.DistinguishedName ?? GetIdentityDistinguishedName(Identity, session, "Move"); + if (entry == null) + { + // Errors already written. + return; + } + + HashSet searchProperties = OpenADObject.DEFAULT_PROPERTIES + .Select(p => p.Item1) + .ToHashSet(_caseInsensitiveComparer); + + DistinguishedName dn = DistinguishedName.Parse(entry); + DistinguishedName newRootDN = DistinguishedName.Parse(TargetPath); + RelativeDistinguishedName originalRDN = dn.RelativeNames[0]; + DistinguishedName newDN = new(new[] { originalRDN }.Concat(newRootDN.RelativeNames).ToArray()); + + SearchResultEntry? searchResult = null; + WriteVerbose($"Moving '{entry}' -> '{newDN}'"); + if (ShouldProcess($"'{entry}' -> '{newDN}'", "Rename")) + { + ModifyDNResponse resp = Operations.LdapModifyDNRequest( + session.Connection, + entry, + originalRDN.ToString(), + true, + TargetPath, + null, + CancelToken, + this); + if (resp.Result.ResultCode != LDAPResultCode.Success) + { + return; + } + + if (PassThru) + { + WriteVerbose($"Getting PassThru result for '{newDN}'"); + searchResult = Operations.LdapSearchRequest( + session.Connection, + newDN.ToString(), + SearchScope.Base, + 0, + session.OperationTimeout, + new FilterPresent("objectClass"), + searchProperties.ToArray(), + null, + CancelToken, + this, + false + ).FirstOrDefault()!; + } + } + else if (PassThru) + { + string entryName = originalRDN.Values[0].Value; + byte[][] emptyValue = SchemaMetadata.ConvertToRawAttributeCollection(string.Empty); + PartialAttribute[] whatIfAttributes = searchProperties + .Where(p => !(new[] { "distinguishedName", "name", "objectGUID" }).Contains(p)) + .Select(p => new PartialAttribute(p, emptyValue)) + .Union(new[] + { + new PartialAttribute("distinguishedName", SchemaMetadata.ConvertToRawAttributeCollection(newDN)), + new PartialAttribute("name", SchemaMetadata.ConvertToRawAttributeCollection(entryName)), + new PartialAttribute("objectGUID", SchemaMetadata.ConvertToRawAttributeCollection(Guid.Empty)), + }).ToArray(); + searchResult = new(0, null, entry, whatIfAttributes); + } + + if (searchResult != null) + { + OpenADEntity resultObj = GetOpenADObject.CreateOutputObject( + session, + searchResult, + searchProperties, + null, + this + ); + WriteObject(resultObj); + } + } +} diff --git a/src/PSOpenAD.Module/Commands/OpenADSessionCmdletBase.cs b/src/PSOpenAD.Module/Commands/OpenADSessionCmdletBase.cs index c12bdd1..684a03a 100644 --- a/src/PSOpenAD.Module/Commands/OpenADSessionCmdletBase.cs +++ b/src/PSOpenAD.Module/Commands/OpenADSessionCmdletBase.cs @@ -1,4 +1,6 @@ +using PSOpenAD.LDAP; using System; +using System.Linq; using System.Management.Automation; using System.Threading; @@ -83,4 +85,46 @@ protected override void ProcessRecord() } protected abstract void ProcessRecordWithSession(OpenADSession session); + + internal string? GetIdentityDistinguishedName( + ADObjectIdentity identity, + OpenADSession session, + string verb) + { + WriteVerbose($"Attempting to get distinguishedName for object with filter '{identity.LDAPFilter}'"); + + SearchResultEntry? entryResult = Operations.LdapSearchRequest( + session.Connection, + session.DefaultNamingContext, + SearchScope.Subtree, + 0, + session.OperationTimeout, + identity.LDAPFilter, + new[] { "distinguishedName" }, + null, + CancelToken, + this, + false + ).FirstOrDefault(); + + PartialAttribute? dnResult = entryResult?.Attributes + .Where(a => string.Equals(a.Name, "distinguishedName", StringComparison.InvariantCultureIgnoreCase)) + .FirstOrDefault(); + if (dnResult == null) + { + ErrorRecord error = new( + new ArgumentException($"Failed to find object to set using the filter '{identity.LDAPFilter}'"), + $"CannotFind{verb}ObjectWithFilter", + ErrorCategory.InvalidArgument, + identity); + WriteError(error); + return null; + } + + (PSObject[] rawDn, bool _) = session.SchemaMetadata.TransformAttributeValue( + dnResult.Name, + dnResult.Values, + this); + return (string)rawDn[0].BaseObject; + } } diff --git a/src/PSOpenAD.Module/Commands/RenameOpenADObject.cs b/src/PSOpenAD.Module/Commands/RenameOpenADObject.cs new file mode 100644 index 0000000..ec4c345 --- /dev/null +++ b/src/PSOpenAD.Module/Commands/RenameOpenADObject.cs @@ -0,0 +1,126 @@ +using PSOpenAD.LDAP; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; + +namespace PSOpenAD.Module.Commands; + +[Cmdlet( + VerbsCommon.Rename, "OpenADObject", + DefaultParameterSetName = DefaultSessionParameterSet, + SupportsShouldProcess = true +)] +[OutputType(typeof(OpenADObject))] +public class RenameOpenADObject : OpenADSessionCmdletBase +{ + private StringComparer _caseInsensitiveComparer = StringComparer.OrdinalIgnoreCase; + + #region Rename-OpenAD* Parameters + + [Parameter( + Mandatory = true, + Position = 0, + ValueFromPipeline = true, + ValueFromPipelineByPropertyName = true + )] + public ADObjectIdentity? Identity { get; set; } + + [Parameter( + Mandatory = true, + Position = 1, + ValueFromPipelineByPropertyName = true + )] + public string NewName { get; set; } = string.Empty; + + [Parameter] + public SwitchParameter PassThru { get; set; } + + #endregion + + protected override void ProcessRecordWithSession(OpenADSession session) + { + ArgumentNullException.ThrowIfNull(Identity); + + string? entry = Identity.DistinguishedName ?? GetIdentityDistinguishedName(Identity, session, "Rename"); + if (entry == null) + { + // Errors already written. + return; + } + + HashSet searchProperties = OpenADObject.DEFAULT_PROPERTIES + .Select(p => p.Item1) + .ToHashSet(_caseInsensitiveComparer); + + DistinguishedName dn = DistinguishedName.Parse(entry); + RelativeDistinguishedName newRDN = new( + new AttributeTypeAndValue[] { + new(dn.RelativeNames[0].Values[0].Type, NewName) + }); + DistinguishedName newDN = new(new[] { newRDN }.Concat(dn.RelativeNames.Skip(1)).ToArray()); + + SearchResultEntry? searchResult = null; + WriteVerbose($"Renaming '{entry}' -> '{newDN}'"); + if (ShouldProcess($"'{entry}' -> '{newDN}'", "Rename")) + { + ModifyDNResponse resp = Operations.LdapModifyDNRequest( + session.Connection, + entry, + newRDN.Values[0].ToString(), + true, + null, + null, + CancelToken, + this); + if (resp.Result.ResultCode != LDAPResultCode.Success) + { + return; + } + + if (PassThru) + { + WriteVerbose($"Getting PassThru result for '{newDN}'"); + searchResult = Operations.LdapSearchRequest( + session.Connection, + newDN.ToString(), + SearchScope.Base, + 0, + session.OperationTimeout, + new FilterPresent("objectClass"), + searchProperties.ToArray(), + null, + CancelToken, + this, + false + ).FirstOrDefault()!; + } + } + else if (PassThru) + { + byte[][] emptyValue = SchemaMetadata.ConvertToRawAttributeCollection(string.Empty); + PartialAttribute[] whatIfAttributes = searchProperties + .Where(p => !(new[] { "distinguishedName", "name", "objectGUID" }).Contains(p)) + .Select(p => new PartialAttribute(p, emptyValue)) + .Union(new[] + { + new PartialAttribute("distinguishedName", SchemaMetadata.ConvertToRawAttributeCollection(newDN.ToString())), + new PartialAttribute("name", SchemaMetadata.ConvertToRawAttributeCollection(NewName)), + new PartialAttribute("objectGUID", SchemaMetadata.ConvertToRawAttributeCollection(Guid.Empty)), + }).ToArray(); + searchResult = new(0, null, newDN.ToString(), whatIfAttributes); + } + + if (searchResult != null) + { + OpenADEntity resultObj = GetOpenADObject.CreateOutputObject( + session, + searchResult, + searchProperties, + null, + this + ); + WriteObject(resultObj); + } + } +} diff --git a/src/PSOpenAD.Module/Commands/SetOpenAD.cs b/src/PSOpenAD.Module/Commands/SetOpenAD.cs index 8c6c2e5..a5c25a1 100644 --- a/src/PSOpenAD.Module/Commands/SetOpenAD.cs +++ b/src/PSOpenAD.Module/Commands/SetOpenAD.cs @@ -54,44 +54,11 @@ protected override void ProcessRecordWithSession(OpenADSession session) { ArgumentNullException.ThrowIfNull(Identity); - string? entry = Identity.DistinguishedName; - if (string.IsNullOrWhiteSpace(entry)) + string? entry = Identity.DistinguishedName ?? GetIdentityDistinguishedName(Identity, session, "Set"); + if (entry == null) { - WriteVerbose($"Attempting to get distinguishedName for object with filter '{Identity.LDAPFilter}'"); - - SearchResultEntry? entryResult = Operations.LdapSearchRequest( - session.Connection, - session.DefaultNamingContext, - SearchScope.Subtree, - 0, - session.OperationTimeout, - Identity.LDAPFilter, - new[] { "distinguishedName" }, - null, - CancelToken, - this, - false - ).FirstOrDefault(); - - PartialAttribute? dnResult = entryResult?.Attributes - .Where(a => string.Equals(a.Name, "distinguishedName", StringComparison.InvariantCultureIgnoreCase)) - .FirstOrDefault(); - if (dnResult == null) - { - ErrorRecord error = new( - new ArgumentException($"Failed to find object to set using the filter '{Identity.LDAPFilter}'"), - "CannotFindSetObjectWithFilter", - ErrorCategory.InvalidArgument, - Identity); - WriteError(error); - return; - } - - (PSObject[] rawDn, bool _) = session.SchemaMetadata.TransformAttributeValue( - dnResult.Name, - dnResult.Values, - this); - entry = (string)rawDn[0].BaseObject; + // Errors already written. + return; } List changes = new(); diff --git a/src/PSOpenAD/LDAP/DistinguishedName.cs b/src/PSOpenAD/LDAP/DistinguishedName.cs index 4c49130..eec2cbe 100644 --- a/src/PSOpenAD/LDAP/DistinguishedName.cs +++ b/src/PSOpenAD/LDAP/DistinguishedName.cs @@ -1,9 +1,79 @@ using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; namespace PSOpenAD.LDAP; -public static class DistinguishedName +public class AttributeTypeAndValue { + /// Stores the original parsed value (if provided) for the ToString() impl + private readonly string? _original; + + /// + /// The attribute type + /// + public string Type { get; } + + /// + /// The literal string representation of the value. + /// + public string Value { get; } + + /// + /// The raw value as an string where necessary characters are escaped. + /// + public string EscapedValue => IsASN1EncodedValue ? Value : EscapeAttributeValue(Value); + + /// + /// Is true when Value is the ASN.1 encoded value '#{hexpairs}' and not the + /// literal string value. + /// + public bool IsASN1EncodedValue { get; } + + /// + /// Creates a new AttributeTypeAndValue instance from the provided values. + /// + /// The attribute type + /// The attribute value + /// + /// The provided value is treated literally and should not be escaped. + /// The EscapedValue property can provide an escaped string from that + /// value or the ToString() method can provide the full ATV string + /// representation. + /// + public AttributeTypeAndValue(string type, string value) + : this(type, value, false, null) + { } + + /// + /// Creates a new AttributeTypeAndValue instance with an ASN.1 BER encoded + /// value. + /// + /// + /// The ASN.1 BER encoded byte[] value. + /// + /// This constructor will set IsASN1EncodedValue and stores the raw ASN.1 + /// BER encoded values as the already escaped string under Value. + /// + public AttributeTypeAndValue(string type, byte[] value) + : this(type, $"#{Convert.ToHexString(value)}", true, null) + { } + + private AttributeTypeAndValue( + string type, + string value, + bool isRawAsn1Value, + string? original) + { + _original = original; + Type = type; + Value = value; + IsASN1EncodedValue = isRawAsn1Value; + } + /// /// Escapes a raw string value that can be used as the attribute value of /// a distinguished name. This implementation will escape the characters @@ -18,11 +88,11 @@ public static string EscapeAttributeValue(ReadOnlySpan value) for (int i = 0; i < value.Length; i++) { char c = value[i]; - if (IsEscapableChar(c, start: i == 0, end: i == value.Length - 1)) + if (ShouldEscapeChar(c, start: i == 0, end: i == value.Length - 1)) { escapedLength++; } - else if (IsEscapableCharAD(c)) + else if (ShouldHexEscapeChar(c)) { escapedLength += 2; } @@ -42,12 +112,12 @@ public static string EscapeAttributeValue(ReadOnlySpan value) for (int i = 0, j = 0; i < value.Length; i++) { char c = value[i]; - if (IsEscapableChar(c, start: i == 0, end: i == value.Length - 1)) + if (ShouldEscapeChar(c, start: i == 0, end: i == value.Length - 1)) { span[j++] = '\\'; span[j++] = c; } - else if (IsEscapableCharAD(c)) + else if (ShouldHexEscapeChar(c)) { string hex = ((short)c).ToString("X2"); span[j++] = '\\'; @@ -64,29 +134,388 @@ public static string EscapeAttributeValue(ReadOnlySpan value) } } - private static bool IsEscapableChar(char c, bool start = false, bool end = false) + /// Tries to parse a attributeTypeAndValue value. + /// + /// ABNF notation for attributeTypeAndValue is: + /// attributeTypeAndValue = attributeType EQUALS attributeValue + /// attributeType = descr / numericoid + /// attributeValue = string / hexstring + /// + /// descr = keystring + /// hexstring = SHARP 1*hexpair + /// hexpair = HEX HEX + /// + /// The input data to parse from. + /// The AttributeTypeValue result if successful. + /// + /// Number of chars consumed in the input data, this is undefined if the + /// parsing had failed. + /// + /// true if value was successfully parsed. + /// + internal static bool TryParse( + ReadOnlySpan data, + [NotNullWhen(true)] out AttributeTypeAndValue? result, + out int consumed) { - if (start && (c == ' ' || c == '#')) + result = null; + + // While not in the spec many implementations allow whitespace between + // each RDN member so this replicates that behaviour. + int startIdx = CountStartWhitespace(data); + ReadOnlySpan buffer = data[startIdx..]; + consumed = startIdx; + + // Parse the attributeType and check the next char is = + if ( + !( + AbnfDecoder.TryParseNumericOid(buffer, out var attrType, out var read) || + AbnfDecoder.TryParseKeyString(buffer, out attrType, out read)) + ) { - return true; + return false; } - else if (end && c == ' ') + + buffer = buffer[read..]; + consumed += read; + + read = CountStartWhitespace(buffer); + buffer = buffer[read..]; + consumed += read; + + if (buffer.Length == 0 || buffer[0] != '=') { - return true; + return false; } - else if (c == ',' || c == '+' || c == '"' || c == '\\' || c == '<' || c == '>' || c == ';') + buffer = buffer[1..]; + consumed += 1; + + read = CountStartWhitespace(buffer); + buffer = buffer[read..]; + consumed += read; + + // Parse the attributeValue which is either in the string or hexstring + // form. + bool isRawAsn1Value = false; + if (TryParseHexString(buffer, out var value, out read)) { - return true; + isRawAsn1Value = true; + } + else if (!TryParseValueString(buffer, out value, out read)) + { + return false; + } + + buffer = buffer[read..]; + consumed += read; + string originalValue = data[startIdx..consumed].ToString(); + + read = CountStartWhitespace(buffer); + consumed += read; + + result = new(attrType, value, isRawAsn1Value, originalValue); + return true; + } + + private static bool TryParseHexString( + ReadOnlySpan data, + [NotNullWhen(true)] out string? value, + out int consumed) + { + value = null; + consumed = 0; + + // A hexstring starts with # and must contain at least 1 hexpair. + if (data.Length < 3 || data[0] != '#') + { + return false; + } + + int hexLength = 1; + ReadOnlySpan buffer = data[1..]; + while (buffer.Length > 1 && AbnfDecoder.IsHex(buffer[0]) && AbnfDecoder.IsHex(buffer[1])) + { + buffer = buffer[2..]; + hexLength += 2; + } + + // The value must be the end of the string or an RDN separator. + int whitespaceRead = CountStartWhitespace(buffer); + buffer = buffer[whitespaceRead..]; + if (buffer.Length > 0 && buffer[0] != ',' && buffer[0] != '+') + { + return false; + } + + value = data[..hexLength].ToString(); + consumed += hexLength; + return true; + } + + private static bool TryParseValueString( + ReadOnlySpan data, + [NotNullWhen(true)] out string? value, + out int consumed) + { + value = null; + consumed = 0; + + // We won't know the final byte count until after we process the + // value but it will not exceed the byte count of the input data. + using var pool = MemoryPool.Shared.Rent(Encoding.UTF8.GetByteCount(data)); + Span byteBuffer = pool.Memory.Span; + int bytesConsumed = 0; + + // Keep track of a buffer to encode only when an escape char is found. + ReadOnlySpan charBuffer = data; + int charsConsumed = 0; + int spaceCount = 0; + bool isStart = true; + + while (data.Length > 0) + { + char c = data[0]; + data = data[1..]; + consumed++; + charsConsumed++; + + // A trailing character must not end in a space, this keeps track + // of how many spaces (if any) to discard at the end. This is + // reset back to 0 when encounting a valid char. + if (c == ' ') + { + spaceCount++; + } + else if (c == '\\') + { + // Encode everything found so far into the byte buffer plus set + // the escaped byte. + if (charsConsumed > 1) + { + int encodedBytes = Encoding.UTF8.GetBytes( + charBuffer[..(charsConsumed - 1)], + byteBuffer[bytesConsumed..]); + bytesConsumed += encodedBytes; + } + + if (data.Length > 0 && (data[0] == '\\' || IsEscapableCharSpecial(data[0]))) + { + byteBuffer[bytesConsumed] = (byte)data[0]; + bytesConsumed++; + + data = data[1..]; + consumed++; + } + else if (data.Length > 1 && AbnfDecoder.IsHex(data[0]) && AbnfDecoder.IsHex(data[1])) + { + byteBuffer[bytesConsumed] = Convert.ToByte(data[..2].ToString(), 16); + bytesConsumed++; + + data = data[2..]; + consumed += 2; + } + else + { + return false; + } + + charBuffer = data; + charsConsumed = 0; + spaceCount = 0; + spaceCount = 0; + } + else if (c == ',' || c == '+') + { + // used to delineate another RDN or attribute in the same RDN. + charsConsumed--; + consumed--; + break; + } + else if (c == '\0' || IsEscapableCharEscaped(c) || (isStart && c == '#')) + { + // These chars cannot be present unless prefixed by \. + return false; + } + else + { + spaceCount = 0; + } + + isStart = false; + } + + // Remove any trailing spaces which we don't want in the final value. + charsConsumed -= spaceCount; + consumed -= spaceCount; // The whitespace is trimmed in parent func. + if (charsConsumed > 0) + { + int encodedBytes = Encoding.UTF8.GetBytes( + charBuffer[..charsConsumed], + byteBuffer[bytesConsumed..]); + bytesConsumed += encodedBytes; } - else + + if (bytesConsumed == 0) { return false; } + + value = Encoding.UTF8.GetString(byteBuffer[..bytesConsumed]); + return true; + } + + internal static int CountStartWhitespace(ReadOnlySpan data) + { + int consumed = 0; + while (data.Length > 0 && data[0] == ' ') + { + data = data[1..]; + consumed++; + } + return consumed; + } + + // https://datatracker.ietf.org/doc/html/rfc4514#section-3 - special + private static bool IsEscapableCharSpecial(char c) + => IsEscapableCharEscaped(c) || c == ' ' || c == '#' || c == '='; + + // https://datatracker.ietf.org/doc/html/rfc4514#section-3 - escaped + private static bool IsEscapableCharEscaped(char c) + => c == '"' || c == '+' || c == ',' || c == ';' || c == '<' || c == '>'; + + // https://datatracker.ietf.org/doc/html/rfc4514#section-2.4 + private static bool ShouldEscapeChar(char c, bool start = false, bool end = false) + { + if (start && (c == ' ' || c == '#')) + { + return true; + } + else if (end && c == ' ') + { + return true; + } + + return IsEscapableCharEscaped(c) || c == '\\'; } // Active Directory also needs to escape these 4 chars that aren't part of the RFC. // They are escaped using \ and the hex representation rather than just \ by itself. // https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ldap/distinguished-names - private static bool IsEscapableCharAD(char c) - => c == '\n' || c == '\r' || c == '=' || c == '/'; + private static bool ShouldHexEscapeChar(char c) + => c == '\0' || c == '\n' || c == '\r' || c == '=' || c == '/'; + + public override string ToString() + => _original ?? $"{Type}={EscapedValue}"; +} + +public class RelativeDistinguishedName +{ + private readonly string? _original; + public AttributeTypeAndValue[] Values { get; } + + public RelativeDistinguishedName(AttributeTypeAndValue[] values) + : this(values, null) + { } + + private RelativeDistinguishedName(AttributeTypeAndValue[] values, string? original) + { + Values = values; + _original = original; + } + + internal static bool TryParse( + ReadOnlySpan value, + [NotNullWhen(true)] out RelativeDistinguishedName? result, + out int consumed) + { + result = null; + consumed = AttributeTypeAndValue.CountStartWhitespace(value); + ReadOnlySpan buffer = value = value[consumed..]; + + List values = new(); + int remainingSpaces; + while (true) + { + if (!AttributeTypeAndValue.TryParse(buffer, out var atv, out int atvConsumed)) + { + return false; + } + + values.Add(atv); + buffer = buffer[atvConsumed..]; + consumed += atvConsumed; + + if (buffer.Length == 0 || buffer[0] == ',') + { + remainingSpaces = atvConsumed - atv.ToString().Length; + break; + } + + buffer = buffer[1..]; + consumed++; + } + + result = new(values.ToArray(), value[..(consumed - remainingSpaces)].ToString()); + return true; + } + + public override string ToString() + => _original ?? string.Join("+", Values.Select(v => v.ToString())); +} + +public class DistinguishedName +{ + private readonly string? _original; + + /// + /// The RelativeDistinguishedNames of the DN. Each entry contains at least + /// 1 AttributeTypeAndValue. + /// + public RelativeDistinguishedName[] RelativeNames { get; } + + public DistinguishedName(RelativeDistinguishedName[] rdns) + : this(rdns, null) + { } + + private DistinguishedName(RelativeDistinguishedName[] rdns, string? original) + { + RelativeNames = rdns; + _original = original; + } + + internal static DistinguishedName Parse(string? dn) + { + if (string.IsNullOrWhiteSpace(dn)) + { + return new(Array.Empty(), ""); + } + + ReadOnlySpan value = dn; + List rdns = new(); + while (value.Length > 0) + { + if (!RelativeDistinguishedName.TryParse(value, out var rdn, out var consumed)) + { + string msg = $"The input string '{dn}' was not a valid DistinguishedName"; + throw new ArgumentException(msg, nameof(dn)); + } + + rdns.Add(rdn); + value = value[consumed..]; + + if (value.Length > 0) + { + value = value[1..]; + } + } + + return new(rdns.ToArray(), dn); + } + + public override string ToString() + => _original ?? string.Join(",", RelativeNames.Select(r => r.ToString())); + + // Here for backwards compatibility. + public static string EscapeAttributeValue(ReadOnlySpan value) + => AttributeTypeAndValue.EscapeAttributeValue(value); } diff --git a/src/PSOpenAD/LDAP/LDAPSession.cs b/src/PSOpenAD/LDAP/LDAPSession.cs index 93295f3..36f9059 100644 --- a/src/PSOpenAD/LDAP/LDAPSession.cs +++ b/src/PSOpenAD/LDAP/LDAPSession.cs @@ -160,6 +160,25 @@ public int Modify( return request.MessageId; } + public int ModifyDN( + string entry, + string newRDN, + bool deleteOldRDN, + string? newSuperior = null, + IEnumerable? controls = null) + { + if (State == SessionState.Closed) + { + throw new InvalidOperationException( + "Cannot perform an ModifyDNRequest on a closed connection"); + } + + ModifyDNRequest request = new(NextMessageId(), controls, entry, newRDN, deleteOldRDN, newSuperior); + PutRequest(request); + + return request.MessageId; + } + public int Search(string baseObject, SearchScope scope, DereferencingPolicy derefAliases, int sizeLimit, int timeLimit, bool typesOnly, LDAPFilter filter, string[] attributeSelection, IEnumerable? controls = null) diff --git a/src/PSOpenAD/LDAP/Messages.cs b/src/PSOpenAD/LDAP/Messages.cs index 95e55bf..4a9cef8 100644 --- a/src/PSOpenAD/LDAP/Messages.cs +++ b/src/PSOpenAD/LDAP/Messages.cs @@ -183,6 +183,10 @@ public static LDAPMessage FromBytes(ReadOnlySpan data, out int bytesConsum return DelResponse.FromBytes(messageId, controls?.ToArray(), protocolOpBuffer, out var _, ruleSet: ruleSet); + case ModifyDNResponse.TAG_NUMBER: + return ModifyDNResponse.FromBytes(messageId, controls?.ToArray(), protocolOpBuffer, out var _, + ruleSet: ruleSet); + case SearchResultReference.TAG_NUMBER: return SearchResultReference.FromBytes(messageId, controls?.ToArray(), protocolOpBuffer, out var _, ruleSet: ruleSet); @@ -778,6 +782,95 @@ public static DelResponse FromBytes(int messageId, IEnumerable? con } } +/// LDAP Modify DN Operation +/// +/// The ASN.1 structure is defined as +/// ModifyDNRequest ::= [APPLICATION 12] SEQUENCE { +/// entry LDAPDN, +/// newrdn RelativeLDAPDN, +/// deleteoldrdn BOOLEAN, +/// newSuperior [0] LDAPDN OPTIONAL } +/// +/// 4.9. Modify DNOperation +internal class ModifyDNRequest : LDAPMessage +{ + public const int TAG_NUMBER = 12; + + public string Entry { get; set; } + + public string NewRDN { get; set; } + + public bool DeleteOldRDN { get; set; } + + public string? NewSuperior { get; set; } + + public ModifyDNRequest( + int messageId, + IEnumerable? controls, + string entry, + string newRDN, + bool deleteOldRDN, + string? newSuperior) + : base(messageId, controls) + { + Entry = entry; + NewRDN = newRDN; + DeleteOldRDN = deleteOldRDN; + NewSuperior = newSuperior; + } + + public override void ToBytes(AsnWriter writer) + { + using AsnWriter.Scope _1 = writer.PushSequence(new Asn1Tag(TagClass.Application, TAG_NUMBER, + true)); + + writer.WriteOctetString(Encoding.UTF8.GetBytes(Entry)); + writer.WriteOctetString(Encoding.UTF8.GetBytes(NewRDN)); + writer.WriteBoolean(DeleteOldRDN); + if (!string.IsNullOrWhiteSpace(NewSuperior)) + { + writer.WriteOctetString( + Encoding.UTF8.GetBytes(NewSuperior), + new Asn1Tag(TagClass.ContextSpecific, 0)); + } + } +} + +/// LDAP Modify DN Response +/// +/// The ASN.1 structure is defined as +/// ModifyDNResponse ::= [APPLICATION 13] LDAPResult +/// +/// 4.9. Modify DN Operation +internal class ModifyDNResponse : LDAPMessage +{ + public const int TAG_NUMBER = 13; + + /// The final result of a search operation. + public LDAPResult Result { get; internal set; } + + public ModifyDNResponse( + int messageId, + IEnumerable? controls, + LDAPResult result) + : base(messageId, controls) + { + Result = result; + } + + public static ModifyDNResponse FromBytes(int messageId, IEnumerable? controls, + ReadOnlySpan data, out int bytesConsumed, AsnEncodingRules ruleSet = AsnEncodingRules.BER) + { + bytesConsumed = 0; + + LDAPResult result = LDAPResult.FromBytes(data, out var consumed, ruleSet: ruleSet); + data = data[consumed..]; + bytesConsumed += consumed; + + return new ModifyDNResponse(messageId, controls, result); + } +} + /// LDAP Search Result Reference /// /// The ASN.1 structure is defined as diff --git a/src/PSOpenAD/Operations.cs b/src/PSOpenAD/Operations.cs index 8e48573..0d1db0b 100644 --- a/src/PSOpenAD/Operations.cs +++ b/src/PSOpenAD/Operations.cs @@ -115,6 +115,51 @@ public static ModifyResponse LdapModifyRequest( return modifyRes; } + /// Performs an LDAP modify DN operation. + /// The LDAP connection to perform the modify on. + /// The entry DN to manage. + /// The new RDN of the entry + /// Delete the old RDN attribute + /// If not null or whitespace, the new object to move the entry to. + /// Custom controls to use with the request + /// Token to cancel any network IO waits + /// The PSCmdlet that is running the operation. + /// The ModifyDNResponse from the request. + public static ModifyDNResponse LdapModifyDNRequest( + IADConnection connection, + string entry, + string newRDN, + bool deleteOldRDN, + string? newSuperior, + IList? controls, + CancellationToken cancelToken, + PSCmdlet? cmdlet) + { + string targetDN = string.IsNullOrWhiteSpace(newSuperior) ? string.Empty : $",{newSuperior}"; + cmdlet?.WriteVerbose($"Starting LDAP modify DN request for '{entry}'->'{newRDN}{targetDN}'"); + + int addId = connection.Session.ModifyDN( + entry, + newRDN, + deleteOldRDN, + newSuperior: newSuperior, + controls: controls); + ModifyDNResponse modifyRes = (ModifyDNResponse)connection.WaitForMessage(addId, cancelToken: cancelToken); + connection.RemoveMessageQueue(addId); + + if (modifyRes.Result.ResultCode != LDAPResultCode.Success) + { + ErrorRecord error = new( + new LDAPException($"Failed to modify DN '{entry}'->'{newRDN}{targetDN}'", modifyRes.Result), + "LDAPModifyFailure", + ErrorCategory.InvalidOperation, + null); + cmdlet?.WriteError(error); + } + + return modifyRes; + } + /// Performs an LDAP search operation. /// The LDAP connection to perform the search on. /// The search base of the query. diff --git a/tests/Move-OpenADObject.Tests.ps1 b/tests/Move-OpenADObject.Tests.ps1 new file mode 100644 index 0000000..1ba90c1 --- /dev/null +++ b/tests/Move-OpenADObject.Tests.ps1 @@ -0,0 +1,119 @@ +. ([IO.Path]::Combine($PSScriptRoot, 'common.ps1')) + +Describe "Move-OpenADObject cmdlets" -Skip:(-not $PSOpenADSettings.Server) { + BeforeAll { + $session = New-TestOpenADSession + $container = (New-OpenADObject -Session $session -Name "PSOpenAD-Test-$([Guid]::NewGuid().Guid)" -Type container -PassThru).DistinguishedName + $sub1 = (New-OpenADOBject -Session $session -Name "Container1" -Type container -Path $container -PassThru).DistinguishedName + $sub2 = (New-OpenADOBject -Session $session -Name "Container2" -Type container -Path $container -PassThru).DistinguishedName + } + + AfterAll { + if ($container) { + Get-OpenADObject -Session $session -LDAPFilter '(objectClass=*)' -SearchBase $container | + Sort-Object -Property { $_.DistinguishedName.Length } -Descending | + Remove-OpenADObject -Session $session + } + Get-OpenADSession | Remove-OpenADSession + } + + Context "Move-OpenADObject" { + It "Move normal object by -Identity DN" { + $obj = New-OpenADObject -Session $session -Name ParamByDN -Path $sub1 -Type container -PassThru + + Move-OpenADObject -Session $session -Identity $obj.DistinguishedName -TargetPath $sub2 + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.DistinguishedName | Should -Be "CN=ParamByDN,$sub2" + } + + It "Moves normal object by -Identity ObjectGuid" { + $obj = New-OpenADObject -Session $session -Name ParamByGuid -Path $sub1 -Type container -PassThru + + Move-OpenADObject -Session $session -Identity $obj.ObjectGuid -TargetPath $sub2 + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.DistinguishedName | Should -Be "CN=ParamByGuid,$sub2" + } + + It "Moves normal object by -Identity OpenADObject" { + $obj = New-OpenADObject -Session $session -Name ParamByObj -Path $sub1 -Type container -PassThru + + Move-OpenADObject -Session $session -Identity $obj -TargetPath $sub2 + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.DistinguishedName | Should -Be "CN=ParamByObj,$sub2" + } + + It "Moves normal object by pipeline" { + $obj = New-OpenADObject -Session $session -Name PipelineObj -Path $sub1 -Type container -PassThru + + $obj | Move-OpenADObject -Session $session -TargetPath $sub2 + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.DistinguishedName | Should -Be "CN=PipelineObj,$sub2" + } + + It "Moves object with complex name" { + $obj = New-OpenADObject -Session $session -Name '#Test=Obj\With,Complex+Name ' -Path $sub1 -Type container -PassThru + + $obj | Move-OpenADObject -Session $session -TargetPath $sub2 + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.Name | Should -Be '#Test=Obj\With,Complex+Name ' + $obj.DistinguishedName | Should -Be "CN=\#Test\3DObj\\With\,Complex\+Name\ ,$sub2" + } + + It "Moves with -PassThru" { + $obj = New-OpenADObject -Session $session -Name PassThruObj -Path $sub1 -Type container -PassThru + + $actual = $obj | Move-OpenADObject -Session $session -TargetPath $sub2 -PassThru + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.DistinguishedName | Should -Be "CN=PassThruObj,$sub2" + $actual.DistinguishedName | Should -Be $obj.DistinguishedName + $actual.ObjectGuid | Should -Be $obj.ObjectGuid + } + + It "Moves with -WhatIf" { + $obj = New-OpenADObject -Session $session -Name WhatIfObj -Path $sub1 -Type container -PassThru + + $obj | Move-OpenADObject -Session $session -TargetPath $sub2 -WhatIf + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.DistinguishedName | Should -Be "CN=WhatIfObj,$sub1" + } + + It "Moves with -WhatIf -PassThru" { + $obj = New-OpenADObject -Session $session -Name WhatIfPassThruObj -Path $sub1 -Type container -PassThru + + $actual = $obj | Move-OpenADObject -Session $session -TargetPath $sub2 -WhatIf -PassThru + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.DistinguishedName | Should -Be "CN=WhatIfPassThruObj,$sub1" + + $actual.DistinguishedName | Should -Be "CN=WhatIfPassThruObj,$sub2" + $actual.ObjectGuid | Should -Be ([Guid]::Empty) + } + + It "Fails with non-existing objectGuid -Identity" { + Move-OpenADObject -Session $session -Identity ([Guid]::Empty) -TargetPath $sub1 -ErrorAction SilentlyContinue -ErrorVariable err + $err.Count | Should -Be 1 + [string]$err[0] | Should -Be "Failed to find object to set using the filter '(objectGUID=\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00)'" + } + + It "Fails with invalid dn -Identity" { + Move-OpenADObject -Session $session -Identity "CN=Fake" -TargetPath 'CN=Foo,DC=domain' -ErrorAction SilentlyContinue -ErrorVariable err + $err.Count | Should -Be 1 + [string]$err[0] | Should -BeLike "Failed to modify DN 'CN=Fake'->'CN=Fake,CN=Foo,DC=domain': *" + } + } +} diff --git a/tests/Rename-OpenADObject.Tests.ps1 b/tests/Rename-OpenADObject.Tests.ps1 new file mode 100644 index 0000000..5f45632 --- /dev/null +++ b/tests/Rename-OpenADObject.Tests.ps1 @@ -0,0 +1,140 @@ +. ([IO.Path]::Combine($PSScriptRoot, 'common.ps1')) + +Describe "Rename-OpenADObject cmdlets" -Skip:(-not $PSOpenADSettings.Server) { + BeforeAll { + $session = New-TestOpenADSession + $container = (New-OpenADObject -Session $session -Name "PSOpenAD-Test-$([Guid]::NewGuid().Guid)" -Type container -PassThru).DistinguishedName + } + + AfterAll { + if ($container) { + Get-OpenADObject -Session $session -LDAPFilter '(objectClass=*)' -SearchBase $container | + Sort-Object -Property { $_.DistinguishedName.Length } -Descending | + Remove-OpenADObject -Session $session + } + Get-OpenADSession | Remove-OpenADSession + } + + Context "Rename-OpenADObject" { + It "Renames normal object by -Identity DN" { + $obj = New-OpenADObject -Session $session -Name ParamByDN -Path $container -Type container -PassThru + + Rename-OpenADObject -Session $session -Identity $obj.DistinguishedName -NewName ParamByDN2 + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.Name | Should -Be ParamByDN2 + $obj.DistinguishedName | Should -Be "CN=ParamByDN2,$container" + } + + It "Renames normal object by -Identity ObjectGuid" { + $obj = New-OpenADObject -Session $session -Name ParamByGuid -Path $container -Type container -PassThru + + Rename-OpenADObject -Session $session -Identity $obj.ObjectGuid -NewName ParamByGuid2 + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.Name | Should -Be ParamByGuid2 + $obj.DistinguishedName | Should -Be "CN=ParamByGuid2,$container" + } + + It "Renames normal object by -Identity OpenADObject" { + $obj = New-OpenADObject -Session $session -Name ParamByObj -Path $container -Type container -PassThru + + Rename-OpenADObject -Session $session -Identity $obj -NewName ParamByObj2 + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.Name | Should -Be ParamByObj2 + $obj.DistinguishedName | Should -Be "CN=ParamByObj2,$container" + } + + It "Renames normal object by pipeline" { + $obj = New-OpenADObject -Session $session -Name PipelineObj -Path $container -Type container -PassThru + + $obj | Rename-OpenADObject -Session $session -NewName { "$($_.Name)2" } + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.Name | Should -Be PipelineObj2 + $obj.DistinguishedName | Should -Be "CN=PipelineObj2,$container" + } + + It "Renames object with OU DN type" { + $obj = New-OpenADObject -Session $session -Name OUObj -Type organizationalUnit -PassThru + try { + $obj | Rename-OpenADObject -Session $session -NewName OUObjNewName + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.Name | Should -Be OUObjNewName + $obj.DistinguishedName | Should -Be "OU=OUObjNewName,$($session.DefaultNamingContext)" + } + finally { + $obj | Remove-OpenADObject -Session $session + } + } + + It "Renames object with complex name" { + $obj = New-OpenADObject -Session $session -Name '#Test=Obj\With,Complex+Name ' -Path $container -Type container -PassThru + + $obj | Rename-OpenADObject -Session $session -NewName { "$($_.Name)2 " } + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.Name | Should -Be '#Test=Obj\With,Complex+Name 2 ' + $obj.DistinguishedName | Should -Be "CN=\#Test\3DObj\\With\,Complex\+Name 2\ ,$container" + } + + It "Renames with -PassThru" { + $obj = New-OpenADObject -Session $session -Name PassThruObj -Path $container -Type container -PassThru + + $actual = $obj | Rename-OpenADObject -Session $session -NewName PassThruObj2 -PassThru + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.Name | Should -Be PassThruObj2 + $obj.DistinguishedName | Should -Be "CN=PassThruObj2,$container" + $actual.DistinguishedName | Should -Be $obj.DistinguishedName + $actual.ObjectGuid | Should -Be $obj.ObjectGuid + } + + It "Renames with -WhatIf" { + $obj = New-OpenADObject -Session $session -Name WhatIfObj -Path $container -Type container -PassThru + + $obj | Rename-OpenADObject -Session $session -NewName WhatIfObj2 -WhatIf + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.Name | Should -Be WhatIfObj + $obj.DistinguishedName | Should -Be "CN=WhatIfObj,$container" + } + + It "Renames with -WhatIf -PassThru" { + $obj = New-OpenADObject -Session $session -Name WhatIfPassThruObj -Path $container -Type container -PassThru + + $actual = $obj | Rename-OpenADObject -Session $session -NewName WhatIfPassThruObj2 -WhatIf -PassThru + + $obj = Get-OpenADObject -Session $session -Identity $obj.ObjectGuid + $obj | Should -Not -BeNullOrEmpty + $obj.Name | Should -Be WhatIfPassThruObj + $obj.DistinguishedName | Should -Be "CN=WhatIfPassThruObj,$container" + + $actual.DistinguishedName | Should -Be "CN=WhatIfPassThruObj2,$container" + $actual.Name | Should -Be WhatIfPassThruObj2 + $actual.ObjectGuid | Should -Be ([Guid]::Empty) + } + + It "Fails with non-existing objectGuid -Identity" { + Rename-OpenADObject -Session $session -Identity ([Guid]::Empty) -NewName test -ErrorAction SilentlyContinue -ErrorVariable err + $err.Count | Should -Be 1 + [string]$err[0] | Should -Be "Failed to find object to set using the filter '(objectGUID=\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00)'" + } + + It "Fails with invalid dn -Identity" { + Rename-OpenADObject -Session $session -Identity "CN=Fake" -NewName test -ErrorAction SilentlyContinue -ErrorVariable err + $err.Count | Should -Be 1 + [string]$err[0] | Should -BeLike "Failed to modify DN 'CN=Fake'->'CN=test': *" + } + } +} diff --git a/tests/units/DistinguishedNameTests.cs b/tests/units/DistinguishedNameTests.cs index 7d27355..4c7ed80 100644 --- a/tests/units/DistinguishedNameTests.cs +++ b/tests/units/DistinguishedNameTests.cs @@ -1,5 +1,5 @@ -using PSOpenAD.LDAP; using System; +using PSOpenAD.LDAP; using Xunit; namespace PSOpenADTests; @@ -32,4 +32,402 @@ public void EscapeAttributeValue(string value, string expected) string actual = DistinguishedName.EscapeAttributeValue(value); Assert.Equal(expected, actual); } + + [Theory] + // Various space permutations + [InlineData("CN=foo", 6, "CN", "foo", "foo")] + [InlineData("cn=foo", 6, "cn", "foo", "foo")] + [InlineData("CN= foo", 7, "CN", "foo", "foo")] + [InlineData("CN= foo", 8, "CN", "foo", "foo")] + [InlineData("CN =foo", 7, "CN", "foo", "foo")] + [InlineData("CN =foo", 8, "CN", "foo", "foo")] + [InlineData("CN = foo", 8, "CN", "foo", "foo")] + [InlineData("CN = foo", 10, "CN", "foo", "foo")] + [InlineData(" CN = foo", 9, "CN", "foo", "foo")] + [InlineData(" CN = foo", 10, "CN", "foo", "foo")] + [InlineData("CN = foo ", 9, "CN", "foo", "foo")] + [InlineData("CN = foo ", 10, "CN", "foo", "foo")] + [InlineData(" CN = foo ", 10, "CN", "foo", "foo")] + [InlineData(" CN = foo ", 12, "CN", "foo", "foo")] + // Escaping starting characters + [InlineData("cn=\\#abc", 8, "cn", "#abc", "\\#abc")] + [InlineData("cn=\\ abc", 8, "cn", " abc", "\\ abc")] + [InlineData("cn=\\ abc", 9, "cn", " abc", "\\ abc")] + [InlineData("cn= \\ abc", 9, "cn", " abc", "\\ abc")] + [InlineData("cn= \\ abc", 10, "cn", " abc", "\\ abc")] + [InlineData("cn= \\ abc", 10, "cn", " abc", "\\ abc")] + [InlineData("cn= \\ abc", 11, "cn", " abc", "\\ abc")] + // Escaping literal characters + [InlineData("cn=foo\\\\bar", 11, "cn", "foo\\bar", "foo\\\\bar")] + [InlineData("cn=foo\\\"bar", 11, "cn", "foo\"bar", "foo\\\"bar")] + [InlineData("cn=foo\\+bar", 11, "cn", "foo+bar", "foo\\+bar")] + [InlineData("cn=foo\\,bar", 11, "cn", "foo,bar", "foo\\,bar")] + [InlineData("cn=foo\\;bar", 11, "cn", "foo;bar", "foo\\;bar")] + [InlineData("cn=foo\\bar", 11, "cn", "foo>bar", "foo\\>bar")] + [InlineData("cn=foo\\ bar", 11, "cn", "foo bar", "foo bar")] + [InlineData("cn=foo\\#bar", 11, "cn", "foo#bar", "foo#bar")] + [InlineData("cn=foo\\=bar", 11, "cn", "foo=bar", "foo\\3Dbar")] + // Escaping hex characters + [InlineData("cn=foo\\00bar", 12, "cn", "foo\0bar", "foo\\00bar")] + [InlineData("cn=foo\\4Ebar", 12, "cn", "fooNbar", "fooNbar")] + [InlineData("cn=foo\\4ebar", 12, "cn", "fooNbar", "fooNbar")] + // RFC examples + [InlineData( + "cn=James \\\"Jim\\\" Smith\\, III", + 28, + "cn", + "James \"Jim\" Smith, III", + "James \\\"Jim\\\" Smith\\, III")] + [InlineData( + "CN=Before\\0dAfter", + 17, + "CN", + "Before\rAfter", + "Before\\0DAfter")] + [InlineData( + "CN=Lu\\C4\\8Di\\C4\\87", + 18, + "CN", + "Lučić", + "Lučić")] + // OID and Hex values + [InlineData( + "1.3.6.1.4.1.1466.0=#FE04024869", + 30, + "1.3.6.1.4.1.1466.0", + "#FE04024869", + "#FE04024869")] + public void AttributeTypeAndValueParse( + string inputString, + int expectedRead, + string expectedType, + string expectedValue, + string expectedEscapedValue) + { + bool wasValid = AttributeTypeAndValue.TryParse(inputString, out var actual, out var actualRead); + + Assert.True(wasValid); + Assert.NotNull(actual); + Assert.Equal(expectedRead, actualRead); + Assert.Equal(expectedType, actual.Type); + Assert.Equal(expectedValue, actual.Value); + Assert.Equal(expectedEscapedValue, actual.EscapedValue); + Assert.Equal(inputString.Trim(' '), actual.ToString()); + } + + [Theory] + [InlineData("cn=foo\\ ", 8, "cn", "foo ", "foo\\ ", "cn=foo\\ ")] + [InlineData("cn=foo\\ ", 9, "cn", "foo ", "foo\\ ", "cn=foo\\ ")] + [InlineData("cn=foo \\ ", 9, "cn", "foo ", "foo \\ ", "cn=foo \\ ")] + [InlineData("cn=foo \\ ", 10, "cn", "foo ", "foo \\ ", "cn=foo \\ ")] + public void AttributeTypeAndValueParseEscapedEndChars( + string inputString, + int expectedRead, + string expectedType, + string expectedValue, + string expectedEscapedValue, + string expectedToString) + { + bool wasValid = AttributeTypeAndValue.TryParse(inputString, out var actual, out var actualRead); + + Assert.True(wasValid); + Assert.NotNull(actual); + Assert.Equal(expectedRead, actualRead); + Assert.Equal(expectedType, actual.Type); + Assert.Equal(expectedValue, actual.Value); + Assert.Equal(expectedEscapedValue, actual.EscapedValue); + Assert.Equal(expectedToString, actual.ToString()); + } + + [Theory] + [InlineData("CN=foo,DC=domain", 6, "CN", "foo", "foo", "CN=foo")] + [InlineData("CN=foo+DC=domain", 6, "CN", "foo", "foo", "CN=foo")] + [InlineData("CN=foo ,DC=domain", 8, "CN", "foo", "foo", "CN=foo")] + [InlineData("CN=#FE04024869 ,DC=domain", 16, "CN", "#FE04024869", "#FE04024869", "CN=#FE04024869")] + public void AttributeTypeAndValueParseWithExtraData( + string inputString, + int expectedRead, + string expectedType, + string expectedValue, + string expectedEscapedValue, + string expectedToString) + { + bool wasValid = AttributeTypeAndValue.TryParse(inputString, out var actual, out var actualRead); + + Assert.True(wasValid); + Assert.NotNull(actual); + Assert.Equal(expectedRead, actualRead); + Assert.Equal(expectedType, actual.Type); + Assert.Equal(expectedValue, actual.Value); + Assert.Equal(expectedEscapedValue, actual.EscapedValue); + Assert.Equal(expectedToString, actual.ToString()); + } + + [Theory] + // No separator + [InlineData("CN")] + // No value + [InlineData("CN=")] + // Type is invalid + [InlineData("CN_DEF=value")] + [InlineData("1CN=value")] + [InlineData("1=value")] + // Value starts with # but isn't valid hex + [InlineData("cn=#")] + [InlineData("cn=#gh")] + [InlineData("cn=#12a")] + // Value contains unescaped chars + [InlineData("cn=foo\0")] + [InlineData("cn=foo\"")] + [InlineData("cn=foo;")] + [InlineData("cn=foo<")] + [InlineData("cn=foo>")] + // Value contains invalid escape chars + [InlineData("cn=foo\\")] + [InlineData("cn=foo\\a")] + [InlineData("cn=foo\\\0")] + // Value contains invalid escape hex pairs + [InlineData("cn=foo\\0")] + [InlineData("cn=foo\\0g")] + [InlineData("cn=foo\\ag")] + public void AttributeTypeAndValueParseFailure(string inputString) + { + bool wasValid = AttributeTypeAndValue.TryParse(inputString, out var actual, out var _); + + Assert.False(wasValid); + Assert.Null(actual); + } + + [Theory] + [InlineData("foo", "foo")] + [InlineData("Foo", "Foo")] + [InlineData(" foo", "\\ foo")] + [InlineData(" foo", "\\ foo")] + [InlineData("foo ", "foo\\ ")] + [InlineData("foo ", "foo \\ ")] + [InlineData("#test", "\\#test")] + [InlineData("foo\\bar", "foo\\\\bar")] + [InlineData("foo\0", "foo\\00")] + public void CreateATVWithString(string inputString, string expectedEscapedValue) + { + AttributeTypeAndValue actual = new("CN", inputString); + + Assert.Equal("CN", actual.Type); + Assert.Equal(expectedEscapedValue, actual.EscapedValue); + Assert.False(actual.IsASN1EncodedValue); + Assert.Equal($"CN={expectedEscapedValue}", actual.ToString()); + } + + [Fact] + public void CreateATVWithByteArray() + { + const string expectedValue = "#0403416263"; + AttributeTypeAndValue actual = new("cn", new byte[] { 4, 3, 65, 98, 99 }); + + Assert.Equal("cn", actual.Type); + Assert.Equal(expectedValue, actual.Value); + Assert.True(actual.IsASN1EncodedValue); + Assert.Equal(expectedValue, actual.EscapedValue); + Assert.Equal($"cn={expectedValue}", actual.ToString()); + } + + [Fact] + public void ParseRelativeDistinguishedNameSingleAttribute() + { + const string rdnString = "cn=foo"; + + bool wasValid = RelativeDistinguishedName.TryParse(rdnString, out var actual, out var consumed); + + Assert.True(wasValid); + Assert.NotNull(actual); + Assert.Equal(6, consumed); + Assert.Equal(rdnString, actual.ToString()); + Assert.Single(actual.Values); + Assert.Equal("cn", actual.Values[0].Type); + Assert.Equal("foo", actual.Values[0].Value); + } + + [Fact] + public void ParseRelativeDistinguishedNameMuliAttribute() + { + const string rdnString = "cn=foo+Name=value\\+test+other=bar"; + + bool wasValid = RelativeDistinguishedName.TryParse(rdnString, out var actual, out var consumed); + + Assert.True(wasValid); + Assert.NotNull(actual); + Assert.Equal(33, consumed); + Assert.Equal(rdnString, actual.ToString()); + Assert.Equal(3, actual.Values.Length); + Assert.Equal("cn", actual.Values[0].Type); + Assert.Equal("foo", actual.Values[0].Value); + Assert.Equal("Name", actual.Values[1].Type); + Assert.Equal("value+test", actual.Values[1].Value); + Assert.Equal("other", actual.Values[2].Type); + Assert.Equal("bar", actual.Values[2].Value); + } + + [Fact] + public void ParseRelativeDistinguishedNameWithExtraData() + { + const string rdnString = " cn = foo + Name = value\\+test + other = bar , test=value"; + + bool wasValid = RelativeDistinguishedName.TryParse(rdnString, out var actual, out var consumed); + + Assert.True(wasValid); + Assert.NotNull(actual); + Assert.Equal(45, consumed); + Assert.Equal("cn = foo + Name = value\\+test + other = bar", actual.ToString()); + Assert.Equal(3, actual.Values.Length); + Assert.Equal("cn", actual.Values[0].Type); + Assert.Equal("foo", actual.Values[0].Value); + Assert.Equal("Name", actual.Values[1].Type); + Assert.Equal("value+test", actual.Values[1].Value); + Assert.Equal("other", actual.Values[2].Type); + Assert.Equal("bar", actual.Values[2].Value); + } + + [Theory] + [InlineData("")] + [InlineData("CN")] + [InlineData("CN=")] + [InlineData("CN=fake\\")] + [InlineData("CN=foo+")] + [InlineData("CN=foo+cn")] + [InlineData("CN=foo+cn=")] + [InlineData("CN=foo+cn=invalid\\")] + public void ParseRelativeDistinguishedNameFailure(string inputString) + { + bool wasValid = RelativeDistinguishedName.TryParse(inputString, out var actual, out var _); + + Assert.False(wasValid); + Assert.Null(actual); + } + + [Fact] + public void CreateRelativeDistinguishedNameSingle() + { + const string expected = "cn=foo\\0Abar"; + RelativeDistinguishedName actual = new(new[] + { + new AttributeTypeAndValue("cn", "foo\nbar"), + }); + + Assert.Single(actual.Values); + Assert.Equal(expected, actual.ToString()); + } + + [Fact] + public void CreateRelativeDistinguishedNameMulti() + { + const string expected = "cn=foo\\ +uid=123"; + RelativeDistinguishedName actual = new(new[] + { + new AttributeTypeAndValue("cn", "foo "), + new AttributeTypeAndValue("uid", "123"), + }); + + Assert.Equal(2, actual.Values.Length); + Assert.Equal(expected, actual.ToString()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ParseDistinguishedNameEmpty(string? inputValue) + { + DistinguishedName dn = DistinguishedName.Parse(inputValue); + + Assert.Empty(dn.RelativeNames); + Assert.Equal("", dn.ToString()); + } + + [Fact] + public void ParseDistinguishedNameOneValue() + { + const string dnString = "CN=foo+uid=123"; + DistinguishedName actual = DistinguishedName.Parse(dnString); + + Assert.Single(actual.RelativeNames); + Assert.Equal(2, actual.RelativeNames[0].Values.Length); + Assert.Equal(dnString, actual.ToString()); + } + + [Fact] + public void ParseDistinguishedNameMultiValue() + { + const string dnString = " CN = foo + uid = 123 , dc= domain ,dc= test "; + const string expectedRdn1 = "CN = foo + uid = 123"; + const string expectedRdn2 = "dc= domain "; + const string expectedRdn3 = "dc= test"; + + DistinguishedName actual = DistinguishedName.Parse(dnString); + + Assert.Equal(3, actual.RelativeNames.Length); + Assert.Equal(dnString, actual.ToString()); + + Assert.Equal(2, actual.RelativeNames[0].Values.Length); + Assert.Equal("CN", actual.RelativeNames[0].Values[0].Type); + Assert.Equal("foo", actual.RelativeNames[0].Values[0].Value); + Assert.Equal("uid", actual.RelativeNames[0].Values[1].Type); + Assert.Equal("123", actual.RelativeNames[0].Values[1].Value); + Assert.Equal(expectedRdn1, actual.RelativeNames[0].ToString()); + + Assert.Single(actual.RelativeNames[1].Values); + Assert.Equal("dc", actual.RelativeNames[1].Values[0].Type); + Assert.Equal("domain", actual.RelativeNames[1].Values[0].Value); + Assert.Equal(expectedRdn2, actual.RelativeNames[1].ToString()); + + Assert.Single(actual.RelativeNames[2].Values); + Assert.Equal("dc", actual.RelativeNames[2].Values[0].Type); + Assert.Equal("test", actual.RelativeNames[2].Values[0].Value); + Assert.Equal(expectedRdn3, actual.RelativeNames[2].ToString()); + } + + [Theory] + [InlineData("CN=foo\\")] + [InlineData("CN=foo+")] + [InlineData("CN=foo,DC")] + public void ParseDistinguishedNameFail(string inputString) + { + var ex = Assert.Throws(() => DistinguishedName.Parse(inputString)); + + Assert.Equal($"The input string '{inputString}' was not a valid DistinguishedName (Parameter 'dn')", ex.Message); + } + + [Fact] + public void CreateDistinguishedNameSingle() + { + const string expected = "cn=foo"; + + DistinguishedName actual = new(new[] + { + new RelativeDistinguishedName(new[] { new AttributeTypeAndValue("cn", "foo") }), + }); + + Assert.Equal(expected, actual.ToString()); + } + + [Fact] + public void CreateDistinguishedNameMulti() + { + const string expected = "cn=foo+uid=123,dc=domain"; + + DistinguishedName actual = new(new[] + { + new RelativeDistinguishedName(new[] + { + new AttributeTypeAndValue("cn", "foo"), + new AttributeTypeAndValue("uid", "123"), + }), + new RelativeDistinguishedName(new[] + { + new AttributeTypeAndValue("dc", "domain"), + }), + }); + + Assert.Equal(expected, actual.ToString()); + } }