records.v

6.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}4344// events_prefix lists a repo's events; event_key names one.45pub fn events_prefix(repo string) string {46	return '${repo}/events'47}4849pub struct FileRecord {50pub:51	path       string52	hash       string53	executable bool54}5556pub struct SaveRecord {57pub:58	id            string59	parent        string60	message       string61	created_at    i6462	machine       string63	is_checkpoint bool64	size_bytes    i6465	agent         string66	session       string67	restores      string68	replayed_from string69pub mut:70	files []FileRecord71}7273// save_record turns a local save into what travels. The file list is part of74// the record, which is why a save needs one round trip, not one per file.75pub fn save_record(store &storage.Store, save history.Save) !SaveRecord {76	files := history.files_of(store, save.id)!77	mut records := []FileRecord{}78	for file in files {79		records << FileRecord{80			path:       file.path81			hash:       file.hash82			executable: file.executable83		}84	}85	return SaveRecord{86		id:            save.id87		parent:        save.parent88		message:       save.message89		created_at:    save.created_at90		machine:       save.machine91		is_checkpoint: save.is_checkpoint92		size_bytes:    save.size_bytes93		agent:         save.agent94		session:       save.session95		restores:      save.restores96		replayed_from: save.replayed_from97		files:         records98	}99}100101pub fn encode_save(record SaveRecord) string {102	return json2.encode(record)103}104105// decode_save reads a record the remote holds. A lone surrogate escape fails only its path,106// not the whole record (spec §5.5).107pub fn decode_save(body []u8) !SaveRecord {108	return json2.decode[SaveRecord](lone_surrogates_as_bytes(body).bytestr())!109}110111// ignored_strip is a strip whose path breaks §5.5. No save can hold such a path,112// so Koh and Kepr both leave the event out rather than guess what it meant.113fn ignored_strip(event events.Event) bool {114	return event.kind == events.kind_strip && !snapshot.legal_path(event.path)115}116117// lone_surrogates_as_bytes writes a `\\uXXXX` surrogate escape with no partner as its118// three bytes. json2 refuses such an escape outright, which would refuse the whole119// save; as bytes it decodes, and the one path holding it fails legal_path (§5.5).120fn lone_surrogates_as_bytes(body []u8) []u8 {121	mut out := []u8{cap: body.len}122	mut i := 0123	for i < body.len {124		if body[i] != `\\` || i + 1 >= body.len {125			out << body[i]126			i++127			continue128		}129		unit := escaped_surrogate(body, i)130		if unit >= 0xd800 && unit <= 0xdbff && escaped_surrogate(body, i + 6) >= 0xdc00 {131			out << body[i..i + 12]132			i += 12133		} else if unit >= 0 {134			out << [u8(0xe0 | (unit >> 12)), u8(0x80 | ((unit >> 6) & 0x3f)), u8(0x80 | (unit & 0x3f))]135			i += 6136		} else {137			// Any other escape, `\\\\` included, passes through whole.138			out << body[i..i + 2]139			i += 2140		}141	}142	return out143}144145// escaped_surrogate reads a `\\uD800`–`\\uDFFF` escape at `at`, or returns -1.146fn escaped_surrogate(body []u8, at int) int {147	if at + 6 > body.len || body[at] != `\\` || body[at + 1] != `u` {148		return -1149	}150	unit := strconv.parse_int(body[at + 2..at + 6].bytestr(), 16, 32) or { return -1 }151	return if unit >= 0xd800 && unit <= 0xdfff { int(unit) } else { -1 }152}153154// drop_unsafe_paths applies the shared path contract before an arriving record is used.155pub fn drop_unsafe_paths(mut record SaveRecord) []string {156	mut kept := []FileRecord{}157	mut dropped := []string{}158	for file in record.files {159		if snapshot.legal_path(file.path) {160			kept << file161		} else {162			dropped << snapshot.escape_path(file.path)163		}164	}165	record.files = kept166	dropped.sort()167	return dropped168}169170pub fn to_save(record SaveRecord) history.Save {171	return history.Save{172		id:            record.id173		parent:        record.parent174		message:       record.message175		created_at:    record.created_at176		machine:       record.machine177		is_checkpoint: record.is_checkpoint178		size_bytes:    record.size_bytes179		agent:         record.agent180		session:       record.session181		restores:      record.restores182		replayed_from: record.replayed_from183	}184}185186pub fn to_files(record SaveRecord) []snapshot.FileEntry {187	mut files := []snapshot.FileEntry{}188	for file in record.files {189		files << snapshot.FileEntry{190			path:       file.path191			hash:       file.hash192			executable: file.executable193		}194	}195	return files196}197198pub struct EventRecord {199pub:200	id         string201	kind       string202	save_id    string203	path       string204	reason     string205	created_at i64206	machine    string207}208209pub fn event_record(event events.Event) EventRecord {210	return EventRecord{211		id:         event.id212		kind:       event.kind213		save_id:    event.save_id214		path:       event.path215		reason:     event.reason216		created_at: event.created_at217		machine:    event.machine218	}219}220221pub fn encode_event(event events.Event) string {222	return json2.encode(event_record(event))223}224225// decode_event reads an event the way decode_save reads a save.226pub fn decode_event(body []u8) !events.Event {227	record := json2.decode[EventRecord](lone_surrogates_as_bytes(body).bytestr())!228	return events.Event{229		id:         record.id230		kind:       record.kind231		save_id:    record.save_id232		path:       record.path233		reason:     record.reason234		created_at: record.created_at235		machine:    record.machine236	}237}