Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 123 additions & 45 deletions core/src/main/java/hudson/model/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,15 @@
import jakarta.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
Expand All @@ -67,12 +70,14 @@
import java.util.function.Predicate;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import jenkins.model.IdStrategy;
import jenkins.model.Jenkins;
import jenkins.model.Loadable;
import jenkins.model.ModelObjectWithContextMenu;
import jenkins.scm.RunWithSCM;
import jenkins.search.SearchGroup;
import jenkins.security.HMACConfidentialKey;
import jenkins.security.ImpersonatingUserDetailsService2;
import jenkins.security.LastGrantedAuthoritiesProperty;
import jenkins.security.UserDetailsCache;
Expand Down Expand Up @@ -174,7 +179,7 @@

@SuppressFBWarnings(value = "SS_SHOULD_BE_STATIC", justification = "Reserved for future use")
private final int version = 10; // Not currently used, but it may be helpful in the future to store a version.
private String id;
String id;
private volatile String fullName;
private volatile String description;

Expand All @@ -185,6 +190,8 @@
XSTREAM.alias("user", User.class);
}

private User() {}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a subtle difference between calling the no-arg ctor and unmarshalling into that object, vs. deserializing a new object: whether properties (and, if anyone cares, version) are initialized.


private User(String id, String fullName) {
this.id = id;
this.fullName = fullName;
Expand All @@ -199,6 +206,10 @@
private void load(String userId) {
clearExistingProperties();
loadFromUserConfigFile(userId);
fixUpAfterLoad();
}

private void fixUpAfterLoad() {
removeNullsThatFailedToLoad();
allocateDefaultPropertyInstancesAsNeeded();
setUserToProperties();
Expand All @@ -225,9 +236,10 @@
}

private void loadFromUserConfigFile(String userId) {
AllUsers.getInstance().migrateUserIdMapper();
XmlFile config = getConfigFile();
try {
if (config != null && config.exists()) {
if (config.exists()) {
config.unmarshal(this);
this.id = userId;
}
Expand All @@ -241,8 +253,7 @@
}

private XmlFile getConfigFile() {
File existingUserFolder = getExistingUserFolder();
return existingUserFolder == null ? null : new XmlFile(XSTREAM, new File(existingUserFolder, CONFIG_XML));
return new XmlFile(XSTREAM, new File(getUserFolderFor(id), CONFIG_XML));
}

/**
Expand Down Expand Up @@ -571,10 +582,10 @@
*/
private static @Nullable User getOrCreateById(@NonNull String id, @NonNull String fullName, boolean create) {
User u = AllUsers.get(id);
if (u == null && (create || UserIdMapper.getInstance().isMapped(id))) {
if (u == null && create) {
u = new User(id, fullName);
AllUsers.put(id, u);
if (!id.equals(fullName) && !UserIdMapper.getInstance().isMapped(id)) {
if (!id.equals(fullName)) {
try {
u.save();
} catch (IOException x) {
Expand Down Expand Up @@ -691,7 +702,6 @@
*/
@Restricted(Beta.class)
public static void reload() throws IOException {
UserIdMapper.getInstance().reload();
AllUsers.reload();
}

Expand All @@ -708,6 +718,32 @@
or greater issues in the realm change, could affect currently logged
in users and even the user making the change. */
Comment on lines 718 to 719
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure rekey has any real test coverage.

@Test
void caseInsensitivity() {
j.jenkins.setSecurityRealm(new IdStrategySpecifyingSecurityRealm(new IdStrategy.CaseInsensitive()));
User user = User.get("john smith");
User user2 = User.get("John Smith");
assertSame(user.getId(), user2.getId(), "Users should have the same id.");
}
@Test
void caseSensitivity() {
j.jenkins.setSecurityRealm(new IdStrategySpecifyingSecurityRealm(new IdStrategy.CaseSensitive()));
User user = User.get("john smith");
User user2 = User.get("John Smith");
assertNotSame(user.getId(), user2.getId(), "Users should not have the same id.");
assertEquals("john smith", User.idStrategy().keyFor(user.getId()));
assertEquals("John Smith", User.idStrategy().keyFor(user2.getId()));
}
@Test
void caseSensitivityEmail() {
j.jenkins.setSecurityRealm(new IdStrategySpecifyingSecurityRealm(new IdStrategy.CaseSensitiveEmailAddress()));
User user = User.get("john.smith@acme.org");
User user2 = User.get("John.Smith@acme.org");
assertNotSame(user.getId(), user2.getId(), "Users should not have the same id.");
assertEquals("john.smith@acme.org", User.idStrategy().keyFor(user.getId()));
assertEquals("John.Smith@acme.org", User.idStrategy().keyFor(user2.getId()));
user2 = User.get("john.smith@ACME.ORG");
assertEquals(user.getId(), user2.getId(), "Users should have the same id.");
assertEquals("john.smith@acme.org", User.idStrategy().keyFor(user2.getId()));
}
private static class IdStrategySpecifyingSecurityRealm extends HudsonPrivateSecurityRealm {
private final IdStrategy idStrategy;
IdStrategySpecifyingSecurityRealm(IdStrategy idStrategy) {
super(true, false, null);
this.idStrategy = idStrategy;
}
@Override
public IdStrategy getUserIdStrategy() {
return idStrategy;
}
}
do not change the strategy after user records have been created.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Interactive testing with mock-security-realm seems fine: switching the id strategy renames the dir of a user whose id contains uppercase letters.)

try {
var subdirectories = getRootDir().listFiles();
if (subdirectories != null) {

Check warning on line 722 in core/src/main/java/hudson/model/User.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 722 is only partially covered, one branch is missing
for (var oldDirectory : subdirectories) {
var dirName = oldDirectory.getName();
if (!HASHED_DIRNAMES.matcher(dirName).matches()) {
continue;
}
var xml = new XmlFile(XSTREAM, new File(oldDirectory, CONFIG_XML));
if (!xml.exists()) {
continue;
}
try {
var user = (User) xml.read();
if (user.id == null) {
continue;
}
var newDirectory = getUserFolderFor(user.id);
if (!oldDirectory.equals(newDirectory)) {
Files.move(oldDirectory.toPath(), newDirectory.toPath(), StandardCopyOption.REPLACE_EXISTING);
LOGGER.info(() -> "migrated " + oldDirectory + " to " + newDirectory);
}
} catch (Exception x) {
LOGGER.log(Level.WARNING, "failed to migrate " + xml, x);
}

Check warning on line 744 in core/src/main/java/hudson/model/User.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 723-744 are not covered by tests
}
}
reload();
} catch (IOException e) {
LOGGER.log(Level.SEVERE, "Failed to perform rekey operation.", e);
Expand Down Expand Up @@ -777,17 +813,9 @@
if (ExtensionList.lookup(AllUsers.class).isEmpty()) {
return;
}
UserIdMapper.getInstance().clear();
AllUsers.clear();
}

private static File getConfigFileFor(String id) {
return new File(getUserFolderFor(id), "config.xml");
}

private static File getUserFolderFor(String id) {
return new File(getRootDir(), idStrategy().filenameOf(id));
}
/**
* Returns the folder that store all the user information.
* Useful for plugins to save a user-specific file aside the config.xml.
Expand All @@ -799,11 +827,8 @@
*/

public @CheckForNull File getUserFolder() {
return getExistingUserFolder();
}

private @CheckForNull File getExistingUserFolder() {
return UserIdMapper.getInstance().getDirectory(id);
var d = getUserFolderFor(id);
return d.isDirectory() ? d : null;
Comment on lines +830 to +831
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dubious (normally you would just want to get the File unconditionally whether it currently exists or not), but following the existing nullability annotation and semantics.

}

/**
Expand All @@ -813,6 +838,21 @@
return new File(Jenkins.get().getRootDir(), "users");
}

private static final int PREFIX_MAX = 14;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original version in UserIdMapper set this to 15 but then subtracted one for the _. I find this a bit clearer. Of course the size is arbitrary; here it allows directory names up to 79 (ASCII) characters, which I think is safe on all operating systems. The bigger concern on Windows is a total path length approaching (IIRC) 254, when not using the UNC \\?\ trick, but if you control the %JENKINS_HOME% length, this should not be too bad: C:\Jenkins\users\quixotichacker_01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b\config.xml is only 107.

Copy link
Copy Markdown
Member

@jtnord jtnord Aug 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bigger concern on Windows is a total path length approaching (IIRC) 254, when not using the UNC \?\ trick, but if you control the %JENKINS_HOME% length, this should not be too bad:

Java has been using the unicode API with \\?\ for a loooong time.
https://github.com/openjdk/jdk21u-dev/blame/d90297a828dff468afc34e2767439e51379f4f95/src/java.base/windows/classes/sun/nio/fs/WindowsPath.java#L174-L178

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I just recall problems with branch-api on Windows.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with branch-api on Windows.

🤔 it should have only been an issue with other tools (like git.exe) and the like, nothing directly inside the JVM (calling cmd.exe from a durable task for example in a long directory would be fun).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an issue with other tools (like git.exe) and the like

Ah, perhaps so, in which case that would indeed not be a concern here.

private static final Pattern DISALLOWED_PREFIX_CHARS = Pattern.compile("[^A-Za-z0-9]");
static final Pattern HASHED_DIRNAMES = Pattern.compile("[a-z0-9]{0," + PREFIX_MAX + "}_[a-f0-9]{64}");
private static final HMACConfidentialKey DIRNAMES = new HMACConfidentialKey(User.class, "DIRNAMES");

private static String getUserFolderNameFor(String id) {
var fullPrefix = DISALLOWED_PREFIX_CHARS.matcher(id).replaceAll("").toLowerCase(Locale.ROOT);
return (fullPrefix.length() > PREFIX_MAX ? fullPrefix.substring(0, PREFIX_MAX) : fullPrefix) + '_' + DIRNAMES.mac(idStrategy().keyFor(id));
}

@SuppressFBWarnings(value = "PATH_TRAVERSAL_IN", justification = "sanitized")
static File getUserFolderFor(String id) {
return new File(getRootDir(), getUserFolderNameFor(id));
}

/**
* Is the ID allowed? Some are prohibited for security reasons. See SECURITY-166.
* <p>
Expand Down Expand Up @@ -852,39 +892,23 @@
if (BulkChange.contains(this)) {
return;
}
XmlFile xmlFile = new XmlFile(XSTREAM, constructUserConfigFile());
XmlFile xmlFile = getConfigFile();
xmlFile.write(this);
SaveableListener.fireOnChange(this, xmlFile);
}

private File constructUserConfigFile() throws IOException {
return new File(putUserFolderIfAbsent(), CONFIG_XML);
}

private File putUserFolderIfAbsent() throws IOException {
return UserIdMapper.getInstance().putIfAbsent(id, true);
}

/**
* Deletes the data directory and removes this user from Hudson.
*
* @throws IOException if we fail to delete.
*/
public void delete() throws IOException {
String idKey = idStrategy().keyFor(id);
File existingUserFolder = getExistingUserFolder();
UserIdMapper.getInstance().remove(id);
AllUsers.remove(id);
deleteExistingUserFolder(existingUserFolder);
Util.deleteRecursive(getUserFolderFor(id));
UserDetailsCache.get().invalidate(idKey);
}

private void deleteExistingUserFolder(File existingUserFolder) throws IOException {
if (existingUserFolder != null && existingUserFolder.exists()) {
Util.deleteRecursive(existingUserFolder);
}
}

/**
* Exposed remote API.
*/
Expand Down Expand Up @@ -947,7 +971,7 @@
public boolean canDelete() {
final IdStrategy strategy = idStrategy();
return hasPermission(Jenkins.ADMINISTER) && !strategy.equals(id, Jenkins.getAuthentication2().getName())
&& UserIdMapper.getInstance().isMapped(id);
&& getUserFolder() != null;
}

/**
Expand Down Expand Up @@ -1074,14 +1098,68 @@
@Restricted(NoExternalUse.class)
public static final class AllUsers {

private boolean migratedUserIdMapper;
private final ConcurrentMap<String, User> byName = new ConcurrentHashMap<>();

@SuppressWarnings("deprecation")
synchronized void migrateUserIdMapper() {
if (!migratedUserIdMapper) {
try {
UserIdMapper.migrate();
Comment on lines +1106 to +1108
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May happen prior to scanAll if JCasC defines users in the built-in realm (mostly useful for demos).

} catch (IOException x) {
LOGGER.log(Level.WARNING, null, x);

Check warning on line 1110 in core/src/main/java/hudson/model/User.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 1109-1110 are not covered by tests
}
migratedUserIdMapper = true;
}
}

@Initializer(after = InitMilestone.JOB_CONFIG_ADAPTED)
public static void scanAll() {
for (String userId : UserIdMapper.getInstance().getConvertedUserIds()) {
User user = new User(userId, userId);
getInstance().byName.putIfAbsent(idStrategy().keyFor(userId), user);
public static void scanAll() throws IOException {
DIRNAMES.createMac(); // force the key to be saved during startup
var instance = getInstance();
instance.migrateUserIdMapper();
var subdirectories = getRootDir().listFiles();
if (subdirectories == null) {
return;
}
var byName = instance.byName;
var idStrategy = idStrategy();
for (var dir : subdirectories) {
var dirName = dir.getName();
if (!HASHED_DIRNAMES.matcher(dirName).matches()) {
LOGGER.fine(() -> "ignoring unrecognized dir " + dir);
continue;
}
var xml = new XmlFile(XSTREAM, new File(dir, CONFIG_XML));
if (!xml.exists()) {

Check warning on line 1134 in core/src/main/java/hudson/model/User.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 1134 is only partially covered, one branch is missing
LOGGER.fine(() -> "ignoring dir " + dir + " with no " + CONFIG_XML);
continue;

Check warning on line 1136 in core/src/main/java/hudson/model/User.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 1135-1136 are not covered by tests
}
var user = new User();
try {
xml.unmarshal(user);
} catch (Exception x) {
LOGGER.log(Level.WARNING, "failed to load " + xml, x);
continue;

Check warning on line 1143 in core/src/main/java/hudson/model/User.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 1141-1143 are not covered by tests
}
if (user.id == null) {

Check warning on line 1145 in core/src/main/java/hudson/model/User.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 1145 is only partially covered, one branch is missing
LOGGER.warning(() -> "ignoring " + xml + " with no <id>");
continue;

Check warning on line 1147 in core/src/main/java/hudson/model/User.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 1146-1147 are not covered by tests
}
var expectedFolderName = getUserFolderNameFor(user.id);
if (!dirName.equals(expectedFolderName)) {

Check warning on line 1150 in core/src/main/java/hudson/model/User.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 1150 is only partially covered, one branch is missing
LOGGER.warning(() -> "ignoring " + xml + " with <id> " + user.id + " expected to be in " + expectedFolderName);
continue;

Check warning on line 1152 in core/src/main/java/hudson/model/User.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 1151-1152 are not covered by tests
}
user.fixUpAfterLoad();
var old = byName.put(idStrategy.keyFor(user.id), user);
if (old != null) {
LOGGER.warning(() -> "duplicate entries for " + user.id);
} else {
LOGGER.fine(() -> "successfully loaded " + user.id + " from " + xml);
}
}
LOGGER.fine(() -> "loaded " + byName.size() + " entries");
}

/**
Expand All @@ -1094,7 +1172,7 @@
return ExtensionList.lookupSingleton(AllUsers.class);
}

private static void reload() {
private static void reload() throws IOException {
getInstance().byName.clear();
UserDetailsCache.get().invalidateAll();
scanAll();
Expand Down Expand Up @@ -1252,7 +1330,7 @@
UserDetails userDetails = UserDetailsCache.get().loadUserByUsername(idOrFullName);
return userDetails.getUsername();
} catch (UsernameNotFoundException x) {
LOGGER.log(Level.FINE, "not sure whether " + idOrFullName + " is a valid username or not", x);
LOGGER.log(Level.FINER, "not sure whether " + idOrFullName + " is a valid username or not", x);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noise in UserTest.

} catch (ExecutionException x) {
LOGGER.log(Level.FINE, "could not look up " + idOrFullName, x);
} finally {
Expand Down
Loading
Loading