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.
DateKeys/internal/testkit/differential.go

671 lines
21 KiB

package testkit
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"strings"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/internal/cbortest"
"g.activething.com/go/DateKeys/profile"
)
// DifferentialSeed seeds the generator of the differential corpus.
const DifferentialSeed = 20260925
// DifferentialFile is testdata/vectors/inspect_differential.json.
type DifferentialFile struct {
Spec string `json:"spec"`
Description string `json:"description"`
Format string `json:"format"`
Seed uint64 `json:"seed"`
Bases []DifferentialBase `json:"bases"`
Mutations []DifferentialCase `json:"mutations"`
}
// DifferentialBase is an official .dkc fixture the mutations edit.
type DifferentialBase struct {
File string `json:"file"`
SHA256 string `json:"sha256"`
}
// DifferentialCase is one mutation and the verdict of capsule.Inspect.
type DifferentialCase struct {
Base int `json:"base"` // index into Bases
Kind string `json:"kind"`
Edits []Edit `json:"edits"`
// Result is ResultOK or the normative code of the failure.
Result string `json:"result"`
// Step is the step of spec §63 that failed; absent when Result is ok.
Step int `json:"step,omitempty"`
}
const differentialFormat = "Each mutation is bases[base].file (in testdata/fixtures) with its edits applied. " +
"An edit is [at, delete, insert]: the delete bytes at offset at of the base are replaced by the bytes of the hex string insert. " +
"The edits of one mutation refer to offsets of the unmodified base, are sorted by offset and do not overlap. " +
"result is the verdict of steps 1 to 8 of spec §63 (capsule.Inspect, the Quicknet profile pinned, no extension known, no network, no secret): " +
"ok, or the normative error code, with step the step that failed. kind names the generator of the mutation and is informative."
// Kinds of differential mutations and how many each base gets.
var differentialKinds = []struct {
kind string
count int
}{
{"flip", 60}, // one bit flipped
{"byte", 30}, // one byte replaced
{"truncate", 25}, // the file cut short
{"insert", 30}, // one to four bytes inserted
{"delete", 30}, // one to four bytes deleted
{"length", 25}, // PUBLIC_HEADER_LEN or SEALED_CONTROL_LEN edited
{"header", 80}, // PUBLIC_HEADER re-encoded with a CBOR-aware change
{"datekey", 25}, // PUBLIC_HEADER re-encoded with another DateKey string
{"age", 60}, // an age header of SEALED_CONTROL or PAYLOAD_AGE edited
}
// InspectVerdict runs capsule.Inspect on dkc with the default registry and
// no extension known, and returns ResultOK or the error code, and the step
// that failed.
func InspectVerdict(dkc []byte) (string, int) {
in, err := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: Registry()})
if err == nil {
return ResultOK, 0
}
step := 0
if n := len(in.Checks); n > 0 && !in.Checks[n-1].OK {
step = in.Checks[n-1].Step
}
return Result(err), step
}
// splitmix64 is the generator of the corpus: fixed, simple and independent
// of the Go release.
type splitmix64 struct{ s uint64 }
func (r *splitmix64) next() uint64 {
r.s += 0x9e3779b97f4a7c15
z := r.s
z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9
z = (z ^ (z >> 27)) * 0x94d049bb133111eb
return z ^ (z >> 31)
}
// intn returns a number in [0, n).
func (r *splitmix64) intn(n int) int { return int(r.next() % uint64(n)) }
func (r *splitmix64) byte() byte { return byte(r.next()) }
func (r *splitmix64) bytes(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = r.byte()
}
return b
}
func pick[T any](r *splitmix64, s []T) T { return s[r.intn(len(s))] }
// diffBase is a base fixture and the offsets of its sections.
type diffBase struct {
file string
dkc []byte
parts Parts
header map[uint64]any
sealedAt, payloadAt int // offsets of SEALED_CONTROL and PAYLOAD_AGE
sealedHdr, payHdr int // lengths of their age headers
interesting int // the end of the bytes Inspect reads, and a little more
regions [][2]int
}
func newDiffBase(dir, name string) (*diffBase, error) {
f, err := LoadFixture(dir, name)
if err != nil {
return nil, err
}
b := &diffBase{file: f.File, dkc: f.DKC, parts: f.Parts}
if b.header, err = cbortest.UnmarshalMap(f.Parts.Header); err != nil {
return nil, err
}
b.sealedAt = capsule.PreludeSize + len(f.Parts.Header)
b.payloadAt = b.sealedAt + len(f.Parts.Sealed)
if b.sealedHdr, err = HeaderLen(f.Parts.Sealed); err != nil {
return nil, err
}
if b.payHdr, err = HeaderLen(f.Parts.Payload); err != nil {
return nil, err
}
b.interesting = min(len(b.dkc), b.payloadAt+b.payHdr+64)
b.regions = [][2]int{
{0, capsule.PreludeSize},
{capsule.PreludeSize, b.sealedAt},
{b.sealedAt, b.sealedAt + b.sealedHdr},
{b.sealedAt + b.sealedHdr, b.payloadAt},
{b.payloadAt, b.payloadAt + b.payHdr},
{b.payloadAt + b.payHdr, len(b.dkc)},
}
return b, nil
}
// position picks an offset, weighted towards the bytes steps 1 to 8 read:
// the prelude, the header, and the age headers of the two age files.
func (b *diffBase) position(r *splitmix64) int {
weights := []int{15, 30, 25, 5, 20, 5}
n := r.intn(100)
for i, w := range weights {
if n < w {
reg := b.regions[i]
if reg[1] > reg[0] {
return reg[0] + r.intn(reg[1]-reg[0])
}
break
}
n -= w
}
return r.intn(len(b.dkc))
}
// lengths returns an edit of the prelude that sets the section lengths.
func (b *diffBase) lengths(headerLen, sealedLen int) Edit {
var v [8]byte
binary.BigEndian.PutUint32(v[0:4], uint32(headerLen))
binary.BigEndian.PutUint32(v[4:8], uint32(sealedLen))
return Edit{At: 8, Delete: 8, Insert: v[:]}
}
// InspectDifferential computes testdata/vectors/inspect_differential.json:
// deterministic mutations of the official .dkc fixtures of dir, each with
// the verdict of capsule.Inspect.
func InspectDifferential(dir string, names []string) (DifferentialFile, error) {
f := DifferentialFile{
Spec: SpecVersion,
Description: "Differential corpus of the pre-unlock checks (spec §63 steps 1 to 8): deterministic mutations of the official .dkc fixtures " +
"with the verdict of the reference implementation. See testdata/README.md.",
Format: differentialFormat,
Seed: DifferentialSeed,
}
r := &splitmix64{s: DifferentialSeed}
for bi, name := range names {
b, err := newDiffBase(dir, name)
if err != nil {
return f, err
}
sum := sha256.Sum256(b.dkc)
f.Bases = append(f.Bases, DifferentialBase{File: b.file, SHA256: hex.EncodeToString(sum[:])})
seen := map[[32]byte]bool{sum: true}
for _, k := range differentialKinds {
made := 0
for attempt := 0; made < k.count; attempt++ {
if attempt > 50*k.count {
return f, fmt.Errorf("differential %s %s: only %d distinct mutations", name, k.kind, made)
}
edits, err := b.mutate(r, k.kind)
if err != nil {
return f, fmt.Errorf("differential %s %s: %w", name, k.kind, err)
}
if edits, err = normalizeEdits(b.dkc, edits); err != nil {
return f, err
}
out, err := ApplyEdits(b.dkc, edits)
if err != nil {
return f, err
}
h := sha256.Sum256(out)
if len(edits) == 0 || seen[h] {
continue
}
seen[h] = true
result, step := InspectVerdict(out)
f.Mutations = append(f.Mutations, DifferentialCase{Base: bi, Kind: k.kind, Edits: edits, Result: result, Step: step})
made++
}
}
}
return f, nil
}
func (b *diffBase) mutate(r *splitmix64, kind string) ([]Edit, error) {
switch kind {
case "flip":
p := b.position(r)
return []Edit{{At: p, Delete: 1, Insert: []byte{b.dkc[p] ^ 1<<r.intn(8)}}}, nil
case "byte":
p := b.position(r)
v := pick(r, []byte{0x00, 0xff, 0x7f, 0x80, 0x18, 0x1b, 0x40, 0x60, 0x80, 0xa0, 0xf6, '\n', ' ', '-', r.byte()})
return []Edit{{At: p, Delete: 1, Insert: []byte{v}}}, nil
case "truncate":
p := r.intn(b.interesting)
return []Edit{{At: p, Delete: len(b.dkc) - p}}, nil
case "insert":
p := b.position(r)
ins := r.bytes(1 + r.intn(4))
if r.intn(3) == 0 {
ins = []byte{pick(r, []byte{0x00, 0xff, 0xf6, 0x20, '\n', ' ', '\r'})}
}
return []Edit{{At: p, Insert: ins}}, nil
case "delete":
p := b.position(r)
return []Edit{{At: p, Delete: min(1+r.intn(4), len(b.dkc)-p)}}, nil
case "length":
return b.lengthEdit(r), nil
case "header":
return b.headerEdit(r)
case "datekey":
m := with(b.header)
m[3] = r.dateKey(b.dateKey())
h, err := cbortest.Marshal(m)
if err != nil {
return nil, err
}
return b.replaceHeader(h, false), nil
case "age":
return b.ageEdit(r)
}
return nil, fmt.Errorf("unknown kind %q", kind)
}
func (b *diffBase) lengthEdit(r *splitmix64) []Edit {
hl, sl := len(b.parts.Header), len(b.parts.Sealed)
vary := func(v int) int {
switch r.intn(8) {
case 0:
return v + 1
case 1:
return v - 1
case 2:
return v + 1 + r.intn(64)
case 3:
return max(0, v-1-r.intn(64))
case 4:
return pick(r, []int{0, 1, 16, 255, 256, 65535, 65536})
case 5:
return pick(r, []int{capsule.MaxPublicHeaderLen, capsule.MaxPublicHeaderLen + 1, capsule.MaxSealedControlLen, capsule.MaxSealedControlLen + 1, 1<<32 - 1})
case 6:
return int(r.next() >> 32)
}
return v ^ 1<<(8+r.intn(24))
}
switch r.intn(4) {
case 0:
hl = vary(hl)
case 1:
sl = vary(sl)
case 2:
hl, sl = sl, hl
default:
hl, sl = vary(hl), vary(sl)
}
return []Edit{b.lengths(hl, sl)}
}
// replaceHeader returns the edits that replace PUBLIC_HEADER with h and,
// unless keepPrelude, set PUBLIC_HEADER_LEN to its length.
func (b *diffBase) replaceHeader(h []byte, keepPrelude bool) []Edit {
edits := []Edit{{At: capsule.PreludeSize, Delete: len(b.parts.Header), Insert: h}}
if !keepPrelude {
edits = append([]Edit{b.lengths(len(h), len(b.parts.Sealed))}, edits...)
}
return edits
}
// dateKey returns the DateKey of the base header.
func (b *diffBase) dateKey() datekey.DateKey {
if s, ok := b.header[3].(string); ok {
if d, err := datekey.Parse(s); err == nil {
return d
}
}
return datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}
}
func (b *diffBase) headerEdit(r *splitmix64) ([]Edit, error) {
m := with(b.header)
dk := b.dateKey()
var v any = m
switch r.intn(12) {
case 0: // remove a key
keys := sortedKeys(m)
delete(m, pick(r, keys))
case 1: // add a key
m[pick(r, []uint64{5, 6, 7, 8, 23, 24, 255, 65535, 1 << 32})] = r.value()
case 2: // change the type of a value
m[pick(r, sortedKeys(m))] = r.value()
case 3:
m[0] = pick(r, []string{"", "datekeyca", "datekeycapx", "DATEKEYCAP", capsule.ControlTypeTag, "datekeys-access-key", strings.Repeat("a", 65)})
case 4:
m[1] = pick(r, []any{uint64(0), uint64(2), uint64(255), uint64(256), uint64(1 << 32), uint64(1<<53 - 1), uint64(1 << 53), uint64(1<<64 - 1), "1", -1})
case 5:
m[2] = r.bytes(pick(r, []int{0, 1, 15, 17, 32}))
case 6:
m[3] = r.dateKey(dk)
case 7:
m[4] = pick(r, []any{uint64(2), uint64(3), uint64(23), uint64(24), uint64(255), uint64(256), uint64(257), uint64(65536), uint64(1 << 32), uint64(1<<53 - 1), uint64(1 << 53)})
case 8: // an extension array
key := uint64(5 + r.intn(2))
arr := r.extensions()
if arr == nil {
// The same extension in both arrays.
m[5] = []any{ext("org.example.a", 1)}
m[6] = []any{ext("org.example.a", 1)}
} else {
m[key] = arr
}
case 9: // a head not in its shortest form
enc, err := cbortest.Marshal(m)
if err != nil {
return nil, err
}
switch r.intn(4) {
case 0:
v = cbortest.Raw(append([]byte{0xb8, enc[0] & 0x1f}, enc[1:]...))
case 1:
m[4] = cbortest.Raw(pick(r, [][]byte{{0x18, 0x00}, {0x18, 0x01}, {0x19, 0x00, 0x00}, {0x1b, 0, 0, 0, 0, 0, 0, 0, 0}}))
case 2:
m[1] = cbortest.Raw{0x18, 0x01}
default:
if id, ok := m[2].([]byte); ok {
m[2] = cbortest.Raw(append([]byte{0x58, byte(len(id))}, id...))
}
}
case 10: // keys out of order or repeated
keys := sortedKeys(m)
var p cbortest.Pairs
for _, k := range keys {
p = append(p, k, m[k])
}
i := 2 * r.intn(len(keys))
j := 2 * r.intn(len(keys))
if r.intn(2) == 0 {
p[i], p[j] = p[j], p[i]
p[i+1], p[j+1] = p[j+1], p[i+1]
} else {
p = append(p, p[i], p[i+1])
}
v = p
default: // the whole item wrapped or framed differently
enc, err := cbortest.Marshal(m)
if err != nil {
return nil, err
}
switch r.intn(6) {
case 0:
v = cbortest.Raw(append([]byte{0xc1}, enc...))
case 1:
v = []any{cbortest.Raw(enc)}
case 2:
v = cbortest.Raw(append(append([]byte{0xbf}, enc[1:]...), 0xff))
case 3:
v = cbortest.Raw(append(enc, 0x00))
case 4:
v = cbortest.Raw(append(enc, enc...))
default:
v = cbortest.Raw(enc[:len(enc)-1])
}
}
h, err := cbortest.Marshal(v)
if err != nil {
return nil, err
}
return b.replaceHeader(h, r.intn(10) == 0), nil
}
func sortedKeys(m map[uint64]any) []uint64 {
var keys []uint64
for k := range m {
keys = append(keys, k)
}
for i := 1; i < len(keys); i++ {
for j := i; j > 0 && keys[j] < keys[j-1]; j-- {
keys[j], keys[j-1] = keys[j-1], keys[j]
}
}
return keys
}
// value returns a value of a random type, most of them outside the profile.
func (r *splitmix64) value() any {
return pick(r, []any{
uint64(0), uint64(1), uint64(1 << 53), "x", []byte{0}, []byte{}, []any{}, map[uint64]any{},
cbortest.Raw{0xf6}, cbortest.Raw{0xf5}, cbortest.Raw{0x20}, cbortest.Raw{0xc1, 0x00},
cbortest.Raw{0xf9, 0x3c, 0x00}, cbortest.Raw{0x5f, 0x40, 0xff},
})
}
// dateKey returns a DateKey string derived from dk, most of them invalid.
func (r *splitmix64) dateKey(dk datekey.DateKey) any {
s := dk.Compact()
enc := func(j string) string { return datekey.Prefix + b64(j) }
switch r.intn(11) {
case 0: // one character changed
i := len(datekey.Prefix) + r.intn(len(s)-len(datekey.Prefix))
c := pick(r, []byte("AZaz09-_=+/ ."))
return s[:i] + string(c) + s[i+1:]
case 1:
return s[:len(datekey.Prefix)+r.intn(len(s)-len(datekey.Prefix))]
case 2:
return s + "="
case 3:
return "DK1_" + s[len(datekey.Prefix):]
case 4: // canonical, but of a profile that is not pinned
return datekey.DateKey{ProfileID: pick(r, []string{"datekeys:evmnet:v1", "datekeys:quicknet:v2", "drand:quicknet"}), Round: dk.Round}.Compact()
case 5: // canonical, but of a round the tlock stanza does not name or the profile never reaches
return datekey.DateKey{ProfileID: dk.ProfileID, Round: pick(r, []uint64{1, dk.Round - 1, dk.Round + 1, 66884212,
profile.Quicknet().MaxRound(), profile.Quicknet().MaxRound() + 1, datekey.MaxRound})}.Compact()
case 6:
return enc(fmt.Sprintf(`{"version":1,"network":"%s","round":%s}`, dk.ProfileID,
pick(r, []string{"0", "-1", "1.0", "1e3", "9007199254740992", strconv.FormatUint(dk.Round+1, 10), `"1000"`})))
case 7:
return enc(fmt.Sprintf(`{"version":1, "network":"%s", "round":%d}`, dk.ProfileID, dk.Round))
case 8:
return datekey.Prefix
case 9:
return cbortest.Raw(append([]byte{0x62}, 0xc0, 0x80))
}
return s + strings.Repeat("A", 200+r.intn(100))
}
func (r *splitmix64) extensions() []any {
many := func(n int) []any {
out := make([]any, n)
for i := range out {
out[i] = ext(fmt.Sprintf("org.example.%03d", i), 1)
}
return out
}
switch r.intn(16) {
case 0:
return []any{}
case 1:
return []any{ext("org.example.a", 1)}
case 2:
return []any{ext("org.example.a", 1, r.bytes(1+r.intn(8)))}
case 3:
return []any{ext("org.example.a", 1, []byte{})}
case 4:
return []any{ext("org.example.a", 1, pick(r, []any{"text", uint64(7), cbortest.Raw{0xf6}, map[uint64]any{}, []any{}, cbortest.Raw{0x58, 0x01, 0x2a}, cbortest.Raw{0xc1, 0x41, 0x00}}))}
case 5:
return []any{ext("org.example.a", 1<<32)}
case 6:
return many(64)
case 7:
return many(65)
case 8:
return []any{ext("org.example.b", 1), ext("org.example.a", 1)}
case 9:
return []any{ext("org.example.a", 1), ext("org.example.a", 1)}
case 10:
return []any{map[uint64]any{0: "org.example.a", 1: uint64(1), 3: uint64(0)}}
case 11:
return []any{map[uint64]any{0: "org.example.a"}}
case 12:
return []any{ext("\ufefforg.example.a", 1)}
case 13:
return []any{ext("\U00010000", 1), ext("\uff61", 1)}
case 14:
return []any{ext("\uff61", 1), ext("\U00010000", 1)}
}
return nil
}
// ageEdit edits the age header of SEALED_CONTROL (and then, most of the
// time, SEALED_CONTROL_LEN) or of PAYLOAD_AGE.
func (b *diffBase) ageEdit(r *splitmix64) ([]Edit, error) {
sealed := r.intn(5) < 3
at, n := b.payloadAt, b.payHdr
if sealed {
at, n = b.sealedAt, b.sealedHdr
}
hdr := string(b.dkc[at : at+n])
lines := strings.SplitAfter(hdr, "\n")
lines = lines[:len(lines)-1] // SplitAfter leaves an empty string after the final newline
// lines: intro, stanza lines (argument line, body lines), MAC line.
stanzaAt := 1
mac := len(lines) - 1
switch r.intn(11) {
case 0: // intro line
lines[0] = pick(r, []string{"age-encryption.org/v2\n", "age-encryption.org/V1\n", "age-encryption.org/v1 \n", "age-encryption.org/\n", "\n"})
case 1: // stanza type
f := strings.Fields(lines[stanzaAt])
f[1] = pick(r, []string{"tlock", "TLOCK", "X25519", "x25519", "scrypt", "tlock2", "t"})
lines[stanzaAt] = strings.Join(f, " ") + "\n"
case 2: // one argument
f := strings.Fields(lines[stanzaAt])
if len(f) > 2 {
i := 2 + r.intn(len(f)-2)
f[i] = r.arg(f[i])
lines[stanzaAt] = strings.Join(f, " ") + "\n"
}
case 3: // one more argument, or one less
f := strings.Fields(lines[stanzaAt])
if r.intn(2) == 0 {
f = append(f, pick(r, []string{"extra", "1000", "AAAA"}))
} else if len(f) > 2 {
f = f[:len(f)-1]
}
lines[stanzaAt] = strings.Join(f, " ") + "\n"
case 4: // the stanza twice
stanza := lines[stanzaAt:mac]
lines = append(append(append([]string{}, lines[:mac]...), stanza...), lines[mac])
case 5: // an extra stanza
extra := pick(r, []string{
"-> X25519 " + strings.Repeat("A", 43) + "\n" + strings.Repeat("B", 43) + "\n",
"-> scrypt c2FsdHNhbHRzYWx0c2FsdA 18\n" + strings.Repeat("C", 43) + "\n",
"-> tlock 1000 " + profile.Quicknet().ChainHashHex() + "\n" + strings.Repeat("D", 64) + "\n\n",
"-> grease-x !@#\n\n",
})
lines = append(append(append([]string{}, lines[:mac]...), extra), lines[mac])
case 6: // no stanza
lines = []string{lines[0], lines[mac]}
case 7: // a body line
if mac-stanzaAt > 1 {
i := stanzaAt + 1 + r.intn(mac-stanzaAt-1)
l := strings.TrimSuffix(lines[i], "\n")
switch r.intn(4) {
case 0:
if len(l) > 0 {
j := r.intn(len(l))
l = l[:j] + string(pick(r, []byte("A/+=_-"))) + l[j+1:]
}
case 1:
l += "A"
case 2:
l += " "
default:
if len(l) > 0 {
l = l[:len(l)-1]
}
}
lines[i] = l + "\n"
}
case 8: // the MAC line
l := lines[mac]
switch r.intn(4) {
case 0:
l = "--" + l[3:]
case 1:
j := 4 + r.intn(len(l)-5)
l = l[:j] + string(pick(r, []byte("AB/+"))) + l[j+1:]
case 2:
l = "---\n"
default:
l = strings.TrimSuffix(l, "\n") + " \n"
}
lines[mac] = l
case 9: // a line ending
i := r.intn(len(lines))
lines[i] = strings.TrimSuffix(lines[i], "\n") + "\r\n"
default: // an empty line
i := 1 + r.intn(len(lines)-1)
lines = append(append(append([]string{}, lines[:i]...), "\n"), lines[i:]...)
}
edited := []byte(strings.Join(lines, ""))
edits := []Edit{{At: at, Delete: n, Insert: edited}}
if sealed && r.intn(10) != 0 {
edits = append([]Edit{b.lengths(len(b.parts.Header), len(b.parts.Sealed)-n+len(edited))}, edits...)
}
return edits, nil
}
// arg returns a variant of a stanza argument.
func (r *splitmix64) arg(a string) string {
if _, err := strconv.ParseUint(a, 10, 64); err == nil {
return pick(r, []string{"999", "1001", "0" + a, a + "0", "0", "-1", "18446744073709551616", a + "a", "2000"})
}
if len(a) == 0 {
return "A"
}
i := r.intn(len(a))
switch r.intn(4) {
case 0:
return a[:i] + string(pick(r, []byte("0aAf/+"))) + a[i+1:]
case 1:
return strings.ToUpper(a)
case 2:
return a[:len(a)-1]
}
return a + "A"
}
// MarshalDifferential writes f with one mutation per line.
func MarshalDifferential(f DifferentialFile) ([]byte, error) {
head := f
head.Mutations = nil
b, err := json.MarshalIndent(head, "", " ")
if err != nil {
return nil, err
}
// Replace the closing "\n}" and the null mutations.
b = bytes.TrimSuffix(b, []byte("\n}"))
b = bytes.TrimSuffix(b, []byte(",\n \"mutations\": null"))
var out bytes.Buffer
out.Write(b)
out.WriteString(",\n \"mutations\": [\n")
for i, m := range f.Mutations {
line, err := json.Marshal(m)
if err != nil {
return nil, err
}
out.WriteString(" ")
out.Write(line)
if i < len(f.Mutations)-1 {
out.WriteByte(',')
}
out.WriteByte('\n')
}
out.WriteString(" ]\n}\n")
return out.Bytes(), nil
}
// WriteDifferential writes f to path with MarshalDifferential.
func WriteDifferential(path string, f DifferentialFile) error {
b, err := MarshalDifferential(f)
if err != nil {
return err
}
return writeFile(path, b)
}

Powered by TurnKey Linux.