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
8 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 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.961+3.0.97

view file

src/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 {

view file

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 {

view file

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 }

view file

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+}

view file

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+}

view file

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+}

view file

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 }

view file