records.v

7.3 KB · raw

1//! What a save and an event look like on the remote.2//!3//! Records are JSON so a person can read the bucket, and the layout is the4//! same for every backend:5//!6//   <repo>/format          the remote layout, `koh-remote 1`7//   <repo>/objects/<hash>   the object's compressed bytes, as in the table8//   <repo>/saves/<id>       the full save record, including its file list9//   <repo>/events/<id>      one flag, clear or strip event10//   <repo>/head             the ID of the newest save on the line11//12// Only `head` is ever overwritten, and only with a matching version tag.1314module exchange1516import json217import src.events18import src.history19import src.snapshot20import src.storage21import strconv2223// One spelling for each key of the layout above (spec §9.2), so no caller builds a path by hand.24pub fn format_key(repo string) string {25	return '${repo}/format'26}2728pub fn object_key(repo string, hash string) string {29	return '${repo}/objects/${hash}'30}3132pub fn save_key(repo string, id string) string {33	return '${repo}/saves/${id}'34}3536pub fn event_key(repo string, id string) string {37	return '${repo}/events/${id}'38}3940pub fn head_key(repo string) string {41	return '${repo}/head'42}4344pub fn bundle_key(repo string) string {45	return '${repo}/bundle'46}4748pub fn events_prefix(repo string) string {49	return '${repo}/events'50}5152pub struct FileRecord {53pub:54	path       string55	hash       string56	executable bool57}5859pub struct SaveRecord {60pub:61	id            string62	parent        string63	message       string64	created_at    i6465	machine       string66	is_checkpoint bool67	size_bytes    i6468	agent         string69	session       string70	restores      string71	replayed_from string72pub mut:73	files []FileRecord74}7576// save_record turns a local save into what travels. The file list is part of77// the record, which is why a save needs one round trip, not one per file.78pub fn save_record(store &storage.Store, save history.Save) !SaveRecord {79	files := history.files_of(store, save.id)!80	mut records := []FileRecord{}81	for file in files {82		records << FileRecord{83			path:       file.path84			hash:       file.hash85			executable: file.executable86		}87	}88	return SaveRecord{89		id:            save.id90		parent:        save.parent91		message:       save.message92		created_at:    save.created_at93		machine:       save.machine94		is_checkpoint: save.is_checkpoint95		size_bytes:    save.size_bytes96		agent:         save.agent97		session:       save.session98		restores:      save.restores99		replayed_from: save.replayed_from100		files:         records101	}102}103104pub fn encode_save(record SaveRecord) string {105	return json2.encode(record)106}107108pub fn valid_save_id(id string) bool {109	return id.len in [6, 12]110		&& id.bytes().all((it >= `0` && it <= `9`) || (it >= `a` && it <= `f`))111}112113pub fn valid_event_id(id string) bool {114	return id.len == 12 && valid_save_id(id)115}116117pub fn validate_save(record SaveRecord, expected string) ! {118	if !valid_save_id(expected) {119		return error('the remote has invalid save ID ${expected}')120	}121	if record.id != expected {122		return error('remote save record ID ${record.id} disagrees with key ${expected}')123	}124	if ![record.parent, record.restores, record.replayed_from].all(it == '' || valid_save_id(it)) {125		return error('remote save ${record.id} has an invalid save ID')126	}127	if record.created_at < 0 || record.size_bytes < 0 {128		return error('remote save ${record.id} has invalid metadata')129	}130}131132pub fn validate_hashes(id string, files []FileRecord) ! {133	for file in files {134		if !storage.valid_hash(file.hash) {135			return error('remote save ${id} has invalid object hash ${file.hash}')136		}137	}138}139140// decode_save reads a record the remote holds. A lone surrogate escape fails only its path,141// not the whole record (spec §5.5).142pub fn decode_save(body []u8) !SaveRecord {143	return json2.decode[SaveRecord](lone_surrogates_as_bytes(body).bytestr())!144}145146// ignored_strip is a strip whose path breaks §5.5. No save can hold such a path,147// so Koh and Kepr both leave the event out rather than guess what it meant.148fn ignored_strip(event events.Event) bool {149	return event.kind == events.kind_strip && !snapshot.legal_path(event.path)150}151152// lone_surrogates_as_bytes writes a `\\uXXXX` surrogate escape with no partner as its153// three bytes. json2 refuses such an escape outright, which would refuse the whole154// save; as bytes it decodes, and the one path holding it fails legal_path (§5.5).155fn lone_surrogates_as_bytes(body []u8) []u8 {156	mut out := []u8{cap: body.len}157	mut i := 0158	for i < body.len {159		if body[i] != `\\` || i + 1 >= body.len {160			out << body[i]161			i++162			continue163		}164		unit := escaped_surrogate(body, i)165		if unit >= 0xd800 && unit <= 0xdbff && escaped_surrogate(body, i + 6) >= 0xdc00 {166			out << body[i..i + 12]167			i += 12168		} else if unit >= 0 {169			out << [u8(0xe0 | (unit >> 12)), u8(0x80 | ((unit >> 6) & 0x3f)), u8(0x80 | (unit & 0x3f))]170			i += 6171		} else {172			// Any other escape, `\\\\` included, passes through whole.173			out << body[i..i + 2]174			i += 2175		}176	}177	return out178}179180// escaped_surrogate reads a `\\uD800`–`\\uDFFF` escape at `at`, or returns -1.181fn escaped_surrogate(body []u8, at int) int {182	if at + 6 > body.len || body[at] != `\\` || body[at + 1] != `u` {183		return -1184	}185	unit := strconv.parse_int(body[at + 2..at + 6].bytestr(), 16, 32) or { return -1 }186	return if unit >= 0xd800 && unit <= 0xdfff { int(unit) } else { -1 }187}188189// drop_unsafe_paths applies the shared path contract before an arriving record is used.190pub fn drop_unsafe_paths(mut record SaveRecord) []string {191	mut kept := []FileRecord{}192	mut dropped := []string{}193	for file in record.files {194		if snapshot.legal_path(file.path) {195			kept << file196		} else {197			dropped << snapshot.escape_path(file.path)198		}199	}200	record.files = kept201	dropped.sort()202	return dropped203}204205pub fn to_save(record SaveRecord) history.Save {206	return history.Save{207		id:            record.id208		parent:        record.parent209		message:       record.message210		created_at:    record.created_at211		machine:       record.machine212		is_checkpoint: record.is_checkpoint213		size_bytes:    record.size_bytes214		agent:         record.agent215		session:       record.session216		restores:      record.restores217		replayed_from: record.replayed_from218	}219}220221pub fn to_files(record SaveRecord) []snapshot.FileEntry {222	mut files := []snapshot.FileEntry{}223	for file in record.files {224		files << snapshot.FileEntry{225			path:       file.path226			hash:       file.hash227			executable: file.executable228		}229	}230	return files231}232233pub struct EventRecord {234pub:235	id         string236	kind       string237	save_id    string238	path       string239	reason     string240	created_at i64241	machine    string242}243244pub fn event_record(event events.Event) EventRecord {245	return EventRecord{246		id:         event.id247		kind:       event.kind248		save_id:    event.save_id249		path:       event.path250		reason:     event.reason251		created_at: event.created_at252		machine:    event.machine253	}254}255256pub fn encode_event(event events.Event) string {257	return json2.encode(event_record(event))258}259260// decode_event applies the same lone-surrogate rule as decode_save.261pub fn decode_event(body []u8) !events.Event {262	record := json2.decode[EventRecord](lone_surrogates_as_bytes(body).bytestr())!263	return events.Event{264		id:         record.id265		kind:       record.kind266		save_id:    record.save_id267		path:       record.path268		reason:     record.reason269		created_at: record.created_at270		machine:    record.machine271	}272}