1 # lock/world: everything `ez lock` reads, as one value, and what the lock may 2 # depend on in it. The planner (lock/plan.bend) is a pure function of a 3 # World; the interpreter (lock/run.bend) gathers one, answering each question 4 # the planner asks by IO, and executes the plan the planner returns. The 5 # design is docs/rfc/ez-lock-planner.md. 6 # 7 # A World is the ledger's text, the committed `.bend` files, one answer per 8 # package the planner asked about, and, under `--upgrade`, one answer per 9 # question the upgrade asked (lock/up.bend). There is no field for the 10 # environment, the clock, untracked files, `.ez/origins.toml` or BEND_LIB's 11 # path, because the lock reads none of them. A package a dependency imports 12 # by `@` adds two questions, asked only when the ledger does 13 # not name it: the lock being rewritten, which already records every name it 14 # resolved, and, for a name it does not record, what the hub says the name 15 # names. Both are answers like any other, and enter the lock through their 16 # verdicts. 17 import Base 18 import ./lock.bend as L 19 import ./up.bend as Up 20 import ../ledger/upgrade.bend as U 21 import ../ledger/manifest.bend as M 22 import ../pkg/pkg.bend as K 23 import ../hub/hub.bend as Web 24 import ../share/sha.bend as Sha 25 import ../share/say.bend as Say 26 27 # the command as it was run: `--upgrade`, the name `--package` gave ("" for 28 # none), and the directory it runs in, which is the project root. A relative 29 # path source is recorded as it was given, relative to that root, and every 30 # question the lock asks about it names it anchored there, as the planner 31 # computes it (`P.anchor.src`, `Up.anchor`). 32 type Args is Data: 33 Args{upgrade: Bool, only: String, here: String} 34 35 # one committed `.bend` file: its path and its text 36 type Source is Data: 37 Source{at: String, text: String} 38 39 # what `git ls-files '*.bend'` answered: the committed sources, or why git 40 # could not list them 41 type Listing is Data: 42 Listed{files: List<&2, Source>} 43 Unlisted{why: String} 44 45 # how the interpreter came by a package's bytes. A tree already under 46 # BEND_LIB, vendored or left by an earlier lock, was read from there. One that 47 # was not was cloned at the rev the ledger pins, and weighed. A hub package was 48 # served by the hub. 49 type How is Data: 50 Lib{} 51 Clone{nar: String} 52 Served{} 53 54 # a question the planner asks, answered by one IO action: a package's 55 # manifest and the text of each file it names, where the source says to read 56 # it from, and the hub is where a hub package is served; the hash the hub 57 # says a `@` names, `GET /name/`; or the lock being 58 # rewritten, ez.lock.toml as it is. 59 type Ask is Data: 60 Pkg{hash: String, src: L.Src, hub: String} 61 Name{nv: String, hub: String} 62 Lock{} 63 64 # the answer to one question: the manifest found and the text of each file it 65 # names, in the manifest's order; what the hub said a name names, as it said 66 # it; the lock's text, "" when there is none; or why IO could not answer 67 type Answer is Data: 68 Got{how: How, manifest: String, srcs: List<&2, String>} 69 Miss{why: String} 70 Said{text: String} 71 Locked{text: String} 72 73 # one question, answered 74 type Reply is Data: 75 Reply{ask: Ask, answer: Answer} 76 77 # everything `ez lock` reads. The ledger is ez.toml's text, or None when 78 # there is no ez.toml. `ups` answers the upgrade's questions, and is empty for 79 # a plain lock, which asks none. 80 type World is Data: 81 World{args: Args, ledger: Maybe<&2, String>, listing: Listing, 82 replies: List<&2, Reply>, ups: List<&2, Up.Reply>} 83 84 # what the planner makes of one answer: the files and their texts, once every 85 # check passed, or why it refused them; or, for a name or the lock, the names 86 # it resolves, each to its hash 87 type Verdict is Data: 88 Ok{files: List<&2, K.Item>, srcs: List<&2, String>} 89 No{why: String} 90 Named{names: List<&2, L.Name>} 91 92 # one package hash and its verdict 93 type Judged is Data: 94 Judged{hash: String, verdict: Verdict} 95 96 # the part of a World the lock may depend on: the ledger the lock is made 97 # from, read, which under `--upgrade` is the one the upgrade leaves; why the 98 # upgrade refuses, "" when it does not; the committed sources; and the verdict 99 # on every package. A reply enters only through its verdict, so how the bytes 100 # arrived does not, and bytes that failed a check enter only as the refusal 101 # they caused. 102 type Inputs is Data: 103 Inputs{ledger: M.Read, stop: String, listing: Listing, judged: List<&2, Judged>} 104 105 # whether the command was `ez lock --upgrade` 106 def args.upgrade(world: World) -> Bool: 107 World{args, _ledger, _listing, _replies, _ups} = world 108 Args{up, _only, _here} = args 109 up 110 111 # the name `--package` gave, "" for none 112 def args.only(world: World) -> String: 113 World{args, _ledger, _listing, _replies, _ups} = world 114 Args{_up, only, _here} = args 115 only 116 117 # the directory the command runs in 118 def here.of(args: Args) -> String: 119 Args{_up, _only, here} = args 120 here 121 122 # the directory the lock runs in, which is the project root 123 def args.here(world: World) -> String: 124 World{args, _ledger, _listing, _replies, _ups} = world 125 here.of(args) 126 127 # the ledger's text 128 def ledger.of(world: World) -> Maybe<&2, String>: 129 World{_args, ledger, _listing, _replies, _ups} = world 130 ledger 131 132 # the lock's path, which is fixed 133 def lockfile() -> String: 134 "ez.lock.toml" 135 136 # what a question asks about, which is the key its answer is judged and 137 # looked up under: a package's hash, a name, or the lock's path. A `0x` name, 138 # a `@` and the lock's path are three different strings. 139 def ask.hash(ask: Ask) -> String: 140 match ask: 141 case Pkg{hash, _src, _hub}: 142 hash 143 case Name{nv, _hub}: 144 nv 145 case Lock{}: 146 lockfile() 147 148 # --------------------------------------------------------------------------- 149 # judging one answer 150 151 # whether every text hashes to the sum its file is recorded with. A list of 152 # texts that is longer or shorter than the files is not the package. 153 def sums.ok(fs: List<&2, K.Item>, ss: List<&2, String>) -> Bool: 154 match fs ss: 155 case Nil{} Nil{}: 156 True{} 157 case Nil{} Con{_h, _t}: 158 False{} 159 case Con{_f, _g} Nil{}: 160 False{} 161 case Con{f, g} Con{s, t}: 162 +rest = sums.ok(g, t) 163 Bool.and(String.eq(Sha.hex(s), K.file.sum(f)), rest) 164 165 # each file with its text, as it is laid 166 def laid(fs: List<&2, K.Item>, ss: List<&2, String>) -> List<&2, Source>: 167 match fs ss: 168 case Nil{} Nil{}: 169 [] 170 case Nil{} Con{_s, _t}: 171 [] 172 case Con{_f, _g} Nil{}: 173 [] 174 case Con{f, g} Con{s, t}: 175 Source{K.file.at(f), s} <> laid(g, t) 176 177 # a laid file weighed by its own text 178 def weigh(src: Source) -> K.Item: 179 Source{at, +text} = src 180 K.Item{at, Sha.hex(text)} 181 182 # every laid file weighed by its own text 183 def weighs(ls: List<&2, Source>) -> List<&2, K.Item>: 184 match ls: 185 case []: 186 [] 187 case h <> t: 188 weigh(h) <> weighs(t) 189 190 # the manifest of the files laid, weighed by their own texts, which is the 191 # manifest a tree is laid with 192 def manifest(+ls: List<&2, Source>) -> String: 193 K.manifest_of(K.files_of(weighs(ls))) 194 195 # the `0x` name of that manifest 196 def named(+ls: List<&2, Source>) -> String: 197 K.hash_of(K.files_of(weighs(ls))) 198 199 # the verdict once the files, weighed by their own texts, were asked whether 200 # they hash to the name. Their manifest is the one the tree is laid with, and 201 # the manifest that was read hashes to the name already, so a package whose 202 # manifest is not written the way ez writes one (a line per file, in path 203 # order, each file once) is refused here rather than laid under a name its 204 # laid manifest does not hash to. 205 def body.laid(ok: Bool, +hash: String, fs: List<&2, K.Item>, ss: List<&2, String>) -> Verdict: 206 match ok: 207 case True{}: 208 Ok{fs, ss} 209 case False{}: 210 No{"ez: " ++ hash ++ ": its manifest is not written the way its name is taken" ++ 211 " (one line per file, in path order, each file once)"} 212 213 # the verdict once the texts were weighed against the manifest 214 def body.sums(ok: Bool, +hash: String, +fs: List<&2, K.Item>, +ss: List<&2, String>) -> Verdict: 215 match ok: 216 case True{}: 217 body.laid(String.eq(hash, named(laid(fs, ss))), hash, fs, ss) 218 case False{}: 219 No{"ez: " ++ hash ++ ": a file does not match its manifest"} 220 221 # the verdict once each path was asked whether it escapes the package 222 def body.escape.go(clean: Bool, +bad: String, +hash: String, +fs: List<&2, K.Item>, +ss: List<&2, String>) -> Verdict: 223 match clean: 224 case True{}: 225 body.sums(sums.ok(fs, ss), hash, fs, ss) 226 case False{}: 227 No{"ez: " ++ hash ++ ": its manifest escapes the package (" ++ bad ++ ")"} 228 229 # the verdict once no path was found to escape the package 230 def body.escape(+bad: String, +hash: String, +fs: List<&2, K.Item>, +ss: List<&2, String>) -> Verdict: 231 body.escape.go(String.is_empty(bad), bad, hash, fs, ss) 232 233 # the verdict once the manifest was known to be the one the hash names 234 def body.named(ok: Bool, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict: 235 match ok: 236 case True{}: 237 +fs = L.manifest.files(String.lines(manifest)) 238 body.escape(K.escaped(fs), hash, fs, ss) 239 case False{}: 240 No{"ez: " ++ hash ++ ": its manifest does not hash to its name"} 241 242 # a package's bytes, checked the same way whatever they came from: the 243 # manifest is the one the `0x` name is the digest of, no path leaves the 244 # package, each text hashes to its file's sum, and the files, weighed by their 245 # own texts, hash to the name, which is `ez fetch`'s check too. A tree under 246 # BEND_LIB is checked against its name like any other, so a stale or edited 247 # tree there is refused rather than locked. 248 def body(+hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict: 249 body.named(Web.matches(L.want(hash), manifest), hash, manifest, ss) 250 251 # a git package that arrived with no files is refused, never locked as an 252 # empty table 253 def git.body(empty: Bool, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict: 254 match empty: 255 case True{}: 256 No{"ez: " ++ hash ++ ": its manifest names no files"} 257 case False{}: 258 body(hash, manifest, ss) 259 260 # a git package's bytes under the rule its name was taken by. Since bend 261 # 2.0.27 a package holds the LICENSE files beside its sources, and the walk 262 # takes them along. A ledger written before names the same checkout without 263 # them, and that package is the checkout's too: when the walk's manifest does 264 # not hash to the name, the package without its LICENSE files is judged 265 # instead, and locked when that is the one the name is the digest of. 266 def git.rule(named: Bool, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict: 267 match named: 268 case True{}: 269 git.body(List.is_empty(&2, K.Item, L.manifest.files(String.lines(manifest))), 270 hash, manifest, ss) 271 case False{}: 272 +fs = L.manifest.files(String.lines(manifest)) 273 +bs = K.bare(fs) 274 git.body(List.is_empty(&2, K.Item, bs), hash, K.manifest.lines(bs), 275 K.bare.srcs(fs, ss)) 276 277 # a git package's bytes once its checkout was weighed. A tree read from 278 # BEND_LIB is judged as a clone that weighed to the narHash the ledger 279 # records, so the same bytes lock the same way whichever way they came. 280 def git.weighed.go(same: Bool, +why: String, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict: 281 match same: 282 case True{}: 283 git.rule(Web.matches(L.want(hash), manifest), hash, manifest, ss) 284 case False{}: 285 No{why} 286 287 def git.weighed(+why: String, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict: 288 git.weighed.go(String.is_empty(why), why, hash, manifest, ss) 289 290 # a git package, by how it arrived 291 def git.how( 292 how: How, 293 +hash: String, 294 +url: String, 295 +rev: String, 296 +nar: String, 297 +manifest: String, 298 +ss: List<&2, String> 299 ) -> Verdict: 300 match how: 301 case Lib{}: 302 git.weighed(L.nar.why(nar, nar, url, rev), hash, manifest, ss) 303 case Clone{got}: 304 git.weighed(L.nar.why(nar, got, url, rev), hash, manifest, ss) 305 case Served{}: 306 No{"ez: " ++ hash ++ " is a git package and was not read from git"} 307 308 # a hub package, by how it arrived. Only the hub serves one. 309 def hub.how(how: How, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict: 310 match how: 311 case Lib{}: 312 No{"ez: " ++ hash ++ " is a hub package and was not served by the hub"} 313 case Clone{_got}: 314 No{"ez: " ++ hash ++ " is a hub package and was not served by the hub"} 315 case Served{}: 316 body(hash, manifest, ss) 317 318 # a package's answer against where the ledger says it comes from 319 def accept.src(src: L.Src, how: How, +hash: String, +manifest: String, +ss: List<&2, String>) -> Verdict: 320 match src: 321 case L.Hub{}: 322 hub.how(how, hash, manifest, ss) 323 case L.Git{url, rev, _entry, _root, nar, _tag}: 324 git.how(how, hash, url, rev, nar, manifest, ss) 325 326 # why a hub package could not be read. One the ledger names is explained the 327 # way a hub miss always was. One it does not name is said to be unnamed, since 328 # that, and not the hub, is what a person has to fix. 329 def hub.why(named: Bool, +hash: String, +hub: String, +why: String) -> String: 330 match named: 331 case True{}: 332 Say.hub.explain(hash, L.url_of(hub, hash, "manifest"), why) 333 case False{}: 334 "ez: " ++ hash ++ " is imported, ez.toml does not name it, and the hub at " ++ 335 hub ++ " did not serve it (" ++ why ++ "). A package taken from git is recorded with `ez add`." 336 337 # why IO could not answer, said for where the package comes from 338 def miss.src(src: L.Src, named: Bool, +hash: String, +hub: String, +why: String) -> String: 339 match src: 340 case L.Hub{}: 341 hub.why(named, hash, hub, why) 342 case L.Git{_url, _rev, _entry, _root, _nar, _tag}: 343 why 344 345 # what the hub said a name names, once it is known whether the answer is a 346 # `0x` name 347 def said.ok(ok: Bool, +nv: String, +hash: String, +hub: String) -> Verdict: 348 match ok: 349 case True{}: 350 Named{[L.Name{nv, hash}]} 351 case False{}: 352 No{"ez: the hub at " ++ hub ++ " names no package " ++ nv ++ " (it answered `" ++ 353 hash ++ "`)"} 354 355 # the hub's answer for a name: the `0x` name it gives, trimmed as bend trims 356 # it, or a refusal when it gives none 357 def said(+nv: String, +hub: String, +text: String) -> Verdict: 358 +hash = String.trim(text) 359 said.ok(L.hash.ok(hash), nv, hash, hub) 360 361 # the lock being rewritten: every name it records that bend would read back 362 def locked(text: String) -> Verdict: 363 Named{L.names.read(text)} 364 365 # one answer judged 366 def accept(+orgs: List<&2, L.Origin>, +hub: String, +hash: String, answer: Answer) -> Verdict: 367 match answer: 368 case Miss{why}: 369 No{miss.src(L.origin(orgs, hash), L.known(orgs, hash), hash, hub, why)} 370 case Got{how, manifest, ss}: 371 accept.src(L.origin(orgs, hash), how, hash, manifest, ss) 372 case Said{text}: 373 said(hash, hub, text) 374 case Locked{text}: 375 locked(text) 376 377 # every reply judged, in the order they were given 378 def judge(+orgs: List<&2, L.Origin>, +hub: String, rs: List<&2, Reply>) -> List<&2, Judged>: 379 match rs: 380 case []: 381 [] 382 case Reply{ask, answer} <> t: 383 +hash = ask.hash(ask) 384 Judged{hash, accept(orgs, hub, hash, answer)} <> judge(orgs, hub, t) 385 386 # the names a verdict resolves: those of a name's or the lock's answer, and 387 # none for a package 388 def verdict.names(verdict: Verdict) -> List<&2, L.Name>: 389 match verdict: 390 case Ok{_fs, _ss}: 391 [] 392 case No{_why}: 393 [] 394 case Named{ns}: 395 ns 396 397 # every name the answers resolve, in the order they were given. The lock's 398 # answer is asked for before any name's, so a name it records is never put 399 # to the hub. 400 def names.of(js: List<&2, Judged>) -> List<&2, L.Name>: 401 match js: 402 case []: 403 [] 404 case Judged{_h, v} <> t: 405 List.append(&2, L.Name, verdict.names(v), names.of(t)) 406 407 # the ledger, read. A missing one is refused as a ledger that does not 408 # parse: only `ez init` makes a ledger, and a lock of none is not a lock. 409 def parsed(ledger: Maybe<&2, String>) -> M.Read: 410 match ledger: 411 case None{}: 412 M.Bad{"no ez.toml here; ez lock works on a project's ledger, and ez init makes one"} 413 case Some{text}: 414 M.parse(text) 415 416 # the origins a ledger records 417 def origins(+ledger: Maybe<&2, String>) -> List<&2, L.Origin>: 418 L.ledger.read(parsed(ledger)) 419 420 # the dependency ez.toml names `name`, as `M.dep` finds it 421 def dep(world: World, name: String) -> M.Dep: 422 World{_args, ledger, _listing, _replies, _ups} = world 423 M.dep(parsed(ledger), name) 424 425 # the hub a ledger names 426 def ledger.hub(+ledger: Maybe<&2, String>) -> String: 427 M.hub_of(parsed(ledger)) 428 429 # --------------------------------------------------------------------------- 430 # the ledger the lock is made from 431 432 # a plain lock's is ez.toml as it is. An upgrade's is what the upgrade 433 # decides on its answers: the ledger it writes, or its refusal, or the 434 # questions it still has. 435 def next.up(up: Bool, +only: String, +ledger: Maybe<&2, String>, ups: List<&2, Up.Reply>) -> Up.Next: 436 match up: 437 case False{}: 438 Up.plain(parsed(ledger)) 439 case True{}: 440 Up.next(only, parsed(ledger), ups) 441 442 # what the upgrade decides on a World, or nothing around a plain lock 443 def next(args: Args, +ledger: Maybe<&2, String>, ups: List<&2, Up.Reply>) -> Up.Next: 444 Args{up, +only, _here} = args 445 next.up(up, only, ledger, ups) 446 447 # the origins the lock reads packages by 448 def next.origins(+nx: Up.Next) -> List<&2, L.Origin>: 449 L.ledger.read(Up.next.read(nx)) 450 451 # the hub it names 452 def next.hub(+nx: Up.Next) -> String: 453 M.hub_of(Up.next.read(nx)) 454 455 # a tree the upgrade checked out at a new rev, as the answer to the package 456 # question the lock would ask about it: a clone, weighed 457 def laid.reply(laid: Up.Laid, +orgs: List<&2, L.Origin>, +hub: String) -> Reply: 458 Up.Laid{+hash, nar, manifest, srcs} = laid 459 Reply{Pkg{hash, L.origin(orgs, hash), hub}, Got{Clone{nar}, manifest, srcs}} 460 461 def laid.replies(ls: List<&2, Up.Laid>, +orgs: List<&2, L.Origin>, +hub: String) -> List<&2, Reply>: 462 match ls: 463 case []: 464 [] 465 case h <> t: 466 laid.reply(h, orgs, hub) <> laid.replies(t, orgs, hub) 467 468 # the package answers the lock reads: the trees the upgrade checked out, then 469 # every answer the World holds 470 def replies.of(+nx: Up.Next, replies: List<&2, Reply>) -> List<&2, Reply>: 471 List.append(&2, Reply, 472 laid.replies(Up.next.trees(nx), next.origins(nx), next.hub(nx)), replies) 473 474 # every package answer judged against the ledger the lock is made from 475 def judged.of(+nx: Up.Next, replies: List<&2, Reply>) -> List<&2, Judged>: 476 List.append(&2, Judged, 477 judge(next.origins(nx), next.hub(nx), 478 laid.replies(Up.next.trees(nx), next.origins(nx), next.hub(nx))), 479 judge(next.origins(nx), next.hub(nx), replies)) 480 481 # every committed source with each hash the upgrade moved rewritten in its 482 # imports, as the upgrade writes it 483 def sources.swap(+ss: List<&2, U.Swap>, fs: List<&2, Source>) -> List<&2, Source>: 484 match fs: 485 case []: 486 [] 487 case Source{at, text} <> t: 488 Source{at, U.reimport.many(ss, text)} <> sources.swap(ss, t) 489 490 def listing.swap(+ss: List<&2, U.Swap>, listing: Listing) -> Listing: 491 match listing: 492 case Listed{fs}: 493 Listed{sources.swap(ss, fs)} 494 case Unlisted{why}: 495 Unlisted{why} 496 497 # the committed sources as the lock reads them: as the upgrade leaves them, 498 # which is as they are when no hash moved, and always for a plain lock 499 def listing.moved(ss: List<&2, U.Swap>, listing: Listing) -> Listing: 500 match ss: 501 case []: 502 listing 503 case h <> t: 504 listing.swap(h <> t, listing) 505 506 def listing.of(+nx: Up.Next, listing: Listing) -> Listing: 507 listing.moved(Up.next.swaps(nx), listing) 508 509 # what a World is, as far as the lock may tell, once the upgrade decided 510 def inputs.of(+nx: Up.Next, listing: Listing, replies: List<&2, Reply>) -> Inputs: 511 Inputs{Up.next.read(nx), Up.next.stop(nx), listing.of(nx, listing), 512 judged.of(nx, replies)} 513 514 # what a World is, as far as the lock may tell 515 def inputs(world: World) -> Inputs: 516 World{args, +ledger, listing, replies, ups} = world 517 inputs.of(next(args, ledger, ups), listing, replies) 518 519 # --------------------------------------------------------------------------- 520 # a fresh clone's World 521 522 # one answer as a fresh clone would have it: a tree read from BEND_LIB is 523 # instead cloned at the rev the ledger pins and weighed to the narHash the 524 # ledger records. Anything else is what it was. 525 def reclone.how(how: How, +src: L.Src) -> How: 526 match how: 527 case Lib{}: 528 Clone{L.src.nar(src)} 529 case Clone{got}: 530 Clone{got} 531 case Served{}: 532 Served{} 533 534 def reclone.one(+orgs: List<&2, L.Origin>, +hash: String, answer: Answer) -> Answer: 535 match answer: 536 case Miss{why}: 537 Miss{why} 538 case Got{how, manifest, ss}: 539 Got{reclone.how(how, L.origin(orgs, hash)), manifest, ss} 540 case Said{text}: 541 Said{text} 542 case Locked{text}: 543 Locked{text} 544 545 # every answer as a fresh clone would have it. A tree the upgrade checked 546 # out is a clone already. 547 def reclone.all(+orgs: List<&2, L.Origin>, rs: List<&2, Reply>) -> List<&2, Reply>: 548 match rs: 549 case []: 550 [] 551 case Reply{+ask, answer} <> t: 552 Reply{ask, reclone.one(orgs, ask.hash(ask), answer)} <> reclone.all(orgs, t) 553 554 # the World a fresh clone of the same commit gathers: the same ledger, the 555 # same committed sources, and every tree it would have read from BEND_LIB 556 # cloned and weighed instead, since a clone has nothing there 557 def reclone(world: World) -> World: 558 World{+args, +ledger, listing, replies, +ups} = world 559 World{args, ledger, listing, 560 reclone.all(next.origins(next(args, ledger, ups)), replies), ups}