Skip to content

Commit 322cf95

Browse files
authored
Merge pull request #489 from CodeForPhilly/issue/483-malformed-stored-version-can-permanently-break-c
issue/483 malformed stored version can permanently break c
2 parents 18bd00f + 9d9f01a commit 322cf95

6 files changed

Lines changed: 259 additions & 51 deletions

File tree

‎builder-api/src/main/java/org/acme/controller/EligibilityCheckResource.java‎

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import com.fasterxml.jackson.databind.JsonNode;
1212
import org.acme.auth.AuthUtils;
1313
import org.acme.constants.CheckStatus;
14+
import org.acme.model.domain.CheckVersion;
1415
import org.acme.model.domain.EligibilityCheck;
1516
import org.acme.model.dto.EligibilityCheck.CheckDmnRequest;
1617
import org.acme.model.dto.EligibilityCheck.CreateCheckRequest;
@@ -21,12 +22,12 @@
2122
import org.acme.service.CustomCheckDmnTemplate;
2223
import org.acme.service.DmnService;
2324

24-
import java.util.Comparator;
2525
import java.util.HashMap;
2626
import java.util.List;
2727
import java.util.Map;
28-
import java.util.Objects;
2928
import java.util.Optional;
29+
import java.util.Set;
30+
import java.util.stream.Collectors;
3031

