//! What a save and an event look like on the remote. //! //! Records are JSON so a person can read the bucket, and the layout is the //! same for every backend: //! // /format the remote layout, `koh-remote 1` // /objects/ the object's compressed bytes, as in the table // /saves/ the full save record, including its file list // /events/ one flag, clear or strip event // /head the ID of the newest save on the line // // Only `head` is ever overwritten, and only with a matching version tag. module exchange import json2 import src.events import src.history import src.snapshot import src.storage import strconv // One spelling for each key of the layout above (spec §9.2), so no caller builds a path by hand. pub fn format_key(repo string) string { return '${repo}/format' } pub fn object_key(repo string, hash string) string { return '${repo}/objects/${hash}' } pub fn save_key(repo string, id string) string { return '${repo}/saves/${id}' } pub fn event_key(repo string, id string) string { return '${repo}/events/${id}' } pub fn head_key(repo string) string { return '${repo}/head' } pub fn bundle_key(repo string) string { return '${repo}/bundle' } pub fn events_prefix(repo string) string { return '${repo}/events' } pub struct FileRecord { pub: path string hash string executable bool } pub struct SaveRecord { pub: id string parent string message string created_at i64 machine string is_checkpoint bool size_bytes i64 agent string session string restores string replayed_from string pub mut: files []FileRecord } // save_record turns a local save into what travels. The file list is part of // the record, which is why a save needs one round trip, not one per file. pub fn save_record(store &storage.Store, save history.Save) !SaveRecord { files := history.files_of(store, save.id)! mut records := []FileRecord{} for file in files { records << FileRecord{ path: file.path hash: file.hash executable: file.executable } } return SaveRecord{ id: save.id parent: save.parent message: save.message created_at: save.created_at machine: save.machine is_checkpoint: save.is_checkpoint size_bytes: save.size_bytes agent: save.agent session: save.session restores: save.restores replayed_from: save.replayed_from files: records } } pub fn encode_save(record SaveRecord) string { return json2.encode(record) } pub fn valid_save_id(id string) bool { return id.len in [6, 12] && id.bytes().all((it >= `0` && it <= `9`) || (it >= `a` && it <= `f`)) } pub fn valid_event_id(id string) bool { return id.len == 12 && valid_save_id(id) } pub fn validate_save(record SaveRecord, expected string) ! { if !valid_save_id(expected) { return error('the remote has invalid save ID ${expected}') } if record.id != expected { return error('remote save record ID ${record.id} disagrees with key ${expected}') } if ![record.parent, record.restores, record.replayed_from].all(it == '' || valid_save_id(it)) { return error('remote save ${record.id} has an invalid save ID') } if record.created_at < 0 || record.size_bytes < 0 { return error('remote save ${record.id} has invalid metadata') } } pub fn validate_hashes(id string, files []FileRecord) ! { for file in files { if !storage.valid_hash(file.hash) { return error('remote save ${id} has invalid object hash ${file.hash}') } } } // decode_save reads a record the remote holds. A lone surrogate escape fails only its path, // not the whole record (spec §5.5). pub fn decode_save(body []u8) !SaveRecord { return json2.decode[SaveRecord](lone_surrogates_as_bytes(body).bytestr())! } // ignored_strip is a strip whose path breaks §5.5. No save can hold such a path, // so Koh and Kepr both leave the event out rather than guess what it meant. fn ignored_strip(event events.Event) bool { return event.kind == events.kind_strip && !snapshot.legal_path(event.path) } // lone_surrogates_as_bytes writes a `\\uXXXX` surrogate escape with no partner as its // three bytes. json2 refuses such an escape outright, which would refuse the whole // save; as bytes it decodes, and the one path holding it fails legal_path (§5.5). fn lone_surrogates_as_bytes(body []u8) []u8 { mut out := []u8{cap: body.len} mut i := 0 for i < body.len { if body[i] != `\\` || i + 1 >= body.len { out << body[i] i++ continue } unit := escaped_surrogate(body, i) if unit >= 0xd800 && unit <= 0xdbff && escaped_surrogate(body, i + 6) >= 0xdc00 { out << body[i..i + 12] i += 12 } else if unit >= 0 { out << [u8(0xe0 | (unit >> 12)), u8(0x80 | ((unit >> 6) & 0x3f)), u8(0x80 | (unit & 0x3f))] i += 6 } else { // Any other escape, `\\\\` included, passes through whole. out << body[i..i + 2] i += 2 } } return out } // escaped_surrogate reads a `\\uD800`–`\\uDFFF` escape at `at`, or returns -1. fn escaped_surrogate(body []u8, at int) int { if at + 6 > body.len || body[at] != `\\` || body[at + 1] != `u` { return -1 } unit := strconv.parse_int(body[at + 2..at + 6].bytestr(), 16, 32) or { return -1 } return if unit >= 0xd800 && unit <= 0xdfff { int(unit) } else { -1 } } // drop_unsafe_paths applies the shared path contract before an arriving record is used. pub fn drop_unsafe_paths(mut record SaveRecord) []string { mut kept := []FileRecord{} mut dropped := []string{} for file in record.files { if snapshot.legal_path(file.path) { kept << file } else { dropped << snapshot.escape_path(file.path) } } record.files = kept dropped.sort() return dropped } pub fn to_save(record SaveRecord) history.Save { return history.Save{ id: record.id parent: record.parent message: record.message created_at: record.created_at machine: record.machine is_checkpoint: record.is_checkpoint size_bytes: record.size_bytes agent: record.agent session: record.session restores: record.restores replayed_from: record.replayed_from } } pub fn to_files(record SaveRecord) []snapshot.FileEntry { mut files := []snapshot.FileEntry{} for file in record.files { files << snapshot.FileEntry{ path: file.path hash: file.hash executable: file.executable } } return files } pub struct EventRecord { pub: id string kind string save_id string path string reason string created_at i64 machine string } pub fn event_record(event events.Event) EventRecord { return EventRecord{ id: event.id kind: event.kind save_id: event.save_id path: event.path reason: event.reason created_at: event.created_at machine: event.machine } } pub fn encode_event(event events.Event) string { return json2.encode(event_record(event)) } // decode_event applies the same lone-surrogate rule as decode_save. pub fn decode_event(body []u8) !events.Event { record := json2.decode[EventRecord](lone_surrogates_as_bytes(body).bytestr())! return events.Event{ id: record.id kind: record.kind save_id: record.save_id path: record.path reason: record.reason created_at: record.created_at machine: record.machine } }