Skip to content

Commit 57f1bb5

Browse files
Merge pull request #14 from robocode-dev/ch-009-m004-file-io-sandboxing
CH-009: confine robot file I/O to the data directory
2 parents 78078fc + 2a8b458 commit 57f1bb5

19 files changed

Lines changed: 499 additions & 80 deletions

File tree

.clue/id-ledger.yaml

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ counters:
55
ARCH: "3"
66
C: "7"
77
CAP: "8"
8-
CH: "8"
8+
CH: "9"
99
CRIT: "8"
1010
DES: "8"
1111
EVT: "15"
12+
FIO: "4"
1213
G: "2"
13-
IDR: "6"
14+
IDR: "7"
1415
OQ: "4"
1516
P: "1"
1617
PDR: "2"
@@ -243,6 +244,11 @@ entries:
243244
state: reserved
244245
prefix: CH
245246
component: "8"
247+
- id: CH-009
248+
kind: numeric
249+
state: live
250+
prefix: CH
251+
component: "9"
246252
- id: CRIT-001
247253
kind: numeric
248254
state: live
@@ -398,6 +404,26 @@ entries:
398404
state: live
399405
prefix: EVT
400406
component: "15"
407+
- id: FIO-001
408+
kind: numeric
409+
state: live
410+
prefix: FIO
411+
component: "1"
412+
- id: FIO-002
413+
kind: numeric
414+
state: live
415+
prefix: FIO
416+
component: "2"
417+
- id: FIO-003
418+
kind: numeric
419+
state: live
420+
prefix: FIO
421+
component: "3"
422+
- id: FIO-004
423+
kind: numeric
424+
state: live
425+
prefix: FIO
426+
component: "4"
401427
- id: G-001
402428
kind: numeric
403429
state: live
@@ -438,6 +464,11 @@ entries:
438464
state: live
439465
prefix: IDR
440466
component: "6"
467+
- id: IDR-007
468+
kind: numeric
469+
state: live
470+
prefix: IDR
471+
component: "7"
441472
- id: OQ-001
442473
kind: numeric
443474
state: live
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package conformance.probes;
2+
3+
import robocode.AdvancedRobot;
4+
import robocode.RobocodeFileOutputStream;
5+
6+
import java.io.File;
7+
import java.io.IOException;
8+
9+
/**
10+
* Ported from classic's {@code tested.robots.FileWriteSize}: writes three 100000-byte
11+
* chunks against the documented 200000-byte quota, so the third write must be refused at
12+
* exactly the point classic refuses it.
13+
*/
14+
public class FileQuotaProbe extends AdvancedRobot {
15+
16+
@Override
17+
public void run() {
18+
out.println("DataQuota:" + getDataQuotaAvailable());
19+
20+
byte[] chunk = new byte[100_000];
21+
File file = getDataFile("quota-test");
22+
file.delete();
23+
24+
RobocodeFileOutputStream stream = null;
25+
try {
26+
stream = new RobocodeFileOutputStream(file);
27+
for (int i = 0; i < 3; i++) {
28+
stream.write(chunk);
29+
out.println("WroteChunk:" + i);
30+
}
31+
} catch (IOException e) {
32+
out.println("QuotaExceeded:" + e.getMessage());
33+
} finally {
34+
if (stream != null) {
35+
try {
36+
stream.close();
37+
} catch (IOException ignored) {
38+
// already reported above
39+
}
40+
}
41+
file.delete();
42+
}
43+
44+
while (true) {
45+
turnLeft(1);
46+
}
47+
}
48+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package conformance.probes;
2+
3+
import robocode.AdvancedRobot;
4+
import robocode.RobocodeFileOutputStream;
5+
6+
import java.io.File;
7+
import java.io.IOException;
8+
9+
/**
10+
* Writes through {@code getDataFile} with a plain name and with a root-relative name that
11+
* looks like it escapes the data directory, then reports what was resolved and what the
12+
* directory listing sees, so a conformance test can prove the two calls agree.
13+
*/
14+
public class FileRedirectionProbe extends AdvancedRobot {
15+
16+
@Override
17+
public void run() {
18+
out.println("DataDirectory:" + getDataDirectory().getAbsolutePath());
19+
writeAndReport("plain-name.txt");
20+
writeAndReport("/root-relative-name.txt");
21+
listDirectory();
22+
while (true) {
23+
turnLeft(1);
24+
}
25+
}
26+
27+
private void writeAndReport(String name) {
28+
try {
29+
File file = getDataFile(name);
30+
out.println("Resolved:" + name + ":" + file.getAbsolutePath());
31+
try (RobocodeFileOutputStream stream = new RobocodeFileOutputStream(file)) {
32+
stream.write(42);
33+
}
34+
out.println("WriteSucceeded:" + name + ":" + file.exists());
35+
} catch (IOException | SecurityException e) {
36+
out.println("WriteFailed:" + name + ":" + e);
37+
}
38+
}
39+
40+
private void listDirectory() {
41+
File[] files = getDataDirectory().listFiles();
42+
if (files == null) {
43+
out.println("DirectoryListing:none");
44+
return;
45+
}
46+
for (File file : files) {
47+
out.println("DirectoryListing:" + file.getName());
48+
}
49+
}
50+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package dev.robocode.tankroyale.bridge.conformance;
2+
3+
import org.junit.jupiter.api.DisplayName;
4+
import org.junit.jupiter.api.Test;
5+
6+
import java.nio.file.Path;
7+
8+
import static org.junit.jupiter.api.Assertions.assertFalse;
9+
import static org.junit.jupiter.api.Assertions.assertTrue;
10+
11+
/**
12+
* Acceptance evidence for FIO-003 — a robot's data directory is capped at the classic
13+
* 200000-byte size limit, refused at the same point classic refuses it.
14+
*/
15+
class FileQuotaConformanceTest extends ConformanceTestBase {
16+
17+
private static final String ROBOT = "conformance.probes.FileQuotaProbe";
18+
private static final Path SOURCE = ConformanceHarness.repoRoot().resolve(Path.of(
19+
"compat-test", "conformance-robots", "conformance", "probes", "FileQuotaProbe.java"));
20+
21+
@Test
22+
@DisplayName("FIO-003: a write past the quota is refused with classic's own message")
23+
void testFIO003_IntegrationPositive_WritePastQuotaIsRefused() {
24+
assertOnBothEngines(ROBOT, SOURCE, (outcome, engine) -> {
25+
assertTrue(outcome.anyConsoleContains("DataQuota:200000"),
26+
() -> "the documented quota was not reported as 200000 on " + engine
27+
+ " (" + outcome.summary() + ")");
28+
assertTrue(outcome.anyConsoleContains("WroteChunk:0") && outcome.anyConsoleContains("WroteChunk:1"),
29+
() -> "the first two chunks, which fit inside the quota, did not both write on " + engine
30+
+ " (" + outcome.summary() + ")");
31+
assertTrue(outcome.anyConsoleContains("QuotaExceeded:")
32+
&& outcome.anyConsoleContains("200000 bytes"),
33+
() -> "the third chunk was not refused with the classic quota message on " + engine
34+
+ " (" + outcome.summary() + ")");
35+
});
36+
}
37+
38+
@Test
39+
@DisplayName("FIO-003 negative: the write that exceeds the quota does not also succeed")
40+
void testFIO003_IntegrationNegative_TheRefusedChunkDoesNotAlsoReportSuccess() {
41+
assertOnBothEngines(ROBOT, SOURCE, (outcome, engine) ->
42+
assertFalse(outcome.anyConsoleContains("WroteChunk:2"),
43+
() -> "the third, quota-exceeding chunk reported as written on " + engine
44+
+ " (" + outcome.summary() + ")"));
45+
}
46+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package dev.robocode.tankroyale.bridge.conformance;
2+
3+
import org.junit.jupiter.api.DisplayName;
4+
import org.junit.jupiter.api.Test;
5+
6+
import java.nio.file.Path;
7+
8+
import static org.junit.jupiter.api.Assertions.assertFalse;
9+
import static org.junit.jupiter.api.Assertions.assertTrue;
10+
11+
/**
12+
* Acceptance evidence for FIO-001 and FIO-002 — a root-relative path a robot names is
13+
* redirected into its data directory, and {@code getDataFile} and {@code getDataDirectory}
14+
* agree about where that directory is.
15+
*/
16+
class FileRedirectionConformanceTest extends ConformanceTestBase {
17+
18+
private static final String ROBOT = "conformance.probes.FileRedirectionProbe";
19+
private static final Path SOURCE = ConformanceHarness.repoRoot().resolve(Path.of(
20+
"compat-test", "conformance-robots", "conformance", "probes", "FileRedirectionProbe.java"));
21+
22+
@Test
23+
@DisplayName("FIO-001: a root-relative path is redirected into the data directory")
24+
void testFIO001_IntegrationPositive_RootRelativePathIsRedirectedIntoDataDirectory() {
25+
assertOnBothEngines(ROBOT, SOURCE, (outcome, engine) -> {
26+
assertTrue(outcome.anyConsoleContains("WriteSucceeded:/root-relative-name.txt:true"),
27+
() -> "the root-relative write did not succeed on " + engine
28+
+ " (" + outcome.summary() + ")");
29+
assertTrue(outcome.anyConsoleContains("DirectoryListing:root-relative-name.txt"),
30+
() -> "the redirected file did not land in the data directory on " + engine
31+
+ " (" + outcome.summary() + ")");
32+
});
33+
}
34+
35+
@Test
36+
@DisplayName("FIO-001 negative: the redirected name is not written where it was named")
37+
void testFIO001_IntegrationNegative_NothingIsWrittenAtTheNamedPath() {
38+
assertOnBothEngines(ROBOT, SOURCE, (outcome, engine) -> {
39+
for (String console : outcome.consoles()) {
40+
for (String line : console.split("\\R")) {
41+
if (line.startsWith("Resolved:/root-relative-name.txt:")) {
42+
String resolved = line.substring("Resolved:/root-relative-name.txt:".length());
43+
assertFalse(resolved.equals("/root-relative-name.txt")
44+
|| resolved.equals("\\root-relative-name.txt"),
45+
() -> "the name resolved to the unredirected path on " + engine + ": " + line);
46+
}
47+
}
48+
}
49+
});
50+
}
51+
52+
@Test
53+
@DisplayName("FIO-002: getDataFile and getDataDirectory resolve against the same place")
54+
void testFIO002_IntegrationPositive_DataFileAndDataDirectoryAgree() {
55+
assertOnBothEngines(ROBOT, SOURCE, (outcome, engine) -> {
56+
assertTrue(outcome.anyConsoleContains("WriteSucceeded:plain-name.txt:true"),
57+
() -> "the plain-name write did not succeed on " + engine
58+
+ " (" + outcome.summary() + ")");
59+
assertTrue(outcome.anyConsoleContains("DirectoryListing:plain-name.txt"),
60+
() -> "getDataDirectory's listing did not see what getDataFile wrote on " + engine
61+
+ " (" + outcome.summary() + ")");
62+
});
63+
}
64+
65+
@Test
66+
@DisplayName("FIO-002 negative: the directory listing reports no file the probe did not write")
67+
void testFIO002_IntegrationNegative_DirectoryListingReportsNoUnwrittenFile() {
68+
assertOnBothEngines(ROBOT, SOURCE, (outcome, engine) ->
69+
assertFalse(outcome.anyConsoleContains("DirectoryListing:none"),
70+
() -> "the data directory listing came back empty on " + engine
71+
+ " (" + outcome.summary() + ")"));
72+
}
73+
}

docs/architecture/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ The corpus splits the goal into promises. They are not arbitrary; each sits at a
7474
| `CAP-008` call-routing fidelity | The same boundary, the other axis: what happens to a *call*. Every peer method proven to reach the right Bot API call, with completeness enforced. |
7575
| `CAP-001` event dispatch parity | The path from server to robot: which events arrive, in what order, interruptible where. |
7676
| `CAP-002` physics and state parity | The same path, but about the values the events and status carry. |
77-
| `CAP-004` file I/O sandboxing | Inside the adapter, at the file wrappers. Specified, not implemented. |
77+
| `CAP-004` file I/O sandboxing | Inside the adapter, at the file wrappers. Implemented for the `getDataFile`-reached surface; the raw-`java.io` case is agent-held (`IDR-007`). |
7878
| `CAP-006` team robot support | Inside the wrapper: the one-jar-to-one-bot-directory assumption has to give. Not implemented. |
7979
| `CAP-005` score parity | The whole picture, end to end. Detects; does not localise. |
8080
| `CAP-007` the harness | The apparatus itself, which has been wrong in ways mistaken for the bridge being wrong. |
@@ -101,7 +101,7 @@ It also has a practical consequence for anyone writing a conformance test: an ex
101101

102102
Classic Robocode's internals. The bridge reproduces observable behaviour and the API surface, not the implementation, and is free to reach the same behaviour by other means.
103103

104-
The rest of classic's sandbox. `CAP-004` covers file I/O because that has an observed defect; threads, reflection, and sockets are real gaps that are scoped out rather than forgotten.
104+
The rest of classic's sandbox. `CAP-004` covers file I/O reached through `getDataFile`/`getDataDirectory` because that had an observed defect; a raw `java.io` call that bypasses that surface, along with threads, reflection, and sockets, are real gaps that are scoped out rather than forgotten (`IDR-007`).
105105

106106
## The documents
107107

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,30 @@
11
---
22
id: CAP-004
33
type: capability
4-
status: draft
5-
links: [G-001, C-005]
4+
status: active
5+
links: [G-001, C-005, IDR-007]
66
goal: G-001
77
title: Robot file I/O sandboxing
8-
provenance: inferred
8+
provenance: verified
99
reversal-cost: high
1010
---
1111

1212
# CAP-004 — Robot file I/O sandboxing
1313

14-
Classic Robocode confines everything a robot writes to that robot's own data directory. A robot that opens an absolute path gets a file inside its data directory instead, and never learns the difference. This capability is the promise that the bridge does the same.
15-
16-
It currently does not. This capability is a specification for work not yet done, which is why it is the one place in this corpus where the criteria describe behaviour that does not exist.
14+
Classic Robocode confines everything a robot writes, through its own data-file API, to that robot's own data directory. A robot that opens a root-relative path gets a file inside its data directory instead, and never learns the difference. This capability is that redirection, implemented at the one point the bridge controls: `RobotData.getDataFile`/`getDataDirectory`.
1715

1816
## Why it exists as its own capability
1917

20-
Robots depend on the redirection, and the dependency is invisible in their source. A robot that saves its learned targeting data to a root path is not misbehaving — it is a robot whose author relied on the engine to place the file, correctly, because under classic the engine always did. One such bot in the collection produces access-denied errors in the thousands over a single battle, and its behaviour under the bridge is not a degraded version of its classic behaviour but a different robot: one whose learning never persists.
21-
22-
The safety reading points the same way. Rumble jars are downloaded code run unmodified on a maintainer's machine. Classic sandboxes them; the bridge does not.
18+
Robots depend on the redirection, and the dependency is invisible in their source. A robot that saves its learned targeting data to a root path is not misbehaving — it is a robot whose author relied on the engine to place the file, correctly, because under classic the engine always did. One such bot in the collection produced access-denied errors in the thousands over a single battle before this capability existed, and its behaviour under the bridge was not a degraded version of its classic behaviour but a different robot: one whose learning never persisted.
2319

2420
## What it covers
2521

26-
Path confinement for the file wrappers a robot uses, the resolution rule that makes `getDataFile` and `getDataDirectory` agree with each other, and the size cap classic enforces on a robot's data directory.
22+
Path confinement inside `getDataFile`/`getDataDirectory` — asterisks stripped, `..` rejected in the stripped name (stricter than classic's own check order, for safety — see `design.md`), and a `java.io.File` merge used so a root-relative name is re-rooted inside the directory rather than overriding it (a true drive-letter-absolute name fails the write on both engines instead) — plus the 200000-byte quota classic enforces on a robot's data directory.
2723

2824
## What it does not cover
2925

30-
The rest of classic's sandbox. Classic also restricts threads, reflection, sockets, and other ambient authority, and its own test suite has robots for each. Those are real gaps in the bridge and they are deliberately not promised here: this capability is scoped to file I/O, which is the part with an active, observed defect. Widening the scope to the whole sandbox would turn a milestone into a project.
26+
The rest of classic's sandbox, and, within file I/O itself, a raw `java.io` call that never goes through `getDataFile`. Classic blocks those unconditionally on path with a JVM `SecurityManager`, a mechanism JDK 24 removed and this bridge cannot reproduce; `IDR-007` records why `FIO-004` — the criterion that names this — stays `@draft` rather than closing on evidence a bridge probe cannot honestly produce. Threads, reflection, sockets, and other ambient authority are the same shape of gap and are likewise not promised here.
3127

3228
## Status
3329

34-
`draft`, and honestly so. Every criterion describes behaviour the bridge lacks. `M-004` is the plan door, and `C-005` is the constraint these criteria discharge.
35-
36-
The one thing that makes this cheaper than it looks: classic's own test suite already contains robots that assert confinement by attempting to escape it. `M-004` implements the sandbox and ports those robots in the same milestone, so the criteria gain machine evidence at the moment the behaviour appears.
30+
`active`. `FIO-001``FIO-003` are proven; `FIO-004` remains `@draft` per `IDR-007`. `M-004` is the plan door, and `C-005` is the constraint these criteria discharge — partially: the redirection and quota rules are machine-enforced, the raw-`java.io` case is not.

0 commit comments

Comments
 (0)