| 1 |
1 |
|
//! A repository on disk: the `.oxy` directory, one workspace, the operation |
| 2 |
2 |
|
//! log, and the working-copy change (spec §12, §13.1). |
| 3 |
3 |
|
//! |
| 4 |
|
- |
//! Not yet implemented from the spec: seals, attestations and trust policy, |
| 5 |
|
- |
//! packs, checkpoints, multiple workspaces, and remotes. Changes are signed individually when `core.sign` is |
|
4 |
+ |
//! Not yet implemented from the spec: trust policy (every remote is |
|
5 |
+ |
//! "unverified", so unsigned pushes are accepted), packs, checkpoints, |
|
6 |
+ |
//! multiple workspaces, and network transports (only local paths). Changes are signed individually when `core.sign` is |
| 6 |
7 |
|
//! `change`; the default `seal` leaves them unsigned until push exists. |
| 7 |
8 |
|
|
|
9 |
+ |
use crate::attest::{Attest, AttestBody}; |
| 8 |
10 |
|
use crate::diff::{ranges, split_lines, text_edits}; |
|
11 |
+ |
use crate::protocol::{open_remote, Bundle, RefResult, RefUpdate, RemoteRef, SignedAttest}; |
| 9 |
12 |
|
use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType}; |
| 10 |
13 |
|
use crate::identity::{self, Domain, Identity, SecretKey, Signature}; |
| 11 |
14 |
|
use crate::ids::{ChangeId, NodeId, WorkspaceId}; |
|
|
… |
2 unchanged lines |
| 14 |
17 |
|
use crate::state::{FileContent, Snapshot, State}; |
| 15 |
18 |
|
use crate::store::{Store, StoreError}; |
| 16 |
19 |
|
use crate::tree::OpKey; |
|
20 |
+ |
use rand::RngCore; |
| 17 |
21 |
|
use std::collections::{BTreeMap, BTreeSet, HashMap}; |
| 18 |
22 |
|
use std::fs; |
| 19 |
23 |
|
use std::io::Write; |
|
|
… |
35 unchanged lines |
| 55 |
59 |
|
/// push-time seals (`false`, the spec default). |
| 56 |
60 |
|
pub sign_changes: bool, |
| 57 |
61 |
|
pub bookmark: String, |
|
62 |
+ |
/// `remote.<name>.url`. |
|
63 |
+ |
pub remotes: BTreeMap<String, String>, |
| 58 |
64 |
|
} |
| 59 |
65 |
|
|
| 60 |
66 |
|
impl Config { |
|
|
… |
5 unchanged lines |
| 66 |
72 |
|
author_key: None, |
| 67 |
73 |
|
sign_changes: false, |
| 68 |
74 |
|
bookmark: "main".into(), |
|
75 |
+ |
remotes: BTreeMap::new(), |
| 69 |
76 |
|
} |
| 70 |
77 |
|
} |
| 71 |
78 |
|
|
|
|
… |
17 unchanged lines |
| 89 |
96 |
|
} |
| 90 |
97 |
|
} |
| 91 |
98 |
|
"workspace.bookmark" => self.bookmark = value.into(), |
|
99 |
+ |
k if k.starts_with("remote.") && k.ends_with(".url") => { |
|
100 |
+ |
let name = &k["remote.".len()..k.len() - ".url".len()]; |
|
101 |
+ |
if name.is_empty() || name.contains('/') { |
|
102 |
+ |
return fail(format!("invalid remote name in {key:?}")); |
|
103 |
+ |
} |
|
104 |
+ |
if value.is_empty() { |
|
105 |
+ |
self.remotes.remove(name); |
|
106 |
+ |
} else { |
|
107 |
+ |
self.remotes.insert(name.to_string(), value.into()); |
|
108 |
+ |
} |
|
109 |
+ |
} |
| 92 |
110 |
|
_ => return fail(format!("unknown configuration key {key:?}")), |
| 93 |
111 |
|
} |
| 94 |
112 |
|
Ok(()) |
|
|
… |
14 unchanged lines |
| 109 |
127 |
|
} |
| 110 |
128 |
|
|
| 111 |
129 |
|
fn save(&self, path: &Path) -> Result<()> { |
| 112 |
|
- |
let text = format!( |
|
130 |
+ |
let mut text = format!( |
| 113 |
131 |
|
"author.name = \"{}\"\nauthor.email = \"{}\"\nauthor.key = \"{}\"\ncore.sign = \"{}\"\nworkspace.bookmark = \"{}\"\n", |
| 114 |
132 |
|
self.author_name, |
| 115 |
133 |
|
self.author_email, |
|
|
… |
1 unchanged lines |
| 117 |
135 |
|
if self.sign_changes { "change" } else { "seal" }, |
| 118 |
136 |
|
self.bookmark |
| 119 |
137 |
|
); |
|
138 |
+ |
for (name, url) in &self.remotes { |
|
139 |
+ |
text.push_str(&format!("remote.{name}.url = \"{url}\"\n")); |
|
140 |
+ |
} |
| 120 |
141 |
|
write_atomic(path, text.as_bytes()) |
| 121 |
142 |
|
} |
| 122 |
143 |
|
} |
|
|
… |
87 unchanged lines |
| 210 |
231 |
|
#[derive(Clone, Debug, PartialEq, Eq)] |
| 211 |
232 |
|
pub enum SignatureStatus { |
| 212 |
233 |
|
Unsigned, |
| 213 |
|
- |
Valid { key: KeyId, name: String, email: String }, |
|
234 |
+ |
Valid { key: KeyId, name: String, email: String, sealed: bool }, |
| 214 |
235 |
|
Invalid(String), |
| 215 |
236 |
|
} |
| 216 |
237 |
|
|
|
|
… |
5 unchanged lines |
| 222 |
243 |
|
// ------------------------------------------------------------ lifecycle |
| 223 |
244 |
|
|
| 224 |
245 |
|
pub fn init(root: &Path) -> Result<Repo> { |
|
246 |
+ |
let mut id = [0u8; 16]; |
|
247 |
+ |
rand::rngs::OsRng.fill_bytes(&mut id); |
|
248 |
+ |
Self::init_with_id(root, id) |
|
249 |
+ |
} |
|
250 |
+ |
|
|
251 |
+ |
/// Initialize a repository with a given repository ID (clones share the |
|
252 |
+ |
/// ID of their origin, spec §14.4). |
|
253 |
+ |
pub fn init_with_id(root: &Path, id: [u8; 16]) -> Result<Repo> { |
|
254 |
+ |
fs::create_dir_all(root)?; |
| 225 |
255 |
|
let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); |
| 226 |
256 |
|
let oxy = root.join(OXY_DIR); |
| 227 |
257 |
|
if oxy.exists() { |
|
|
… |
1 unchanged lines |
| 229 |
259 |
|
} |
| 230 |
260 |
|
fs::create_dir_all(oxy.join("ops"))?; |
| 231 |
261 |
|
write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?; |
|
262 |
+ |
write_atomic(&oxy.join("id"), hex::encode(id).as_bytes())?; |
| 232 |
263 |
|
let config = Config::default_for_user(); |
| 233 |
264 |
|
config.save(&oxy.join("config"))?; |
| 234 |
265 |
|
let workspace = WorkspaceId::random(); |
|
|
… |
64 unchanged lines |
| 299 |
330 |
|
Ok(()) |
| 300 |
331 |
|
} |
| 301 |
332 |
|
|
|
333 |
+ |
/// The repository ID (spec §14.4). Repositories made before IDs existed get one on first use. |
|
334 |
+ |
pub fn id(&self) -> Result<[u8; 16]> { |
|
335 |
+ |
let path = self.oxy.join("id"); |
|
336 |
+ |
match fs::read_to_string(&path) { |
|
337 |
+ |
Ok(h) => hex::decode(h.trim()).ok().and_then(|v| v.try_into().ok()).ok_or_else(|| RepoError::Msg("corrupt .oxy/id".into())), |
|
338 |
+ |
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { |
|
339 |
+ |
let mut id = [0u8; 16]; |
|
340 |
+ |
rand::rngs::OsRng.fill_bytes(&mut id); |
|
341 |
+ |
write_atomic(&path, hex::encode(id).as_bytes())?; |
|
342 |
+ |
Ok(id) |
|
343 |
+ |
} |
|
344 |
+ |
Err(e) => Err(e.into()), |
|
345 |
+ |
} |
|
346 |
+ |
} |
|
347 |
+ |
|
| 302 |
348 |
|
pub fn root(&self) -> &Path { |
| 303 |
349 |
|
&self.root |
| 304 |
350 |
|
} |
|
|
… |
81 unchanged lines |
| 386 |
432 |
|
} |
| 387 |
433 |
|
let mut last_err = String::new(); |
| 388 |
434 |
|
for sig in &sigs { |
| 389 |
|
- |
let id = match self.store.get_identity(&sig.key) { |
| 390 |
|
- |
Ok(id) => id, |
| 391 |
|
- |
Err(_) => { |
| 392 |
|
- |
last_err = format!("unknown key {}", sig.key.short(12)); |
| 393 |
|
- |
continue; |
|
435 |
+ |
match self.check_signer(&change, sig, Domain::Change, hash) { |
|
436 |
+ |
Ok(id) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email, sealed: false }), |
|
437 |
+ |
Err(e) => last_err = e, |
|
438 |
+ |
} |
|
439 |
+ |
} |
|
440 |
+ |
Ok(SignatureStatus::Invalid(last_err)) |
|
441 |
+ |
} |
|
442 |
+ |
|
|
443 |
+ |
/// Verify `sig` over `target` and check the author-or-delegate rule for `change`. |
|
444 |
+ |
fn check_signer(&self, change: &Change, sig: &Signature, domain: Domain, target: &ObjectRef) -> std::result::Result<Identity, String> { |
|
445 |
+ |
let id = self.store.get_identity(&sig.key).map_err(|_| format!("unknown key {}", sig.key.short(12)))?; |
|
446 |
+ |
let authorised = |
|
447 |
+ |
sig.key == change.author || self.store.get_identity(&change.author).map(|a| a.delegates.contains(&sig.key)).unwrap_or(false); |
|
448 |
+ |
if !authorised { |
|
449 |
+ |
return Err(format!("key {} is neither the author nor a delegate", sig.key.short(12))); |
|
450 |
+ |
} |
|
451 |
+ |
identity::verify(&id, sig, domain, target).map_err(|e| e.to_string())?; |
|
452 |
+ |
Ok(id) |
|
453 |
+ |
} |
|
454 |
+ |
|
|
455 |
+ |
/// Like `verify_change`, but a change with no valid individual signature |
|
456 |
+ |
/// is also authenticated by a valid seal covering it (spec §14.4). |
|
457 |
+ |
pub fn authenticate_change(&self, hash: &ChangeHash) -> Result<SignatureStatus> { |
|
458 |
+ |
let direct = self.verify_change(hash)?; |
|
459 |
+ |
if matches!(direct, SignatureStatus::Valid { .. }) { |
|
460 |
+ |
return Ok(direct); |
|
461 |
+ |
} |
|
462 |
+ |
let change = self.store.get_change(hash)?; |
|
463 |
+ |
let mut last_err = match &direct { |
|
464 |
+ |
SignatureStatus::Invalid(e) => e.clone(), |
|
465 |
+ |
_ => String::new(), |
|
466 |
+ |
}; |
|
467 |
+ |
for seal_ref in self.store.seals_for(hash)? { |
|
468 |
+ |
let attest = self.store.get_attest(&seal_ref)?; |
|
469 |
+ |
let AttestBody::Seal { hashes } = &attest.body else { continue }; |
|
470 |
+ |
if !hashes.contains(hash) { |
|
471 |
+ |
continue; |
|
472 |
+ |
} |
|
473 |
+ |
for sig in self.store.signatures(&seal_ref)? { |
|
474 |
+ |
match self.check_signer(&change, &sig, Domain::Attest, &seal_ref) { |
|
475 |
+ |
Ok(id) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email, sealed: true }), |
|
476 |
+ |
Err(e) => last_err = e, |
|
477 |
+ |
} |
|
478 |
+ |
} |
|
479 |
+ |
} |
|
480 |
+ |
if last_err.is_empty() { Ok(SignatureStatus::Unsigned) } else { Ok(SignatureStatus::Invalid(last_err)) } |
|
481 |
+ |
} |
|
482 |
+ |
|
|
483 |
+ |
/// Store and sign an attestation with the configured key. |
|
484 |
+ |
fn sign_attest(&self, attest: &Attest) -> Result<SignedAttest> { |
|
485 |
+ |
let Some((key, sk)) = self.secret_key()? else { |
|
486 |
+ |
return fail("no secret key: run `oxy identity create` first"); |
|
487 |
+ |
}; |
|
488 |
+ |
let r = self.store.put_attest(attest)?; |
|
489 |
+ |
let sig = sk.sign(Domain::Attest, &key, &r); |
|
490 |
+ |
self.store.put_signature(&r, &sig)?; |
|
491 |
+ |
if let AttestBody::Seal { hashes } = &attest.body { |
|
492 |
+ |
for h in hashes { |
|
493 |
+ |
self.store.index_seal(h, &r)?; |
|
494 |
+ |
} |
|
495 |
+ |
} |
|
496 |
+ |
Ok(SignedAttest { attest: attest.clone(), sigs: vec![sig] }) |
|
497 |
+ |
} |
|
498 |
+ |
|
|
499 |
+ |
// ------------------------------------------------------------ serving (spec §16) |
|
500 |
+ |
|
|
501 |
+ |
fn ref_attest_path(&self, bookmark: &str) -> PathBuf { |
|
502 |
+ |
self.oxy.join("attest").join(bookmark) |
|
503 |
+ |
} |
|
504 |
+ |
|
|
505 |
+ |
/// The latest reference attestation stored for a bookmark. |
|
506 |
+ |
pub fn latest_ref_attest(&self, bookmark: &str) -> Result<Option<(ObjectRef, SignedAttest)>> { |
|
507 |
+ |
match fs::read_to_string(self.ref_attest_path(bookmark)) { |
|
508 |
+ |
Ok(h) => { |
|
509 |
+ |
let r = ObjectRef::from_hex(h.trim()).ok_or_else(|| RepoError::Msg("corrupt attestation pointer".into()))?; |
|
510 |
+ |
Ok(Some((r, SignedAttest { attest: self.store.get_attest(&r)?, sigs: self.store.signatures(&r)? }))) |
|
511 |
+ |
} |
|
512 |
+ |
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), |
|
513 |
+ |
Err(e) => Err(e.into()), |
|
514 |
+ |
} |
|
515 |
+ |
} |
|
516 |
+ |
|
|
517 |
+ |
fn set_ref_attest(&self, bookmark: &str, r: &ObjectRef) -> Result<()> { |
|
518 |
+ |
fs::create_dir_all(self.oxy.join("attest"))?; |
|
519 |
+ |
write_atomic(&self.ref_attest_path(bookmark), r.to_hex().as_bytes()) |
|
520 |
+ |
} |
|
521 |
+ |
|
|
522 |
+ |
pub fn remote_refs(&self) -> Result<Vec<RemoteRef>> { |
|
523 |
+ |
let view = self.view()?; |
|
524 |
+ |
let mut out = Vec::new(); |
|
525 |
+ |
for (name, state) in &view.bookmarks { |
|
526 |
+ |
out.push(RemoteRef { |
|
527 |
+ |
name: name.clone(), |
|
528 |
+ |
state: *state, |
|
529 |
+ |
heads: self.store.get_state(state)?.heads, |
|
530 |
+ |
attestation: self.latest_ref_attest(name)?.map(|(_, a)| a), |
|
531 |
+ |
}); |
|
532 |
+ |
} |
|
533 |
+ |
Ok(out) |
|
534 |
+ |
} |
|
535 |
+ |
|
|
536 |
+ |
/// Every change reachable from `heads` that is in the store. |
|
537 |
+ |
fn closure_in_store(&self, heads: &[ChangeHash]) -> Result<BTreeSet<ChangeHash>> { |
|
538 |
+ |
let mut seen = BTreeSet::new(); |
|
539 |
+ |
let mut stack: Vec<ChangeHash> = heads.to_vec(); |
|
540 |
+ |
while let Some(h) = stack.pop() { |
|
541 |
+ |
if seen.contains(&h) || !self.store.has(&h) { |
|
542 |
+ |
continue; |
|
543 |
+ |
} |
|
544 |
+ |
seen.insert(h); |
|
545 |
+ |
stack.extend(self.store.get_change(&h)?.deps); |
|
546 |
+ |
} |
|
547 |
+ |
Ok(seen) |
|
548 |
+ |
} |
|
549 |
+ |
|
|
550 |
+ |
/// `closure(want) \ closure(have)`, dependencies first (spec §16.2). |
|
551 |
+ |
pub fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>> { |
|
552 |
+ |
let have = self.closure_in_store(have)?; |
|
553 |
+ |
let mut out = Vec::new(); |
|
554 |
+ |
let mut done: BTreeSet<ChangeHash> = have.clone(); |
|
555 |
+ |
// Iterative post-order DFS. |
|
556 |
+ |
let mut stack: Vec<(ChangeHash, bool)> = want.iter().rev().map(|h| (*h, false)).collect(); |
|
557 |
+ |
while let Some((h, expanded)) = stack.pop() { |
|
558 |
+ |
if done.contains(&h) { |
|
559 |
+ |
continue; |
|
560 |
+ |
} |
|
561 |
+ |
if !self.store.has(&h) { |
|
562 |
+ |
return fail(format!("change {} is not in this repository", h.short(12))); |
|
563 |
+ |
} |
|
564 |
+ |
if expanded { |
|
565 |
+ |
done.insert(h); |
|
566 |
+ |
out.push(h); |
|
567 |
+ |
continue; |
|
568 |
+ |
} |
|
569 |
+ |
stack.push((h, true)); |
|
570 |
+ |
for d in self.store.get_change(&h)?.deps { |
|
571 |
+ |
if !done.contains(&d) { |
|
572 |
+ |
stack.push((d, false)); |
| 394 |
573 |
|
} |
|
574 |
+ |
} |
|
575 |
+ |
} |
|
576 |
+ |
Ok(out) |
|
577 |
+ |
} |
|
578 |
+ |
|
|
579 |
+ |
/// Bundle changes (in the given dependency order) with their signatures, |
|
580 |
+ |
/// seals, identities, blobs, chunks. |
|
581 |
+ |
pub fn bundle_for(&self, changes: &[ChangeHash]) -> Result<Bundle> { |
|
582 |
+ |
let mut bundle = Bundle::default(); |
|
583 |
+ |
let mut objects: BTreeSet<ObjectRef> = BTreeSet::new(); |
|
584 |
+ |
let mut seals: BTreeSet<ObjectRef> = BTreeSet::new(); |
|
585 |
+ |
let add_object = |r: &ObjectRef, objects: &mut BTreeSet<ObjectRef>| { |
|
586 |
+ |
if !r.is_zero() && self.store.has(r) { |
|
587 |
+ |
objects.insert(*r); |
|
588 |
+ |
} |
|
589 |
+ |
}; |
|
590 |
+ |
for h in changes { |
|
591 |
+ |
let c = self.store.get_change(h)?; |
|
592 |
+ |
let sigs = self.store.signatures(h)?; |
|
593 |
+ |
add_object(&c.author, &mut objects); |
|
594 |
+ |
for s in &sigs { |
|
595 |
+ |
add_object(&s.key, &mut objects); |
|
596 |
+ |
} |
|
597 |
+ |
for e in &c.edits { |
|
598 |
+ |
if let Edit::Replace { old, new, .. } = e { |
|
599 |
+ |
for b in [old, new].into_iter().flatten() { |
|
600 |
+ |
if self.store.has(b) { |
|
601 |
+ |
objects.insert(*b); |
|
602 |
+ |
if let Ok(blob) = Blob::decode(&self.store.get_typed(b, ObjectType::Blob)?) { |
|
603 |
+ |
objects.extend(blob.chunks.iter().copied()); |
|
604 |
+ |
} |
|
605 |
+ |
} |
|
606 |
+ |
} |
|
607 |
+ |
} |
|
608 |
+ |
} |
|
609 |
+ |
for s in self.store.seals_for(h)? { |
|
610 |
+ |
seals.insert(s); |
|
611 |
+ |
} |
|
612 |
+ |
bundle.changes.push((c, sigs)); |
|
613 |
+ |
} |
|
614 |
+ |
for r in seals { |
|
615 |
+ |
let attest = self.store.get_attest(&r)?; |
|
616 |
+ |
let sigs = self.store.signatures(&r)?; |
|
617 |
+ |
for s in &sigs { |
|
618 |
+ |
add_object(&s.key, &mut objects); |
|
619 |
+ |
} |
|
620 |
+ |
bundle.attests.push(SignedAttest { attest, sigs }); |
|
621 |
+ |
} |
|
622 |
+ |
for r in objects { |
|
623 |
+ |
let (ty, body) = self.store.get(&r)?; |
|
624 |
+ |
bundle.objects.push((ty, body)); |
|
625 |
+ |
} |
|
626 |
+ |
Ok(bundle) |
|
627 |
+ |
} |
|
628 |
+ |
|
|
629 |
+ |
/// Store a bundle's contents. With `strict`, every change must |
|
630 |
+ |
/// authenticate (signature or seal by its author or a delegate). |
|
631 |
+ |
pub fn import_bundle(&self, bundle: &Bundle, strict: bool) -> Result<Vec<ChangeHash>> { |
|
632 |
+ |
for (ty, body) in &bundle.objects { |
|
633 |
+ |
match ty { |
|
634 |
+ |
ObjectType::Chunk | ObjectType::Blob | ObjectType::Identity | ObjectType::State => { |
|
635 |
+ |
self.store.put(*ty, body)?; |
|
636 |
+ |
} |
|
637 |
+ |
other => return fail(format!("bundle may not carry {} objects", other.name())), |
|
638 |
+ |
} |
|
639 |
+ |
} |
|
640 |
+ |
for sa in &bundle.attests { |
|
641 |
+ |
let r = self.store.put_attest(&sa.attest)?; |
|
642 |
+ |
for sig in &sa.sigs { |
|
643 |
+ |
self.store.put_signature(&r, sig)?; |
|
644 |
+ |
} |
|
645 |
+ |
if let AttestBody::Seal { hashes } = &sa.attest.body { |
|
646 |
+ |
for h in hashes { |
|
647 |
+ |
self.store.index_seal(h, &r)?; |
|
648 |
+ |
} |
|
649 |
+ |
} |
|
650 |
+ |
} |
|
651 |
+ |
let incoming: BTreeSet<ChangeHash> = bundle.changes.iter().map(|(c, _)| c.hash()).collect(); |
|
652 |
+ |
let mut imported = Vec::new(); |
|
653 |
+ |
for (c, sigs) in &bundle.changes { |
|
654 |
+ |
let h = c.hash(); |
|
655 |
+ |
for d in &c.deps { |
|
656 |
+ |
if !self.store.has(d) && !incoming.contains(d) { |
|
657 |
+ |
return fail(format!("change {} depends on {}, which is neither stored nor in the bundle", h.short(12), d.short(12))); |
|
658 |
+ |
} |
|
659 |
+ |
} |
|
660 |
+ |
self.store.put_change(c)?; |
|
661 |
+ |
for sig in sigs { |
|
662 |
+ |
self.store.put_signature(&h, sig)?; |
|
663 |
+ |
} |
|
664 |
+ |
imported.push(h); |
|
665 |
+ |
} |
|
666 |
+ |
if strict { |
|
667 |
+ |
for h in &imported { |
|
668 |
+ |
if !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) { |
|
669 |
+ |
return fail(format!("change {} is not authenticated by its author", h.short(12))); |
|
670 |
+ |
} |
|
671 |
+ |
} |
|
672 |
+ |
} |
|
673 |
+ |
Ok(imported) |
|
674 |
+ |
} |
|
675 |
+ |
|
|
676 |
+ |
/// Verify a reference attestation against an update it claims to describe. |
|
677 |
+ |
fn check_ref_attest(&self, sa: &SignedAttest, name: &str, old_state: &ObjectRef, new_state: &ObjectRef, last_serial: Option<u64>) -> std::result::Result<u64, String> { |
|
678 |
+ |
let AttestBody::Ref { name: n, old_state: o, new_state: nw, serial } = &sa.attest.body else { |
|
679 |
+ |
return Err("attestation is not a ref attestation".into()); |
|
680 |
+ |
}; |
|
681 |
+ |
if n != name || o != old_state || nw != new_state { |
|
682 |
+ |
return Err("attestation does not describe this update".into()); |
|
683 |
+ |
} |
|
684 |
+ |
if sa.attest.repo != self.id().map_err(|e| e.to_string())? { |
|
685 |
+ |
return Err("attestation is for a different repository".into()); |
|
686 |
+ |
} |
|
687 |
+ |
if let Some(last) = last_serial { |
|
688 |
+ |
if *serial <= last { |
|
689 |
+ |
return Err(format!("attestation serial {serial} is not greater than the last seen {last}")); |
|
690 |
+ |
} |
|
691 |
+ |
} |
|
692 |
+ |
let r = sa.attest.hash(); |
|
693 |
+ |
let sig = sa.sigs.first().ok_or("attestation carries no signature")?; |
|
694 |
+ |
let id = self.store.get_identity(&sig.key).map_err(|_| format!("unknown key {}", sig.key.short(12)))?; |
|
695 |
+ |
identity::verify(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?; |
|
696 |
+ |
Ok(*serial) |
|
697 |
+ |
} |
|
698 |
+ |
|
|
699 |
+ |
/// Accept a push (spec §16.3). Without a trust policy every repository |
|
700 |
+ |
/// is unverified, so unsigned changes and unattested updates are accepted; |
|
701 |
+ |
/// attestations that are present must still be valid. |
|
702 |
+ |
pub fn receive_push(&self, bundle: Bundle, updates: Vec<RefUpdate>) -> Result<Vec<RefResult>> { |
|
703 |
+ |
self.import_bundle(&bundle, false)?; |
|
704 |
+ |
let (op_ref, op) = self.head_op()?; |
|
705 |
+ |
let mut view = op.view.clone(); |
|
706 |
+ |
let mut results = Vec::new(); |
|
707 |
+ |
let mut names = Vec::new(); |
|
708 |
+ |
for u in updates { |
|
709 |
+ |
let outcome = self.apply_update(&mut view, &u); |
|
710 |
+ |
let (ok, reason) = match outcome { |
|
711 |
+ |
Ok(()) => (true, String::new()), |
|
712 |
+ |
Err(e) => (false, e), |
| 395 |
713 |
|
}; |
| 396 |
|
- |
let authorised = sig.key == change.author |
| 397 |
|
- |
|| self.store.get_identity(&change.author).map(|a| a.delegates.contains(&sig.key)).unwrap_or(false); |
| 398 |
|
- |
if !authorised { |
| 399 |
|
- |
last_err = format!("key {} is neither the author nor a delegate", sig.key.short(12)); |
| 400 |
|
- |
continue; |
|
714 |
+ |
if ok { |
|
715 |
+ |
names.push(u.name.clone()); |
|
716 |
+ |
} |
|
717 |
+ |
results.push(RefResult { name: u.name, ok, reason }); |
|
718 |
+ |
} |
|
719 |
+ |
if !names.is_empty() { |
|
720 |
+ |
self.record_op(&[op_ref], &format!("push received: {}", names.join(" ")), view)?; |
|
721 |
+ |
} |
|
722 |
+ |
Ok(results) |
|
723 |
+ |
} |
|
724 |
+ |
|
|
725 |
+ |
fn apply_update(&self, view: &mut View, u: &RefUpdate) -> std::result::Result<(), String> { |
|
726 |
+ |
let empty = self.store.put_state(&StateObject::new(vec![])).map_err(|e| e.to_string())?; |
|
727 |
+ |
let current = view.bookmarks.get(&u.name).copied().unwrap_or(empty); |
|
728 |
+ |
if current != u.old_state { |
|
729 |
+ |
return Err("stale: the remote bookmark has moved; pull first".into()); |
|
730 |
+ |
} |
|
731 |
+ |
let new_state = self.store.get_state(&u.new_state).map_err(|_| "new state object is missing".to_string())?; |
|
732 |
+ |
for h in &new_state.heads { |
|
733 |
+ |
if !self.store.has(h) { |
|
734 |
+ |
return Err(format!("head {} is missing", h.short(12))); |
|
735 |
+ |
} |
|
736 |
+ |
} |
|
737 |
+ |
if !u.force { |
|
738 |
+ |
let old_heads = self.store.get_state(¤t).map_err(|e| e.to_string())?.heads; |
|
739 |
+ |
let closure = self.closure_in_store(&new_state.heads).map_err(|e| e.to_string())?; |
|
740 |
+ |
if let Some(h) = old_heads.iter().find(|h| !closure.contains(h)) { |
|
741 |
+ |
return Err(format!("not a fast-forward: current head {} would be dropped (use --force)", h.short(12))); |
|
742 |
+ |
} |
|
743 |
+ |
} |
|
744 |
+ |
if let Some(sa) = &u.attestation { |
|
745 |
+ |
let last = self.latest_ref_attest(&u.name).map_err(|e| e.to_string())?.and_then(|(_, a)| match a.attest.body { |
|
746 |
+ |
AttestBody::Ref { serial, .. } => Some(serial), |
|
747 |
+ |
_ => None, |
|
748 |
+ |
}); |
|
749 |
+ |
self.check_ref_attest(sa, &u.name, &u.old_state, &u.new_state, last)?; |
|
750 |
+ |
let r = self.store.put_attest(&sa.attest).map_err(|e| e.to_string())?; |
|
751 |
+ |
for sig in &sa.sigs { |
|
752 |
+ |
self.store.put_signature(&r, sig).map_err(|e| e.to_string())?; |
|
753 |
+ |
} |
|
754 |
+ |
self.set_ref_attest(&u.name, &r).map_err(|e| e.to_string())?; |
|
755 |
+ |
} |
|
756 |
+ |
view.bookmarks.insert(u.name.clone(), u.new_state); |
|
757 |
+ |
Ok(()) |
|
758 |
+ |
} |
|
759 |
+ |
|
|
760 |
+ |
// ------------------------------------------------------------ client side |
|
761 |
+ |
|
|
762 |
+ |
fn remote_url(&self, name: &str) -> Result<String> { |
|
763 |
+ |
self.config.remotes.get(name).cloned().ok_or_else(|| RepoError::Msg(format!("no remote named {name:?}; add one with `oxy remote add`"))) |
|
764 |
+ |
} |
|
765 |
+ |
|
|
766 |
+ |
fn serial_path(&self, remote: &str, bookmark: &str) -> PathBuf { |
|
767 |
+ |
self.oxy.join("remotes").join(remote).join(format!("{bookmark}.serial")) |
|
768 |
+ |
} |
|
769 |
+ |
|
|
770 |
+ |
fn last_seen_serial(&self, remote: &str, bookmark: &str) -> Option<u64> { |
|
771 |
+ |
fs::read_to_string(self.serial_path(remote, bookmark)).ok().and_then(|s| s.trim().parse().ok()) |
|
772 |
+ |
} |
|
773 |
+ |
|
|
774 |
+ |
fn record_serial(&self, remote: &str, bookmark: &str, serial: u64) -> Result<()> { |
|
775 |
+ |
let p = self.serial_path(remote, bookmark); |
|
776 |
+ |
fs::create_dir_all(p.parent().unwrap())?; |
|
777 |
+ |
write_atomic(&p, serial.to_string().as_bytes()) |
|
778 |
+ |
} |
|
779 |
+ |
|
|
780 |
+ |
/// Every head this repository knows: bookmarks, remote tracking, working copy. |
|
781 |
+ |
fn known_heads(&self, view: &View) -> Result<Vec<ChangeHash>> { |
|
782 |
+ |
let mut have: BTreeSet<ChangeHash> = BTreeSet::new(); |
|
783 |
+ |
for st in view.bookmarks.values().chain(view.remotes.values().flat_map(|b| b.values())) { |
|
784 |
+ |
if let Ok(s) = self.store.get_state(st) { |
|
785 |
+ |
have.extend(s.heads); |
|
786 |
+ |
} |
|
787 |
+ |
} |
|
788 |
+ |
have.extend(view.workspaces.values().copied()); |
|
789 |
+ |
Ok(have.into_iter().collect()) |
|
790 |
+ |
} |
|
791 |
+ |
|
|
792 |
+ |
/// Fetch every bookmark of a remote into `remotes/<name>/bookmarks/*` (spec §10.2). |
|
793 |
+ |
pub fn fetch(&self, remote_name: &str) -> Result<Vec<(String, Vec<ChangeHash>, usize)>> { |
|
794 |
+ |
let remote = open_remote(&self.remote_url(remote_name)?)?; |
|
795 |
+ |
if remote.repo_id()? != self.id()? { |
|
796 |
+ |
return fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)")); |
|
797 |
+ |
} |
|
798 |
+ |
let (op_ref, op) = self.head_op()?; |
|
799 |
+ |
let mut view = op.view.clone(); |
|
800 |
+ |
let have = self.known_heads(&view)?; |
|
801 |
+ |
let mut report = Vec::new(); |
|
802 |
+ |
for r in remote.refs()? { |
|
803 |
+ |
let needed = remote.negotiate(&have, &r.heads)?; |
|
804 |
+ |
let bundle = remote.fetch(&needed)?; |
|
805 |
+ |
let imported = self.import_bundle(&bundle, false)?; |
|
806 |
+ |
for h in &r.heads { |
|
807 |
+ |
if !self.store.has(h) { |
|
808 |
+ |
return fail(format!("remote did not send head {}", h.short(12))); |
|
809 |
+ |
} |
|
810 |
+ |
} |
|
811 |
+ |
let state = self.store.put_state(&StateObject::new(r.heads.clone()))?; |
|
812 |
+ |
if state != r.state { |
|
813 |
+ |
return fail(format!("remote state {} does not match its heads", r.state.short(12))); |
|
814 |
+ |
} |
|
815 |
+ |
if let Some(sa) = &r.attestation { |
|
816 |
+ |
let AttestBody::Ref { old_state, new_state, .. } = &sa.attest.body else { |
|
817 |
+ |
return fail("remote sent a non-ref attestation for a bookmark"); |
|
818 |
+ |
}; |
|
819 |
+ |
let (old_state, new_state) = (*old_state, *new_state); |
|
820 |
+ |
let serial = self |
|
821 |
+ |
.check_ref_attest(sa, &r.name, &old_state, &new_state, self.last_seen_serial(remote_name, &r.name)) |
|
822 |
+ |
.map_err(|e| RepoError::Msg(format!("attestation for {}/{}: {e}", remote_name, r.name)))?; |
|
823 |
+ |
if new_state != r.state { |
|
824 |
+ |
return fail(format!("attestation for {}/{} does not cover its current state", remote_name, r.name)); |
|
825 |
+ |
} |
|
826 |
+ |
let ar = self.store.put_attest(&sa.attest)?; |
|
827 |
+ |
for sig in &sa.sigs { |
|
828 |
+ |
self.store.put_signature(&ar, sig)?; |
|
829 |
+ |
} |
|
830 |
+ |
self.record_serial(remote_name, &r.name, serial)?; |
|
831 |
+ |
} |
|
832 |
+ |
view.remotes.entry(remote_name.to_string()).or_default().insert(r.name.clone(), state); |
|
833 |
+ |
report.push((r.name, r.heads, imported.len())); |
|
834 |
+ |
} |
|
835 |
+ |
if view != op.view { |
|
836 |
+ |
self.record_op(&[op_ref], &format!("oxy fetch {remote_name}"), view)?; |
|
837 |
+ |
} |
|
838 |
+ |
Ok(report) |
|
839 |
+ |
} |
|
840 |
+ |
|
|
841 |
+ |
/// Fetch, then take the union of the local bookmark's state with the |
|
842 |
+ |
/// remote's: no merge change, conflicts become reified (spec §9.4). |
|
843 |
+ |
pub fn pull(&self, remote_name: &str) -> Result<PullReport> { |
|
844 |
+ |
self.fetch(remote_name)?; |
|
845 |
+ |
let (op_ref, op) = self.head_op()?; |
|
846 |
+ |
let bookmark = self.config.bookmark.clone(); |
|
847 |
+ |
let local = self.heads_of(&op.view, &bookmark)?; |
|
848 |
+ |
let Some(remote_state) = op.view.remotes.get(remote_name).and_then(|b| b.get(&bookmark)).copied() else { |
|
849 |
+ |
return Ok(PullReport { heads: local, changed: false, checked_out: false }); |
|
850 |
+ |
}; |
|
851 |
+ |
let remote_heads = self.store.get_state(&remote_state)?.heads; |
|
852 |
+ |
let mut all = local.clone(); |
|
853 |
+ |
all.extend(remote_heads); |
|
854 |
+ |
let st = self.load_state(&all)?; |
|
855 |
+ |
let mut heads = st.maximal(&all); |
|
856 |
+ |
heads.sort(); |
|
857 |
+ |
let mut sorted_local = local.clone(); |
|
858 |
+ |
sorted_local.sort(); |
|
859 |
+ |
if heads == sorted_local { |
|
860 |
+ |
return Ok(PullReport { heads, changed: false, checked_out: false }); |
|
861 |
+ |
} |
|
862 |
+ |
let mut view = op.view.clone(); |
|
863 |
+ |
view.bookmarks.insert(bookmark.clone(), self.store.put_state(&StateObject::new(heads.clone()))?); |
|
864 |
+ |
self.record_op(&[op_ref], &format!("oxy pull {remote_name}"), view)?; |
|
865 |
+ |
// Follow along if the working copy was clean and on the old heads. |
|
866 |
+ |
let snap = self.snapshot("oxy pull")?; |
|
867 |
+ |
let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty(); |
|
868 |
+ |
let mut base = snap.base.maximal(&snap.wc.deps); |
|
869 |
+ |
base.sort(); |
|
870 |
+ |
let mut checked_out = false; |
|
871 |
+ |
if clean && base == sorted_local { |
|
872 |
+ |
self.checkout(&heads, false, "oxy pull (checkout)")?; |
|
873 |
+ |
checked_out = true; |
|
874 |
+ |
} |
|
875 |
+ |
Ok(PullReport { heads, changed: true, checked_out }) |
|
876 |
+ |
} |
|
877 |
+ |
|
|
878 |
+ |
/// Push the current bookmark (spec §16.3), sealing this author's unsigned |
|
879 |
+ |
/// changes and attesting the reference when a key is available. |
|
880 |
+ |
pub fn push(&self, remote_name: &str, force: bool) -> Result<Vec<RefResult>> { |
|
881 |
+ |
let remote = open_remote(&self.remote_url(remote_name)?)?; |
|
882 |
+ |
if remote.repo_id()? != self.id()? { |
|
883 |
+ |
return fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)")); |
|
884 |
+ |
} |
|
885 |
+ |
let (op_ref, op) = self.head_op()?; |
|
886 |
+ |
let bookmark = self.config.bookmark.clone(); |
|
887 |
+ |
let local_heads = self.heads_of(&op.view, &bookmark)?; |
|
888 |
+ |
let refs = remote.refs()?; |
|
889 |
+ |
let empty = self.store.put_state(&StateObject::new(vec![]))?; |
|
890 |
+ |
let (remote_state, remote_heads, last_serial) = match refs.iter().find(|r| r.name == bookmark) { |
|
891 |
+ |
Some(r) => ( |
|
892 |
+ |
r.state, |
|
893 |
+ |
r.heads.clone(), |
|
894 |
+ |
r.attestation.as_ref().and_then(|a| match a.attest.body { |
|
895 |
+ |
AttestBody::Ref { serial, .. } => Some(serial), |
|
896 |
+ |
_ => None, |
|
897 |
+ |
}), |
|
898 |
+ |
), |
|
899 |
+ |
None => (empty, vec![], None), |
|
900 |
+ |
}; |
|
901 |
+ |
let new_state = self.store.put_state(&StateObject::new(local_heads.clone()))?; |
|
902 |
+ |
if new_state == remote_state { |
|
903 |
+ |
return Ok(vec![RefResult { name: bookmark, ok: true, reason: "already up to date".into() }]); |
|
904 |
+ |
} |
|
905 |
+ |
for h in &remote_heads { |
|
906 |
+ |
if !self.store.has(h) { |
|
907 |
+ |
return fail(format!("the remote has change {} which you do not; pull first", h.short(12))); |
|
908 |
+ |
} |
|
909 |
+ |
} |
|
910 |
+ |
if !force { |
|
911 |
+ |
let closure = self.closure_in_store(&local_heads)?; |
|
912 |
+ |
if let Some(h) = remote_heads.iter().find(|h| !closure.contains(h)) { |
|
913 |
+ |
return fail(format!("not a fast-forward: remote head {} is not in your state; pull first or use --force", h.short(12))); |
|
914 |
+ |
} |
|
915 |
+ |
} |
|
916 |
+ |
let to_send = self.negotiate(&remote_heads, &local_heads)?; |
|
917 |
+ |
let mut bundle = self.bundle_for(&to_send)?; |
|
918 |
+ |
let key = self.secret_key()?.map(|(k, _)| k); |
|
919 |
+ |
if let Some(key) = key { |
|
920 |
+ |
// Seal this author's changes that carry no valid authenticator. |
|
921 |
+ |
let mut unsigned = Vec::new(); |
|
922 |
+ |
for h in &to_send { |
|
923 |
+ |
let c = self.store.get_change(h)?; |
|
924 |
+ |
if c.author == key && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) { |
|
925 |
+ |
unsigned.push(*h); |
|
926 |
+ |
} |
|
927 |
+ |
} |
|
928 |
+ |
for chunk in unsigned.chunks(crate::attest::MAX_SEAL_HASHES) { |
|
929 |
+ |
let sa = self.sign_attest(&Attest::seal(self.id()?, chunk.to_vec()))?; |
|
930 |
+ |
bundle.attests.push(sa); |
| 401 |
931 |
|
} |
| 402 |
|
- |
match identity::verify(&id, sig, Domain::Change, hash) { |
| 403 |
|
- |
Ok(()) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email }), |
| 404 |
|
- |
Err(e) => last_err = e.to_string(), |
|
932 |
+ |
bundle = self.bundle_for(&to_send)?; |
|
933 |
+ |
} |
|
934 |
+ |
bundle.objects.push((ObjectType::State, StateObject::new(local_heads.clone()).encode())); |
|
935 |
+ |
let attestation = match key { |
|
936 |
+ |
Some(_) => Some(self.sign_attest(&Attest { |
|
937 |
+ |
repo: self.id()?, |
|
938 |
+ |
hlc: Hlc::now(), |
|
939 |
+ |
body: AttestBody::Ref { name: bookmark.clone(), old_state: remote_state, new_state, serial: last_serial.unwrap_or(0) + 1 }, |
|
940 |
+ |
})?), |
|
941 |
+ |
None => None, |
|
942 |
+ |
}; |
|
943 |
+ |
let serial = attestation.as_ref().and_then(|a| match a.attest.body { |
|
944 |
+ |
AttestBody::Ref { serial, .. } => Some(serial), |
|
945 |
+ |
_ => None, |
|
946 |
+ |
}); |
|
947 |
+ |
let results = remote.push(bundle, vec![RefUpdate { name: bookmark.clone(), old_state: remote_state, new_state, force, attestation }])?; |
|
948 |
+ |
if results.iter().any(|r| r.ok) { |
|
949 |
+ |
let mut view = op.view.clone(); |
|
950 |
+ |
view.remotes.entry(remote_name.to_string()).or_default().insert(bookmark.clone(), new_state); |
|
951 |
+ |
self.record_op(&[op_ref], &format!("oxy push {remote_name}"), view)?; |
|
952 |
+ |
if let Some(s) = serial { |
|
953 |
+ |
self.record_serial(remote_name, &bookmark, s)?; |
| 405 |
954 |
|
} |
| 406 |
955 |
|
} |
| 407 |
|
- |
Ok(SignatureStatus::Invalid(last_err)) |
|
956 |
+ |
Ok(results) |
| 408 |
957 |
|
} |
| 409 |
958 |
|
|
|
959 |
+ |
/// Clone a remote into `dest`: same repository ID, `origin` remote, |
|
960 |
+ |
/// bookmark set to the remote's, working copy checked out. |
|
961 |
+ |
pub fn clone(url: &str, dest: &Path) -> Result<Repo> { |
|
962 |
+ |
let remote = open_remote(url)?; |
|
963 |
+ |
let id = remote.repo_id()?; |
|
964 |
+ |
let mut repo = Repo::init_with_id(dest, id)?; |
|
965 |
+ |
repo.config.remotes.insert("origin".into(), url.to_string()); |
|
966 |
+ |
repo.save_config()?; |
|
967 |
+ |
repo.fetch("origin")?; |
|
968 |
+ |
let (op_ref, op) = repo.head_op()?; |
|
969 |
+ |
let bookmark = repo.config.bookmark.clone(); |
|
970 |
+ |
if let Some(state) = op.view.remotes.get("origin").and_then(|b| b.get(&bookmark)).copied() { |
|
971 |
+ |
let heads = repo.store.get_state(&state)?.heads; |
|
972 |
+ |
let mut view = op.view.clone(); |
|
973 |
+ |
view.bookmarks.insert(bookmark, state); |
|
974 |
+ |
repo.record_op(&[op_ref], &format!("oxy clone {url}"), view)?; |
|
975 |
+ |
repo.checkout(&heads, false, "oxy clone (checkout)")?; |
|
976 |
+ |
} |
|
977 |
+ |
Ok(repo) |
|
978 |
+ |
} |
|
979 |
+ |
|
|
980 |
+ |
|
| 410 |
981 |
|
fn new_working_copy(&self, deps: Vec<ChangeHash>, message: &str) -> Change { |
| 411 |
982 |
|
let mut c = Change { |
| 412 |
983 |
|
change_id: ChangeId::random(), |
|
|
… |
504 unchanged lines |
| 917 |
1488 |
|
} |
| 918 |
1489 |
|
} |
| 919 |
1490 |
|
|
|
1491 |
+ |
#[derive(Debug, Clone, PartialEq, Eq)] |
|
1492 |
+ |
pub struct PullReport { |
|
1493 |
+ |
pub heads: Vec<ChangeHash>, |
|
1494 |
+ |
pub changed: bool, |
|
1495 |
+ |
pub checked_out: bool, |
|
1496 |
+ |
} |
|
1497 |
+ |
|
| 920 |
1498 |
|
#[derive(Debug, Default, Clone, PartialEq, Eq)] |
| 921 |
1499 |
|
pub struct CheckoutReport { |
| 922 |
1500 |
|
pub written: usize, |
|
|
… |
694 unchanged lines |
| 1617 |
2195 |
|
repo.save_config().unwrap(); |
| 1618 |
2196 |
|
write(root, "a.txt", b"a\nb\nc\n"); |
| 1619 |
2197 |
|
let h2 = repo.commit("signed", &[]).unwrap(); |
| 1620 |
|
- |
assert_eq!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, name: "Ada".into(), email: "[email protected]".into() }); |
|
2198 |
+ |
assert_eq!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, name: "Ada".into(), email: "[email protected]".into(), sealed: false }); |
| 1621 |
2199 |
|
|
| 1622 |
2200 |
|
// A signature by a key that is not the author or a delegate is rejected. |
| 1623 |
2201 |
|
let other = SecretKey::generate(); |
|
|
… |
74 unchanged lines |
| 1698 |
2276 |
|
} |
| 1699 |
2277 |
|
|
| 1700 |
2278 |
|
#[test] |
|
2279 |
+ |
fn clone_push_pull_with_seals_and_attestations() { |
|
2280 |
+ |
let dir = tempfile::tempdir().unwrap(); |
|
2281 |
+ |
let root = dir.path(); |
|
2282 |
+ |
let origin_path = root.join("origin"); |
|
2283 |
+ |
let origin = Repo::init(&origin_path).unwrap(); |
|
2284 |
+ |
let origin_url = origin_path.to_string_lossy().to_string(); |
|
2285 |
+ |
|
|
2286 |
+ |
// A clones, creates an identity (default core.sign = seal), commits, pushes. |
|
2287 |
+ |
let mut a = Repo::clone(&origin_url, &root.join("a")).unwrap().with_keys_dir(root.join("keys-a")); |
|
2288 |
+ |
assert_eq!(a.id().unwrap(), origin.id().unwrap()); |
|
2289 |
+ |
let (key_a, _) = a.create_identity("Ada", "[email protected]").unwrap(); |
|
2290 |
+ |
write(&root.join("a"), "f.txt", b"a\n"); |
|
2291 |
+ |
let h1 = a.commit("first", &[]).unwrap(); |
|
2292 |
+ |
let results = a.push("origin", false).unwrap(); |
|
2293 |
+ |
assert!(results[0].ok, "{}", results[0].reason); |
|
2294 |
+ |
assert_eq!(origin.bookmark_heads().unwrap(), vec![h1]); |
|
2295 |
+ |
// The change was sealed at push and the reference attested. |
|
2296 |
+ |
assert!(matches!(origin.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { key, sealed: true, .. } if key == key_a)); |
|
2297 |
+ |
assert_eq!(origin.verify_change(&h1).unwrap(), SignatureStatus::Unsigned); |
|
2298 |
+ |
let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); |
|
2299 |
+ |
assert!(matches!(att.attest.body, AttestBody::Ref { serial: 1, .. })); |
|
2300 |
+ |
let again = a.push("origin", false).unwrap(); |
|
2301 |
+ |
assert!(again[0].ok && again[0].reason.contains("up to date")); |
|
2302 |
+ |
let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); |
|
2303 |
+ |
assert!(matches!(att.attest.body, AttestBody::Ref { serial: 1, .. }), "a no-op push does not attest again"); |
|
2304 |
+ |
|
|
2305 |
+ |
// B clones and sees the file and the seal. |
|
2306 |
+ |
let b = Repo::clone(&origin_url, &root.join("b")).unwrap(); |
|
2307 |
+ |
assert_eq!(fs::read(root.join("b/f.txt")).unwrap(), b"a\n"); |
|
2308 |
+ |
assert_eq!(b.bookmark_heads().unwrap(), vec![h1]); |
|
2309 |
+ |
assert!(matches!(b.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { sealed: true, .. })); |
|
2310 |
+ |
|
|
2311 |
+ |
// B (no identity) commits and pushes unsigned; the unverified origin accepts. |
|
2312 |
+ |
write(&root.join("b"), "f.txt", b"a\nb\n"); |
|
2313 |
+ |
let h2 = b.commit("second", &[]).unwrap(); |
|
2314 |
+ |
assert!(b.push("origin", false).unwrap()[0].ok); |
|
2315 |
+ |
assert_eq!(origin.bookmark_heads().unwrap(), vec![h2]); |
|
2316 |
+ |
|
|
2317 |
+ |
// A commits an independent change; push is refused until A pulls. |
|
2318 |
+ |
write(&root.join("a"), "g.txt", b"g\n"); |
|
2319 |
+ |
let h3 = a.commit("third", &[]).unwrap(); |
|
2320 |
+ |
assert!(a.push("origin", false).is_err()); |
|
2321 |
+ |
let pr = a.pull("origin").unwrap(); |
|
2322 |
+ |
assert!(pr.changed && pr.checked_out); |
|
2323 |
+ |
let mut want = vec![h2, h3]; |
|
2324 |
+ |
want.sort(); |
|
2325 |
+ |
assert_eq!(pr.heads, want); |
|
2326 |
+ |
assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nb\n"); |
|
2327 |
+ |
assert_eq!(fs::read(root.join("a/g.txt")).unwrap(), b"g\n"); |
|
2328 |
+ |
let results = a.push("origin", false).unwrap(); |
|
2329 |
+ |
assert!(results[0].ok, "{}", results[0].reason); |
|
2330 |
+ |
let mut origin_heads = origin.bookmark_heads().unwrap(); |
|
2331 |
+ |
origin_heads.sort(); |
|
2332 |
+ |
assert_eq!(origin_heads, want); |
|
2333 |
+ |
let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap(); |
|
2334 |
+ |
assert!(matches!(att.attest.body, AttestBody::Ref { serial: 2, .. })); |
|
2335 |
+ |
|
|
2336 |
+ |
// B pulls and ends up identical. |
|
2337 |
+ |
let pr = b.pull("origin").unwrap(); |
|
2338 |
+ |
assert_eq!(pr.heads, want); |
|
2339 |
+ |
assert_eq!(fs::read(root.join("b/g.txt")).unwrap(), b"g\n"); |
|
2340 |
+ |
assert!(!b.snapshot("status").unwrap().changed); |
|
2341 |
+ |
|
|
2342 |
+ |
// Concurrent edits of the same line: the union is a reified conflict. |
|
2343 |
+ |
write(&root.join("a"), "f.txt", b"a\nA\n"); |
|
2344 |
+ |
a.commit("a-edit", &[]).unwrap(); |
|
2345 |
+ |
a.push("origin", false).unwrap(); |
|
2346 |
+ |
write(&root.join("b"), "f.txt", b"a\nB\n"); |
|
2347 |
+ |
b.commit("b-edit", &[]).unwrap(); |
|
2348 |
+ |
let pr = b.pull("origin").unwrap(); |
|
2349 |
+ |
assert!(pr.changed && pr.checked_out); |
|
2350 |
+ |
let text = fs::read_to_string(root.join("b/f.txt")).unwrap(); |
|
2351 |
+ |
assert!(text.contains("<<<<<<< oxy conflict"), "{text}"); |
|
2352 |
+ |
write(&root.join("b"), "f.txt", b"a\nAB\n"); |
|
2353 |
+ |
let hr = b.commit("resolve", &[]).unwrap(); |
|
2354 |
+ |
assert_eq!(b.store().get_change(&hr).unwrap().kind, ChangeKind::Resolution); |
|
2355 |
+ |
assert!(b.push("origin", false).unwrap()[0].ok); |
|
2356 |
+ |
a.pull("origin").unwrap(); |
|
2357 |
+ |
assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nAB\n"); |
|
2358 |
+ |
assert_eq!(a.bookmark_heads().unwrap(), vec![hr]); |
|
2359 |
+ |
} |
|
2360 |
+ |
|
|
2361 |
+ |
#[test] |
|
2362 |
+ |
fn receive_push_rejects_bad_updates() { |
|
2363 |
+ |
let dir = tempfile::tempdir().unwrap(); |
|
2364 |
+ |
let root = dir.path(); |
|
2365 |
+ |
let origin = Repo::init(&root.join("origin")).unwrap(); |
|
2366 |
+ |
let url = root.join("origin").to_string_lossy().to_string(); |
|
2367 |
+ |
let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_keys_dir(root.join("keys")); |
|
2368 |
+ |
let (key, _) = a.create_identity("Ada", "a@b", ).unwrap(); |
|
2369 |
+ |
write(&root.join("a"), "f.txt", b"x\n"); |
|
2370 |
+ |
let h1 = a.commit("one", &[]).unwrap(); |
|
2371 |
+ |
assert!(a.push("origin", false).unwrap()[0].ok); |
|
2372 |
+ |
let empty = origin.store().put_state(&StateObject::new(vec![])).unwrap(); |
|
2373 |
+ |
let s1 = origin.store().put_state(&StateObject::new(vec![h1])).unwrap(); |
|
2374 |
+ |
|
|
2375 |
+ |
// Missing head. |
|
2376 |
+ |
let ghost = origin.store().put_state(&StateObject::new(vec![ObjectRef([9; 32])])).unwrap(); |
|
2377 |
+ |
let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: ghost, force: false, attestation: None }]).unwrap(); |
|
2378 |
+ |
assert!(!r[0].ok && r[0].reason.contains("missing"), "{}", r[0].reason); |
|
2379 |
+ |
|
|
2380 |
+ |
// Stale old_state. |
|
2381 |
+ |
let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: None }]).unwrap(); |
|
2382 |
+ |
assert!(!r[0].ok && r[0].reason.contains("stale"), "{}", r[0].reason); |
|
2383 |
+ |
|
|
2384 |
+ |
// Non-fast-forward without force is refused; with force it passes. |
|
2385 |
+ |
let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: false, attestation: None }]).unwrap(); |
|
2386 |
+ |
assert!(!r[0].ok && r[0].reason.contains("fast-forward"), "{}", r[0].reason); |
|
2387 |
+ |
let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: true, attestation: None }]).unwrap(); |
|
2388 |
+ |
assert!(r[0].ok); |
|
2389 |
+ |
assert_eq!(origin.bookmark_heads().unwrap(), vec![]); |
|
2390 |
+ |
|
|
2391 |
+ |
// Attestation with a stale serial is a rollback and is refused. |
|
2392 |
+ |
let sa = a.sign_attest(&Attest { repo: a.id().unwrap(), hlc: Hlc::now(), body: AttestBody::Ref { name: "main".into(), old_state: empty, new_state: s1, serial: 1 } }).unwrap(); |
|
2393 |
+ |
let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa.clone()) }]).unwrap(); |
|
2394 |
+ |
assert!(!r[0].ok && r[0].reason.contains("serial"), "{}", r[0].reason); |
|
2395 |
+ |
// An attestation for a different update is refused too. |
|
2396 |
+ |
let sa2 = a.sign_attest(&Attest { repo: a.id().unwrap(), hlc: Hlc::now(), body: AttestBody::Ref { name: "main".into(), old_state: s1, new_state: empty, serial: 5 } }).unwrap(); |
|
2397 |
+ |
let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa2) }]).unwrap(); |
|
2398 |
+ |
assert!(!r[0].ok && r[0].reason.contains("describe"), "{}", r[0].reason); |
|
2399 |
+ |
let _ = key; |
|
2400 |
+ |
} |
|
2401 |
+ |
|
|
2402 |
+ |
#[test] |
| 1701 |
2403 |
|
fn format1_repositories_are_migrated_on_open() { |
| 1702 |
2404 |
|
use crate::oplog::{Format1Op, Format1View}; |
| 1703 |
2405 |
|
let dir = tempfile::tempdir().unwrap(); |
|
|
… |
112 unchanged lines |