Skip to content

Commit 232f8c5

Browse files
Milton Adina Shisiaclaude
andcommitted
fix: thirteenth-round audit (duplicate-key data loss, corrupt-file UX, docs)
- Editing a price/tax-rate to a key (effective date + amount) that already exists silently dropped it via a no-op TreeSet.add. addPrice/addTaxRate now return whether the add landed; the edit panels detect a collision, roll back to the prior values, and warn instead of persisting a silent deletion. - Corrupt data file is no longer a dead end: load() moves the unreadable file aside to a unique .corrupt-<timestamp> sibling and throws actionable recovery guidance (and the next launch starts cleanly from the seed). Documented the recovery procedure in README. - README no longer attributes find-sec-bugs to the security workflow; it runs in the mvn verify gate (CI), matching SECURITY.md. Build green: 64 tests, JaCoCo gate, SpotBugs + find-sec-bugs 0; gitleaks/Trivy/Semgrep clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 455f1d6 commit 232f8c5

8 files changed

Lines changed: 133 additions & 17 deletions

File tree

README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ java -Dpos.data.file=/path/to/store.csv -jar target/point-of-sale-1.0.0.jar
119119
Application logs are written to a rolling file at `~/.pos/logs/pos-N.log` (so diagnostics survive even
120120
when the app is launched by double-clicking the jar, with no console attached).
121121

122+
**Recovering from a corrupt data file.** Rather than silently overwriting damaged data, the app refuses
123+
to start on an unreadable data file. It moves the bad file aside to `…/StoreData_v2024FA.csv.corrupt-<timestamp>`
124+
and reports where, then starts fresh from the bundled seed on the next launch. To restore older data,
125+
replace the data file with a known-good copy (e.g. a renamed `.corrupt-…` backup).
126+
122127
## Demo credentials
123128

124129
At the login screen:
@@ -183,10 +188,10 @@ masked to the last four digits and never written to disk. The bundled data is en
183188
this project is an educational demo that must not handle real cardholder data.
184189

185190
Security is verified empirically, not assumed: a dedicated [security workflow](.github/workflows/security.yml)
186-
runs **CodeQL**, **Semgrep**, **Trivy** + **OWASP Dependency-Check**, **gitleaks**, and **find-sec-bugs**
187-
(also a `mvn verify` gate) on every push, with **Dependabot** watching dependencies — currently a clean,
188-
zero-finding pass. Full details, the threat model, and how to report a vulnerability are in
189-
[SECURITY.md](SECURITY.md).
191+
runs **CodeQL**, **Semgrep**, **Trivy** + **OWASP Dependency-Check**, and **gitleaks** on every push,
192+
complemented by **find-sec-bugs** in the `mvn verify` build gate (CI) and **Dependabot** watching
193+
dependencies — currently a clean, zero-finding pass. Full details, the threat model, and how to report a
194+
vulnerability are in [SECURITY.md](SECURITY.md).
190195

191196
## License
192197

