1use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::process::{Command, Stdio};
22
23use anyhow::{bail, Context, Result};
24use serde_json::Value;
25
26#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
28pub struct Scope {
29 pub name: String,
31 pub recipients: Vec<String>,
33 #[serde(default)]
37 pub projects: BTreeMap<String, String>,
38}
39
40impl Scope {
41 #[must_use]
43 pub fn for_project(&self, project: &str) -> String {
44 self.projects
45 .get(project)
46 .cloned()
47 .unwrap_or_else(|| self.name.clone())
48 }
49}
50
51#[derive(Debug, Clone, Default, serde::Deserialize)]
53struct Local {
54 #[serde(default)]
55 default_scope: Option<String>,
56 #[serde(default)]
60 repos: Vec<String>,
61}
62
63fn local() -> Local {
64 std::fs::read_to_string(config_dir().join("sync.toml"))
65 .ok()
66 .and_then(|t| toml::from_str::<Local>(&t).ok())
67 .unwrap_or_default()
68}
69
70fn expand(path: &str) -> PathBuf {
71 match path.strip_prefix("~/") {
72 Some(rest) => std::env::var_os("HOME")
73 .map_or_else(|| PathBuf::from(path), |h| PathBuf::from(h).join(rest)),
74 None => PathBuf::from(path),
75 }
76}
77
78#[must_use]
80pub fn sender_entity(host: &str) -> String {
81 format!("sync:{host}")
82}
83
84#[must_use]
86pub fn host() -> String {
87 std::fs::read_to_string("/proc/sys/kernel/hostname")
88 .map(|h| h.trim().to_string())
89 .ok()
90 .filter(|h| !h.is_empty())
91 .unwrap_or_else(|| "host".into())
92}
93
94fn config_dir() -> PathBuf {
95 std::env::var_os("XDG_CONFIG_HOME")
96 .map(PathBuf::from)
97 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
98 .unwrap_or_else(|| PathBuf::from(".config"))
99 .join("ljos")
100}
101
102fn state_dir() -> PathBuf {
103 std::env::var_os("XDG_STATE_HOME")
104 .map(PathBuf::from)
105 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
106 .unwrap_or_else(|| PathBuf::from(".local/state"))
107 .join("ljos")
108 .join("sync")
109}
110
111#[must_use]
113pub fn identity_path() -> PathBuf {
114 config_dir().join("age.key")
115}
116
117pub fn public_key() -> Result<String> {
124 let key = identity_path();
125 if !key.exists() {
126 if let Some(dir) = key.parent() {
127 std::fs::create_dir_all(dir)?;
128 }
129 let made = Command::new("age-keygen")
130 .arg("-o")
131 .arg(&key)
132 .stdin(Stdio::null())
133 .output()
134 .context("age-keygen not on PATH; install age")?;
135 if !made.status.success() {
136 bail!(
137 "age-keygen: {}",
138 String::from_utf8_lossy(&made.stderr).trim()
139 );
140 }
141 }
142 let out = Command::new("age-keygen")
143 .arg("-y")
144 .arg(&key)
145 .output()
146 .context("age-keygen not on PATH; install age")?;
147 if !out.status.success() {
148 bail!(
149 "age-keygen -y: {}",
150 String::from_utf8_lossy(&out.stderr).trim()
151 );
152 }
153 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
154}
155
156pub fn scope_of_repo(root: &Path) -> Result<Option<Scope>> {
162 let path = root.join(".ljos").join("sync.toml");
163 match std::fs::read_to_string(&path) {
164 Ok(text) => Ok(Some(
165 toml::from_str(&text).with_context(|| path.display().to_string())?,
166 )),
167 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
168 Err(e) => Err(e).with_context(|| path.display().to_string()),
169 }
170}
171
172fn default_scope(repo_scope: &str) -> String {
173 local()
174 .default_scope
175 .unwrap_or_else(|| repo_scope.to_string())
176}
177
178#[must_use]
180pub fn atom_scope(atom: &Value, default: &str) -> String {
181 atom["entities"]
182 .as_array()
183 .into_iter()
184 .flatten()
185 .filter_map(Value::as_str)
186 .find_map(|e| e.strip_prefix("scope:"))
187 .map_or_else(|| default.to_string(), str::to_string)
188}
189
190#[must_use]
193pub fn travelling(atom: &Value) -> Value {
194 let mut a = atom.clone();
195 if let Some(map) = a.as_object_mut() {
196 map.remove("embedding");
197 }
198 a
199}
200
201#[must_use]
203pub fn is_own(atom: &Value) -> bool {
204 !atom["entities"]
205 .as_array()
206 .into_iter()
207 .flatten()
208 .filter_map(Value::as_str)
209 .any(|e| e.starts_with("sync:"))
210}
211
212#[must_use]
215pub fn log_plaintext(atoms: &[Value], scope: &str, default: &str) -> String {
216 let mut mine: Vec<Value> = atoms
217 .iter()
218 .filter(|a| is_own(a) && atom_scope(a, default) == scope)
219 .map(travelling)
220 .collect();
221 mine.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
222 mine.iter().map(|a| format!("{a}\n")).collect()
223}
224
225fn log_dir(root: &Path) -> PathBuf {
226 root.join(".ljos").join("atoms")
227}
228
229fn seal(plain: &str, recipients: &[String], out: &Path) -> Result<()> {
231 if recipients.is_empty() {
232 bail!("sync: the scope names no recipients; add this machine's `ljos sync --key` to .ljos/sync.toml");
233 }
234 let mut cmd = Command::new("age");
235 for r in recipients {
236 cmd.arg("-r").arg(r);
237 }
238 let tmp = out.with_extension("age.tmp");
239 let mut child = cmd
240 .arg("-o")
241 .arg(&tmp)
242 .stdin(Stdio::piped())
243 .stderr(Stdio::piped())
244 .spawn()
245 .context("age not on PATH; install age")?;
246 use std::io::Write;
247 child
248 .stdin
249 .take()
250 .context("age: no stdin")?
251 .write_all(plain.as_bytes())?;
252 let done = child.wait_with_output()?;
253 if !done.status.success() {
254 let _ = std::fs::remove_file(&tmp);
255 bail!("age: {}", String::from_utf8_lossy(&done.stderr).trim());
256 }
257 std::fs::rename(&tmp, out)?;
258 Ok(())
259}
260
261fn open(path: &Path, identity: &Path) -> Result<Option<String>> {
264 let out = Command::new("age")
265 .arg("-d")
266 .arg("-i")
267 .arg(identity)
268 .arg(path)
269 .stdin(Stdio::null())
270 .output()
271 .context("age not on PATH; install age")?;
272 if out.status.success() {
273 Ok(Some(String::from_utf8_lossy(&out.stdout).into_owned()))
274 } else if String::from_utf8_lossy(&out.stderr).contains("no identity matched") {
275 Ok(None)
276 } else {
277 bail!(
278 "age -d {}: {}",
279 path.display(),
280 String::from_utf8_lossy(&out.stderr).trim()
281 )
282 }
283}
284
285pub fn export(root: &Path, scope: &Scope, atoms: &[Value]) -> Result<String> {
292 let host = host();
293 let plain = log_plaintext(atoms, &scope.name, &default_scope(&scope.name));
294 let count = plain.lines().count();
295 let dir = log_dir(root);
296 std::fs::create_dir_all(&dir)?;
297 let stamp = dir.join(format!("{host}.digest"));
298 let digest = format!(
299 "{} {}\n",
300 crate::work_id(&plain),
301 crate::work_id(&scope.recipients.join(","))
302 );
303 if std::fs::read_to_string(&stamp).is_ok_and(|d| d == digest) {
304 return Ok(format!(
305 "sync: {count} {} atoms unchanged since the last export\n",
306 scope.name
307 ));
308 }
309 seal(
310 &plain,
311 &scope.recipients,
312 &dir.join(format!("{host}.jsonl.age")),
313 )?;
314 std::fs::write(&stamp, digest)?;
315 Ok(format!(
316 "sync: exported {count} {} atoms to .ljos/atoms/{host}.jsonl.age, sealed to {} recipient{}\n",
317 scope.name,
318 scope.recipients.len(),
319 if scope.recipients.len() == 1 { "" } else { "s" }
320 ))
321}
322
323#[derive(Debug, Default, PartialEq)]
326pub struct Incoming {
327 pub post: Vec<Value>,
328 pub retired: Vec<String>,
329}
330
331#[must_use]
335pub fn incoming(
336 plain: &str,
337 host: &str,
338 before: &BTreeSet<String>,
339 held: &BTreeSet<String>,
340) -> Incoming {
341 let mut post = Vec::new();
342 let mut now = BTreeSet::new();
343 for line in plain.lines().filter(|l| !l.trim().is_empty()) {
344 let Ok(mut atom) = serde_json::from_str::<Value>(line) else {
345 continue;
346 };
347 let text = atom["text"].as_str().unwrap_or("").to_string();
348 if text.is_empty() {
349 continue;
350 }
351 let id = crate::work_id(&text);
352 now.insert(id.clone());
353 if held.contains(&id) {
354 continue;
355 }
356 if let Some(map) = atom.as_object_mut() {
357 let mut entities: Vec<Value> = map
358 .get("entities")
359 .and_then(Value::as_array)
360 .cloned()
361 .unwrap_or_default();
362 let tag = sender_entity(host);
363 if !entities.iter().any(|e| e.as_str() == Some(tag.as_str())) {
364 entities.push(Value::String(tag));
365 }
366 map.insert("entities".into(), Value::Array(entities));
367 map.insert(
368 "origin".into(),
369 Value::String(crate::admit::ORIGIN_PEER.to_string()),
370 );
371 map.remove("id");
372 }
373 post.push(atom);
374 }
375 let retired = before.difference(&now).cloned().collect();
376 Incoming { post, retired }
377}
378
379fn taken_path(scope: &str, host: &str) -> PathBuf {
380 state_dir().join(scope).join(format!("{host}.taken"))
381}
382
383fn read_taken(scope: &str, host: &str) -> BTreeSet<String> {
384 std::fs::read_to_string(taken_path(scope, host))
385 .map(|t| t.lines().map(str::to_string).collect())
386 .unwrap_or_default()
387}
388
389fn write_taken(scope: &str, host: &str, plain: &str) -> Result<()> {
390 let path = taken_path(scope, host);
391 if let Some(dir) = path.parent() {
392 std::fs::create_dir_all(dir)?;
393 }
394 let ids: BTreeSet<String> = plain
395 .lines()
396 .filter_map(|l| serde_json::from_str::<Value>(l).ok())
397 .filter_map(|a| a["text"].as_str().map(crate::work_id))
398 .collect();
399 std::fs::write(path, ids.into_iter().map(|i| i + "\n").collect::<String>())?;
400 Ok(())
401}
402
403pub fn import(root: &Path, scope: &Scope) -> Result<String> {
410 let me = host();
411 let identity = identity_path();
412 if !identity.exists() {
413 return Ok("sync: no age identity on this machine; `ljos sync --key` makes one\n".into());
414 }
415 let dir = log_dir(root);
416 let Ok(entries) = std::fs::read_dir(&dir) else {
417 return Ok(format!("sync: no {} logs yet\n", scope.name));
418 };
419 let client = crate::pack()?;
420 let mut out = String::new();
421 let mut paths: Vec<PathBuf> = entries
422 .flatten()
423 .map(|e| e.path())
424 .filter(|p| p.to_string_lossy().ends_with(".jsonl.age"))
425 .collect();
426 paths.sort();
427 for path in paths {
428 let host = path
429 .file_name()
430 .and_then(|n| n.to_str())
431 .and_then(|n| n.strip_suffix(".jsonl.age"))
432 .unwrap_or("")
433 .to_string();
434 if host.is_empty() || host == me {
435 continue;
436 }
437 let Some(plain) = open(&path, &identity)? else {
438 out.push_str(&format!(
439 "sync: {host}'s {} log is not sealed to this machine\n",
440 scope.name
441 ));
442 continue;
443 };
444 let workspace = client.workspace();
445 let tag = sender_entity(&host);
448 let held: BTreeSet<String> = crate::atoms_lean(&client, &workspace)?
449 .iter()
450 .filter(|a| {
451 a["entities"]
452 .as_array()
453 .into_iter()
454 .flatten()
455 .any(|e| e.as_str() == Some(tag.as_str()))
456 })
457 .filter_map(|a| a["text"].as_str().map(crate::work_id))
458 .collect();
459 let got = incoming(&plain, &host, &read_taken(&scope.name, &host), &held);
460 let (mut kept, mut refused) = (0usize, 0usize);
461 for mut atom in got.post {
462 if let Some(map) = atom.as_object_mut() {
463 map.insert("workspace".into(), Value::String(workspace.clone()));
464 }
465 match crate::admit::post_kept(&client, &atom) {
466 Ok(_) => kept += 1,
467 Err(_) => refused += 1,
468 }
469 }
470 let mut retired = 0usize;
471 if !got.retired.is_empty() {
472 for atom in crate::atoms_lean(&client, &workspace)? {
473 let text = atom["text"].as_str().unwrap_or("");
474 let from_host = atom["entities"]
475 .as_array()
476 .into_iter()
477 .flatten()
478 .any(|e| e.as_str() == Some(tag.as_str()));
479 if from_host && got.retired.contains(&crate::work_id(text)) {
480 if let Some(id) = atom["id"].as_str() {
481 if crate::packset_forget(id, None).is_ok() {
482 retired += 1;
483 }
484 }
485 }
486 }
487 }
488 write_taken(&scope.name, &host, &plain)?;
489 out.push_str(&format!(
490 "sync: {host}: {kept} {} atoms taken, {refused} refused, {retired} retired\n",
491 scope.name
492 ));
493 }
494 Ok(out)
495}
496
497#[must_use]
499pub fn scope_for_issue(issue: &str) -> Option<String> {
500 let hit = vissue_core::Layout::resolve(None, None)
501 .and_then(vissue_core::Router::load)
502 .and_then(|router| router.find_by_id(issue))
503 .ok()?;
504 let root = hit
505 .path
506 .ancestors()
507 .find(|d| d.join(".git").exists())?
508 .to_path_buf();
509 scope_of_repo(&root)
510 .ok()
511 .flatten()
512 .map(|s| s.for_project(&hit.project))
513}
514
515fn tracker_root() -> Result<PathBuf> {
517 let layout = vissue_core::Layout::resolve(None, None).map_err(anyhow::Error::from)?;
518 Ok(layout.root().to_path_buf())
519}
520
521fn git(root: &Path, args: &[&str]) -> std::io::Result<std::process::Output> {
522 Command::new("git")
523 .arg("-C")
524 .arg(root)
525 .args(args)
526 .stdin(Stdio::null())
527 .output()
528}
529
530fn commit_log(root: &Path, scope: &str) -> String {
533 let host = host();
534 let files = [
535 format!(".ljos/atoms/{host}.jsonl.age"),
536 format!(".ljos/atoms/{host}.digest"),
537 ];
538 let common = git(root, &["rev-parse", "--git-common-dir"])
540 .ok()
541 .filter(|o| o.status.success())
542 .map(|o| root.join(String::from_utf8_lossy(&o.stdout).trim()))
543 .unwrap_or_else(|| root.join(".git"));
544 let held = crate::CommitLock::acquire(&common.join("ljos-commit.lock"));
545 let mut add = vec!["add", "--"];
546 add.extend(files.iter().map(String::as_str));
547 if git(root, &add).map_or(true, |o| !o.status.success()) {
548 return "sync: could not stage the log\n".into();
549 }
550 let staged = git(
551 root,
552 &["diff", "--cached", "--quiet", "--", &files[0], &files[1]],
553 );
554 if staged.is_ok_and(|o| o.status.success()) {
555 return String::new();
556 }
557 let message = format!("chore(sync): {host} {scope} atoms");
558 let mut commit = vec!["commit", "-q", "--only", "-m", message.as_str(), "--"];
559 commit.extend(files.iter().map(String::as_str));
560 match git(root, &commit) {
561 Ok(o) if o.status.success() => {}
562 Ok(o) => {
563 return format!(
564 "sync: commit refused: {}\n",
565 String::from_utf8_lossy(&o.stderr)
566 .lines()
567 .next()
568 .unwrap_or("")
569 )
570 }
571 Err(e) => return format!("sync: git: {e}\n"),
572 }
573 drop(held);
574 let mut pushed = vec![];
575 let pushed_first = git(root, &["push", "-q"]).is_ok_and(|o| o.status.success());
578 let pushed_after_merge = !pushed_first
579 && git(root, &["pull", "-q", "--no-rebase", "--no-edit"]).is_ok_and(|o| o.status.success())
580 && git(root, &["push", "-q"]).is_ok_and(|o| o.status.success());
581 if pushed_first || pushed_after_merge {
582 pushed.push("upstream".to_string());
583 }
584 if let Some(up) = crate::tracker_upstream(root) {
585 for (remote, branch) in crate::tracker_mirrors(root, &up).unwrap_or_default() {
586 let refspec = format!("HEAD:refs/heads/{branch}");
587 if git(root, &["push", "-q", &remote, &refspec]).is_ok_and(|o| o.status.success()) {
588 pushed.push(remote);
589 }
590 }
591 }
592 format!(
593 "sync: committed {message}; pushed to {}\n",
594 if pushed.is_empty() {
595 "nothing".to_string()
596 } else {
597 pushed.join(", ")
598 }
599 )
600}
601
602fn catch_up(root: &Path) -> String {
607 if git(root, &["fetch", "-q", "--all"]).map_or(true, |o| !o.status.success()) {
608 return "sync: fetch failed; reading the logs as they are\n".into();
609 }
610 let Some(up) = crate::tracker_upstream(root) else {
611 return String::new();
612 };
613 let mut refs = vec![up.clone()];
614 refs.extend(
615 crate::tracker_mirrors(root, &up)
616 .unwrap_or_default()
617 .into_iter()
618 .map(|(r, b)| format!("{r}/{b}")),
619 );
620 let mut stuck = Vec::new();
621 for r in refs {
622 let forward = git(root, &["merge", "-q", "--ff-only", &r]);
623 if forward.is_ok_and(|o| o.status.success()) {
624 continue;
625 }
626 let merged = git(root, &["merge", "-q", "--no-edit", &r]);
629 if !merged.is_ok_and(|o| o.status.success()) {
630 let _ = git(root, &["merge", "--abort"]);
631 stuck.push(r);
632 }
633 }
634 if stuck.is_empty() {
635 String::new()
636 } else {
637 format!(
638 "sync: could not merge {}; `ljos doctor` names the split\n",
639 stuck.join(", ")
640 )
641 }
642}
643
644pub fn sync_repo(pull_import: bool, export_log: bool) -> Result<String> {
653 sync_repos(pull_import, export_log, true)
654}
655
656pub fn sync_repo_in_passing(pull_import: bool, export_log: bool) -> Result<String> {
665 sync_repos(pull_import, export_log, false)
666}
667
668fn sync_repos(pull_import: bool, export_log: bool, asked: bool) -> Result<String> {
669 let listed = local().repos;
670 let roots: Vec<PathBuf> = if listed.is_empty() {
671 vec![tracker_root()?]
672 } else {
673 listed.iter().map(|r| expand(r)).collect()
674 };
675 let mut out = String::new();
676 for root in roots {
677 out.push_str(&sync_one(&root, pull_import, export_log, asked)?);
678 }
679 Ok(out)
680}
681
682fn sync_one(root: &Path, pull_import: bool, export_log: bool, asked: bool) -> Result<String> {
683 let root = root.to_path_buf();
684 let Some(scope) = scope_of_repo(&root)? else {
685 if !asked {
686 return Ok(String::new());
687 }
688 return Ok(format!(
689 "sync: {} names no scope; write .ljos/sync.toml with name and recipients (`ljos sync --key` prints this machine's)\n",
690 root.display()
691 ));
692 };
693 let mut out = String::new();
694 if pull_import {
695 out.push_str(&catch_up(&root));
696 out.push_str(&import(&root, &scope)?);
697 }
698 if export_log {
699 let client = crate::pack()?;
700 let atoms = crate::atoms_lean(&client, &client.workspace())?;
701 out.push_str(&export(&root, &scope, &atoms)?);
702 out.push_str(&commit_log(&root, &scope.name));
703 }
704 Ok(out)
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710
711 fn atom(id: &str, text: &str, entities: &[&str]) -> Value {
712 serde_json::json!({"id": id, "text": text, "kind": "lesson",
713 "entities": entities, "embedding": [0.1, 0.2]})
714 }
715
716 #[test]
717 fn a_repository_with_no_scope_is_named_only_when_sync_is_asked_for() {
718 let root = std::env::temp_dir().join(format!("ljos-sync-noscope-{}", std::process::id()));
719 std::fs::create_dir_all(&root).unwrap();
720 let asked = sync_one(&root, true, false, true).unwrap();
721 assert!(asked.contains("names no scope"), "{asked}");
722 assert_eq!(sync_one(&root, true, false, false).unwrap(), "");
723 assert_eq!(sync_one(&root, false, true, false).unwrap(), "");
724 let _ = std::fs::remove_dir(&root);
725 }
726
727 #[test]
728 fn a_log_carries_own_atoms_of_its_scope_without_vectors() {
729 let atoms = vec![
730 atom("b", "second", &["seat:x"]),
731 atom("a", "first", &[]),
732 atom("c", "personal", &["scope:personal"]),
733 atom("d", "imported", &["sync:otherhost"]),
734 ];
735 let plain = log_plaintext(&atoms, "surf", "surf");
736 let lines: Vec<Value> = plain
737 .lines()
738 .map(|l| serde_json::from_str(l).unwrap())
739 .collect();
740 assert_eq!(lines.len(), 2, "{plain}");
741 assert_eq!(lines[0]["id"], "a");
742 assert_eq!(lines[1]["id"], "b");
743 assert!(lines.iter().all(|a| a.get("embedding").is_none()));
744 assert_eq!(log_plaintext(&atoms, "personal", "surf").lines().count(), 1);
745 }
746
747 #[test]
748 fn a_project_named_in_the_scope_file_takes_its_own_scope() {
749 let scope: Scope = toml::from_str(
750 "name = \"personal\"\nrecipients = [\"age1x\"]\n[projects]\ntools = \"shared\"\n",
751 )
752 .unwrap();
753 assert_eq!(scope.for_project("tools"), "shared");
754 assert_eq!(scope.for_project("garden"), "personal");
755 let bare: Scope = toml::from_str("name = \"shared\"\nrecipients = []\n").unwrap();
756 assert!(bare.projects.is_empty());
757 }
758
759 #[test]
760 fn an_import_tags_the_sender_and_names_what_it_retired() {
761 let plain = format!(
762 "{}\n{}\n",
763 atom("a", "kept", &["seat:x"]),
764 atom("b", "new", &[])
765 );
766 let before: BTreeSet<String> = [crate::work_id("kept"), crate::work_id("dropped")].into();
767 let got = incoming(&plain, "rglat", &before, &BTreeSet::new());
768 assert_eq!(got.post.len(), 2);
769 let again = incoming(&plain, "rglat", &before, &[crate::work_id("kept")].into());
770 assert_eq!(
771 again.post.len(),
772 1,
773 "a text already held is not posted again"
774 );
775 assert_eq!(again.post[0]["text"], "new");
776 assert!(got.post.iter().all(|a| a.get("id").is_none()));
777 assert!(got.post.iter().all(|a| a["entities"]
778 .as_array()
779 .unwrap()
780 .iter()
781 .any(|e| e == "sync:rglat")));
782 assert!(got.post.iter().all(|a| a["origin"] == "peer"));
783 assert_eq!(got.retired, vec![crate::work_id("dropped")]);
784 let claimed = "{\"id\":\"c\",\"text\":\"claimed\",\"kind\":\"lesson\",\"origin\":\"user-declared\",\"entities\":[]}\n";
785 let overwrote = incoming(claimed, "rglat", &BTreeSet::new(), &BTreeSet::new());
786 assert_eq!(overwrote.post[0]["origin"], "peer");
787 }
788
789 #[test]
790 fn a_sealed_log_opens_for_a_recipient_and_not_for_another() {
791 let dir = tempfile::tempdir().unwrap();
792 let key = |name: &str| {
793 let path = dir.path().join(name);
794 let made = Command::new("age-keygen")
795 .arg("-o")
796 .arg(&path)
797 .output()
798 .unwrap();
799 assert!(made.status.success(), "age-keygen must be installed");
800 let public = Command::new("age-keygen")
801 .arg("-y")
802 .arg(&path)
803 .output()
804 .unwrap();
805 (
806 path,
807 String::from_utf8_lossy(&public.stdout).trim().to_string(),
808 )
809 };
810 let (mine, my_public) = key("mine.key");
811 let (other, _) = key("other.key");
812 let out = dir.path().join("host.jsonl.age");
813 seal("{\"text\":\"x\"}\n", &[my_public], &out).unwrap();
814 assert_eq!(
815 open(&out, &mine).unwrap().as_deref(),
816 Some("{\"text\":\"x\"}\n")
817 );
818 assert_eq!(open(&out, &other).unwrap(), None);
819 assert!(seal("x", &[], &out).is_err(), "no recipients is refused");
820 }
821
822 #[test]
823 fn an_atoms_scope_is_its_entity_or_the_default() {
824 assert_eq!(
825 atom_scope(&atom("a", "t", &["scope:personal"]), "surf"),
826 "personal"
827 );
828 assert_eq!(atom_scope(&atom("a", "t", &["seat:x"]), "surf"), "surf");
829 }
830}