From c8588ae52002160ea2e72339eb5071e14994905c Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Thu, 12 Nov 2026 20:29:58 +0530 Subject: [PATCH] copying: fold in graph copying (shared nodes, cycles, identity-map fix), serialization rules (constructors, record canonical constructor, ObjectInputFilter), record array components, clone() as a defensive copy, two more compile-error exhibits; 24 tests Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017ic4b4BL3cEPHQTk2McE7C --- copying/README.md | 12 ++- copying/output/09-tests.txt | 5 +- copying/output/12-graph-copy.txt | 11 ++ copying/output/13-serialization-rules.txt | 11 ++ copying/output/14-record-arrays.txt | 9 ++ copying/output/15-defensive-clone.txt | 7 ++ copying/output/16-forgot-override.txt | 5 + copying/output/17-with-expression.txt | 11 ++ copying/scripts/broken.sh | 18 ++-- copying/scripts/run-all.sh | 4 + copying/src/broken/ForgotToOverride.java | 13 +++ copying/src/broken/WithExpression.java | 11 ++ .../ankurm/copying/DefensiveCloneDemo.java | 33 ++++++ .../com/ankurm/copying/GraphCopyDemo.java | 82 ++++++++++++++ .../com/ankurm/copying/RecordArrayDemo.java | 44 ++++++++ .../copying/SerializationRulesDemo.java | 76 +++++++++++++ .../com/ankurm/copying/CopyingExtrasTest.java | 101 ++++++++++++++++++ 17 files changed, 442 insertions(+), 11 deletions(-) create mode 100644 copying/output/12-graph-copy.txt create mode 100644 copying/output/13-serialization-rules.txt create mode 100644 copying/output/14-record-arrays.txt create mode 100644 copying/output/15-defensive-clone.txt create mode 100644 copying/output/16-forgot-override.txt create mode 100644 copying/output/17-with-expression.txt create mode 100644 copying/src/broken/ForgotToOverride.java create mode 100644 copying/src/broken/WithExpression.java create mode 100644 copying/src/main/java/com/ankurm/copying/DefensiveCloneDemo.java create mode 100644 copying/src/main/java/com/ankurm/copying/GraphCopyDemo.java create mode 100644 copying/src/main/java/com/ankurm/copying/RecordArrayDemo.java create mode 100644 copying/src/main/java/com/ankurm/copying/SerializationRulesDemo.java create mode 100644 copying/src/test/java/com/ankurm/copying/CopyingExtrasTest.java diff --git a/copying/README.md b/copying/README.md index 28b118a..8b71a63 100644 --- a/copying/README.md +++ b/copying/README.md @@ -17,7 +17,7 @@ Module in `java-core-examples`, the Java-core series. There is no `docs/` folder export JAVA_HOME=/path/to/jdk-25 mvn compile scripts/run.sh CloneDemo # one demo -scripts/broken.sh # the deliberate compile error +scripts/broken.sh # the three deliberate compile errors scripts/run-all.sh # regenerate every file in output/ ``` @@ -28,15 +28,21 @@ scripts/run-all.sh # regenerate every file in output/ | `ShallowVsDeepDemo.java` | Assignment vs shallow copy vs deep copy on one `Person` graph. | | `CloneDemo.java` | `clone()` skips the constructor, is shallow, needs `Cloneable`, and a parent that uses `new` breaks subclasses. Arrays and `ArrayList.clone()` are shallow too. | | `src/broken/FinalFieldClone.java` | Does not compile: a `final` field cannot be deep-copied inside `clone()`. Not part of the Maven build. | +| `src/broken/ForgotToOverride.java` | Does not compile: `implements Cloneable` without a public `clone()` override leaves `clone()` protected. | +| `src/broken/WithExpression.java` | Does not compile on JDK 25: the `with` expression of JEP 468 (derived record creation) is not part of the language yet. | | `CopyConstructorDemo.java` | Copy constructors with `final` fields, subtype slicing, and a virtual `copy()` that fixes it. | | `SerializationCopyDemo.java` | Deep copy via `ObjectOutputStream`: `transient` loss, shared references and cycles preserved, `NotSerializableException`. | | `RecordCopyDemo.java` | Records are only shallowly immutable: `List.copyOf` in the compact constructor, withers, a mutable `Date` component. | | `DefensiveCopyDemo.java` | Leaks through constructor and getter; `unmodifiableList` (view) vs `List.copyOf` (snapshot). | | `CollectionCopyDemo.java` | Every standard list, map and array copy is shallow; the deep versions. | | `CopyOfIdentityDemo.java` | When `List.copyOf` returns the same instance and when it copies; null elements. | +| `GraphCopyDemo.java` | Shared nodes and cycles: a recursive copy constructor duplicates the shared node and overflows the stack on a cycle; an identity-map copy and serialization both handle them. | +| `SerializationRulesDemo.java` | Which constructors run during a serialization copy (classes vs records) and an `ObjectInputFilter` allow-list. | +| `RecordArrayDemo.java` | An array record component leaks through the constructor and the accessor; the generated `equals()` compares arrays by identity. | +| `DefensiveCloneDemo.java` | Why a defensive copy must not call `clone()` on a non-final parameter type (a `Date` subclass hands back itself). | | `CopyCostDemo.java` | Indicative cost of copy constructor vs `clone()` vs serialization. Timing: results drift per run. | -| `CopyingTest.java` | Pins every claim above as an assertion (17 tests). | -| `output/01-11` | Captured runs of everything above. `10-cost.txt` is the only non-deterministic file. | +| `CopyingTest.java`, `CopyingExtrasTest.java` | Pin every claim above as an assertion (17 + 7 = 24 tests). | +| `output/01-17` | Captured runs of everything above. `10-cost.txt` is the only non-deterministic file. | ## License MIT - see [LICENSE](../LICENSE). diff --git a/copying/output/09-tests.txt b/copying/output/09-tests.txt index 0629364..eea9ba1 100644 --- a/copying/output/09-tests.txt +++ b/copying/output/09-tests.txt @@ -1,3 +1,4 @@ -[INFO] Tests run: 17, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.145 s -- in com.ankurm.copying.CopyingTest -[INFO] Tests run: 17, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 17, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.150 s -- in com.ankurm.copying.CopyingTest +[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.053 s -- in com.ankurm.copying.CopyingExtrasTest +[INFO] Tests run: 24, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS diff --git a/copying/output/12-graph-copy.txt b/copying/output/12-graph-copy.txt new file mode 100644 index 0000000..4932114 --- /dev/null +++ b/copying/output/12-graph-copy.txt @@ -0,0 +1,11 @@ +== 1. Diamond: root.left and root.right are the same node == + original : left == right : true + copy constructor : left == right : false + identity-map copy : left == right : true + serialization copy : left == right : true + +== 2. Cycle: a -> b -> a == + original : a.left.left == a : true + copy constructor : StackOverflowError + identity-map copy : copy.left.left == copy : true + identity-map copy : copy != original : true diff --git a/copying/output/13-serialization-rules.txt b/copying/output/13-serialization-rules.txt new file mode 100644 index 0000000..03e2c38 --- /dev/null +++ b/copying/output/13-serialization-rules.txt @@ -0,0 +1,11 @@ +== 1. Classes: only the first non-serializable superclass constructor runs == + after new Child() : Child runs = 1, Parent runs = 1 + after the copy : Child runs = 1, Parent runs = 2 + +== 2. Records: the canonical constructor DOES run == + after new Money(..) : canonical runs = 1 + after the copy : canonical runs = 2 + +== 3. An allow-list filter on the reading side == + list of String and Money : [text, Money[paise=1]] + list holding an AtomicInteger : InvalidClassException: filter status: REJECTED diff --git a/copying/output/14-record-arrays.txt b/copying/output/14-record-arrays.txt new file mode 100644 index 0000000..4bfd575 --- /dev/null +++ b/copying/output/14-record-arrays.txt @@ -0,0 +1,9 @@ +== 1. RawPacket keeps the caller's array and returns its own == + after raw[0] = 99 : [99, 2, 3] + after rp.data()[1] = 88 : [99, 88, 3] + +== 2. Packet copies in the constructor and in the accessor == + after the same two writes : [1, 2, 3] + +== 3. The generated equals() still compares the arrays by identity == + new Packet({1}).equals(new Packet({1})) : false diff --git a/copying/output/15-defensive-clone.txt b/copying/output/15-defensive-clone.txt new file mode 100644 index 0000000..edbd95b --- /dev/null +++ b/copying/output/15-defensive-clone.txt @@ -0,0 +1,7 @@ +== 1. A 'defensive' copy made with clone() trusts the argument's own clone() == + clone() result is the attacker's object : true + clone() result class : EvilDate + +== 2. A copy made with the constructor does not == + new Date(getTime()) is the attacker's object : false + new Date(getTime()) result class : Date diff --git a/copying/output/16-forgot-override.txt b/copying/output/16-forgot-override.txt new file mode 100644 index 0000000..7b51130 --- /dev/null +++ b/copying/output/16-forgot-override.txt @@ -0,0 +1,5 @@ +$ javac src/broken/ForgotToOverride.java +src/broken/ForgotToOverride.java:10: error: clone() has protected access in Object + Object copy = foo.clone(); + ^ +1 error diff --git a/copying/output/17-with-expression.txt b/copying/output/17-with-expression.txt new file mode 100644 index 0000000..a8a3265 --- /dev/null +++ b/copying/output/17-with-expression.txt @@ -0,0 +1,11 @@ +$ javac src/broken/WithExpression.java +src/broken/WithExpression.java:8: error: ';' expected + Point q = p with { x = 10; }; + ^ +src/broken/WithExpression.java:8: error: not a statement + Point q = p with { x = 10; }; + ^ +src/broken/WithExpression.java:8: error: ';' expected + Point q = p with { x = 10; }; + ^ +3 errors diff --git a/copying/scripts/broken.sh b/copying/scripts/broken.sh index 0dec055..037620c 100755 --- a/copying/scripts/broken.sh +++ b/copying/scripts/broken.sh @@ -1,9 +1,15 @@ #!/usr/bin/env bash -# Compiles the deliberately broken source and captures the compiler's own message. +# Compiles each deliberately broken source and captures the compiler's own message. cd "$(dirname "$0")/.." OUT=$(mktemp -d) -{ - echo '$ javac src/broken/FinalFieldClone.java' - javac -d "$OUT" src/broken/FinalFieldClone.java 2>&1 | grep -v '^Picked up JAVA_TOOL_OPTIONS' || true -} > output/02-final-field-clone.txt -cat output/02-final-field-clone.txt +capture() { # + { + echo "\$ javac src/broken/$1" + javac -d "$OUT" "src/broken/$1" 2>&1 | grep -v '^Picked up JAVA_TOOL_OPTIONS' || true + } > "output/$2.txt" + cat "output/$2.txt" +} +capture FinalFieldClone.java 02-final-field-clone +capture ForgotToOverride.java 16-forgot-override +capture WithExpression.java 17-with-expression +rm -rf "$OUT" diff --git a/copying/scripts/run-all.sh b/copying/scripts/run-all.sh index be338e2..586b755 100755 --- a/copying/scripts/run-all.sh +++ b/copying/scripts/run-all.sh @@ -13,6 +13,10 @@ run RecordCopyDemo 06-records run DefensiveCopyDemo 07-defensive run CollectionCopyDemo 08-collections run CopyOfIdentityDemo 11-copyof-identity +run GraphCopyDemo 12-graph-copy +run SerializationRulesDemo 13-serialization-rules +run RecordArrayDemo 14-record-arrays +run DefensiveCloneDemo 15-defensive-clone mvn -B test 2>&1 | grep -E 'Tests run|BUILD|ERROR|FAIL' | grep -v 'Picked up' > output/09-tests.txt run CopyCostDemo 10-cost echo "regenerated:"; ls output diff --git a/copying/src/broken/ForgotToOverride.java b/copying/src/broken/ForgotToOverride.java new file mode 100644 index 0000000..f975f07 --- /dev/null +++ b/copying/src/broken/ForgotToOverride.java @@ -0,0 +1,13 @@ +/** Implements Cloneable but forgets to override clone(), then calls it. Does not compile. */ +public class ForgotToOverride { + + static class Foo implements Cloneable { + int x = 1; + } + + public static void main(String[] args) { + Foo foo = new Foo(); + Object copy = foo.clone(); + System.out.println(copy); + } +} diff --git a/copying/src/broken/WithExpression.java b/copying/src/broken/WithExpression.java new file mode 100644 index 0000000..7f17b10 --- /dev/null +++ b/copying/src/broken/WithExpression.java @@ -0,0 +1,11 @@ +/** A "wither" expression on a record (JEP 468, derived record creation). Not part of JDK 25. */ +public class WithExpression { + + record Point(int x, int y) {} + + public static void main(String[] args) { + Point p = new Point(1, 2); + Point q = p with { x = 10; }; + System.out.println(q); + } +} diff --git a/copying/src/main/java/com/ankurm/copying/DefensiveCloneDemo.java b/copying/src/main/java/com/ankurm/copying/DefensiveCloneDemo.java new file mode 100644 index 0000000..1cb0c10 --- /dev/null +++ b/copying/src/main/java/com/ankurm/copying/DefensiveCloneDemo.java @@ -0,0 +1,33 @@ +package com.ankurm.copying; + +import java.util.Date; + +/** + * Why a defensive copy must not call clone() on a parameter whose type is not final. + * Run: scripts/run.sh DefensiveCloneDemo + */ +public class DefensiveCloneDemo { + + /** A Date subclass whose clone() hands back the attacker's own object. */ + static class EvilDate extends Date { + EvilDate(long t) { super(t); } + + @Override + public Object clone() { return this; } + } + + public static void main(String[] args) { + Date attacker = new EvilDate(3_000); + + System.out.println("== 1. A 'defensive' copy made with clone() trusts the argument's own clone() =="); + Date viaClone = (Date) attacker.clone(); + System.out.println(" clone() result is the attacker's object : " + (viaClone == attacker)); + System.out.println(" clone() result class : " + viaClone.getClass().getSimpleName()); + + System.out.println(); + System.out.println("== 2. A copy made with the constructor does not =="); + Date viaConstructor = new Date(attacker.getTime()); + System.out.println(" new Date(getTime()) is the attacker's object : " + (viaConstructor == attacker)); + System.out.println(" new Date(getTime()) result class : " + viaConstructor.getClass().getSimpleName()); + } +} diff --git a/copying/src/main/java/com/ankurm/copying/GraphCopyDemo.java b/copying/src/main/java/com/ankurm/copying/GraphCopyDemo.java new file mode 100644 index 0000000..ba769d2 --- /dev/null +++ b/copying/src/main/java/com/ankurm/copying/GraphCopyDemo.java @@ -0,0 +1,82 @@ +package com.ankurm.copying; + +import java.io.Serializable; +import java.util.IdentityHashMap; +import java.util.Map; + +/** + * Real object graphs have shared nodes and cycles. A recursive copy constructor treats every + * reference as a brand new object: it duplicates shared nodes and never finishes on a cycle. + * Run: scripts/run.sh GraphCopyDemo + */ +public class GraphCopyDemo { + + static class Node implements Serializable { + String name; + Node left; + Node right; + + Node(String name) { this.name = name; } + + /** Naive "deep" copy constructor: recurse into every reference. */ + Node(Node other) { + this.name = other.name; + this.left = other.left == null ? null : new Node(other.left); + this.right = other.right == null ? null : new Node(other.right); + } + + /** The fix: remember what was already copied, by identity, and reuse it. */ + static Node copyWithMemo(Node n, Map seen) { + if (n == null) return null; + Node done = seen.get(n); + if (done != null) return done; + Node c = new Node(n.name); + seen.put(n, c); // register BEFORE recursing, so a cycle finds it and stops + c.left = copyWithMemo(n.left, seen); + c.right = copyWithMemo(n.right, seen); + return c; + } + } + + static Node diamond() { + Node root = new Node("root"); + Node shared = new Node("shared"); + root.left = shared; + root.right = shared; + return root; + } + + static Node cycle() { + Node a = new Node("a"); + Node b = new Node("b"); + a.left = b; + b.left = a; // b points back at a + return a; + } + + public static void main(String[] args) throws Exception { + System.out.println("== 1. Diamond: root.left and root.right are the same node =="); + Node d = diamond(); + System.out.println(" original : left == right : " + (d.left == d.right)); + Node naive = new Node(d); + System.out.println(" copy constructor : left == right : " + (naive.left == naive.right)); + Node memo = Node.copyWithMemo(d, new IdentityHashMap<>()); + System.out.println(" identity-map copy : left == right : " + (memo.left == memo.right)); + Node ser = SerializationCopyDemo.deepCopy(d); + System.out.println(" serialization copy : left == right : " + (ser.left == ser.right)); + + System.out.println(); + System.out.println("== 2. Cycle: a -> b -> a =="); + Node c = cycle(); + System.out.println(" original : a.left.left == a : " + (c.left.left == c)); + try { + new Node(c); + System.out.println(" copy constructor : finished"); + } catch (StackOverflowError e) { + System.out.println(" copy constructor : StackOverflowError"); + } + Node memoCycle = Node.copyWithMemo(c, new IdentityHashMap<>()); + System.out.println(" identity-map copy : copy.left.left == copy : " + (memoCycle.left.left == memoCycle)); + System.out.println(" identity-map copy : copy != original : " + (memoCycle != c)); + } +} diff --git a/copying/src/main/java/com/ankurm/copying/RecordArrayDemo.java b/copying/src/main/java/com/ankurm/copying/RecordArrayDemo.java new file mode 100644 index 0000000..8a6c0a6 --- /dev/null +++ b/copying/src/main/java/com/ankurm/copying/RecordArrayDemo.java @@ -0,0 +1,44 @@ +package com.ankurm.copying; + +import java.util.Arrays; + +/** + * An array component of a record leaks twice: through the constructor and through the accessor. + * Run: scripts/run.sh RecordArrayDemo + */ +public class RecordArrayDemo { + + /** Stores the caller's array and hands it back. */ + record RawPacket(byte[] data) {} + + /** Copies on the way in and on the way out. */ + record Packet(byte[] data) { + Packet { data = data.clone(); } + + @Override + public byte[] data() { return data.clone(); } + } + + public static void main(String[] args) { + System.out.println("== 1. RawPacket keeps the caller's array and returns its own =="); + byte[] raw = {1, 2, 3}; + RawPacket rp = new RawPacket(raw); + raw[0] = 99; + System.out.println(" after raw[0] = 99 : " + Arrays.toString(rp.data())); + rp.data()[1] = 88; + System.out.println(" after rp.data()[1] = 88 : " + Arrays.toString(rp.data())); + + System.out.println(); + System.out.println("== 2. Packet copies in the constructor and in the accessor =="); + byte[] raw2 = {1, 2, 3}; + Packet pk = new Packet(raw2); + raw2[0] = 99; + pk.data()[1] = 88; + System.out.println(" after the same two writes : " + Arrays.toString(pk.data())); + + System.out.println(); + System.out.println("== 3. The generated equals() still compares the arrays by identity =="); + System.out.println(" new Packet({1}).equals(new Packet({1})) : " + + new Packet(new byte[]{1}).equals(new Packet(new byte[]{1}))); + } +} diff --git a/copying/src/main/java/com/ankurm/copying/SerializationRulesDemo.java b/copying/src/main/java/com/ankurm/copying/SerializationRulesDemo.java new file mode 100644 index 0000000..243aa37 --- /dev/null +++ b/copying/src/main/java/com/ankurm/copying/SerializationRulesDemo.java @@ -0,0 +1,76 @@ +package com.ankurm.copying; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InvalidClassException; +import java.io.ObjectInputFilter; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Two rules of the serialization copy that surprise people: which constructors run, and what an + * ObjectInputFilter can refuse. Run: scripts/run.sh SerializationRulesDemo + */ +public class SerializationRulesDemo { + + /** NOT serializable, with a no-arg constructor: the one deserialization WILL run. */ + static class Parent { + static int runs = 0; + Parent() { runs++; } + } + + static class Child extends Parent implements Serializable { + static int runs = 0; + Child() { runs++; } + } + + record Money(long paise) implements Serializable { + static int runs = 0; + Money { runs++; } // compact canonical constructor + } + + @SuppressWarnings("unchecked") + static T deepCopy(T obj, ObjectInputFilter filter) throws IOException, ClassNotFoundException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) { + out.writeObject(obj); + } + try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) { + in.setObjectInputFilter(filter); + return (T) in.readObject(); + } + } + + public static void main(String[] args) throws Exception { + System.out.println("== 1. Classes: only the first non-serializable superclass constructor runs =="); + Child original = new Child(); + System.out.println(" after new Child() : Child runs = " + Child.runs + ", Parent runs = " + Parent.runs); + SerializationCopyDemo.deepCopy(original); + System.out.println(" after the copy : Child runs = " + Child.runs + ", Parent runs = " + Parent.runs); + + System.out.println(); + System.out.println("== 2. Records: the canonical constructor DOES run =="); + Money m = new Money(5000); + System.out.println(" after new Money(..) : canonical runs = " + Money.runs); + SerializationCopyDemo.deepCopy(m); + System.out.println(" after the copy : canonical runs = " + Money.runs); + + System.out.println(); + System.out.println("== 3. An allow-list filter on the reading side =="); + ObjectInputFilter filter = ObjectInputFilter.Config.createFilter("com.ankurm.copying.*;java.util.*;java.lang.*;!*"); + List fine = new ArrayList<>(List.of("text", new Money(1))); + System.out.println(" list of String and Money : " + deepCopy((ArrayList) fine, filter)); + List sneaky = new ArrayList<>(List.of("text", new AtomicInteger(1))); + try { + deepCopy((ArrayList) sneaky, filter); + System.out.println(" list holding an AtomicInteger : copied"); + } catch (InvalidClassException e) { + System.out.println(" list holding an AtomicInteger : " + e.getClass().getSimpleName() + ": " + e.getMessage()); + } + } +} diff --git a/copying/src/test/java/com/ankurm/copying/CopyingExtrasTest.java b/copying/src/test/java/com/ankurm/copying/CopyingExtrasTest.java new file mode 100644 index 0000000..7b3fdea --- /dev/null +++ b/copying/src/test/java/com/ankurm/copying/CopyingExtrasTest.java @@ -0,0 +1,101 @@ +package com.ankurm.copying; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.ankurm.copying.GraphCopyDemo.Node; +import java.io.InvalidClassException; +import java.io.ObjectInputFilter; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Date; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +/** Pins the claims in the "Going further" sections added after the first version of the article. */ +class CopyingExtrasTest { + + @Test + void copyConstructorDuplicatesSharedNodesButIdentityMapAndSerializationKeepThem() throws Exception { + Node d = GraphCopyDemo.diamond(); + assertSame(d.left, d.right); + Node naive = new Node(d); + assertNotSame(naive.left, naive.right); + Node memo = Node.copyWithMemo(d, new IdentityHashMap<>()); + assertSame(memo.left, memo.right); + assertNotSame(d.left, memo.left); + Node ser = SerializationCopyDemo.deepCopy(d); + assertSame(ser.left, ser.right); + } + + @Test + void copyConstructorOverflowsTheStackOnACycleButIdentityMapDoesNot() { + Node c = GraphCopyDemo.cycle(); + assertThrows(StackOverflowError.class, () -> new Node(c)); + Node memo = Node.copyWithMemo(c, new IdentityHashMap<>()); + assertSame(memo, memo.left.left); + assertNotSame(c, memo); + } + + @Test + void deserializationRunsOnlyTheFirstNonSerializableSuperclassConstructor() throws Exception { + var original = new SerializationRulesDemo.Child(); + int childRuns = SerializationRulesDemo.Child.runs; + int parentRuns = SerializationRulesDemo.Parent.runs; + SerializationCopyDemo.deepCopy(original); + assertEquals(childRuns, SerializationRulesDemo.Child.runs); + assertEquals(parentRuns + 1, SerializationRulesDemo.Parent.runs); + } + + @Test + void deserializingARecordRunsItsCanonicalConstructor() throws Exception { + var m = new SerializationRulesDemo.Money(5000); + int before = SerializationRulesDemo.Money.runs; + var copy = SerializationCopyDemo.deepCopy(m); + assertEquals(before + 1, SerializationRulesDemo.Money.runs); + assertEquals(m, copy); + } + + @Test + void allowListFilterRejectsClassesOutsideTheList() throws Exception { + ObjectInputFilter filter = ObjectInputFilter.Config.createFilter("com.ankurm.copying.*;java.util.*;java.lang.*;!*"); + var fine = new ArrayList(List.of("text", new SerializationRulesDemo.Money(1))); + assertEquals(fine, SerializationRulesDemo.deepCopy((ArrayList) fine, filter)); + var sneaky = new ArrayList(List.of("text", new AtomicInteger(1))); + InvalidClassException e = assertThrows(InvalidClassException.class, + () -> SerializationRulesDemo.deepCopy((Serializable) sneaky, filter)); + assertTrue(e.getMessage().contains("REJECTED"), e.getMessage()); + } + + @Test + void recordArrayComponentLeaksUnlessCopiedInAndOut() { + byte[] raw = {1, 2, 3}; + var rp = new RecordArrayDemo.RawPacket(raw); + raw[0] = 99; + rp.data()[1] = 88; + assertArrayEquals(new byte[]{99, 88, 3}, rp.data()); + + byte[] raw2 = {1, 2, 3}; + var pk = new RecordArrayDemo.Packet(raw2); + raw2[0] = 99; + pk.data()[1] = 88; + assertArrayEquals(new byte[]{1, 2, 3}, pk.data()); + assertNotEquals(new RecordArrayDemo.Packet(new byte[]{1}), new RecordArrayDemo.Packet(new byte[]{1})); + } + + @Test + void cloneIsNotADefensiveCopyOfANonFinalType() { + Date attacker = new DefensiveCloneDemo.EvilDate(3_000); + assertSame(attacker, attacker.clone()); + Date safe = new Date(attacker.getTime()); + assertNotSame(attacker, safe); + assertEquals(Date.class, safe.getClass()); + } +}