src/main/java/POSDM/CsvStoreRepository.java

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,25 +103,60 @@ public static CsvStoreRepository defaultRepository() {
103103
public Store load() {
104104
Store store = new Store();
105105
boolean fromDataFile = Files.exists(dataFile);
106+
boolean corrupt;
106107
try (BufferedReader reader = openForRead()) {
107108
if (reader == null) {
108109
LOG.info("No store data file or seed resource found; starting with an empty store");
109110
return store;
110111
}
111112
parse(reader, store);
112-
if (fromDataFile && isEffectivelyEmpty(store) && Files.size(dataFile) > 0) {
113-
// A present, non-empty file that yields no usable records is corrupt. Refuse to
114-
// return a silently-empty store, which a later save would overwrite irrecoverably.
115-
throw new StorePersistenceException(
116-
"Store data file is present but could not be parsed (possibly corrupt): "
117-
+ dataFile);
118-
}
113+
// A present, non-empty file that yields no usable records is corrupt.
114+
corrupt = fromDataFile && isEffectivelyEmpty(store) && Files.size(dataFile) > 0;
119115
} catch (IOException e) {
120116
throw new StorePersistenceException("Failed to read store data from " + dataFile, e);
121117
}
118+
if (corrupt) {
119+
// Refuse to return a silently-empty store (a later save would overwrite the file
120+
// irrecoverably). The reader is now closed, so move the bad file aside — the next
121+
// launch
122+
// then starts cleanly from the seed without the user touching a hidden dotfile — and
123+
// fail with actionable guidance rather than a bare path.
124+
Path quarantined = quarantineCorruptFile();
125+
String guidance =
126+
quarantined != null
127+
? " It has been moved to "
128+
+ quarantined
129+
+ ", so the application will start fresh on the next launch. To"
130+
+ " restore older data, replace "
131+
+ dataFile
132+
+ " with a known-good copy."
133+
: " Rename or remove "
134+
+ dataFile
135+
+ " to start fresh, or restore a known-good backup.";
136+
throw new StorePersistenceException(
137+
"Your saved data file could not be read and may be damaged." + guidance);
138+
}
122139
return store;
123140
}
124141

142+
/**
143+
* Best-effort move of a corrupt data file to a unique {@code .corrupt-<timestamp>} sibling.
144+
*
145+
* @return the new path, or {@code null} if it could not be moved (logged)
146+
*/
147+
private Path quarantineCorruptFile() {
148+
try {
149+
String stamp =
150+
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"));
151+
Path target = dataFile.resolveSibling(dataFile.getFileName() + ".corrupt-" + stamp);
152+
Files.move(dataFile, target);
153+
return target;
154+
} catch (IOException | RuntimeException e) {
155+
LOG.log(Level.WARNING, "Could not move aside the corrupt data file " + dataFile, e);
156+
return null;
157+
}
158+
}
159+
125160
private static boolean isEffectivelyEmpty(Store store) {
126161
return store.getName().isEmpty()
127162
&& store.getItems().isEmpty()

src/main/java/POSPD/Item.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,12 @@ public void setTaxCategory(String taxCategory, Store store) {
9191
* Adds a price to this item's price history.
9292
*
9393
* @param price the price to add
94+
* @return {@code true} if it was added; {@code false} if an equal price (same effective date
95+
* and amount) is already present, in which case the set is unchanged
9496
*/
95-
public void addPrice(Price price) {
97+
public boolean addPrice(Price price) {
9698
price.setItem(this);
97-
prices.add(price);
99+
return prices.add(price);
98100
}
99101

100102
/**

src/main/java/POSPD/TaxCategory.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,11 @@ public BigDecimal getTaxRateForDate(LocalDate date) {
6464
* Adds a tax rate to this category.
6565
*
6666
* @param taxRate the rate to add
67+
* @return {@code true} if it was added; {@code false} if an equal rate (same effective date and
68+
* rate) is already present, in which case the set is unchanged
6769
*/
68-
public void addTaxRate(TaxRate taxRate) {
69-
taxRates.add(taxRate);
70+
public boolean addTaxRate(TaxRate taxRate) {
71+
return taxRates.add(taxRate);
7072
}
7173

7274
/**

src/main/java/POSUI/PriceEditPanel.java

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,15 @@ public void actionPerformed(ActionEvent e) {
131131
JOptionPane.WARNING_MESSAGE);
132132
return;
133133
}
134+
// Capture the current values so the edit can roll back if it would collide
135+
// with an existing price (a TreeSet add is a no-op on an equal key).
136+
BigDecimal origAmount = price.getPrice();
137+
LocalDate origEffective = price.getEffectiveDate();
138+
LocalDate origEnd =
139+
price instanceof PromoPrice
140+
? ((PromoPrice) price).getEndDate()
141+
: null;
142+
134143
// Remove before mutating so the Item's price TreeSet re-sorts.
135144
if (!isAdd) {
136145
item.removePrice(price);
@@ -140,7 +149,23 @@ public void actionPerformed(ActionEvent e) {
140149
if (price instanceof PromoPrice) {
141150
((PromoPrice) price).setEndDate(end);
142151
}
143-
item.addPrice(price);
152+
if (!item.addPrice(price)) {
153+
// An equal price already exists; roll back so nothing is silently lost.
154+
price.setPrice(origAmount);
155+
price.setEffectiveDate(origEffective);
156+
if (price instanceof PromoPrice) {
157+
((PromoPrice) price).setEndDate(origEnd);
158+
}
159+
if (!isAdd) {
160+
item.addPrice(price);
161+
}
162+
JOptionPane.showMessageDialog(
163+
PriceEditPanel.this,
164+
"A price with that effective date and amount already exists.",
165+
"Invalid input",
166+
JOptionPane.WARNING_MESSAGE);
167+
return;
168+
}
144169
if (!SaveSupport.saveOrWarn(null, storeService)) {
145170
return;
146171
}

src/main/java/POSUI/TaxRateEditPanel.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,31 @@ public void actionPerformed(ActionEvent e) {
7474
JOptionPane.WARNING_MESSAGE);
7575
return;
7676
}
77+
// Capture current values so the edit can roll back if it would collide with
78+
// an existing rate (a TreeSet add is a no-op on an equal key).
79+
BigDecimal origRate = taxRate.getTaxRate();
80+
LocalDate origEffective = taxRate.getEffectiveDate();
81+
7782
// Remove before mutating so the TreeSet re-sorts on the new effective date.
7883
if (!isAdd) {
7984
taxCategory.removeTaxRate(taxRate);
8085
}
8186
taxRate.setTaxRate(rate);
8287
taxRate.setEffectiveDate(effective);
83-
taxCategory.addTaxRate(taxRate);
88+
if (!taxCategory.addTaxRate(taxRate)) {
89+
// An equal rate already exists; roll back so nothing is silently lost.
90+
taxRate.setTaxRate(origRate);
91+
taxRate.setEffectiveDate(origEffective);
92+
if (!isAdd) {
93+
taxCategory.addTaxRate(taxRate);
94+
}
95+
JOptionPane.showMessageDialog(
96+
TaxRateEditPanel.this,
97+
"A rate with that effective date and rate already exists.",
98+
"Invalid input",
99+
JOptionPane.WARNING_MESSAGE);
100+
return;
101+
}
84102
if (!SaveSupport.saveOrWarn(null, storeService)) {
85103
return;
86104
}

src/test/java/POSDM/PersistenceRoundTripTest.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,14 @@ void corruptFileRejected(@TempDir Path dir) throws IOException {
259259
Path file = dir.resolve("store.csv");
260260
Files.writeString(file, " garbage binary\nnot a known record type\n");
261261
assertThrows(StorePersistenceException.class, () -> new CsvStoreRepository(file).load());
262+
// The corrupt file is quarantined (moved aside) so the next launch starts cleanly.
263+
assertFalse(Files.exists(file), "corrupt file should be moved aside");
264+
try (java.util.stream.Stream<Path> entries = Files.list(dir)) {
265+
assertTrue(
266+
entries.anyMatch(
267+
p -> p.getFileName().toString().startsWith("store.csv.corrupt-")),
268+
"a .corrupt- backup should remain");
269+
}
262270
}
263271

264272
@Test

src/test/java/POSPD/PriceOrderingTest.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package POSPD;
22

33
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertFalse;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
46

7+
import java.math.BigDecimal;
8+
import java.time.LocalDate;
59
import org.junit.jupiter.api.DisplayName;
610
import org.junit.jupiter.api.Test;
711

@@ -37,4 +41,21 @@ void identicalPricesCollapse() {
3741
item.addPrice(new Price("2.00", "1/1/24"));
3842
assertEquals(1, item.getPrices().size());
3943
}
44+
45+
@Test
46+
@DisplayName("addPrice/addTaxRate report a duplicate key instead of silently dropping it")
47+
void addReportsDuplicate() {
48+
Item item = new Item("1", "Widget");
49+
assertTrue(item.addPrice(new Price("2.00", "1/1/24")));
50+
assertFalse(
51+
item.addPrice(new Price("2.00", "1/1/24")), "equal price must report not-added");
52+
assertEquals(1, item.getPrices().size());
53+
54+
TaxCategory food = new TaxCategory("Food");
55+
assertTrue(food.addTaxRate(new TaxRate(LocalDate.of(2024, 1, 1), new BigDecimal("0.07"))));
56+
assertFalse(
57+
food.addTaxRate(new TaxRate(LocalDate.of(2024, 1, 1), new BigDecimal("0.07"))),
58+
"equal rate must report not-added");
59+
assertEquals(1, food.getTaxRates().size());
60+
}
4061
}

0 commit comments

Comments
 (0)