@@ -5,6 +5,7 @@ pub mod app;
55pub mod config;
66pub mod form;
77pub mod ldap;
8+ pub mod passwd;
89pub mod samba;
910pub mod schema;
1011pub mod testdata;
@@ -149,11 +150,85 @@ fn is_samba_account(object_classes: &[String]) -> bool {
149150///
150151/// Factored out of `main` (no `rpassword`, no terminal) so the live test can drive
151152/// it with a known password.
153+ /// Search `base` (subtree/base scope per `scope`) for `filter`, returning the
154+ /// matching entries with their `objectClass` values. The shared LDAP round-trip
155+ /// behind both passwd-target resolution branches.
156+ fn search_object_classes (
157+ worker : & WorkerHandle ,
158+ base : & str ,
159+ scope : SearchScope ,
160+ filter : & str ,
161+ ) -> Result < Vec < crate :: ldap:: worker:: LdapEntry > > {
162+ match worker. request ( Request :: Search {
163+ id : 1 ,
164+ base : base. to_string ( ) ,
165+ scope,
166+ filter : filter. to_string ( ) ,
167+ attrs : vec ! [ "objectClass" . to_string( ) ] ,
168+ size_limit : None ,
169+ } ) ? {
170+ Response :: Entries { entries, .. } => Ok ( entries) ,
171+ Response :: SearchError { msg, .. } => Err ( anyhow ! ( "searching {base}: {msg}" ) ) ,
172+ other => Err ( anyhow ! (
173+ "unexpected response searching {base}: {}" ,
174+ describe_response( & other)
175+ ) ) ,
176+ }
177+ }
178+
179+ /// Resolve the `passwd` argument to a concrete `(dn, objectClass values)`. A DN
180+ /// argument is base-read to confirm it exists; a bare username is searched across
181+ /// every configured profile's `search_base`, requiring a single match.
182+ fn resolve_passwd_target (
183+ worker : & WorkerHandle ,
184+ profiles : & [ crate :: config:: EntryProfile ] ,
185+ arg : & str ,
186+ ) -> Result < ( String , Vec < String > ) > {
187+ if passwd:: looks_like_dn ( arg) {
188+ let entry = search_object_classes ( worker, arg, SearchScope :: Base , "(objectClass=*)" ) ?
189+ . into_iter ( )
190+ . next ( )
191+ . ok_or_else ( || anyhow ! ( "entry not found: {arg}" ) ) ?;
192+ let ocs = entry. attrs . get ( "objectClass" ) . cloned ( ) . unwrap_or_default ( ) ;
193+ return Ok ( ( entry. dn , ocs) ) ;
194+ }
195+
196+ // Bare username: gather candidates across every profile's search base.
197+ let mut matched: Vec < crate :: ldap:: worker:: LdapEntry > = Vec :: new ( ) ;
198+ for ( base, filter) in passwd:: username_searches ( profiles, arg) {
199+ matched. extend ( search_object_classes (
200+ worker,
201+ & base,
202+ SearchScope :: Subtree ,
203+ & filter,
204+ ) ?) ;
205+ }
206+ let dns = matched. iter ( ) . map ( |e| e. dn . clone ( ) ) . collect ( ) ;
207+ match passwd:: resolve_outcome ( dns) {
208+ passwd:: Resolution :: Unique ( dn) => {
209+ let ocs = matched
210+ . into_iter ( )
211+ . find ( |e| e. dn . eq_ignore_ascii_case ( & dn) )
212+ . and_then ( |e| e. attrs . get ( "objectClass" ) . cloned ( ) )
213+ . unwrap_or_default ( ) ;
214+ Ok ( ( dn, ocs) )
215+ }
216+ passwd:: Resolution :: NotFound => Err ( anyhow ! (
217+ "no entry found for username \" {arg}\" in any configured profile; \
218+ pass a full DN instead"
219+ ) ) ,
220+ passwd:: Resolution :: Ambiguous ( dns) => Err ( anyhow ! (
221+ "username \" {arg}\" matches multiple entries:\n {}\n pass a full DN instead" ,
222+ dns. join( "\n " )
223+ ) ) ,
224+ }
225+ }
226+
152227pub fn run_passwd (
153228 config : Config ,
154229 bind_password : String ,
155- target_dn : & str ,
156- new_password : & str ,
230+ target_arg : & str ,
231+ prompt : impl FnOnce ( & str ) -> Result < String > ,
157232) -> Result < String > {
158233 // TLS gate FIRST — before spawning the worker or touching the network.
159234 if !samba:: password:: is_secure ( & config. server ) {
@@ -163,42 +238,28 @@ pub fn run_passwd(
163238 ) ) ;
164239 }
165240
241+ // Profiles are needed for username resolution after `config` moves into the
242+ // worker, so capture them first.
243+ let profiles = config. profiles . clone ( ) ;
166244 let worker = WorkerHandle :: spawn ( config, bind_password) ?;
167245
168- // Read the target's objectClass values to detect a sambaSamAccount.
169- let object_classes = match worker. request ( Request :: Search {
170- id : 1 ,
171- base : target_dn. to_string ( ) ,
172- scope : SearchScope :: Base ,
173- filter : "(objectClass=*)" . to_string ( ) ,
174- attrs : vec ! [ "objectClass" . to_string( ) ] ,
175- size_limit : None ,
176- } ) ? {
177- Response :: Entries { entries, .. } => entries
178- . into_iter ( )
179- . next ( )
180- . ok_or_else ( || anyhow ! ( "entry not found: {target_dn}" ) ) ?
181- . attrs
182- . get ( "objectClass" )
183- . cloned ( )
184- . unwrap_or_default ( ) ,
185- Response :: SearchError { msg, .. } => return Err ( anyhow ! ( msg) ) ,
186- other => {
187- return Err ( anyhow ! (
188- "unexpected response reading {target_dn}: {}" ,
189- describe_response( & other)
190- ) )
191- }
192- } ;
246+ // Resolve the target to a concrete DN (and its objectClass values, used for
247+ // samba detection) BEFORE prompting for the new password, so an unknown or
248+ // ambiguous username fails fast instead of after two password entries.
249+ let ( target_dn, object_classes) = resolve_passwd_target ( & worker, & profiles, target_arg) ?;
193250 let is_samba = is_samba_account ( & object_classes) ;
194251
252+ // Now that the target is known, prompt for the new password.
253+ let new_password = prompt ( & target_dn) ?;
254+ let target_dn = target_dn. as_str ( ) ;
255+
195256 // Domain discovery is intentionally omitted from the password path:
196257 // `build_password_mods` derives `sambaNTPassword` from the cleartext alone and
197258 // never needs the domain SID / RID base (those are only required when
198259 // *creating* a samba account, not when re-setting its password). Keeping the
199260 // flow free of best-effort discovery keeps it correct and simple.
200261
201- let mods = samba:: password:: build_password_mods ( new_password, is_samba, now_unix_secs ( ) ?) ;
262+ let mods = samba:: password:: build_password_mods ( & new_password, is_samba, now_unix_secs ( ) ?) ;
202263
203264 match worker. request ( Request :: Modify {
204265 id : 2 ,
0 commit comments