Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Erlang review — issue #3159 sitemanage test-source Xlint residual

**Date:** 2026-08-12
**Branch:** `fix/issue-3159-sitemanage-test-xlint`
**Scope:** `projects/sitemanage` **test-source only** project `-Xlint` residual after main-source misc zero (#3107 / open PR #3158)
**Reviewer persona:** Erlang (independent of implementer)

## Summary

Clears **all 60** test-source project `-Xlint` diagnostics for `projects/sitemanage` with real fixes preferred over blanket suppress. Main sources untouched (open #3158 owns remaining main-source residual on this base). Path-injection security assertions preserved after dropping `sun.misc.Unsafe`.

| Metric | Before | After |
|--------|--------|-------|
| test-source project Xlint | **60** | **0** |
| main-source project Xlint on this base | 40 (owned by #3158) | 40 (untouched) |

## Clusters cleared

| Cluster | Approach |
|---------|----------|
| **this-escape** (REST clients) | Final/safe setters; protected `requestHeaders` / `postContentType` field seeds; `PSRestClient(String)` ctor chain; remove redundant `fillInStackTrace`; justified `@SuppressWarnings("this-escape")` only on intentional Throwable parse ctors |
| **serialVersionUID** | Nested concurrency/exception stubs + dummy validation exceptions |
| **serial-field** | `transient` on parsed `PSErrors` / `PSValidationErrors` exception payloads |
| **unchecked** | Typed `TypeReference` JSON; `Class.cast` / `@SuppressWarnings` on ArgumentCaptor generics; chained `thenReturn` (no generic varargs arrays); typed `IPSGenericDao` mock |
| **heap-pollution** | `@SafeVarargs` + `@SuppressWarnings("varargs")` on `PSTestDataCleaner` |
| **sun.misc.Unsafe** | Real ctors + Mockito for path-injection tests (`PSCloudService` public API; `PSCSSParser` public ctor; `PSSiteDataService` mock shell + private Method.invoke) |
| **static** | `PSThumbnailProcessMonitor.incrementCount/decrementCount` qualified by type name |

## Findings

- **Bugs:** none. Security tests still assert `IllegalArgumentException` for traversal/NUL/separator payloads before File I/O.
- **Behavior:** test-only; no production main-source change; REST client header seeding equivalent.
- **Cross-platform:** path-injection tests keep portable path construction; no hardcoded separators introduced.
- **C2 API shape:** no production types made `final`/sealed; no public production signature changes. Test REST helper visibility changes (`protected` fields, `final` helpers) are test-classpath only.

## Build evidence

```text
cd projects/sitemanage && ../../mvnw.cmd clean install
BUILD SUCCESS
Tests run: 1090, Failures: 0, Errors: 0, Skipped: 125

cd projects/sitemanage && ../../mvnw.cmd test-compile -Dmaven.compiler.showWarnings=true
test-source [WARNING] *.java: 0
```

## Verdict

**PASS** for commit/PR. Residual: none for test-source Xlint on sitemanage; main-source remains #3158 / #3107.

> Co-Authored by Grok Build using grok-4.5 with agent main.
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ void createDelegatesToContentWsWithLocales() throws Exception {
assertEquals(ContentTranslationsAdaptor.ROLE_TRANSLATION, out.getCreated().get(0).getRole());
assertEquals(Long.valueOf(100L), out.getCreated().get(0).getSourceContentId());

@SuppressWarnings("unchecked")
ArgumentCaptor<List<IPSGuid>> guidsCap = ArgumentCaptor.forClass(List.class);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<PSAutoTranslation>> settingsCap = ArgumentCaptor.forClass(List.class);
verify(contentWs)
.newTranslations(guidsCap.capture(), settingsCap.capture(), isNull(), eq(true));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,11 @@ void clear_deletesSharedNonInlineAndLocalBindings_notAsset() throws Exception {
mockRelationship(false, PSRelationshipConfig.TYPE_ACTIVE_ASSEMBLY);
PSRelationship localRel = mockRelationship(false, PSRelationshipConfig.TYPE_LOCAL_CONTENT);

// loadRelationships is called twice (shared filter then local filter)
// loadRelationships is called twice (shared filter then local filter).
// Chain thenReturn to avoid unchecked generic array creation for varargs.
when(systemWs.loadRelationships(any(PSRelationshipFilter.class)))
.thenReturn(List.of(sharedWidgetRel), List.of(localRel));
.thenReturn(List.of(sharedWidgetRel))
.thenReturn(List.of(localRel));

int cleared = service.clearAssetWidgetRelationshipsForAsset(ASSET_ID);

Expand All @@ -130,7 +132,8 @@ void clear_skipsInlineActiveAssembly() throws Exception {

// Shared path: both returned from AA filter; service keeps only non-inline
when(systemWs.loadRelationships(any(PSRelationshipFilter.class)))
.thenReturn(List.of(inline, widget), Collections.emptyList());
.thenReturn(List.of(inline, widget))
.thenReturn(Collections.emptyList());

int cleared = service.clearAssetWidgetRelationshipsForAsset(ASSET_ID);

Expand All @@ -146,7 +149,8 @@ void clear_skipsInlineActiveAssembly() throws Exception {
void clear_whenNoBindings_isIdempotent() throws Exception {
stubAssetGuid();
when(systemWs.loadRelationships(any(PSRelationshipFilter.class)))
.thenReturn(Collections.emptyList(), Collections.emptyList());
.thenReturn(Collections.emptyList())
.thenReturn(Collections.emptyList());

int cleared = service.clearAssetWidgetRelationshipsForAsset(ASSET_ID);

Expand All @@ -160,7 +164,8 @@ void clear_whenOnlyShared_deletesOnce() throws Exception {

PSRelationship shared = mockRelationship(false, PSRelationshipConfig.TYPE_ACTIVE_ASSEMBLY);
when(systemWs.loadRelationships(any(PSRelationshipFilter.class)))
.thenReturn(List.of(shared), Collections.emptyList());
.thenReturn(List.of(shared))
.thenReturn(Collections.emptyList());

int cleared = service.clearAssetWidgetRelationshipsForAsset(ASSET_ID);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,62 +17,41 @@

import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;

import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import com.percussion.licensemanagement.service.impl.PSLicenseService;
import com.percussion.pagemanagement.service.IPSPageService;
import com.percussion.pagemanagement.service.IPSRenderService;
import com.percussion.share.dao.IPSFolderHelper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import sun.misc.Unsafe;

/**
* Path-injection regression for {@link PSCloudService#generateThumbUrl}: siteName and pageId are
* validated with {@code requireSafeFileName} before any File I/O under {@code PSServer.getRxDir()}.
*
* <p>Uses {@link Unsafe#allocateInstance} so validation runs without constructing Spring deps or
* requiring a live server (same pattern as {@code PSSiteDataServicePathInjectionTest}).
* <p>Instantiates {@link PSCloudService} via its public constructor with Mockito-mocked
* dependencies (same pattern as {@code PSImportThemeHelperPathInjectionTest}) — no {@code
* sun.misc.Unsafe}.
*/
public class PSCloudServicePathInjectionTest {

private static final Unsafe UNSAFE;
private static final Method GENERATE_THUMB_URL;

static {
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
UNSAFE = (Unsafe) f.get(null);
GENERATE_THUMB_URL =
PSCloudService.class.getDeclaredMethod("generateThumbUrl", String.class, String.class);
GENERATE_THUMB_URL.setAccessible(true);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}

private static String invokeGenerateThumbUrl(String pageId, String siteName) throws Exception {
Object instance = UNSAFE.allocateInstance(PSCloudService.class);
try {
return (String) GENERATE_THUMB_URL.invoke(instance, pageId, siteName);
} catch (InvocationTargetException ite) {
Throwable cause = ite.getCause();
if (cause instanceof Exception) {
throw (Exception) cause;
}
if (cause instanceof Error) {
throw (Error) cause;
}
throw ite;
}
private static PSCloudService service() {
return new PSCloudService(
mock(IPSFolderHelper.class),
mock(IPSRenderService.class),
mock(IPSPageService.class),
mock(PSLicenseService.class));
}

@Test
@DisplayName("generateThumbUrl rejects parent traversal in siteName before File I/O")
void rejectsTraversalInSiteName() {
PSCloudService svc = service();
IllegalArgumentException ex =
assertThrows(
IllegalArgumentException.class,
() -> invokeGenerateThumbUrl("page1", "../escape"),
() -> svc.generateThumbUrl("page1", "../escape"),
"siteName traversal must be rejected by requireSafeFileName");
assertTrue(
ex.getMessage() != null && !ex.getMessage().isBlank(),
Expand All @@ -82,32 +61,36 @@ void rejectsTraversalInSiteName() {
@Test
@DisplayName("generateThumbUrl rejects parent traversal in pageId before File I/O")
void rejectsTraversalInPageId() {
PSCloudService svc = service();
assertThrows(
IllegalArgumentException.class,
() -> invokeGenerateThumbUrl("../escape", "goodSite"),
() -> svc.generateThumbUrl("../escape", "goodSite"),
"pageId traversal must be rejected by requireSafeFileName");
}

@Test
@DisplayName("generateThumbUrl rejects path separators in siteName")
void rejectsSlashInSiteName() {
PSCloudService svc = service();
assertThrows(
IllegalArgumentException.class, () -> invokeGenerateThumbUrl("page1", "site/name"));
IllegalArgumentException.class, () -> svc.generateThumbUrl("page1", "site/name"));
assertThrows(
IllegalArgumentException.class, () -> invokeGenerateThumbUrl("page1", "site\\name"));
IllegalArgumentException.class, () -> svc.generateThumbUrl("page1", "site\\name"));
}

@Test
@DisplayName("generateThumbUrl rejects path separators in pageId")
void rejectsSlashInPageId() {
PSCloudService svc = service();
assertThrows(
IllegalArgumentException.class, () -> invokeGenerateThumbUrl("page/id", "goodSite"));
IllegalArgumentException.class, () -> svc.generateThumbUrl("page/id", "goodSite"));
}

@Test
@DisplayName("generateThumbUrl rejects NUL in siteName")
void rejectsNulInSiteName() {
PSCloudService svc = service();
assertThrows(
IllegalArgumentException.class, () -> invokeGenerateThumbUrl("page1", "site\0name"));
IllegalArgumentException.class, () -> svc.generateThumbUrl("page1", "site\0name"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ public class PSFolderServiceRestClient extends PSDataServiceRestClient<PSFolderI

public PSFolderServiceRestClient(String url) {
super(PSFolderItem.class, url, "/Rhythmyx/services/foldermanagement/folders");
setPostContentType(MediaType.APPLICATION_XML);
// Direct field seed — avoids this-escape on overridable/setter path.
this.postContentType = MediaType.APPLICATION_XML;
}

public List<PSFolderItem> getAssociatedFolders(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,48 +124,64 @@ void handleErrorMarksFailedForConcurrencyAndNonConcurrency() {
* simple-name equality without putting those libraries on the test compile classpath.
*/
static final class LockAcquisitionException extends RuntimeException {
private static final long serialVersionUID = 1L;

LockAcquisitionException(String m) {
super(m);
}
}

static final class OptimisticLockException extends RuntimeException {
private static final long serialVersionUID = 1L;

OptimisticLockException(String m) {
super(m);
}
}

static final class PessimisticLockException extends RuntimeException {
private static final long serialVersionUID = 1L;

PessimisticLockException(String m) {
super(m);
}
}

static final class ConcurrencyFailureException extends RuntimeException {
private static final long serialVersionUID = 1L;

ConcurrencyFailureException(String m) {
super(m);
}
}

static final class CannotAcquireLockException extends RuntimeException {
private static final long serialVersionUID = 1L;

CannotAcquireLockException(String m) {
super(m);
}
}

static final class ObjectOptimisticLockingFailureException extends RuntimeException {
private static final long serialVersionUID = 1L;

ObjectOptimisticLockingFailureException(String m) {
super(m);
}
}

static final class StaleObjectStateException extends RuntimeException {
private static final long serialVersionUID = 1L;

StaleObjectStateException(String m) {
super(m);
}
}

static final class StaleStateException extends RuntimeException {
private static final long serialVersionUID = 1L;

StaleStateException(String m) {
super(m);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.json.JsonMapper;

/** REST client for metadata service. Sunny Sal says: "Metadata management, Java 11 style!" */
Expand All @@ -52,9 +53,10 @@ public List<PSGadget> getCurrentGadgets() {
var response = GET(concatPath(getPath(), GADGETS_KEY));
var metadata = PSSerializerUtils.unmarshal(response, PSMetadata.class);
var mapper = JsonMapper.builder().build();
var dataMap = mapper.readValue(metadata.getData(), java.util.Map.class);
var dashboardConfigMap = (java.util.Map<String, Object>) dataMap.get("DashboardConfig");
config = mapper.convertValue(dashboardConfigMap, PSDashboardConfiguration.class);
Map<String, Object> dataMap =
mapper.readValue(metadata.getData(), new TypeReference<Map<String, Object>>() {});
Object dashboardConfigObj = dataMap.get("DashboardConfig");
config = mapper.convertValue(dashboardConfigObj, PSDashboardConfiguration.class);
} catch (Exception e) {
log.error(PSExceptionUtils.getMessageForLog(e));
log.debug(PSExceptionUtils.getDebugMessageForLog(e));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,19 @@ public class PSThumbnailProcessMonitorTest {
void testThumbnailMonitor() {
var mon = new PSThumbnailProcessMonitor();
assertEquals(0L, mon.getCurrentCount());
mon.incrementCount();
// Static counters: qualify by type name (not via instance expression).
PSThumbnailProcessMonitor.incrementCount();
assertEquals(1L, mon.getCurrentCount());
mon.incrementCount();
PSThumbnailProcessMonitor.incrementCount();
assertEquals(2L, mon.getCurrentCount());
mon.decrementCount();
PSThumbnailProcessMonitor.decrementCount();
assertEquals(1L, mon.getCurrentCount());
mon.incrementCount(5);
PSThumbnailProcessMonitor.incrementCount(5);
assertEquals(6L, mon.getCurrentCount());
mon.decrementCount(4);
PSThumbnailProcessMonitor.decrementCount(4);
assertEquals(2L, mon.getCurrentCount());
mon.decrementCount(2);
PSThumbnailProcessMonitor.decrementCount(2);
assertEquals(0L, mon.getCurrentCount());
mon.decrementCount();
PSThumbnailProcessMonitor.decrementCount();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ public void init(String uid, String pwd, String community) throws Exception {
PSSpringWebApplicationContextUtils.injectDependencies(this);
PSRequestInfo.resetRequestInfo();
PSRequest req = PSRequest.getContextForRequest();
PSRequestInfo.initRequestInfo((Map) null);
PSRequestInfo.initRequestInfo(new java.util.HashMap<String, Object>());
PSRequestInfo.setRequestInfo(PSRequestInfo.KEY_PSREQUEST, req);
setSecurityWs(PSSecurityWsLocator.getSecurityWebservice());
securityWs.login(request, response, uid, pwd, null, community, null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,24 @@
public class PSRenderLinkServiceClient extends PSObjectRestClient {
private final String path = "/Rhythmyx/services/pagemanagement/renderlink";

{
addAccept("text/html");
addAccept("image/*");
public PSRenderLinkServiceClient() {
super();
seedAccepts();
}

public PSRenderLinkServiceClient(String baseUrl) {
super(baseUrl);
seedAccepts();
}

private void seedAccepts() {
// Direct header mutation after super — no this-escape method dispatch.
String accept = requestHeaders.get("Accept");
if (accept == null || accept.isBlank()) {
requestHeaders.put("Accept", "text/html,image/*");
} else {
requestHeaders.put("Accept", accept + ",text/html,image/*");
}
}

public String getPath() {
Expand Down
Loading
Loading