Allow OSM tiles in the sandbox, log metres

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 18:13:26 +01:00
parent 1475ab913b
commit d0bb8d700e
5 changed files with 161 additions and 13 deletions

View File

@ -7,7 +7,7 @@ import 'package:flutter/foundation.dart';
import 'package:workout_app/services/pc_poke_wire.dart';
/// Makes every `dart:io` HTTP connection fail before a socket is opened --
/// except the workout poke to the configured PC.
/// except the workout poke to the configured PC and OSM map tiles.
///
/// Installed as `HttpOverrides.global` by `main` in the sandbox. Firebase and
/// GitHub sync both go through `package:http`, whose IO client is built from
@ -19,6 +19,11 @@ import 'package:workout_app/services/pc_poke_wire.dart';
/// workout can time the PC round trip. The PC verifies the signed
/// `"sandbox": true` and never writes or credits it. Firebase and GitHub are
/// https on 443 and can never match.
///
/// The other exception is `https://tile.openstreetmap.org/{z}/{x}/{y}.png`
/// (zoom 0-19) for the run map, under OSM's tile usage policy: the map sends
/// a real User-Agent, caches tiles on disk, never prefetches, and nothing else
/// in the app GETs that host.
class SandboxHttpOverrides extends HttpOverrides {
/// The PC host the poke may reach; set by `PcPairing` whenever the host is
/// loaded or saved. Null means no exception at all.
@ -32,6 +37,19 @@ class SandboxHttpOverrides extends HttpOverrides {
uri.port == kPcPokePort &&
uri.path == kPcPokePath;
/// The only tile host the sandbox may reach.
static const tileHost = 'tile.openstreetmap.org';
static final _tilePath = RegExp(r'^/(\d|1[0-9])/\d+/\d+\.png$');
/// Whether [uri] is an OSM raster tile (https, port 443, zoom <= 19).
static bool allowsTile(Uri uri) =>
uri.scheme == 'https' &&
uri.host == tileHost &&
uri.port == 443 &&
!uri.hasQuery &&
_tilePath.hasMatch(uri.path);
@override
HttpClient createHttpClient(SecurityContext? context) {
return super.createHttpClient(context)
@ -42,6 +60,10 @@ class SandboxHttpOverrides extends HttpOverrides {
);
return Socket.startConnect(uri.host, uri.port);
}
if (proxyHost == null && allowsTile(uri)) {
debugPrint('WorkoutSandbox: allowing OSM tile ${uri.path}');
return Socket.startConnect(uri.host, uri.port);
}
debugPrint(
'WorkoutSandbox: refused network connection to ${uri.host} — the '
'sandbox never talks to Firebase or GitHub.',

View File

@ -76,7 +76,7 @@ class _SandboxSectionState extends State<_SandboxSection> {
const _SectionHeader('SANDBOX'),
const SizedBox(height: 4),
Text(
'This install is the sandbox: its own data, no network (only the '
'This install is the sandbox: its own data, no sync (only the '
'signed sandbox poke to the PC LINK address, which the PC never '
'credits), no LAN server. Nothing here can reach the daily build.',
style: TextStyle(

View File

@ -102,7 +102,7 @@ class RunRecorder extends ChangeNotifier {
if (_phase != RunPhase.recording) return;
_filter!.breakSegment();
_phase = RunPhase.paused;
SandboxLog.event('run paused', {'km': distanceM});
SandboxLog.event('run paused', {'m': distanceM});
notifyListeners();
}
@ -110,7 +110,7 @@ class RunRecorder extends ChangeNotifier {
void resume() {
if (_phase != RunPhase.paused) return;
_phase = RunPhase.recording;
SandboxLog.event('run resumed', {'km': distanceM});
SandboxLog.event('run resumed', {'m': distanceM});
notifyListeners();
}
@ -141,7 +141,7 @@ class RunRecorder extends ChangeNotifier {
);
}
SandboxLog.event('run finished', {
'km': distanceM,
'm': distanceM,
'secs': _moving,
'points': points.length,
'saved': track != null,
@ -223,7 +223,7 @@ class RunRecorder extends ChangeNotifier {
'finish the run to save what was captured.',
level: 900,
);
SandboxLog.event('run stream ended', {'km': distanceM});
SandboxLog.event('run stream ended', {'m': distanceM});
}
@override

View File

@ -9,7 +9,8 @@ void main() {
final printed = <String>[];
setUp(() {
printed.clear();
debugPrint = (String? message, {int? wrapWidth}) => printed.add(message ?? '');
debugPrint = (String? message, {int? wrapWidth}) =>
printed.add(message ?? '');
});
tearDown(() {
debugPrint = debugPrintThrottled;
@ -24,7 +25,10 @@ void main() {
() async => client.getUrl(Uri.parse('http://firebase.example.invalid/x')),
throwsA(isA<SocketException>()),
);
expect(printed.single, contains('refused network connection to firebase.example.invalid'));
expect(
printed.single,
contains('refused network connection to firebase.example.invalid'),
);
client.close(force: true);
});
@ -54,9 +58,8 @@ void main() {
SandboxHttpOverrides.pokeHost = host;
final client = SandboxHttpOverrides().createHttpClient(null);
await expectLater(
() async => client.postUrl(
Uri.parse('https://$host:$kPcPokePort$kPcPokePath'),
),
() async =>
client.postUrl(Uri.parse('https://$host:$kPcPokePort$kPcPokePath')),
throwsA(isA<SocketException>()),
);
expect(printed.single, contains('refused network connection to $host'));
@ -72,7 +75,10 @@ void main() {
),
throwsA(isA<SocketException>()),
);
expect(printed.single, contains('refused network connection to 10.0.0.9'));
expect(
printed.single,
contains('refused network connection to 10.0.0.9'),
);
client.close(force: true);
});
@ -92,8 +98,98 @@ void main() {
if (outcome is SocketException) {
expect(outcome.message, isNot(contains('sandbox')));
}
expect(printed.first, contains('allowing the PC poke to $host:$kPcPokePort'));
expect(
printed.first,
contains('allowing the PC poke to $host:$kPcPokePort'),
);
client.close(force: true);
});
});
group('the OSM tile exception', () {
Uri tile([
String path = '/17/70123/42650.png',
String host = 'tile.openstreetmap.org',
]) => Uri.parse('https://$host$path');
test('allows https tiles on the one host, zoom 0 to 19', () {
for (final path in [
'/0/0/0.png',
'/9/1/2.png',
'/17/70123/42650.png',
'/19/1/2.png',
]) {
expect(
SandboxHttpOverrides.allowsTile(tile(path)),
isTrue,
reason: path,
);
}
});
test('refuses everything else, including look-alike hosts', () {
for (final other in [
tile('/20/1/2.png'),
tile('/17/1/2.png?x=1'),
tile('/17/1/2.jpg'),
tile('/'),
tile('/api/0.6/map'),
tile('/17/1/2.png', 'a.tile.openstreetmap.org'),
tile('/17/1/2.png', 'openstreetmap.org'),
tile('/17/1/2.png', 'tile.openstreetmap.org.evil.example'),
tile('/17/1/2.png').replace(scheme: 'http', port: 80),
tile('/17/1/2.png').replace(port: 8443),
Uri.parse('https://kuhy-cloud.duckdns.org/x.json'),
]) {
expect(
SandboxHttpOverrides.allowsTile(other),
isFalse,
reason: '$other',
);
}
});
test('a tile reaches the socket layer, not the sandbox refusal', () async {
final client = SandboxHttpOverrides().createHttpClient(null);
Object outcome = 'connected';
try {
final request = await client.getUrl(tile());
request.abort();
} on SocketException catch (error) {
outcome = error;
}
if (outcome is SocketException) {
expect(outcome.message, isNot(contains('sandbox')));
}
expect(printed.first, contains('allowing OSM tile /17/70123/42650.png'));
client.close(force: true);
});
test('kuhy-cloud and a tile-looking path elsewhere stay refused', () async {
final client = SandboxHttpOverrides().createHttpClient(null);
for (final uri in [
Uri.parse('https://kuhy-cloud.duckdns.org/17/1/2.png'),
Uri.parse('https://kuhy-cloud.duckdns.org/x.json'),
]) {
await expectLater(
() => client.getUrl(uri),
throwsA(isA<SocketException>()),
);
}
expect(printed, hasLength(2));
expect(
printed.first,
contains('refused network connection to kuhy-cloud.duckdns.org'),
);
client.close(force: true);
});
test('the PC poke is unchanged next to the tile exception', () {
SandboxHttpOverrides.pokeHost = '127.0.0.1';
final poke = Uri.parse('http://127.0.0.1:$kPcPokePort$kPcPokePath');
expect(SandboxHttpOverrides.allows(poke), isTrue);
expect(SandboxHttpOverrides.allowsTile(poke), isFalse);
expect(SandboxHttpOverrides.allows(tile()), isFalse);
});
});
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/models/run_track.dart';
import 'package:workout_app/sandbox/sandbox.dart';
import 'package:workout_app/services/position_source_scripted.dart';
import 'package:workout_app/services/run_recorder.dart';
@ -51,6 +53,34 @@ void main() {
},
);
test('the sandbox trace labels distance in metres, not km', () async {
TestWidgetsFlutterBinding.ensureInitialized();
final printed = <String>[];
debugPrint = (message, {wrapWidth}) => printed.add(message ?? '');
Sandbox.enabled = true;
addTearDown(() {
debugPrint = debugPrintThrottled;
Sandbox.enabled = false;
});
final recorder = RunRecorder(
sourceFor: (_) => ScriptedPositionSource(straightRun(340)),
);
await recorder.start();
await pumpEventQueue();
await recorder.finish();
final lines = printed.where(
(l) => l.contains('run stream ended') || l.contains('run finished'),
);
expect(lines, hasLength(2));
expect(
lines.first,
startsWith('WorkoutSandbox: run stream ended {m: 20'),
);
expect(lines.last, startsWith('WorkoutSandbox: run finished {m: 20'));
expect(lines.join(), isNot(contains('km:')));
recorder.dispose();
});
test('a stream closing after finish is ignored', () async {
final source = ManualPositionSource();
final recorder = _recorder(source);