3132
@Path("/api/custom-checks")
3233
public class EligibilityCheckResource {
@@ -344,7 +345,7 @@ public Response publishCustomCheck(@Context SecurityIdentity identity, @PathPara
344345
.entity(Map.of("error", "could not read published versions of Check, published check version was not created"))
345346
.build();
346347
}
347-
check.setVersion(versionForPublish(check.getVersion(), publishedChecks));
348+
check.setVersion(versionForPublish(check, publishedChecks));
348349

349350
// Update the working check so the extracted input definition and current version are saved.
350351
try {
@@ -444,34 +445,36 @@ public Response getPublishedVersionsOfWorkingCheck(@Context SecurityIdentity ide
444445

445446
// ========== Private Helper Methods ==========
446447

447-
private static final Comparator<int[]> VERSION_ORDER = Comparator
448-
.<int[]>comparingInt(v -> v[0])
449-
.thenComparingInt(v -> v[1])
450-
.thenComparingInt(v -> v[2]);
448+
/* The first publish keeps the working version; later ones increment past the highest published
449+
version, so a working version that lags what is already published cannot produce a duplicate
450+
published id. */
451+
String versionForPublish(EligibilityCheck check, List<EligibilityCheck> publishedChecks) {
452+
Set<String> publishedIds = publishedChecks.stream()
453+
.map(EligibilityCheck::getId)
454+
.filter(id -> id != null)
455+
.collect(Collectors.toSet());
451456

452-
/* The first publish keeps the working version; later ones increment past the highest published version,
453-
so a working version that lags what is already published cannot produce a duplicate published id. */
454-
String versionForPublish(String workingVersion, List<EligibilityCheck> publishedChecks) {
455-
return publishedChecks.stream()
457+
int[] version = publishedChecks.stream()
456458
.map(EligibilityCheck::getVersion)
457-
.filter(Objects::nonNull)
458-
.map(this::normalize)
459-
.max(VERSION_ORDER)
460-
.map(this::incrementMajorVersion)
461-
.orElse(workingVersion);
459+
.flatMap(publishedVersion -> parsePublishedVersion(publishedVersion).stream())
460+
.max(CheckVersion.ORDER)
461+
.map(CheckVersion::nextMajor)
462+
.orElseGet(() -> CheckVersion.parse(check.getVersion()).orElseGet(CheckVersion::initial));
463+
464+
/* A published id is derived from the version and can never be written twice, so skip past
465+
versions that are already taken. A version ignored above still holds its id, which is the
466+
only remaining record that the version was used. */
467+
while (publishedIds.contains(eligibilityCheckRepository.getPublishedId(check, CheckVersion.format(version)))) {
468+
version = CheckVersion.nextMajor(version);
469+
}
470+
return CheckVersion.format(version);
462471
}
463472

464-
private String incrementMajorVersion(int[] version) {
465-
return (version[0] + 1) + ".0.0"; // increment major, reset minor and patch
466-
}
467-
468-
private int[] normalize(String version) {
469-
String[] parts = version.split("\\.");
470-
int[] nums = new int[]{0, 0, 0};
471-
472-
for (int i = 0; i < parts.length && i < 3; i++) {
473-
nums[i] = Integer.parseInt(parts[i]);
473+
private Optional<int[]> parsePublishedVersion(String version) {
474+
Optional<int[]> parsed = CheckVersion.parse(version);
475+
if (parsed.isEmpty()) {
476+
Log.warn("Ignoring malformed published check version: " + version);
474477
}
475-
return nums;
478+
return parsed;
476479
}
477480
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package org.acme.model.domain;
2+
3+
import java.util.Comparator;
4+
import java.util.Optional;
5+
6+
/* Check versions are stored as free-form strings, so a stored version can be anything at all.
7+
Parsing yields an empty result for values that are not versions, which lets callers ignore
8+
corrupt data instead of failing every later read and publish of the check. */
9+
public final class CheckVersion {
10+
11+
private static final int PART_COUNT = 3;
12+
13+
public static final Comparator<int[]> ORDER = Comparator
14+
.<int[]>comparingInt(version -> version[0])
15+
.thenComparingInt(version -> version[1])
16+
.thenComparingInt(version -> version[2]);
17+
18+
private CheckVersion() {
19+
}
20+
21+
/* A version is one to three dot-separated non-negative numbers; missing parts count as zero. */
22+
public static Optional<int[]> parse(String version) {
23+
if (version == null) {
24+
return Optional.empty();
25+
}
26+
27+
// Java drops trailing empty parts, so dot-only strings such as "." split into no parts at all
28+
String[] parts = version.split("\\.");
29+
if (parts.length == 0 || parts.length > PART_COUNT) {
30+
return Optional.empty();
31+
}
32+
33+
int[] numbers = new int[PART_COUNT];
34+
for (int i = 0; i < parts.length; i++) {
35+
try {
36+
numbers[i] = Integer.parseInt(parts[i]);
37+
} catch (NumberFormatException e) {
38+
return Optional.empty();
39+
}
40+
if (numbers[i] < 0) {
41+
return Optional.empty();
42+
}
43+
}
44+
return Optional.of(numbers);
45+
}
46+
47+
/* Orders stored versions, treating one that cannot be read as older than one that can. */
48+
public static int compare(String version1, String version2) {
49+
Optional<int[]> parsed1 = parse(version1);
50+
Optional<int[]> parsed2 = parse(version2);
51+
if (parsed1.isPresent() && parsed2.isPresent()) {
52+
return ORDER.compare(parsed1.get(), parsed2.get());
53+
}
54+
return Boolean.compare(parsed1.isPresent(), parsed2.isPresent());
55+
}
56+
57+
public static int[] initial() {
58+
return new int[]{1, 0, 0};
59+
}
60+
61+
public static int[] nextMajor(int[] version) {
62+
return new int[]{version[0] + 1, 0, 0}; // increment major, reset minor and patch
63+
}
64+
65+
public static String format(int[] version) {
66+
return version[0] + "." + version[1] + "." + version[2];
67+
}
68+
}

‎builder-api/src/main/java/org/acme/persistence/EligibilityCheckRepository.java‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,7 @@ public interface EligibilityCheckRepository {
3333
void deleteWorkingCustomCheck(String checkId) throws Exception;
3434

3535
void updatePublishedCustomCheck(EligibilityCheck check) throws Exception;
36+
37+
/* The published document id a check would get at the given version. */
38+
String getPublishedId(EligibilityCheck check, String version);
3639
}

‎builder-api/src/main/java/org/acme/persistence/impl/EligibilityCheckRepositoryImpl.java‎

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.acme.constants.CollectionNames;
1111
import org.acme.constants.FieldNames;
1212
import org.acme.model.domain.Benefit;
13+
import org.acme.model.domain.CheckVersion;
1314
import org.acme.model.domain.EligibilityCheck;
1415
import org.acme.persistence.EligibilityCheckRepository;
1516
import org.acme.persistence.FirestoreUtils;
@@ -55,33 +56,11 @@ public List<EligibilityCheck> getLatestVersionPublishedCustomChecks(String userI
5556
.collect(java.util.stream.Collectors.toMap(
5657
check -> getPublishedPrefix(check),
5758
check -> check,
58-
(check1, check2) -> compareVersions(check1.getVersion(), check2.getVersion()) > 0 ? check1 : check2
59+
(check1, check2) -> CheckVersion.compare(check1.getVersion(), check2.getVersion()) > 0 ? check1 : check2
5960
));
6061
return new ArrayList<>(latestVersionMap.values());
6162
}
6263

63-
private static int compareVersions(String v1, String v2) {
64-
int[] a = normalize(v1);
65-
int[] b = normalize(v2);
66-
67-
for (int i = 0; i < 3; i++) {
68-
if (a[i] != b[i]) {
69-
return a[i] - b[i];
70-
}
71-
}
72-
return 0;
73-
}
74-
75-
private static int[] normalize(String version) {
76-
String[] parts = version.split("\\.");
77-
int[] nums = new int[] {0, 0, 0};
78-
79-
for (int i = 0; i < parts.length && i < 3; i++) {
80-
nums[i] = Integer.parseInt(parts[i]);
81-
}
82-
return nums;
83-
}
84-
8564
public List<EligibilityCheck> getPublishedCheckVersions(EligibilityCheck workingCustomCheck) throws Exception {
8665
Map<String, String> fieldValues = Map.of(
8766
"ownerId", workingCustomCheck.getOwnerId(),
@@ -198,6 +177,10 @@ public String getPublishedPrefix(EligibilityCheck check) {
198177
}
199178

200179
public String getPublishedId(EligibilityCheck check) {
201-
return getPublishedPrefix(check) + "-" + check.getVersion().toString();
180+
return getPublishedId(check, check.getVersion());
181+
}
182+
183+
public String getPublishedId(EligibilityCheck check, String version) {
184+
return getPublishedPrefix(check) + "-" + version;
202185
}
203186
}

‎builder-api/src/test/java/org/acme/controller/EligibilityCheckResourceTest.java‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class EligibilityCheckResourceTest {
3232

3333
private static final String USER_ID = "owner-1";
3434
private static final String CHECK_ID = "check-1";
35+
private static final String PUBLISHED_PREFIX = "P-" + USER_ID + "-my-module-my-check";
3536

3637
private final EligibilityCheckResource resource = new EligibilityCheckResource();
3738
private final EligibilityCheckRepository repository = mock(EligibilityCheckRepository.class);
@@ -66,6 +67,8 @@ void setUp() throws Exception {
6667
EligibilityCheck check = invocation.getArgument(0);
6768
return "W-" + check.getOwnerId() + "-" + check.getModule() + "-" + check.getName();
6869
});
70+
when(repository.getPublishedId(any(), anyString()))
71+
.thenAnswer(invocation -> PUBLISHED_PREFIX + "-" + invocation.<String>getArgument(1));
6972
}
7073

7174
@Test
@@ -218,6 +221,81 @@ void staleWorkingVersionDoesNotReusePublishedVersion() throws Exception {
218221
assertEquals("3.0.0", capturedPublishedVersion());
219222
}
220223

224+
@Test
225+
void malformedPublishedVersionsAreIgnored() throws Exception {
226+
workingCheck.setVersion("2.0.0");
227+
when(repository.getPublishedCheckVersions(workingCheck))
228+
.thenReturn(List.of(
229+
publishedVersion(""),
230+
publishedVersion("not-a-version"),
231+
publishedVersion("1.invalid.0"),
232+
publishedVersion("2.0.0")
233+
));
234+
235+
Response response = resource.publishCustomCheck(identity, CHECK_ID);
236+
237+
assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
238+
assertEquals("3.0.0", capturedPublishedVersion());
239+
}
240+
241+
// Dot-only strings split into no parts at all, so they must not be read as version 0.0.0
242+
@Test
243+
void dotOnlyPublishedVersionsAreIgnored() throws Exception {
244+
workingCheck.setVersion("2.0.0");
245+
when(repository.getPublishedCheckVersions(workingCheck))
246+
.thenReturn(List.of(publishedVersion("."), publishedVersion("...")));
247+
248+
Response response = resource.publishCustomCheck(identity, CHECK_ID);
249+
250+
assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
251+
assertEquals("2.0.0", capturedPublishedVersion());
252+
}
253+
254+
/* Ignoring a corrupt version must not hand back a version whose published id is already taken:
255+
that document could never be written, so publishing would fail for good. */
256+
@Test
257+
void publishSkipsVersionsWhosePublishedIdAlreadyExists() throws Exception {
258+
workingCheck.setVersion("2.0.0");
259+
when(repository.getPublishedCheckVersions(workingCheck))
260+
.thenReturn(List.of(
261+
publishedVersion("1.0.0"),
262+
publishedVersion("v2.0.0", PUBLISHED_PREFIX + "-2.0.0")
263+
));
264+
265+
Response response = resource.publishCustomCheck(identity, CHECK_ID);
266+
267+
assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
268+
assertEquals("3.0.0", capturedPublishedVersion());
269+
}
270+
271+
@Test
272+
void publishKeepsSkippingUntilAnUnusedVersionIsFound() throws Exception {
273+
workingCheck.setVersion("1.0.0");
274+
when(repository.getPublishedCheckVersions(workingCheck))
275+
.thenReturn(List.of(
276+
publishedVersion("1.0.0"),
277+
publishedVersion("v2.0.0", PUBLISHED_PREFIX + "-2.0.0"),
278+
publishedVersion("v3.0.0", PUBLISHED_PREFIX + "-3.0.0")
279+
));
280+
281+
Response response = resource.publishCustomCheck(identity, CHECK_ID);
282+
283+
assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
284+
assertEquals("4.0.0", capturedPublishedVersion());
285+
}
286+
287+
// A corrupt working version cannot be published as-is, or the next publish inherits the corruption
288+
@Test
289+
void firstPublishOfACorruptWorkingVersionStartsAtTheInitialVersion() throws Exception {
290+
workingCheck.setVersion("not-a-version");
291+
when(repository.getPublishedCheckVersions(workingCheck)).thenReturn(List.of());
292+
293+
Response response = resource.publishCustomCheck(identity, CHECK_ID);
294+
295+
assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
296+
assertEquals("1.0.0", capturedPublishedVersion());
297+
}
298+
221299
// An unreadable version list must not be mistaken for "never published"
222300
@Test
223301
void publishFailsWhenPublishedVersionsCannotBeRead() throws Exception {
@@ -232,8 +310,14 @@ void publishFailsWhenPublishedVersionsCannotBeRead() throws Exception {
232310
}
233311

234312
private EligibilityCheck publishedVersion(String version) {
313+
return publishedVersion(version, PUBLISHED_PREFIX + "-" + version);
314+
}
315+
316+
/* The id is fixed when a version is published, so a version field corrupted later no longer matches it */
317+
private EligibilityCheck publishedVersion(String version, String id) {
235318
EligibilityCheck published = new EligibilityCheck("my-check", "my-module", "a check", List.of(), USER_ID);
236319
published.setVersion(version);
320+
published.setId(id);
237321
return published;
238322
}
239323

0 commit comments

Comments
 (0)