You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
487 lines
16 KiB
487 lines
16 KiB
|
2 days ago
|
/// Steps 1 to 8 of spec §63, as Inspect of package capsule of datekeys-go
|
||
|
|
/// (inspect.go) and inspect.ts and prefix.ts of datekeys-ts, and the view of
|
||
|
|
/// `datekeys inspect -json` (Go's internal/inspectview).
|
||
|
|
///
|
||
|
|
/// The inspection never contacts a release source and never uses a secret,
|
||
|
|
/// so an invalid capsule is rejected before it can cause an observable query
|
||
|
|
/// (spec §27, §63). It reads the prelude, PUBLIC_HEADER, SEALED_CONTROL and
|
||
|
|
/// the age header of PAYLOAD_AGE; the rest of the payload is not read, so a
|
||
|
|
/// large file needs only its first [inspectedLength] bytes.
|
||
|
|
library;
|
||
|
|
|
||
|
|
import 'dart:collection';
|
||
|
|
import 'dart:convert';
|
||
|
|
import 'dart:typed_data';
|
||
|
|
|
||
|
|
import 'age.dart';
|
||
|
|
import 'agewrap.dart';
|
||
|
|
import 'bytes.dart';
|
||
|
|
import 'datekey.dart';
|
||
|
|
import 'errors.dart';
|
||
|
|
import 'extension.dart';
|
||
|
|
import 'framing.dart';
|
||
|
|
import 'header.dart';
|
||
|
|
import 'profile.dart';
|
||
|
|
import 'source.dart';
|
||
|
|
|
||
|
|
/// One step of the flow of spec §63, as CheckResult of Go.
|
||
|
|
final class CheckResult {
|
||
|
|
/// The result of [step], called [name]: passed with [detail], or failed
|
||
|
|
/// with the message of the error as [detail] and its code as [error].
|
||
|
|
const CheckResult(this.step, this.name, this.ok, this.detail, [this.error]);
|
||
|
|
|
||
|
|
/// The step of spec §63, 1 to 18.
|
||
|
|
final int step;
|
||
|
|
|
||
|
|
/// The name of the step, such as `header validation`.
|
||
|
|
final String name;
|
||
|
|
|
||
|
|
/// Whether the step passed.
|
||
|
|
final bool ok;
|
||
|
|
|
||
|
|
/// The detail of a step that passed, informative text of the reference, or
|
||
|
|
/// the message of the error of a step that failed.
|
||
|
|
final String detail;
|
||
|
|
|
||
|
|
/// The normative code of a step that failed, such as
|
||
|
|
/// `ERR_ROUND_MISMATCH`; null when it passed.
|
||
|
|
final String? error;
|
||
|
|
|
||
|
|
/// The check as Go's JSON writes it, the empty fields omitted.
|
||
|
|
Map<String, Object?> toJson() => {
|
||
|
|
'step': step,
|
||
|
|
'name': name,
|
||
|
|
'ok': ok,
|
||
|
|
if (detail.isNotEmpty) 'detail': detail,
|
||
|
|
if (error != null && error!.isNotEmpty) 'error': error,
|
||
|
|
};
|
||
|
|
|
||
|
|
@override
|
||
|
|
String toString() =>
|
||
|
|
'step $step $name: ${ok ? 'ok' : 'FAIL'}'
|
||
|
|
'${detail.isEmpty ? '' : ', $detail'}';
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The visible part of an age recipient stanza: its type and arguments.
|
||
|
|
final class StanzaInfo {
|
||
|
|
/// The stanza of [type] with [args].
|
||
|
|
StanzaInfo(this.type, List<String> args) : args = List.unmodifiable(args);
|
||
|
|
|
||
|
|
/// The type, such as `tlock` or `X25519`.
|
||
|
|
final String type;
|
||
|
|
|
||
|
|
/// The arguments after the type.
|
||
|
|
final List<String> args;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The result of steps 1 to 8 of spec §63, produced without network and
|
||
|
|
/// without secrets, as Inspection of Go. On failure, the fields of the
|
||
|
|
/// steps that passed are set, and [error] holds the failure, also recorded
|
||
|
|
/// as the last check.
|
||
|
|
final class Inspection {
|
||
|
|
Inspection._({
|
||
|
|
required this.prelude,
|
||
|
|
required this.publicHeader,
|
||
|
|
required this.header,
|
||
|
|
required this.profile,
|
||
|
|
required this.unlockAt,
|
||
|
|
required this.outerStanzas,
|
||
|
|
required this.payloadStanzas,
|
||
|
|
required this.unusableExtensions,
|
||
|
|
required List<CheckResult> checks,
|
||
|
|
required this.error,
|
||
|
|
}) : checks = UnmodifiableListView(checks);
|
||
|
|
|
||
|
|
/// The prelude, once step 2 passed. Its format is the format of the
|
||
|
|
/// capsule, which a caller should show: format 1 does not hide the number
|
||
|
|
/// of credentials or the exact length of the content (spec §55.2, §70).
|
||
|
|
final Prelude? prelude;
|
||
|
|
|
||
|
|
/// The exact bytes of PUBLIC_HEADER, once step 3 passed.
|
||
|
|
final Uint8List? publicHeader;
|
||
|
|
|
||
|
|
/// PUBLIC_HEADER, once decoded at step 4.
|
||
|
|
final Header? header;
|
||
|
|
|
||
|
|
/// The pinned profile of the DateKey, once found at step 4.
|
||
|
|
final Profile? profile;
|
||
|
|
|
||
|
|
/// The round time of the DateKey (spec §15), once step 7 passed.
|
||
|
|
final Instant? unlockAt;
|
||
|
|
|
||
|
|
/// The stanzas of OUTER_TIME_AGE, once parsed at step 5.
|
||
|
|
final List<StanzaInfo>? outerStanzas;
|
||
|
|
|
||
|
|
/// The stanzas of PAYLOAD_AGE, once parsed at step 6.
|
||
|
|
final List<StanzaInfo>? payloadStanzas;
|
||
|
|
|
||
|
|
/// The known noncritical extensions of PUBLIC_HEADER whose data the
|
||
|
|
/// extension registry rejects. The capsule stays valid; the application
|
||
|
|
/// must not use them (spec §54).
|
||
|
|
final List<Unusable> unusableExtensions;
|
||
|
|
|
||
|
|
/// The checks of the steps run, in order. The opening appends those of
|
||
|
|
/// steps 9 to 18 to the inspection it returns.
|
||
|
|
final List<CheckResult> checks;
|
||
|
|
|
||
|
|
/// The failure of steps 1 to 8, or null when they pass.
|
||
|
|
final DateKeysException? error;
|
||
|
|
|
||
|
|
/// Whether steps 1 to 8 pass.
|
||
|
|
bool get valid => error == null;
|
||
|
|
|
||
|
|
/// The format of the capsule, once step 2 passed.
|
||
|
|
CapsuleFormat? get format => prelude?.format;
|
||
|
|
|
||
|
|
/// Where PAYLOAD_AGE starts, once step 2 passed: 16 + PUBLIC_HEADER_LEN +
|
||
|
|
/// SEALED_CONTROL_LEN (spec §63).
|
||
|
|
int? get payloadOffset {
|
||
|
|
final p = prelude;
|
||
|
|
return p == null
|
||
|
|
? null
|
||
|
|
: dkcPreludeSize + p.publicHeaderLen + p.sealedControlLen;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The public note of PUBLIC_HEADER (spec §24.1), text of the creator that
|
||
|
|
/// nobody has checked, when it has one that is usable.
|
||
|
|
String? get publicNote => header?.publicNote;
|
||
|
|
|
||
|
|
/// Whether PUBLIC_HEADER holds a public note that breaks the rules of
|
||
|
|
/// text, which a reader does not show and says so (spec §24.1).
|
||
|
|
bool get unusableNote => header?.unusableNote ?? false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The names of steps 1 to 8, as Go records them.
|
||
|
|
const _names = {
|
||
|
|
1: 'parse DKC1',
|
||
|
|
2: 'prelude',
|
||
|
|
3: 'public header',
|
||
|
|
4: 'header validation',
|
||
|
|
5: 'sealed control structure',
|
||
|
|
6: 'payload structure',
|
||
|
|
7: 'condition',
|
||
|
|
8: 'tlock stanza',
|
||
|
|
};
|
||
|
|
|
||
|
|
/// What the opening takes from the inspection: the inspection, the list
|
||
|
|
/// that its checks are a view of, and the sections of the capsule once steps
|
||
|
|
/// 1 to 8 pass.
|
||
|
|
typedef InspectedCapsule = ({
|
||
|
|
Inspection inspection,
|
||
|
|
List<CheckResult> checks,
|
||
|
|
CapsuleSections? sections,
|
||
|
|
});
|
||
|
|
|
||
|
|
/// Runs steps 1 to 8 on [dkc], a whole .dkc or its first bytes, at least
|
||
|
|
/// [inspectedLength] of them; [afterPrelude], when given, runs right after
|
||
|
|
/// step 2 passes, and what it throws ends the inspection there and
|
||
|
|
/// propagates, as the afterPrelude of Go's inspect. For the opening.
|
||
|
|
InspectedCapsule inspectSections(
|
||
|
|
List<int> dkc,
|
||
|
|
ProfileRegistry registry,
|
||
|
|
ExtensionRegistry? extensions, {
|
||
|
|
void Function(Prelude prelude)? afterPrelude,
|
||
|
|
}) {
|
||
|
|
final checks = <CheckResult>[];
|
||
|
|
Prelude? prelude;
|
||
|
|
Uint8List? publicHeader;
|
||
|
|
Header? header;
|
||
|
|
Profile? profile;
|
||
|
|
Instant? unlockAt;
|
||
|
|
List<StanzaInfo>? outer;
|
||
|
|
List<StanzaInfo>? payload;
|
||
|
|
var unusable = const <Unusable>[];
|
||
|
|
|
||
|
|
InspectedCapsule done([DateKeysException? error, CapsuleSections? s]) => (
|
||
|
|
inspection: Inspection._(
|
||
|
|
prelude: prelude,
|
||
|
|
publicHeader: publicHeader,
|
||
|
|
header: header,
|
||
|
|
profile: profile,
|
||
|
|
unlockAt: unlockAt,
|
||
|
|
outerStanzas: outer,
|
||
|
|
payloadStanzas: payload,
|
||
|
|
unusableExtensions: unusable,
|
||
|
|
checks: checks,
|
||
|
|
error: error,
|
||
|
|
),
|
||
|
|
checks: checks,
|
||
|
|
sections: s,
|
||
|
|
);
|
||
|
|
void pass(int step, String detail) =>
|
||
|
|
checks.add(CheckResult(step, _names[step]!, true, detail));
|
||
|
|
InspectedCapsule fail(int step, DateKeysException e) {
|
||
|
|
checks.add(CheckResult(step, _names[step]!, false, e.message, e.code.code));
|
||
|
|
return done(e);
|
||
|
|
}
|
||
|
|
|
||
|
|
void passPrelude(Prelude p) {
|
||
|
|
prelude = p;
|
||
|
|
pass(
|
||
|
|
2,
|
||
|
|
'DKC1 v${p.format.version}, PUBLIC_HEADER_LEN=${p.publicHeaderLen}, '
|
||
|
|
'SEALED_CONTROL_LEN=${p.sealedControlLen}',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Steps 1 to 3: DKC1, the prelude and the exact PUBLIC_HEADER bytes.
|
||
|
|
final CapsuleSections s;
|
||
|
|
try {
|
||
|
|
s = splitCapsule(dkc);
|
||
|
|
} on FramingException catch (e) {
|
||
|
|
if (e.step > 1) pass(1, 'magic DKC1');
|
||
|
|
final p = e.prelude;
|
||
|
|
if (p != null) {
|
||
|
|
passPrelude(p);
|
||
|
|
afterPrelude?.call(p);
|
||
|
|
}
|
||
|
|
return fail(e.step, e.error);
|
||
|
|
}
|
||
|
|
pass(1, 'magic DKC1');
|
||
|
|
passPrelude(s.prelude);
|
||
|
|
afterPrelude?.call(s.prelude);
|
||
|
|
publicHeader = Uint8List.fromList(s.publicHeader);
|
||
|
|
pass(3, '${s.publicHeader.length} bytes');
|
||
|
|
|
||
|
|
// Step 4: canonical CBOR, canonical DateKey, pinned profile, known
|
||
|
|
// critical extensions with valid data.
|
||
|
|
final Header h;
|
||
|
|
try {
|
||
|
|
h = decodeHeader(s.publicHeader);
|
||
|
|
} on DateKeysException catch (e) {
|
||
|
|
return fail(4, e);
|
||
|
|
}
|
||
|
|
header = h;
|
||
|
|
final p = registry.lookup(h.dateKey.profileId);
|
||
|
|
if (p == null) {
|
||
|
|
return fail(
|
||
|
|
4,
|
||
|
|
DateKeysException(
|
||
|
|
ErrorCode.unknownProfile,
|
||
|
|
'capsule: profile ${goQuote(utf8Bytes(h.dateKey.profileId))} is not '
|
||
|
|
'pinned',
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
profile = p;
|
||
|
|
try {
|
||
|
|
withContext(
|
||
|
|
'capsule: PUBLIC_HEADER',
|
||
|
|
() => checkCritical(h.critical, extensions, ExtensionObject.publicHeader),
|
||
|
|
);
|
||
|
|
} on DateKeysException catch (e) {
|
||
|
|
return fail(4, e);
|
||
|
|
}
|
||
|
|
unusable = List.unmodifiable(
|
||
|
|
checkNoncritical(h.noncritical, extensions, ExtensionObject.publicHeader),
|
||
|
|
);
|
||
|
|
pass(
|
||
|
|
4,
|
||
|
|
'capsule_id=${h.capsuleIdHex} datekey=${compactDateKey(h.dateKey)} '
|
||
|
|
'policy=${h.policy.label} profile=${p.id}${unusableDetail(unusable)}',
|
||
|
|
);
|
||
|
|
|
||
|
|
// Step 5: OUTER_TIME_AGE holds exactly one stanza, of type tlock.
|
||
|
|
final sealed = s.sealedControl;
|
||
|
|
if (sealed == null) return fail(5, truncatedSealedControl());
|
||
|
|
final List<AgeStanza> outerStanzas;
|
||
|
|
try {
|
||
|
|
outerStanzas = ageStanzas(sealed);
|
||
|
|
} on DateKeysException catch (e) {
|
||
|
|
return fail(5, e.wrap('capsule: SEALED_CONTROL'));
|
||
|
|
}
|
||
|
|
outer = _infos(outerStanzas);
|
||
|
|
if (outerStanzas.length != 1 || outerStanzas[0].type != stanzaTlock) {
|
||
|
|
return fail(
|
||
|
|
5,
|
||
|
|
DateKeysException(
|
||
|
|
ErrorCode.policyStructureMismatch,
|
||
|
|
'capsule: OUTER_TIME_AGE must hold exactly one tlock stanza, found '
|
||
|
|
'${outerStanzas.length}',
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
pass(5, 'one tlock stanza');
|
||
|
|
|
||
|
|
// Step 6: PAYLOAD_AGE holds exactly one stanza, of type X25519. Only its
|
||
|
|
// age header is read.
|
||
|
|
final List<AgeStanza> payloadStanzas;
|
||
|
|
try {
|
||
|
|
payloadStanzas = ageStanzas(s.payload);
|
||
|
|
} on DateKeysException catch (e) {
|
||
|
|
return fail(6, e.wrap('capsule: PAYLOAD_AGE'));
|
||
|
|
}
|
||
|
|
payload = _infos(payloadStanzas);
|
||
|
|
try {
|
||
|
|
checkPayloadStanzas(payloadStanzas);
|
||
|
|
} on DateKeysException catch (e) {
|
||
|
|
return fail(6, e);
|
||
|
|
}
|
||
|
|
pass(6, 'one X25519 stanza');
|
||
|
|
|
||
|
|
// Step 7: resolve and verify the time condition locally.
|
||
|
|
final Instant unlock;
|
||
|
|
try {
|
||
|
|
validateDateKey(h.dateKey, p);
|
||
|
|
unlock = roundTime(p, h.dateKey.round);
|
||
|
|
} on DateKeysException catch (e) {
|
||
|
|
return fail(7, e);
|
||
|
|
}
|
||
|
|
unlockAt = unlock;
|
||
|
|
pass(7, 'round ${h.dateKey.round}, unlock at ${formatRfc3339(unlock)}');
|
||
|
|
|
||
|
|
// Step 8: the tlock stanza names the DateKey round and the pinned chain.
|
||
|
|
try {
|
||
|
|
checkTimeStanzas(
|
||
|
|
outerStanzas,
|
||
|
|
round: h.dateKey.round,
|
||
|
|
chainHashHex: p.chainHashHex,
|
||
|
|
profileId: p.id,
|
||
|
|
);
|
||
|
|
} on DateKeysException catch (e) {
|
||
|
|
return fail(8, e);
|
||
|
|
}
|
||
|
|
pass(8, 'round ${h.dateKey.round}, chain ${p.chainHashHex}');
|
||
|
|
return done(null, s);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The detail that a step adds for the unusable noncritical extensions of an
|
||
|
|
/// object, as unusable of Go: empty when there is none.
|
||
|
|
String unusableDetail(List<Unusable> u) =>
|
||
|
|
u.isEmpty ? '' : ', ${u.length} unusable noncritical extensions';
|
||
|
|
|
||
|
|
List<StanzaInfo> _infos(List<AgeStanza> stanzas) =>
|
||
|
|
List.unmodifiable([for (final s in stanzas) StanzaInfo(s.type, s.args)]);
|
||
|
|
|
||
|
|
/// Runs steps 1 to 8 of spec §63 on [dkc], as Inspect of Go: the framing,
|
||
|
|
/// a canonical PUBLIC_HEADER with a canonical DateKey, a pinned profile and
|
||
|
|
/// known critical extensions, the stanzas of OUTER_TIME_AGE and of
|
||
|
|
/// PAYLOAD_AGE, the time condition, and the round and the chain hash of the
|
||
|
|
/// tlock stanza. [dkc] is the whole .dkc, or its first [inspectedLength]
|
||
|
|
/// bytes, which give the same result. The profiles are those of [registry],
|
||
|
|
/// the default registry when null, with Quicknet pinned; [extensions] are
|
||
|
|
/// those the application implements, none when null, the state of the base
|
||
|
|
/// protocol V1.
|
||
|
|
///
|
||
|
|
/// It never throws for an invalid capsule: the failure is in
|
||
|
|
/// [Inspection.error], and is the last check.
|
||
|
|
Inspection inspectCapsule(
|
||
|
|
List<int> dkc, {
|
||
|
|
ProfileRegistry? registry,
|
||
|
|
ExtensionRegistry? extensions,
|
||
|
|
}) =>
|
||
|
|
inspectSections(dkc, registry ?? defaultRegistry(), extensions).inspection;
|
||
|
|
|
||
|
|
/// [inspectCapsule] of the .dkc that [source] reads, of which only its first
|
||
|
|
/// [inspectedLength] bytes are read. What [source] throws propagates.
|
||
|
|
Future<Inspection> inspectCapsuleSource(
|
||
|
|
ByteSource source, {
|
||
|
|
ProfileRegistry? registry,
|
||
|
|
ExtensionRegistry? extensions,
|
||
|
|
}) async => inspectCapsule(
|
||
|
|
await readInspected(source),
|
||
|
|
registry: registry,
|
||
|
|
extensions: extensions,
|
||
|
|
);
|
||
|
|
|
||
|
|
/// The first [inspectedLength] bytes of the .dkc that [source] reads: its
|
||
|
|
/// first 16, and then, when its prelude passes steps 1 and 2, the rest of
|
||
|
|
/// them.
|
||
|
|
Future<Uint8List> readInspected(ByteSource source) async {
|
||
|
|
final head = await readRange(source, 0, dkcPreludeSize);
|
||
|
|
final n = inspectedLength(source.length, head);
|
||
|
|
return n == head.length ? head : readRange(source, 0, n);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// How many leading bytes of a .dkc of [size] bytes steps 1 to 8 need,
|
||
|
|
/// given its first min([size], 16) bytes [head], as inspectedLength of
|
||
|
|
/// datekeys-ts. Inspecting that prefix gives exactly the result of
|
||
|
|
/// inspecting the whole file:
|
||
|
|
///
|
||
|
|
/// - a file shorter than 16 bytes is read whole;
|
||
|
|
/// - a prelude that steps 1 and 2 reject fails on its 16 bytes alone, so
|
||
|
|
/// nothing more is read: a stray video or a .dkk costs 16 bytes;
|
||
|
|
/// - otherwise steps 3 and 5 compare the file length with the end of
|
||
|
|
/// PUBLIC_HEADER and of SEALED_CONTROL, whose lengths are within the limits
|
||
|
|
/// of spec §57; the prefix is the whole file whenever it ends before
|
||
|
|
/// PAYLOAD_AGE plus the bytes below;
|
||
|
|
/// - steps 5 and 6 parse the age headers of SEALED_CONTROL, whole in the
|
||
|
|
/// prefix, and of PAYLOAD_AGE, whose parser reads at most 2 MiB and
|
||
|
|
/// otherwise only asks whether the file goes further, which one more byte
|
||
|
|
/// answers. The rest of the payload is never read.
|
||
|
|
int inspectedLength(int size, List<int> head) {
|
||
|
|
if (head.length < dkcPreludeSize) return size;
|
||
|
|
final Prelude p;
|
||
|
|
try {
|
||
|
|
p = parsePrelude(head);
|
||
|
|
} on DateKeysException {
|
||
|
|
// Steps 1 and 2 run this same check on these same 16 bytes.
|
||
|
|
return head.length;
|
||
|
|
}
|
||
|
|
final n = payloadOffset(p) + maxAgeHeaderLength + 1;
|
||
|
|
return size < n ? size : n;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The most bytes of a .dkk that [decodeAccessKey] needs: a valid one is its
|
||
|
|
/// prelude of 12 bytes and a body of at most 16 MiB (spec §57), and one byte
|
||
|
|
/// more is enough to see data after the largest body. A longer file decodes
|
||
|
|
/// to the same error from its first [maxAccessKeyRead] bytes as whole, since
|
||
|
|
/// no framing error states a length it did not read.
|
||
|
|
const maxAccessKeyRead = dkkPreludeSize + maxDkkBodyLen + 1;
|
||
|
|
|
||
|
|
/// The view of `datekeys inspect -json` of [inspection] of [file], as New of
|
||
|
|
/// Go's internal/inspectview: the format once step 2 passed, the fields of
|
||
|
|
/// PUBLIC_HEADER once decoded, unlock_at once step 7 passed, the public note,
|
||
|
|
/// valid, the code of the error and the checks, in the order of the Go
|
||
|
|
/// struct and with its omissions.
|
||
|
|
Map<String, Object?> inspectView(
|
||
|
|
Inspection inspection, {
|
||
|
|
required String file,
|
||
|
|
}) {
|
||
|
|
final v = <String, Object?>{'file': file};
|
||
|
|
final f = inspection.prelude?.format;
|
||
|
|
if (f != null) v['format'] = f.version;
|
||
|
|
final h = inspection.header;
|
||
|
|
if (h != null) {
|
||
|
|
v['capsule_id'] = h.capsuleIdHex;
|
||
|
|
final dk = compactDateKey(h.dateKey);
|
||
|
|
if (dk.isNotEmpty) v['datekey'] = dk;
|
||
|
|
if (h.dateKey.profileId.isNotEmpty) v['profile'] = h.dateKey.profileId;
|
||
|
|
if (h.dateKey.round != 0) v['round'] = h.dateKey.round;
|
||
|
|
}
|
||
|
|
final unlock = inspection.unlockAt;
|
||
|
|
if (unlock != null) v['unlock_at'] = formatRfc3339(unlock);
|
||
|
|
if (h != null) {
|
||
|
|
v['access_policy'] = h.policy.label;
|
||
|
|
final note = h.publicNote;
|
||
|
|
if (note != null && note.isNotEmpty) v['public_note'] = note;
|
||
|
|
if (h.unusableNote) v['public_note_unusable'] = true;
|
||
|
|
}
|
||
|
|
v['valid'] = inspection.valid;
|
||
|
|
final error = inspection.error;
|
||
|
|
if (error != null) v['error'] = error.code.code;
|
||
|
|
v['checks'] = [for (final c in inspection.checks) c.toJson()];
|
||
|
|
return v;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The exact output of `datekeys inspect -json` for [view], as WriteJSON of
|
||
|
|
/// Go's internal/inspectview: json.Encoder with SetIndent("", " "), which
|
||
|
|
/// escapes <, > and & and U+2028 and U+2029, and ends with a newline. Keys
|
||
|
|
/// and the values that are not strings never hold those characters, so
|
||
|
|
/// escaping the whole text only touches strings; dart:convert writes every
|
||
|
|
/// other escape as Go 1.22 and later do.
|
||
|
|
String inspectJson(Map<String, Object?> view) {
|
||
|
|
final text = const JsonEncoder.withIndent(' ').convert(view);
|
||
|
|
final out = StringBuffer();
|
||
|
|
for (final c in text.runes) {
|
||
|
|
switch (c) {
|
||
|
|
case 0x3c || 0x3e || 0x26 || 0x2028 || 0x2029:
|
||
|
|
out.write('\\u${c.toRadixString(16).padLeft(4, '0')}');
|
||
|
|
default:
|
||
|
|
out.writeCharCode(c);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
out.write('\n');
|
||
|
|
return out.toString();
|
||
|
|
}
|