5252import jakarta .servlet .http .HttpServletResponse ;
5353import java .io .File ;
5454import java .io .IOException ;
55+ import java .nio .file .Files ;
56+ import java .nio .file .StandardCopyOption ;
5557import java .util .ArrayList ;
5658import java .util .Arrays ;
5759import java .util .Collection ;
5860import java .util .Collections ;
5961import java .util .HashSet ;
6062import java .util .List ;
63+ import java .util .Locale ;
6164import java .util .Map ;
6265import java .util .Objects ;
6366import java .util .Set ;
6770import java .util .function .Predicate ;
6871import java .util .logging .Level ;
6972import java .util .logging .Logger ;
73+ import java .util .regex .Pattern ;
7074import jenkins .model .IdStrategy ;
7175import jenkins .model .Jenkins ;
7276import jenkins .model .Loadable ;
7377import jenkins .model .ModelObjectWithContextMenu ;
7478import jenkins .scm .RunWithSCM ;
7579import jenkins .search .SearchGroup ;
80+ import jenkins .security .HMACConfidentialKey ;
7681import jenkins .security .ImpersonatingUserDetailsService2 ;
7782import jenkins .security .LastGrantedAuthoritiesProperty ;
7883import jenkins .security .UserDetailsCache ;
@@ -174,7 +179,7 @@ public class User extends AbstractModelObject implements AccessControlled, Descr
174179
175180 @ SuppressFBWarnings (value = "SS_SHOULD_BE_STATIC" , justification = "Reserved for future use" )
176181 private final int version = 10 ; // Not currently used, but it may be helpful in the future to store a version.
177- private String id ;
182+ String id ;
178183 private volatile String fullName ;
179184 private volatile String description ;
180185
@@ -185,6 +190,8 @@ public class User extends AbstractModelObject implements AccessControlled, Descr
185190 XSTREAM .alias ("user" , User .class );
186191 }
187192
193+ private User () {}
194+
188195 private User (String id , String fullName ) {
189196 this .id = id ;
190197 this .fullName = fullName ;
@@ -199,6 +206,10 @@ public void load() {
199206 private void load (String userId ) {
200207 clearExistingProperties ();
201208 loadFromUserConfigFile (userId );
209+ fixUpAfterLoad ();
210+ }
211+
212+ private void fixUpAfterLoad () {
202213 removeNullsThatFailedToLoad ();
203214 allocateDefaultPropertyInstancesAsNeeded ();
204215 setUserToProperties ();
@@ -227,7 +238,7 @@ private void removeNullsThatFailedToLoad() {
227238 private void loadFromUserConfigFile (String userId ) {
228239 XmlFile config = getConfigFile ();
229240 try {
230- if (config != null && config .exists ()) {
241+ if (config .exists ()) {
231242 config .unmarshal (this );
232243 this .id = userId ;
233244 }
@@ -241,8 +252,7 @@ private void clearExistingProperties() {
241252 }
242253
243254 private XmlFile getConfigFile () {
244- File existingUserFolder = getExistingUserFolder ();
245- return existingUserFolder == null ? null : new XmlFile (XSTREAM , new File (existingUserFolder , CONFIG_XML ));
255+ return new XmlFile (XSTREAM , new File (getUserFolderFor (id ), CONFIG_XML ));
246256 }
247257
248258 /**
@@ -571,10 +581,10 @@ public void doSubmitDescription(StaplerRequest2 req, StaplerResponse2 rsp) throw
571581 */
572582 private static @ Nullable User getOrCreateById (@ NonNull String id , @ NonNull String fullName , boolean create ) {
573583 User u = AllUsers .get (id );
574- if (u == null && ( create || UserIdMapper . getInstance (). isMapped ( id )) ) {
584+ if (u == null && create ) {
575585 u = new User (id , fullName );
576586 AllUsers .put (id , u );
577- if (!id .equals (fullName ) && ! UserIdMapper . getInstance (). isMapped ( id ) ) {
587+ if (!id .equals (fullName )) {
578588 try {
579589 u .save ();
580590 } catch (IOException x ) {
@@ -691,7 +701,6 @@ public void doSubmitDescription(StaplerRequest2 req, StaplerResponse2 rsp) throw
691701 */
692702 @ Restricted (Beta .class )
693703 public static void reload () throws IOException {
694- UserIdMapper .getInstance ().reload ();
695704 AllUsers .reload ();
696705 }
697706
@@ -708,6 +717,32 @@ public static void rekey() {
708717 or greater issues in the realm change, could affect currently logged
709718 in users and even the user making the change. */
710719 try {
720+ var subdirectories = getRootDir ().listFiles ();
721+ if (subdirectories != null ) {
722+ for (var oldDirectory : subdirectories ) {
723+ var dirName = oldDirectory .getName ();
724+ if (!HASHED_DIRNAMES .matcher (dirName ).matches ()) {
725+ continue ;
726+ }
727+ var xml = new XmlFile (XSTREAM , new File (oldDirectory , CONFIG_XML ));
728+ if (!xml .exists ()) {
729+ continue ;
730+ }
731+ try {
732+ var user = (User ) xml .read ();
733+ if (user .id == null ) {
734+ continue ;
735+ }
736+ var newDirectory = getUserFolderFor (user .id );
737+ if (!oldDirectory .equals (newDirectory )) {
738+ Files .move (oldDirectory .toPath (), newDirectory .toPath (), StandardCopyOption .REPLACE_EXISTING );
739+ LOGGER .info (() -> "migrated " + oldDirectory + " to " + newDirectory );
740+ }
741+ } catch (Exception x ) {
742+ LOGGER .log (Level .WARNING , "failed to migrate " + xml , x );
743+ }
744+ }
745+ }
711746 reload ();
712747 } catch (IOException e ) {
713748 LOGGER .log (Level .SEVERE , "Failed to perform rekey operation." , e );
@@ -777,17 +812,9 @@ public static void clear() {
777812 if (ExtensionList .lookup (AllUsers .class ).isEmpty ()) {
778813 return ;
779814 }
780- UserIdMapper .getInstance ().clear ();
781815 AllUsers .clear ();
782816 }
783817
784- private static File getConfigFileFor (String id ) {
785- return new File (getUserFolderFor (id ), "config.xml" );
786- }
787-
788- private static File getUserFolderFor (String id ) {
789- return new File (getRootDir (), idStrategy ().filenameOf (id ));
790- }
791818 /**
792819 * Returns the folder that store all the user information.
793820 * Useful for plugins to save a user-specific file aside the config.xml.
@@ -799,11 +826,8 @@ private static File getUserFolderFor(String id) {
799826 */
800827
801828 public @ CheckForNull File getUserFolder () {
802- return getExistingUserFolder ();
803- }
804-
805- private @ CheckForNull File getExistingUserFolder () {
806- return UserIdMapper .getInstance ().getDirectory (id );
829+ var d = getUserFolderFor (id );
830+ return d .isDirectory () ? d : null ;
807831 }
808832
809833 /**
@@ -813,6 +837,21 @@ static File getRootDir() {
813837 return new File (Jenkins .get ().getRootDir (), "users" );
814838 }
815839
840+ private static final int PREFIX_MAX = 14 ;
841+ private static final Pattern DISALLOWED_PREFIX_CHARS = Pattern .compile ("[^A-Za-z0-9]" );
842+ static final Pattern HASHED_DIRNAMES = Pattern .compile ("[a-z0-9]{0," + PREFIX_MAX + "}_[a-f0-9]{64}" );
843+ private static final HMACConfidentialKey DIRNAMES = new HMACConfidentialKey (User .class , "DIRNAMES" );
844+
845+ private static String getUserFolderNameFor (String id ) {
846+ var fullPrefix = DISALLOWED_PREFIX_CHARS .matcher (id ).replaceAll ("" ).toLowerCase (Locale .ROOT );
847+ return (fullPrefix .length () > PREFIX_MAX ? fullPrefix .substring (0 , PREFIX_MAX ) : fullPrefix ) + '_' + DIRNAMES .mac (idStrategy ().keyFor (id ));
848+ }
849+
850+ @ SuppressFBWarnings (value = "PATH_TRAVERSAL_IN" , justification = "sanitized" )
851+ static File getUserFolderFor (String id ) {
852+ return new File (getRootDir (), getUserFolderNameFor (id ));
853+ }
854+
816855 /**
817856 * Is the ID allowed? Some are prohibited for security reasons. See SECURITY-166.
818857 * <p>
@@ -852,39 +891,23 @@ public synchronized void save() throws IOException {
852891 if (BulkChange .contains (this )) {
853892 return ;
854893 }
855- XmlFile xmlFile = new XmlFile ( XSTREAM , constructUserConfigFile () );
894+ XmlFile xmlFile = getConfigFile ( );
856895 xmlFile .write (this );
857896 SaveableListener .fireOnChange (this , xmlFile );
858897 }
859898
860- private File constructUserConfigFile () throws IOException {
861- return new File (putUserFolderIfAbsent (), CONFIG_XML );
862- }
863-
864- private File putUserFolderIfAbsent () throws IOException {
865- return UserIdMapper .getInstance ().putIfAbsent (id , true );
866- }
867-
868899 /**
869900 * Deletes the data directory and removes this user from Hudson.
870901 *
871902 * @throws IOException if we fail to delete.
872903 */
873904 public void delete () throws IOException {
874905 String idKey = idStrategy ().keyFor (id );
875- File existingUserFolder = getExistingUserFolder ();
876- UserIdMapper .getInstance ().remove (id );
877906 AllUsers .remove (id );
878- deleteExistingUserFolder ( existingUserFolder );
907+ Util . deleteRecursive ( getUserFolderFor ( id ) );
879908 UserDetailsCache .get ().invalidate (idKey );
880909 }
881910
882- private void deleteExistingUserFolder (File existingUserFolder ) throws IOException {
883- if (existingUserFolder != null && existingUserFolder .exists ()) {
884- Util .deleteRecursive (existingUserFolder );
885- }
886- }
887-
888911 /**
889912 * Exposed remote API.
890913 */
@@ -947,7 +970,7 @@ public ACL getACL() {
947970 public boolean canDelete () {
948971 final IdStrategy strategy = idStrategy ();
949972 return hasPermission (Jenkins .ADMINISTER ) && !strategy .equals (id , Jenkins .getAuthentication2 ().getName ())
950- && UserIdMapper . getInstance (). isMapped ( id ) ;
973+ && getUserFolder () != null ;
951974 }
952975
953976 /**
@@ -1077,11 +1100,50 @@ public static final class AllUsers {
10771100 private final ConcurrentMap <String , User > byName = new ConcurrentHashMap <>();
10781101
10791102 @ Initializer (after = InitMilestone .JOB_CONFIG_ADAPTED )
1080- public static void scanAll () {
1081- for (String userId : UserIdMapper .getInstance ().getConvertedUserIds ()) {
1082- User user = new User (userId , userId );
1083- getInstance ().byName .putIfAbsent (idStrategy ().keyFor (userId ), user );
1103+ public static void scanAll () throws IOException {
1104+ DIRNAMES .createMac (); // force the key to be saved during startup
1105+ var subdirectories = getRootDir ().listFiles ();
1106+ if (subdirectories == null ) {
1107+ return ;
1108+ }
1109+ var byName = getInstance ().byName ;
1110+ var idStrategy = idStrategy ();
1111+ for (var dir : subdirectories ) {
1112+ var dirName = dir .getName ();
1113+ if (!HASHED_DIRNAMES .matcher (dirName ).matches ()) {
1114+ LOGGER .fine (() -> "ignoring unrecognized dir " + dir );
1115+ continue ;
1116+ }
1117+ var xml = new XmlFile (XSTREAM , new File (dir , CONFIG_XML ));
1118+ if (!xml .exists ()) {
1119+ LOGGER .fine (() -> "ignoring dir " + dir + " with no " + CONFIG_XML );
1120+ continue ;
1121+ }
1122+ var user = new User ();
1123+ try {
1124+ xml .unmarshal (user );
1125+ } catch (Exception x ) {
1126+ LOGGER .log (Level .WARNING , "failed to load " + xml , x );
1127+ continue ;
1128+ }
1129+ if (user .id == null ) {
1130+ LOGGER .warning (() -> "ignoring " + xml + " with no <id>" );
1131+ continue ;
1132+ }
1133+ var expectedFolderName = getUserFolderNameFor (user .id );
1134+ if (!dirName .equals (expectedFolderName )) {
1135+ LOGGER .warning (() -> "ignoring " + xml + " with <id> " + user .id + " expected to be in " + expectedFolderName );
1136+ continue ;
1137+ }
1138+ user .fixUpAfterLoad ();
1139+ var old = byName .put (idStrategy .keyFor (user .id ), user );
1140+ if (old != null ) {
1141+ LOGGER .warning (() -> "duplicate entries for " + user .id );
1142+ } else {
1143+ LOGGER .fine (() -> "successfully loaded " + user .id + " from " + xml );
1144+ }
10841145 }
1146+ LOGGER .fine (() -> "loaded " + byName .size () + " entries" );
10851147 }
10861148
10871149 /**
@@ -1094,7 +1156,7 @@ private static AllUsers getInstance() {
10941156 return ExtensionList .lookupSingleton (AllUsers .class );
10951157 }
10961158
1097- private static void reload () {
1159+ private static void reload () throws IOException {
10981160 getInstance ().byName .clear ();
10991161 UserDetailsCache .get ().invalidateAll ();
11001162 scanAll ();
@@ -1252,7 +1314,7 @@ public String resolveCanonicalId(String idOrFullName, Map<String, ?> context) {
12521314 UserDetails userDetails = UserDetailsCache .get ().loadUserByUsername (idOrFullName );
12531315 return userDetails .getUsername ();
12541316 } catch (UsernameNotFoundException x ) {
1255- LOGGER .log (Level .FINE , "not sure whether " + idOrFullName + " is a valid username or not" , x );
1317+ LOGGER .log (Level .FINER , "not sure whether " + idOrFullName + " is a valid username or not" , x );
12561318 } catch (ExecutionException x ) {
12571319 LOGGER .log (Level .FINE , "could not look up " + idOrFullName , x );
12581320 } finally {
0 commit comments