1234567891011module exchange1213import json214import os15import src.events16import src.fmt17import src.history18import src.remote19import src.snapshot20import src.storage212223const repo_state_key = 'remote_repo'24252627const remote_head_key = 'remote_head'2829pub const no_saves = 'this repo has no saves yet — nothing to offer'30313233const format_value = 'koh-remote 1'3435363738fn put_format(mut backend remote.Backend, repo string) ! {39 if backend.put_if_absent(format_key(repo), format_value.bytes())! {40 return41 }42 check_format(mut backend, repo)!43}4445464748fn check_format(mut backend remote.Backend, repo string) !string {49 existing := backend.get(format_key(repo))!50 if !existing.found {51 return ''52 }53 value := existing.body.bytestr().trim_space()54 if value != format_value {55 return error('the remote "${repo}" is "${value}" — this koh speaks "${format_value}"; update koh')56 }57 return value58}5960pub struct OfferReport {61pub mut:62 repo string63 saves int64 objects int65 events int66 head string67}68697071pub fn remember_repo(store &storage.Store, name string) ! {72 store.set_state(repo_state_key, name)!73}7475767778pub fn repo_name(store &storage.Store, mut backend remote.Backend) !string {79 if remembered := store.state(repo_state_key) {80 return follow_rename(store, mut backend, remembered)!81 }82 name := os.base(store.root).to_lower()83 84 85 check_format(mut backend, name)!86 existing := backend.get(head_key(name))!87 if existing.found {88 theirs := existing.body.bytestr().trim_space()89 if history.by_id(store, theirs) == none {90 return error('the remote already has "${name}" with a history this repo does not share — rename the folder, or use another remote')91 }92 }93 store.set_state(repo_state_key, name)!94 return name95}96979899100fn follow_rename(store &storage.Store, mut backend remote.Backend, remembered string) !string {101 mut name := remembered102 for _ in 0 .. 5 {103 if backend.get(head_key(name))!.found {104 break105 }106 marker := backend.get('.kepr/renamed/${name}')!107 next := marker.body.bytestr().trim_space()108 if !marker.found || next == '' || next.contains('/') || next.contains('\\') || next in [109 '.',110 '..',111 ] {112 break113 }114 name = next115 }116 if name != remembered {117 store.set_state(repo_state_key, name)!118 }119 return name120}121122123124pub fn offer(store &storage.Store, mut backend remote.Backend) !OfferReport {125 name := repo_name(store, mut backend)!126 local := history.head(store) or { return error(no_saves) }127 mut report := OfferReport{128 repo: name129 head: local.id130 }131 if check_format(mut backend, name)! == '' {132 133 put_format(mut backend, name)!134 }135 remote_head := backend.get(head_key(name))!136 137 138 if unapplied_strips(store, mut backend, name)!.len > 0 {139 return error(strip_not_applied)140 }141 mut uploaded := Uploaded{}142 upload_line(store, mut backend, name, local.id, remote_head, mut report, mut uploaded) or {143 return after_failed_upload(store, mut backend, name, uploaded, err)144 }145 146 147 148 if undo_late_strips(store, mut backend, name, uploaded)! {149 return error(strip_not_applied)150 }151 if !backend.put_if_match(head_key(name), local.id.bytes(), remote_head.tag)! {152 return error('remote has new saves — run koh steal')153 }154 store.set_state(remote_head_key, local.id)!155 return report156}157158159160161fn saves_after(store &storage.Store, ours string, theirs string) ![]history.Save {162 mut newer := []history.Save{}163 mut cursor := ours164 for cursor != '' && cursor != theirs {165 save := history.by_id(store, cursor) or { return error('save ${cursor} is missing') }166 newer << save167 cursor = save.parent168 }169 if cursor != theirs {170 return error('${theirs} is not on this line')171 }172 newer.reverse_in_place()173 return newer174}175176const strip_not_applied = 'the remote has a strip this repo has not applied — run koh steal'177178179struct Uploaded {180mut:181 saves []string182 objects map[string]bool183 deleted int 184}185186fn (u Uploaded) done() []string {187 mut done := done_text('deleted', u.deleted, 'damaged remote object')188 if u.saves.len + u.objects.len > 0 {189 done << 'uploaded ${fmt.plural(u.objects.len, 'object')} and ${fmt.plural(u.saves.len, 'save record')}'190 }191 return done192}193194195fn upload_line(store &storage.Store, mut backend remote.Backend, name string, local string, remote_head remote.Object, mut report OfferReport, mut uploaded Uploaded) ! {196 if remote_head.found {197 theirs := remote_head.body.bytestr().trim_space()198 newer := saves_after(store, local, theirs) or {199 return error('remote has new saves — run koh steal')200 }201 for save in newer {202 upload_save(store, mut backend, name, save, mut report, mut uploaded)!203 }204 } else {205 oldest_first := history.line(store)!206 for index := oldest_first.len - 1; index >= 0; index-- {207 upload_save(store, mut backend, name, oldest_first[index], mut report, mut uploaded)!208 }209 }210 upload_events(store, mut backend, name, mut report)!211}212213214215fn after_failed_upload(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded, failure IError) IError {216 undo_late_strips(store, mut backend, repo, uploaded) or {217 return error('${failure.msg()}; removing what a strip on the remote covers also failed: ${err.msg()}')218 }219 return failure220}221222223224pub const unsettled_key = 'upload_unsettled'225226pub fn unsettled(store &storage.Store) ![]string {227 text := store.state(unsettled_key) or { return []string{} }228 return json2.decode[[]string](text) or {229 return error('the note of an unsettled upload is unreadable; it is kept for recovery')230 }231}232233fn undo_late_strips(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded) !bool {234 return settle_late_strips(store, mut backend, repo, uploaded) or {235 failure := err236 mut kept := unsettled(store) or { return error('${failure.msg()}; ${err.msg()}') }237 kept << uploaded.objects.keys().filter(it !in kept)238 store.set_state(unsettled_key, json2.encode(kept)) or {239 return error('${failure.msg()}; ${err.msg()}')240 }241 return failure242 }243}244245246247248249fn settle_late_strips(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded) !bool {250 strips := unapplied_strips(store, mut backend, repo)!251 if strips.len == 0 {252 return false253 }254 for strip in strips {255 queue_strip(store, strip.path, [strip.save_id], uploaded.objects.keys())!256 }257 for id in uploaded.saves {258 strip_record(mut backend, save_key(repo, id), id, strips)!259 }260 drop_stripped_objects(store, mut backend, repo, uploaded, strips)!261 return true262}263264265266267fn strip_record(mut backend remote.Backend, key string, id string, strips []events.Event) ! {268 for _ in 0 .. 5 {269 object := backend.get(key)!270 if !object.found {271 return272 }273 record := decode_save(object.body)!274 kept := record.files.filter(fn [strips, id] (file FileRecord) bool {275 return !strips.any(reaches(it, id) && covers(it.path, file.path))276 })277 if kept.len == record.files.len278 || backend.put_if_match(key, encode_save(rewrite(record, kept)).bytes(), object.tag)! {279 return280 }281 }282 return error('the remote copy of save ${id} kept changing while a stripped path was removed; run koh doctor again')283}284285286287pub struct Partway {288 Error289pub:290 done string291 reason string292}293294pub fn (e Partway) msg() string {295 return e.reason296}297298299pub fn done_text(verb string, done int, noun string) []string {300 return if done == 0 { []string{} } else { ['${verb} ${fmt.plural(done, noun)}'] }301}302303304305pub fn partway(done []string, failure IError) IError {306 mut all := done.clone()307 if failure is Partway {308 all << failure.done309 }310 if all.len == 0 {311 return failure312 }313 return Partway{314 done: all.join('; ')315 reason: failure.msg()316 }317}318319320321322fn drop_stripped_objects(store &storage.Store, mut backend remote.Backend, repo string, uploaded Uploaded, strips []events.Event) ! {323 mut reached := map[string]bool{}324 325 mut kept := map[string]bool{}326 for save in history.all_saves(store)! {327 for file in history.files_of(store, save.id)!.filter(it.hash in uploaded.objects) {328 if strips.any(reaches(it, save.id) && covers(it.path, file.path)) {329 reached[file.hash] = true330 } else {331 kept[file.hash] = true332 }333 }334 }335 if reached.len == 0 {336 return337 }338 for key in backend.list('${repo}/saves')! {339 object := backend.get(key)!340 if object.found {341 record := decode_save(object.body)!342 for file in record.files {343 if !strips.any(reaches(it, record.id) && covers(it.path, file.path)) {344 reached.delete(file.hash)345 }346 }347 }348 }349 for hash, _ in reached {350 if hash !in kept {351 backend.delete(object_key(repo, hash))!352 }353 }354}355356357358359fn upload_save(store &storage.Store, mut backend remote.Backend, repo string, save history.Save, mut report OfferReport, mut uploaded Uploaded) ! {360 files := history.files_of(store, save.id)!361 362 363 364 mut sent := map[string]bool{}365 if save.parent != '' {366 for file in history.files_of(store, save.parent)! {367 sent[file.hash] = true368 }369 }370 for file in files {371 if file.hash in sent {372 continue373 }374 sent[file.hash] = true375 if backend.put_if_absent(object_key(repo, file.hash), store.object_blob(file.hash)!)! {376 report.objects++377 uploaded.objects[file.hash] = true378 }379 }380 record := save_record(store, save)!381 if backend.put_if_absent(save_key(repo, save.id), encode_save(record).bytes())! {382 report.saves++383 uploaded.saves << save.id384 }385}386387388389fn upload_events(store &storage.Store, mut backend remote.Backend, repo string, mut report OfferReport) ! {390 already := backend.list(events_prefix(repo))!391 mut already_sent := map[string]bool{}392 for key in already {393 already_sent[os.base(key)] = true394 }395 for event in events.all(store)! {396 if event.id in already_sent {397 continue398 }399 if backend.put_if_absent(event_key(repo, event.id), encode_event(event).bytes())! {400 report.events++401 }402 }403}404405406407408pub struct Fetched {409pub mut:410 repo_objects map[string][]u8411 saves []SaveRecord 412 events_out []events.Event413 head string414 base string 415 unsafe_paths []string416}417418419420421pub fn fetch_back_to_known(store &storage.Store, mut backend remote.Backend, repo string) !Fetched {422 check_format(mut backend, repo)!423 remote_head := backend.get(head_key(repo))!424 if !remote_head.found {425 return error('the remote has no "${repo}" yet — run koh offer here first')426 }427 mut fetched := Fetched{428 head: remote_head.body.bytestr().trim_space()429 }430 if !valid_save_id(fetched.head) {431 return error('the remote head has invalid save ID ${fetched.head}')432 }433 mut on_line := map[string]bool{}434 for save in history.line(store)! {435 on_line[save.id] = true436 }437 438 fetched.events_out = fetch_events(store, mut backend, repo)!439 strips := known_strips(store, fetched)!440 mut cursor := fetched.head441 mut seen := map[string]bool{}442 for cursor != '' && cursor !in on_line {443 if cursor in seen {444 return error('the remote history has a parent cycle at ${cursor}')445 }446 seen[cursor] = true447 mut record := if saved := history.by_id(store, cursor) {448 save_record(store, saved)!449 } else {450 record_object := backend.get(save_key(repo, cursor))!451 if !record_object.found {452 return error('the remote is missing save ${cursor} — run koh offer there')453 }454 decoded := decode_save(record_object.body)!455 validate_save(decoded, cursor)!456 decoded457 }458 fetched.unsafe_paths << drop_unsafe_paths(mut record)459 validate_hashes(record.id, record.files)!460 for file in record.files {461 if !store.has_object(file.hash)! && file.hash !in fetched.repo_objects462 && !strips.any(reaches(it, record.id) && covers(it.path, file.path)) {463 blob := backend.get(object_key(repo, file.hash))!464 if !blob.found {465 return error('the remote is missing object ${file.hash} that save ${record.id} needs')466 }467 fetched.repo_objects[file.hash] = blob.body468 }469 }470 fetched.saves << record471 cursor = record.parent472 }473 fetched.saves.reverse_in_place()474 fetched.base = cursor475 return fetched476}477478fn fetch_events(store &storage.Store, mut backend remote.Backend, repo string) ![]events.Event {479 have := events.all(store)!480 mut known := map[string]bool{}481 for event in have {482 known[event.id] = true483 }484 mut incoming := []events.Event{}485 for key in backend.list(events_prefix(repo))! {486 id := os.base(key)487 if !valid_event_id(id) {488 return error('the remote has invalid event ID ${id}')489 }490 if id in known {491 continue492 }493 body := backend.get(event_key(repo, id))!494 if !body.found {495 continue496 }497 event := decode_event(body.body)!498 if event.id != id {499 return error('remote event record ID ${event.id} disagrees with key ${id}')500 }501 if !ignored_strip(event) {502 incoming << event503 }504 }505 return incoming506}507508509510511pub fn write_fetched(store &storage.Store, fetched Fetched) ! {512 for hash, blob in fetched.repo_objects {513 store.put_object_blob(hash, blob)!514 }515 mut freed := []string{}516 for event in fetched.events_out {517 if !events.receive(store, event)! {518 continue519 }520 if events.valid_strip(event) {521 freed << stripped_hashes(store, event.path, event.save_id)!522 remove_path(store, event.path, event.save_id)!523 }524 }525 strips := known_strips(store, fetched)!526 for record in fetched.saves {527 for file in record.files {528 if file.hash in fetched.repo_objects529 && strips.any(reaches(it, record.id) && covers(it.path, file.path)) {530 freed << file.hash531 }532 }533 import_arriving(store, record, strips)!534 }535 store.drop_unused(freed)!536}537538539540pub fn write_fetched_records(store &storage.Store, fetched Fetched) ! {541 for hash, blob in fetched.repo_objects {542 store.put_object_blob(hash, blob)!543 }544 strips := known_strips(store, fetched)!545 for record in fetched.saves {546 import_arriving(store, record, strips)!547 }548}549550551552fn known_strips(store &storage.Store, fetched Fetched) ![]events.Event {553 mut strips := events.strip_events(store)!554 strips << fetched.events_out555 return strips.filter(events.valid_strip(it))556}557558559560fn import_arriving(store &storage.Store, record SaveRecord, strips []events.Event) ! {561 if history.by_id(store, record.id) != none {562 return563 }564 validate_save(record, record.id)!565 safe := record.files.filter(snapshot.legal_path(it.path))566 validate_hashes(record.id, safe)!567 files := to_files(record).filter(fn [strips, record] (file snapshot.FileEntry) bool {568 return snapshot.legal_path(file.path)569 && !strips.any(reaches(it, record.id) && covers(it.path, file.path))570 })571 history.import_save(store, to_save(record), files)!572}573574575576fn reaches(strip events.Event, save_id string) bool {577 return strip.save_id == '' || strip.save_id == save_id578}