2b1e04f61099
steal public repos from a Kepr over HTTPS, read-only, with a per-repo source (3.0.97)
- time
- 2026-09-27 16:01 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- deepseek/deepseek-flash
- session
- koh3 help and history
- parent
- 1af82597b777
↓ koh-2b1e04f6.tar.gzatlas at this save
files changed8 files
VERSION+1 −1src/cli/cmd_offer.v+26 −0src/cli/cmd_remote.v+14 −21src/cli/cmd_steal.v+66 −8src/exchange/source.v+45 −0src/remote/web.v+92 −0src/remote/web_test.v+125 −0v.mod+1 −1src/cli/cmd_offer.v+26 −0
@@ -31,6 +31,26 @@3131 defer {3232 store.close()3333 }34+ source := exchange.source_of(store)35+ if source != '' {36+ host := exchange.source_host(source)37+ if !exchange.signed_in_to(host) {38+ return refuse(args.json(), 'this repo came from ${source}, which is read-only. Sign in with "koh remote ${host}" if it\'s yours.')39+ }40+ // Signed in since it was stolen: take whatever the bucket has first, then this is an41+ // ordinary repo. This machine's own saves replay on top, so nothing is lost.42+ mut bucket := this_machine_backend() or { return refuse(args.json(), err.msg()) }43+ if _ := exchange.steal(store, mut bucket, exchange.StealOptions{44+ theirs: true45+ }) {46+ exchange.forget_source(store) or { return refuse(args.json(), err.msg()) }47+ if !args.json() {48+ ok('signed in to ${host}: this repo now syncs with your bucket')49+ }50+ } else {51+ return refuse(args.json(), err.msg())52+ }53+ }3454 mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }3555 if args.flag('builds') {3656 return offer_builds(args, store, mut backend)@@ -116,6 +136,12 @@116136 // `quiet` is for `--json`, where that line would land inside the document.117137 pub fn auto_offer(store &storage.Store, quiet bool) {118138 if !machine.has_remote() {139+ return140+ }141+ // A stolen repo has no bucket of this machine's yet: it rides its source, and a save in it is142+ // simply local. Once the machine is signed in there, this returns to offering as usual.143+ source := exchange.source_of(store)144+ if source != '' && !exchange.signed_in_to(exchange.source_host(source)) {119145 return120146 }121147 mut backend := this_machine_backend() or {src/cli/cmd_remote.v+14 −21
@@ -12,9 +12,11 @@1212 import net.http1313 import net.urllib1414 import os15+import src.exchange1516 import src.fmt1617 import src.machine1718 import src.remote19+import src.storage1820 import term.termios1921 2022 pub fn cmd_remote(raw []string) int {@@ -22,6 +24,17 @@2224 values: ['code']2325 flags: ['json', 'forget']2426 }) or { return usage_error(err.msg()) }27+ // A repo stolen from a Kepr has an address of its own, and it belongs above the machine's.28+ if _ := storage.find_root(os.getwd()) {29+ store := open_store() or { return refuse(args.json(), err.msg()) }30+ defer {31+ store.close()32+ }33+ source := exchange.source_of(store)34+ if source != '' {35+ println(' ${fmt.muted('source:')} ${source} ${fmt.muted('(read-only)')}')36+ }37+ }2538 if args.value('code') != '' && args.positional.len == 0 {2639 return usage_error('--code belongs to "koh remote <host>"')2740 }@@ -126,27 +139,7 @@126139 // kepr_host normalises a Kepr address: https unless the scheme says otherwise, and scheme, host127140 // and port only. A path, a query, a fragment or credentials would send the code somewhere else.128141 fn kepr_host(value string) !string {129- for letter in value {130- if letter <= ` ` {131- return error('"${value}" is not a Kepr address — try kepr.uk')132- }133- }134- text := if value.starts_with('http://') || value.starts_with('https://') {135- value136- } else {137- 'https://${value}'138- }139- parsed := urllib.parse(text) or { return error('"${value}" is not a Kepr address — try kepr.uk') }140- if parsed.scheme !in ['http', 'https'] || parsed.host == '' || parsed.path.trim_right('/') != ''141- || parsed.raw_query != '' || parsed.fragment != '' {142- return error('"${value}" is not a Kepr address — try kepr.uk')143- }144- // Credentials in the address have nowhere to go: `urllib` would quietly drop them, and Kepr145- // would see a different address than the one typed.146- if text.all_after('//').contains('@') {147- return error('"${value}" is not a Kepr address — try kepr.uk')148- }149- return '${parsed.scheme}://${parsed.host}'142+ return remote.kepr_host(value)150143 }151144 152145 fn set_kepr_remote(args Args) int {src/cli/cmd_steal.v+66 −8
@@ -28,6 +28,9 @@2828 if name == '' {2929 return usage_error('steal needs a repo name outside a repo, e.g. koh steal myproject')3030 }31+ if address := web_address(name) {32+ return clone_web(address[0], address[1], args)33+ }3134 return clone_repo(name, args)3235 }3336 store := open_store() or { return refuse(args.json(), err.msg()) }@@ -37,7 +40,16 @@3740 if args.positional.len > 0 {3841 return usage_error('steal takes no name inside a repo — run it outside to fetch a whole repo')3942 }40- mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }43+ // A stolen repo has its own remote: the address it came from, until this machine signs in to44+ // that Kepr, when the bucket takes over.45+ source := exchange.source_of(store)46+ mut upgraded := source != '' && exchange.signed_in_to(exchange.source_host(source))47+ mut backend := remote.Backend(unsafe { nil })48+ if source != '' && !upgraded {49+ backend = exchange.read_only(source) or { return refuse(args.json(), err.msg()) }50+ } else {51+ backend = this_machine_backend() or { return refuse(args.json(), err.msg()) }52+ }4153 mut options := exchange.StealOptions{4254 mine: args.flag('mine')4355 theirs: args.flag('theirs')@@ -46,8 +58,15 @@4658 options.ask = ask_clash4759 }4860 report := exchange.steal(store, mut backend, options) or { return refuse(args.json(), err.msg()) }49- // The remote should end up with whatever this machine just decided.50- offered := offer_after_steal(store, mut backend)61+ if upgraded {62+ exchange.forget_source(store) or { return refuse(args.json(), err.msg()) }63+ if !args.json() {64+ ok('signed in to ${exchange.source_host(source)}: this repo now syncs with your bucket')65+ }66+ }67+ // The remote should end up with whatever this machine just decided — unless there is no remote68+ // to write to: a repo still riding its source stays read-only, and quietly so.69+ offered := if source != '' && !upgraded { true } else { offer_after_steal(store, mut backend) }5170 if args.json() {5271 emit_json(json2.encode(StolenJson{5372 repo: report.repo@@ -138,16 +157,49 @@138157 return 0139158 }140159 160+// web_address splits a `<host>/<repo>` argument out of a steal name: a slash makes it an address161+// (`kepr.uk/nina`, `http://127.0.0.1:3917/shipped`), a bare name is this machine's own remote.162+fn web_address(value string) ?[]string {163+ if !value.contains('/') {164+ return none165+ }166+ repo := value.all_after_last('/')167+ host := value.all_before_last('/')168+ if repo == '' || host == '' {169+ return none170+ }171+ return [host, repo]172+}173+174+// clone_web fetches a public repo over HTTPS, read-only. Signed in to that Kepr, the same address175+// names this machine's own bucket instead, so the bucket's copy — private or not — is the one to176+// take.177+fn clone_web(host string, repo string, args Args) int {178+ base := remote.kepr_host(host) or { return refuse(args.json(), err.msg()) }179+ if exchange.signed_in_to(base) {180+ return clone_repo(repo, args)181+ }182+ mut backend := exchange.read_only('${base}/${repo}') or { return refuse(args.json(), err.msg()) }183+ return clone_with(repo, args, mut backend, '${base}/${repo}')184+}185+141186 // clone_repo fetches a whole repo into ./<name>, for a machine that does not142187 // have it yet.143188 fn clone_repo(name string, args Args) int {189+ mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }190+ return clone_with(name, args, mut backend, '')191+}192+193+// clone_with is that fetch, from whichever remote was chosen: the whole history in, then the head's194+// files onto the disk. A non-empty `source` means the repo came from a read-only address and stays195+// local until this machine signs in there.196+fn clone_with(name string, args Args, mut backend remote.Backend, source string) int {144197 target := os.join_path(os.getwd(), name)145198 if os.exists(target) {146199 return refuse(args.json(), '${name} already exists here — run steal inside it instead')147200 }148- mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }149201 fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }150- store := land_clone(target, name, fetched) or {202+ store := land_clone(target, name, fetched, source) or {151203 // A folder left behind would make the retry refuse as "already exists".152204 os.rmdir_all(target) or {}153205 return refuse(args.json(), err.msg())@@ -180,17 +232,20 @@180232 }181233 ok('fetched "${name}" into ${target}')182234 note('${fetched.saves.len} saves at [${fetched.head}]')235+ if source != '' {236+ note('read-only: this clone keeps up with ${source} — sign in with "koh remote ${exchange.source_host(source)}" to make it yours')237+ }183238 warn_flagged(flagged)184239 return 0185240 }186241 187242 // land_clone creates ./<name> and commits the fetched history into it. Objects188243 // are checked on the way in, so a damaged remote fails here.189-fn land_clone(target string, name string, fetched exchange.Fetched) !storage.Store {244+fn land_clone(target string, name string, fetched exchange.Fetched, source string) !storage.Store {190245 // SQLite creates the file, not the folder holding it.191246 os.mkdir_all(target, os.MkdirParams{})!192247 mut store := storage.create(target)!193- fill_clone(store, name, fetched) or {248+ fill_clone(store, name, fetched, source) or {194249 store.rollback()195250 store.close()196251 return err@@ -198,11 +253,14 @@198253 return store199254 }200255 201-fn fill_clone(store &storage.Store, name string, fetched exchange.Fetched) ! {256+fn fill_clone(store &storage.Store, name string, fetched exchange.Fetched, source string) ! {202257 store.begin()!203258 exchange.write_fetched(store, fetched)!204259 history.set_head(store, fetched.head)!205260 exchange.remember_repo(store, name)!261+ if source != '' {262+ exchange.remember_source(store, source)!263+ }206264 snapshot.mark_restore(store)!207265 store.commit()!208266 }src/exchange/source.v+45 −0
@@ -0,0 +1,45 @@1+//! A repo stolen from a Kepr that is not signed in: its own address, kept read-only.2+//!3+//! This is the addendum's one exception to the machine-wide remote rule. The4+//! address lives in the repo's `state`, so nothing about it can leak into another5+//! repo, and it is dropped the moment this machine signs in to that Kepr.6+7+module exchange8+9+import src.machine10+import src.remote11+import src.storage12+13+const source_key = 'source'14+15+/// The address a stolen repo came from, `<base>/<repo>`, or '' for an ordinary repo.16+pub fn source_of(store &storage.Store) string {17+ return store.state(source_key) or { '' }18+}19+20+/// The Kepr part of a source: what `koh remote <host>` would have to name.21+pub fn source_host(source string) string {22+ return source.all_before_last('/')23+}24+25+/// True when this machine was set up by `koh remote <host>` for the same host, so that host's26+/// addresses are this machine's own bucket (Part 2 of the addendum).27+pub fn signed_in_to(host string) bool {28+ settings := machine.load_remote() or { return false }29+ return settings.kind == machine.kind_s3 && settings.kepr != '' && settings.kepr == host30+}31+32+/// read_only opens the backend a stolen repo came from: public, and never written to.33+pub fn read_only(source string) !remote.Backend {34+ return remote.new_web(source_host(source))!35+}36+37+/// remember_source and forget_source are the only two writes: a repo is either a stolen one or an38+/// ordinary one, never both.39+pub fn remember_source(store &storage.Store, source string) ! {40+ store.set_state(source_key, source)!41+}42+43+pub fn forget_source(store &storage.Store) ! {44+ store.set_state(source_key, '')!45+}src/remote/web.v+92 −0
@@ -0,0 +1,92 @@1+//! The web backend: read-only access to a public Koh mirror.2+module remote3+4+import net.http5+import net.urllib6+7+pub struct Web {8+pub mut:9+ base string10+}11+12+pub fn new_web(base string) !Web {13+ return Web{ base: kepr_host(base)! }14+}15+16+pub fn kepr_host(value string) !string {17+ for letter in value {18+ if letter <= ` ` {19+ return error('"${value}" is not a Kepr address — try kepr.uk')20+ }21+ }22+ text := if value.starts_with('http://') || value.starts_with('https://') {23+ value24+ } else {25+ 'https://${value}'26+ }27+ parsed := urllib.parse(text) or { return error('"${value}" is not a Kepr address — try kepr.uk') }28+ if parsed.scheme !in ['http', 'https'] || parsed.host == '' || parsed.path.trim_right('/') != '' || parsed.raw_query != '' || parsed.fragment != '' {29+ return error('"${value}" is not a Kepr address — try kepr.uk')30+ }31+ if text.all_after('//').contains('@') {32+ return error('"${value}" is not a Kepr address — try kepr.uk')33+ }34+ return '${parsed.scheme}://${parsed.host}'35+}36+37+pub fn (mut w Web) get(key string) !Object {38+ response := http.fetch(39+ url: w.url(key)40+ method: .get41+ enable_http2: false42+ allow_redirect: false43+ )!44+ if response.status_code == 404 {45+ return Object{}46+ }47+ if response.status_code < 200 || response.status_code >= 300 {48+ return error('web GET ${key} failed: ${response.status_code}')49+ }50+ return Object{ found: true, body: response.body.bytes(), tag: response.header.get_custom('etag', http.HeaderQueryConfig{}) or { '' } }51+}52+53+pub fn (mut w Web) list(prefix string) ![]string {54+ response := http.fetch(55+ url: w.url(prefix)56+ method: .get57+ enable_http2: false58+ allow_redirect: false59+ )!60+ if response.status_code == 404 {61+ return []string{}62+ }63+ if response.status_code < 200 || response.status_code >= 300 {64+ return error('web GET ${prefix} failed: ${response.status_code}')65+ }66+ mut result := []string{}67+ for id in response.body.split_into_lines() {68+ if id.trim_space() != '' { result << '${prefix}/${id.trim_space()}' }69+ }70+ return result71+}72+73+pub fn (mut w Web) put_if_absent(key string, body []u8) !bool {74+ return error('${w.repo(key)} is read-only.')75+}76+77+pub fn (mut w Web) put_if_match(key string, body []u8, tag string) !bool {78+ return error('${w.repo(key)} is read-only.')79+}80+81+pub fn (mut w Web) delete(key string) ! {82+ return error('${w.repo(key)} is read-only.')83+}84+85+fn (w &Web) url(key string) string {86+ parts := key.split('/')87+ return '${w.base}/${parts[0]}/koh/${parts[1..].join('/')}'88+}89+90+fn (w &Web) repo(key string) string {91+ return '${w.base}/${key.all_before('/')}'92+}src/remote/web_test.v+125 −0
@@ -0,0 +1,125 @@1+//! The web backend: public repos over HTTP, and never a write.2+3+module remote4+5+import net6+import net.http7+8+struct FakeMirror {9+mut:10+ asked []string11+}12+13+fn (mut h FakeMirror) handle(req http.Request) http.Response {14+ h.asked << req.url15+ if req.url == '/repo/koh/format' {16+ return http.Response{ status_code: 200, status_msg: 'OK', body: 'koh-remote 1' }17+ }18+ if req.url == '/repo/koh/head' {19+ return http.Response{ status_code: 200, status_msg: 'OK', body: 'abc123abc123' }20+ }21+ if req.url == '/repo/koh/saves/abc123abc123' {22+ return http.Response{ status_code: 200, status_msg: 'OK', body: '{"id":"abc123abc123","files":[]}' }23+ }24+ if req.url == '/repo/koh/objects/dead' {25+ // Raw bytes, not text: an object is zlib, and must survive intact.26+ return http.Response{ status_code: 200, status_msg: 'OK', body: '\x01\x02\x03' }27+ }28+ if req.url == '/repo/koh/events' {29+ return http.Response{ status_code: 200, status_msg: 'OK', body: 'e1\ne2\n' }30+ }31+ if req.url == '/repo/koh/broken' {32+ return http.Response{ status_code: 500, status_msg: 'Internal Server Error' }33+ }34+ return http.Response{ status_code: 404, status_msg: 'Not Found' }35+}36+37+fn start_fake_mirror() !(int, &http.Server, &FakeMirror) {38+ for port in 19321 .. 19331 {39+ listener := net.listen_tcp(.ip, '127.0.0.1:${port}') or { continue }40+ fake := &FakeMirror{}41+ mut server := &http.Server{42+ listener: listener43+ handler: fake44+ show_startup_message: false45+ }46+ spawn server.listen_and_serve()47+ server.wait_till_running()!48+ return port, server, fake49+ }50+ return error('no free fake mirror port')51+}52+53+fn test_a_public_repo_reads_through_the_web_backend() {54+ port, mut server, _ := start_fake_mirror()!55+ defer {56+ server.stop()57+ }58+ mut web := new_web('http://127.0.0.1:${port}')!59+ format := web.get('repo/format')!60+ assert format.found61+ assert format.body.bytestr() == 'koh-remote 1'62+ assert web.get('repo/head')!.body.bytestr() == 'abc123abc123'63+ assert web.get('repo/saves/abc123abc123')!.body.bytestr() == '{"id":"abc123abc123","files":[]}'64+ // Objects travel as stored, byte for byte: the client inflates them itself.65+ blob := web.get('repo/objects/dead')!66+ assert blob.found67+ assert blob.body == [u8(1), 2, 3]68+ // The event list becomes the keys `koh steal` expects.69+ assert web.list('repo/events')! == ['repo/events/e1', 'repo/events/e2']70+ // A missing key is an answer, not a failure.71+ assert !web.get('repo/objects/nope')!.found72+ assert web.list('repo/events/none')! == []string{}73+}74+75+fn test_a_broken_mirror_and_every_write_are_refused() {76+ port, mut server, _ := start_fake_mirror()!77+ defer {78+ server.stop()79+ }80+ mut web := new_web('http://127.0.0.1:${port}')!81+ if _ := web.get('repo/broken') {82+ assert false, 'a 500 must not read as a missing key'83+ } else {84+ assert err.msg().contains('500')85+ }86+ read_only := 'http://127.0.0.1:${port}/repo is read-only.'87+ if _ := web.put_if_absent('repo/head', 'x'.bytes()) {88+ assert false, 'a public repo is never written to'89+ } else {90+ assert err.msg() == read_only91+ }92+ if _ := web.put_if_match('repo/head', 'x'.bytes(), '"tag"') {93+ assert false, 'a public repo is never written to'94+ } else {95+ assert err.msg() == read_only96+ }97+ if _ := web.delete('repo/head') {98+ assert false, 'a public repo is never written to'99+ } else {100+ assert err.msg() == read_only101+ }102+}103+104+fn test_only_a_bare_host_becomes_a_base() {105+ assert kepr_host('kepr.uk')! == 'https://kepr.uk'106+ assert kepr_host('https://kepr.uk/')! == 'https://kepr.uk'107+ assert kepr_host('http://127.0.0.1:3917')! == 'http://127.0.0.1:3917'108+ for bad in ['kepr.uk/foo', 'kepr.uk?x=1', '[email protected]', 'kepr uk', 'https://'] {109+ if _ := kepr_host(bad) {110+ assert false, '${bad} is not a host'111+ }112+ }113+}114+115+// The base is normalised once, so a call never builds a URL from what was typed.116+fn test_the_base_is_normalised_once() {117+ port, mut server, fake := start_fake_mirror()!118+ defer {119+ server.stop()120+ }121+ mut web := new_web('http://127.0.0.1:${port}/')!122+ assert web.base == 'http://127.0.0.1:${port}'123+ assert web.get('repo/head')!.found124+ assert fake.asked[0] == '/repo/koh/head'125+}v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.0.96'3+ version: '3.0.97'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }