3a0d4b911f90
close the public steal review: a sourced repo is never auto-offered, and a failed clone leaves nothing (3.0.98)
- time
- 2026-09-27 16:12 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- deepseek/deepseek-flash
- session
- koh3 help and history
- parent
- 2b1e04f61099
↓ koh-3a0d4b91.tar.gzatlas at this save
files changed7 files
VERSION+1 −1src/cli/cmd_offer.v+3 −4src/cli/cmd_remote.v+12 −9src/cli/cmd_steal.v+20 −6src/remote/web.v+2 −1tests/cli_test.v+15 −0v.mod+1 −1src/cli/cmd_offer.v+3 −4
@@ -138,10 +138,9 @@138138 if !machine.has_remote() {139139 return140140 }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)) {141+ // A stolen repo rides its source: a save in it is local work and nothing else. The upgrade is142+ // the explicit `steal` or `offer`, which bring the bucket in *before* the source is dropped.143+ if exchange.source_of(store) != '' {145144 return146145 }147146 mut backend := this_machine_backend() or {src/cli/cmd_remote.v+12 −9
@@ -24,15 +24,18 @@2424 values: ['code']2525 flags: ['json', 'forget']2626 }) 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)')}')27+ // A repo stolen from a Kepr has an address of its own, and it belongs above the machine's — as28+ // a line for a person: `--json` stays one document.29+ if !args.json() {30+ if _ := storage.find_root(os.getwd()) {31+ store := open_store() or { return refuse(args.json(), err.msg()) }32+ defer {33+ store.close()34+ }35+ source := exchange.source_of(store)36+ if source != '' {37+ println(' ${fmt.muted('source:')} ${source} ${fmt.muted('(read-only)')}')38+ }3639 }3740 }3841 if args.value('code') != '' && args.positional.len == 0 {src/cli/cmd_steal.v+20 −6
@@ -21,6 +21,9 @@2121 flags: ['list', 'mine', 'theirs', 'json']2222 }) or { return usage_error(err.msg()) }2323 if args.flag('list') {24+ if args.positional.len > 0 {25+ return usage_error('steal --list takes no arguments')26+ }2427 return list_repos(args)2528 }2629 if storage.find_root(os.getwd()) == none {@@ -28,7 +31,11 @@2831 if name == '' {2932 return usage_error('steal needs a repo name outside a repo, e.g. koh steal myproject')3033 }31- if address := web_address(name) {34+ if args.positional.len > 1 {35+ return usage_error('steal takes one repo: koh steal myproject, or koh steal kepr.uk/nina')36+ }37+ address := web_address(name) or { return refuse(args.json(), err.msg()) }38+ if address.len > 0 {3239 return clone_web(address[0], address[1], args)3340 }3441 return clone_repo(name, args)@@ -158,15 +165,16 @@158165 }159166 160167 // 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 {168+// (`kepr.uk/nina`, `http://127.0.0.1:3917/shipped`), a bare name is this machine's own remote. An169+// addressed value that is missing either half is an error, never a name.170+fn web_address(value string) ![]string {163171 if !value.contains('/') {164- return none172+ return []string{}165173 }166174 repo := value.all_after_last('/')167175 host := value.all_before_last('/')168176 if repo == '' || host == '' {169- return none177+ return error('"${value}" is not <host>/<repo>')170178 }171179 return [host, repo]172180 }@@ -200,12 +208,17 @@200208 }201209 fetched := exchange.fetch_repo(mut backend, name) or { return refuse(args.json(), err.msg()) }202210 store := land_clone(target, name, fetched, source) or {203- // A folder left behind would make the retry refuse as "already exists".204211 os.rmdir_all(target) or {}205212 return refuse(args.json(), err.msg())206213 }214+ // Anything that goes wrong from here leaves the folder half-made, and a retry would refuse it215+ // as "already exists", so the whole clone goes.216+ mut finished := false207217 defer {208218 store.close()219+ if !finished {220+ os.rmdir_all(target) or {}221+ }209222 }210223 files := history.files_of(store, fetched.head) or { return refuse(args.json(), err.msg()) }211224 snapshot.restore_files(store, target, snapshot.Ignore{}, files, []) or {@@ -218,6 +231,7 @@218231 }219232 }220233 flagged.sort(a.save_id < b.save_id)234+ finished = true221235 if args.json() {222236 emit_json(json2.encode(StolenJson{223237 repo: namesrc/remote/web.v+2 −1
@@ -31,7 +31,8 @@3131 if text.all_after('//').contains('@') {3232 return error('"${value}" is not a Kepr address — try kepr.uk')3333 }34- return '${parsed.scheme}://${parsed.host}'34+ // A hostname is case-insensitive, so the stored form is the lower-case one.35+ return '${parsed.scheme}://${parsed.host.to_lower()}'3536 }3637 3738 pub fn (mut w Web) get(key string) !Object {tests/cli_test.v+15 −0
@@ -1367,6 +1367,21 @@13671367 return error('no free fake Kepr port')13681368 }13691369 1370+// A slash makes an argument an address, so half an address is an error and never a repo name.1371+fn test_steal_refuses_an_address_that_is_not_one() {1372+ outside := temp_repo('steal-address')1373+ home := temp_repo('steal-address-home')1374+ defer { os.rmdir_all(outside) or {} }1375+ defer { os.rmdir_all(home) or {} }1376+ for bad in ['kepr.uk/', '/shipped', 'kepr.uk/'] {1377+ refused := run_home(outside, home, 'steal', bad)1378+ assert refused.code == 1, bad1379+ assert refused.err.contains('is not <host>/<repo>'), '${bad}: ${refused.err}'1380+ }1381+ assert run_home(outside, home, 'steal', 'one', 'two').code == 21382+ assert run_home(outside, home, 'steal', '--list', 'extra').code == 21383+}1384+13701385 fn test_remote_kepr_saves_and_refuses_without_changes() {13711386 repo := temp_repo('kepr')13721387 home := temp_repo('kepr-home')v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.0.97'3+ version: '3.0.98'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }