1use std::io::{Cursor, Read, Write};
57use std::path::{Path, PathBuf};
58
59use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
60use ring::signature::{UnparsedPublicKey, ED25519};
61use serde::{Deserialize, Serialize};
62use serde_json::{json, Value};
63use sha2::{Digest, Sha256};
64
65use crate::fsx::write_atomic;
66use crate::store::Store;
67
68pub const HUB_URL_ENV: &str = "DBMD_HUB_URL";
70
71pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
74
75pub const BRAIN_KEY_FILE_ENV: &str = "DBMD_BRAIN_KEY_FILE";
81
82pub const AGENT_KEY_FILE_ENV: &str = "DBMD_AGENT_KEY_FILE";
90
91pub const CONFIG_REL_PATH: &str = ".dbmd/config";
94
95const MAX_RESPONSE_BYTES: u64 = 256 * 1024 * 1024;
99
100const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
103
104const MAX_PUSH_FILES: usize = 100_000;
106const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
107const MAX_PACK_BYTES: u64 = 256 * 1024 * 1024;
108
109pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
114
115const CONNECT_TIMEOUT_SECS: u64 = 10;
118const READ_TIMEOUT_SECS: u64 = 120;
119const CONNECT_ATTEMPTS: usize = 3;
120const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
121
122#[derive(Debug, thiserror::Error)]
126pub enum LinkError {
127 #[error(
129 "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
130 )]
131 NoHub,
132
133 #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
135 NoCredential,
136
137 #[error(
140 "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
141 )]
142 BadKey,
143
144 #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
148 BadAgentKey {
149 message: String,
151 },
152
153 #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
155 UnsafeHub {
156 hub: String,
158 },
159
160 #[error("hub unreachable at {hub}: {message}")]
162 Transport {
163 hub: String,
165 message: String,
167 },
168
169 #[error("{what} failed (HTTP {status}): {message}")]
171 Http {
172 what: &'static str,
174 status: u16,
176 message: String,
178 code: Option<String>,
180 },
181
182 #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
185 NotJson {
186 what: &'static str,
188 status: u16,
190 },
191
192 #[error("hub response exceeded {} MB — refusing to buffer it", MAX_RESPONSE_BYTES / (1024 * 1024))]
194 ResponseTooLarge,
195
196 #[error("invalid address `{given}`: {reason}")]
198 BadAddress {
199 given: String,
201 reason: String,
203 },
204
205 #[error(
207 "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
208 )]
209 BadGrantId {
210 given: String,
212 },
213
214 #[error("refusing unsafe path from the hub: `{path}`")]
218 UnsafePath {
219 path: String,
221 },
222
223 #[error(
225 "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB compressed, and {MAX_PUSH_FILES} files",
226 MAX_STORE_BYTES / (1024 * 1024),
227 MAX_PACK_BYTES / (1024 * 1024)
228 )]
229 PushTooLarge {
230 detail: String,
232 },
233
234 #[error(
236 "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
237 MAX_PROPOSE_BYTES / 1024
238 )]
239 ProposeTooLarge {
240 bytes: u64,
242 },
243
244 #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
246 NotUtf8 {
247 path: String,
249 },
250
251 #[error("invalid store pack: {message}")]
253 InvalidPack {
254 message: String,
256 },
257
258 #[error("invalid signed feed: {message}")]
260 InvalidFeed {
261 message: String,
263 },
264
265 #[error(transparent)]
267 Io(#[from] std::io::Error),
268
269 #[error(transparent)]
271 Store(#[from] crate::StoreError),
272}
273
274pub type LinkResult<T> = std::result::Result<T, LinkError>;
276
277#[derive(Debug, Clone, PartialEq, Eq)]
283pub enum AddressTarget {
284 Id(String),
286 Path(String),
290}
291
292const BAD_BRAIN_REASON: &str =
295 "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
296
297const BAD_TARGET_REASON: &str =
300 "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
301
302#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct Address {
308 pub brain: String,
310 pub target: Option<AddressTarget>,
312}
313
314impl Address {
315 pub fn parse(raw: &str) -> LinkResult<Address> {
319 let bad = |reason: &str| LinkError::BadAddress {
320 given: raw.to_string(),
321 reason: reason.to_string(),
322 };
323
324 let trimmed = raw.trim();
325 let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
326 if body.is_empty() {
327 return Err(bad("empty address"));
328 }
329
330 let (brain, rest) = match body.split_once('/') {
331 Some((b, r)) => (b, Some(r)),
332 None => (body, None),
333 };
334
335 if brain.is_empty() {
336 return Err(bad("missing brain reference before `/`"));
337 }
338 if !is_safe_ref(brain) {
339 return Err(bad(BAD_BRAIN_REASON));
340 }
341
342 let target = match rest {
343 None => None,
344 Some("") => return Err(bad("trailing `/` with no record id or path")),
345 Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
346 Some(r) => {
347 if !safe_store_rel_path(r) || !r.ends_with(".md") {
348 return Err(bad(BAD_TARGET_REASON));
349 }
350 Some(AddressTarget::Path(r.to_string()))
351 }
352 };
353
354 Ok(Address {
355 brain: brain.to_string(),
356 target,
357 })
358 }
359}
360
361fn is_safe_ref(s: &str) -> bool {
364 !s.is_empty()
365 && s.len() <= 64
366 && s.bytes()
367 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
368}
369
370pub fn is_valid_handle(s: &str) -> bool {
373 is_safe_ref(s)
374}
375
376pub fn safe_store_rel_path(p: &str) -> bool {
382 if p.is_empty() || p.len() > 512 || p.starts_with('/') {
383 return false;
384 }
385 if !p
386 .bytes()
387 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
388 {
389 return false;
390 }
391 p.split('/')
392 .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
393}
394
395fn require_safe_ref(brain: &str) -> LinkResult<()> {
403 if is_safe_ref(brain) {
404 Ok(())
405 } else {
406 Err(LinkError::BadAddress {
407 given: brain.to_string(),
408 reason: BAD_BRAIN_REASON.to_string(),
409 })
410 }
411}
412
413fn require_valid_handle(handle: &str) -> LinkResult<()> {
415 if is_valid_handle(handle) {
416 Ok(())
417 } else {
418 Err(LinkError::BadAddress {
419 given: handle.to_string(),
420 reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
421 })
422 }
423}
424
425fn require_safe_grant_id(id: &str) -> LinkResult<()> {
429 if is_safe_ref(id) {
430 Ok(())
431 } else {
432 Err(LinkError::BadGrantId {
433 given: id.to_string(),
434 })
435 }
436}
437
438#[derive(Debug, Clone)]
444pub struct HubConfig {
445 pub hub: String,
447 pub key: Option<String>,
449 pub agent_key: Option<AgentSigningKey>,
452 pub brain_key: Option<AgentSigningKey>,
455}
456
457#[derive(Clone)]
460pub struct AgentSigningKey {
461 pkcs8: Vec<u8>,
462 pub multikey: String,
464 pub public_key_spki: String,
466}
467
468impl std::fmt::Debug for AgentSigningKey {
469 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
470 f.debug_struct("AgentSigningKey")
471 .field("multikey", &self.multikey)
472 .field("pkcs8", &"<redacted>")
473 .finish()
474 }
475}
476
477impl HubConfig {
478 pub fn require_key(&self) -> LinkResult<&str> {
481 self.key.as_deref().ok_or(LinkError::NoCredential)
482 }
483}
484
485pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
490 let hub = flag_hub
491 .map(str::to_string)
492 .or_else(|| env_nonempty(HUB_URL_ENV))
493 .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
494 .ok_or(LinkError::NoHub)?;
495 let hub = hub.trim().trim_end_matches('/').to_string();
496 assert_safe_hub(&hub)?;
497
498 let key = match env_nonempty(HUB_KEY_ENV) {
499 Some(raw) => Some(clean_key(&raw)?),
500 None => None,
501 };
502
503 let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
504 Some(path) => Some(load_agent_key(Path::new(&path))?),
505 None => None,
506 };
507
508 let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
509 Some(path) => Some(load_agent_key(Path::new(&path))?),
510 None => None,
511 };
512
513 Ok(HubConfig {
514 hub,
515 key,
516 agent_key,
517 brain_key,
518 })
519}
520
521const ED25519_SPKI_PREFIX: [u8; 12] = [
528 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
529];
530
531fn bad_agent_key(message: &str) -> LinkError {
532 LinkError::BadAgentKey {
533 message: message.to_string(),
534 }
535}
536
537fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
538 ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
542 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
543 .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
544}
545
546fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
548 use ring::signature::KeyPair as _;
549 let mut spki = Vec::with_capacity(44);
550 spki.extend_from_slice(&ED25519_SPKI_PREFIX);
551 spki.extend_from_slice(pair.public_key().as_ref());
552 (
553 URL_SAFE_NO_PAD.encode(&spki),
554 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
555 )
556}
557
558pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
562 load_agent_key(path)
563}
564
565fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
567 let text = std::fs::read_to_string(path)
568 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
569 let pkcs8 = URL_SAFE_NO_PAD
570 .decode(text.trim())
571 .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
572 let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
573 Ok(AgentSigningKey {
574 pkcs8,
575 multikey,
576 public_key_spki,
577 })
578}
579
580#[derive(Debug, Serialize)]
583pub struct GeneratedAgentKey {
584 pub multikey: String,
586 #[serde(rename = "publicKeySpki")]
588 pub public_key_spki: String,
589 #[serde(rename = "keyFile")]
591 pub key_file: String,
592}
593
594pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
599 if out.exists() {
600 return Err(bad_agent_key(
601 "the output file already exists — refusing to overwrite a key",
602 ));
603 }
604 let rng = ring::rand::SystemRandom::new();
605 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
606 .map_err(|_| bad_agent_key("key generation failed"))?;
607 let pair = agent_keypair(pkcs8.as_ref())?;
608 let (spki_b64u, multikey) = public_identity_for(&pair);
609
610 if let Some(parent) = out.parent() {
611 if !parent.as_os_str().is_empty() {
612 std::fs::create_dir_all(parent)?;
613 }
614 }
615 std::fs::write(out, format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())))?;
616 #[cfg(unix)]
617 {
618 use std::os::unix::fs::PermissionsExt as _;
619 std::fs::set_permissions(out, std::fs::Permissions::from_mode(0o600))?;
620 }
621
622 Ok(GeneratedAgentKey {
623 multikey,
624 public_key_spki: spki_b64u,
625 key_file: out.display().to_string(),
626 })
627}
628
629fn linkmd_sig_header(
632 key: &AgentSigningKey,
633 method: &str,
634 path: &str,
635 body: Option<&str>,
636) -> LinkResult<String> {
637 let ts = std::time::SystemTime::now()
638 .duration_since(std::time::UNIX_EPOCH)
639 .map_err(|_| bad_agent_key("system clock is before the epoch"))?
640 .as_secs();
641 let body_hash = match body {
642 Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
643 None => "-".to_string(),
644 };
645 let canonical = format!(
646 "v1\n{}\n{}\n{}\n{}",
647 method.to_uppercase(),
648 path,
649 ts,
650 body_hash
651 );
652 let pair = agent_keypair(&key.pkcs8)?;
653 let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
654 let fingerprint = key.multikey.trim_start_matches("ed25519:");
655 Ok(format!(
656 "LinkMD-Sig v1,key=ed25519:{fingerprint},ts={ts},sig={sig}"
657 ))
658}
659
660#[derive(Serialize)]
667struct WireFeedFile {
668 path: String,
669 sha256: String,
670 bytes: u64,
671}
672
673#[derive(Serialize)]
676struct UnsignedWireEntry<'a> {
677 v: u8,
678 seq: u64,
679 ts: String,
680 brain: &'a str,
681 public_key: &'a str,
682 kind: &'a str,
683 op: &'a str,
684 pack_sha256: &'a str,
685 files: &'a [WireFeedFile],
686 removed: &'a [String],
687 prev_entry_hash: Option<&'a str>,
688}
689
690fn self_custody_entry(
696 key: &AgentSigningKey,
697 seq: u64,
698 ts: String,
699 pack_sha256: &str,
700 files: &[WireFeedFile],
701 prev_entry_hash: Option<&str>,
702) -> LinkResult<String> {
703 let removed: [String; 0] = [];
704 let unsigned = serde_json::to_string(&UnsignedWireEntry {
705 v: 1,
706 seq,
707 ts,
708 brain: &key.multikey,
709 public_key: &key.public_key_spki,
710 kind: "push",
711 op: "snapshot",
712 pack_sha256,
713 files,
714 removed: &removed,
715 prev_entry_hash,
716 })
717 .expect("serialize feed entry");
718 let pair = agent_keypair(&key.pkcs8)?;
719 let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
720 Ok(format!(
721 "{},\"sig\":\"{}\"}}",
722 &unsigned[..unsigned.len() - 1],
723 sig
724 ))
725}
726
727fn env_nonempty(name: &str) -> Option<String> {
730 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
731}
732
733fn config_file_hub(path: &Path) -> Option<String> {
738 let text = std::fs::read_to_string(path).ok()?;
739 for line in text.lines() {
740 let line = line.trim();
741 if line.is_empty() || line.starts_with('#') {
742 continue;
743 }
744 if let Some((k, v)) = line.split_once('=') {
745 if k.trim() == "hub" {
746 let v = v.trim();
747 if !v.is_empty() {
748 return Some(v.to_string());
749 }
750 }
751 }
752 }
753 None
754}
755
756fn assert_safe_hub(hub: &str) -> LinkResult<()> {
759 let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
760 hub: hub.to_string(),
761 })?;
762 if !parsed.username().is_empty()
763 || parsed.password().is_some()
764 || parsed.query().is_some()
765 || parsed.fragment().is_some()
766 {
767 return Err(LinkError::UnsafeHub {
768 hub: hub.to_string(),
769 });
770 }
771 let loopback = match parsed.host() {
772 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
773 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
774 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
775 None => false,
776 };
777 if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
778 Ok(())
779 } else {
780 Err(LinkError::UnsafeHub {
781 hub: hub.to_string(),
782 })
783 }
784}
785
786fn clean_key(raw: &str) -> LinkResult<String> {
791 let k = raw.trim();
792 if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
793 return Err(LinkError::BadKey);
794 }
795 Ok(k.to_string())
796}
797
798#[derive(Debug)]
804pub struct HubResponse {
805 pub status: u16,
807 pub body: Option<Value>,
809}
810
811#[derive(Debug, Clone, Copy, PartialEq, Eq)]
813enum Auth {
814 Required,
816 None,
818 Optional,
822}
823
824fn agent() -> ureq::Agent {
825 ureq::AgentBuilder::new()
826 .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
827 .redirects(0)
831 .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
832 .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
833 .build()
834}
835
836fn request(
841 cfg: &HubConfig,
842 method: &str,
843 path: &str,
844 body: Option<&Value>,
845 auth: Auth,
846) -> LinkResult<HubResponse> {
847 let url = format!("{}{}", cfg.hub, path);
848 let encoded_body = body.map(Value::to_string);
849 let credential = match auth {
852 Auth::Required => Some(match &cfg.agent_key {
853 Some(key) => linkmd_sig_header(key, method, path, encoded_body.as_deref())?,
854 None => format!("Bearer {}", cfg.require_key()?),
855 }),
856 Auth::Optional => match &cfg.agent_key {
857 Some(key) => Some(linkmd_sig_header(
858 key,
859 method,
860 path,
861 encoded_body.as_deref(),
862 )?),
863 None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
864 },
865 Auth::None => None,
866 };
867 let http = agent();
868 let result = with_connect_retries(|| {
869 let mut req = http.request(method, &url);
870 if let Some(value) = &credential {
871 req = req.set("authorization", value);
872 }
873 match &encoded_body {
874 Some(value) => req
875 .set("content-type", "application/json")
876 .send_string(value)
877 .map_err(Box::new),
878 None => req.call().map_err(Box::new),
879 }
880 });
881 let resp = match result {
882 Ok(resp) => resp,
883 Err(error) => match *error {
884 ureq::Error::Status(_, resp) => resp,
885 ureq::Error::Transport(error) => {
886 return Err(LinkError::Transport {
887 hub: cfg.hub.clone(),
888 message: error.to_string(),
889 });
890 }
891 },
892 };
893
894 let status = resp.status();
895 let mut buf = Vec::new();
896 resp.into_reader()
897 .take(MAX_RESPONSE_BYTES + 1)
898 .read_to_end(&mut buf)?;
899 if buf.len() as u64 > MAX_RESPONSE_BYTES {
900 return Err(LinkError::ResponseTooLarge);
901 }
902 let parsed: Option<Value> = serde_json::from_slice(&buf).ok();
903 Ok(HubResponse {
904 status,
905 body: parsed,
906 })
907}
908
909fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
914 matches!(
915 kind,
916 ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
917 )
918}
919
920fn with_connect_retries(
921 mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
922) -> Result<ureq::Response, Box<ureq::Error>> {
923 let mut attempt = 0;
924 loop {
925 match send() {
926 Err(error)
927 if matches!(
928 error.as_ref(),
929 ureq::Error::Transport(transport)
930 if is_pre_request_transport(transport.kind())
931 ) && attempt + 1 < CONNECT_ATTEMPTS =>
932 {
933 std::thread::sleep(std::time::Duration::from_millis(
934 CONNECT_RETRY_BACKOFF_MS[attempt],
935 ));
936 attempt += 1;
937 }
938 result => return result,
939 }
940 }
941}
942
943fn assert_safe_presigned_url(raw: &str) -> LinkResult<()> {
944 let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
945 message: "the hub returned an invalid object-store URL".to_string(),
946 })?;
947 if !parsed.scheme().eq_ignore_ascii_case("https")
948 || !parsed.username().is_empty()
949 || parsed.password().is_some()
950 || parsed.fragment().is_some()
951 {
952 return Err(LinkError::InvalidPack {
953 message: "the hub returned an unsafe object-store URL".to_string(),
954 });
955 }
956 Ok(())
957}
958
959fn put_presigned(raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
960 assert_safe_presigned_url(raw)?;
961 let http = agent();
962 let result = with_connect_retries(|| {
963 let mut req = http.put(raw);
964 if let Some(map) = headers.as_object() {
965 for (name, value) in map {
966 if let Some(value) = value.as_str() {
967 req = req.set(name, value);
968 }
969 }
970 }
971 req.send_bytes(bytes).map_err(Box::new)
972 });
973 match result {
974 Ok(resp) if resp.status() < 300 => Ok(()),
975 Ok(resp) => Err(LinkError::Http {
976 what: "pack upload",
977 status: resp.status(),
978 message: "object store rejected the upload".to_string(),
979 code: None,
980 }),
981 Err(error) => match *error {
982 ureq::Error::Status(_, resp) => Err(LinkError::Http {
983 what: "pack upload",
984 status: resp.status(),
985 message: "object store rejected the upload".to_string(),
986 code: None,
987 }),
988 ureq::Error::Transport(err) => Err(LinkError::Transport {
989 hub: "the object store".to_string(),
990 message: err.to_string(),
991 }),
992 },
993 }
994}
995
996fn get_presigned(raw: &str) -> LinkResult<Vec<u8>> {
997 assert_safe_presigned_url(raw)?;
998 let http = agent();
999 let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1000 Ok(resp) => resp,
1001 Err(error) => match *error {
1002 ureq::Error::Status(_, resp) => {
1003 return Err(LinkError::Http {
1004 what: "pack download",
1005 status: resp.status(),
1006 message: "object store rejected the download".to_string(),
1007 code: None,
1008 });
1009 }
1010 ureq::Error::Transport(err) => {
1011 return Err(LinkError::Transport {
1012 hub: "the object store".to_string(),
1013 message: err.to_string(),
1014 });
1015 }
1016 },
1017 };
1018 let mut bytes = Vec::new();
1019 resp.into_reader()
1020 .take(MAX_PACK_BYTES + 1)
1021 .read_to_end(&mut bytes)?;
1022 if bytes.len() as u64 > MAX_PACK_BYTES {
1023 return Err(LinkError::InvalidPack {
1024 message: "download exceeds the compressed-size limit".to_string(),
1025 });
1026 }
1027 Ok(bytes)
1028}
1029
1030fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
1034 if r.status >= 400 {
1035 let message = r
1036 .body
1037 .as_ref()
1038 .and_then(|b| b.get("error"))
1039 .and_then(Value::as_str)
1040 .unwrap_or("unknown error")
1041 .to_string();
1042 let code = r
1043 .body
1044 .as_ref()
1045 .and_then(|b| b.get("code"))
1046 .and_then(Value::as_str)
1047 .map(str::to_string);
1048 return Err(LinkError::Http {
1049 what,
1050 status: r.status,
1051 message,
1052 code,
1053 });
1054 }
1055 r.body.ok_or(LinkError::NotJson {
1056 what,
1057 status: r.status,
1058 })
1059}
1060
1061fn get_json_absolute(url: &str) -> LinkResult<Value> {
1074 assert_safe_hub(url)?;
1075 let http = agent();
1076 let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
1077 Ok(resp) => resp,
1078 Err(error) => match *error {
1079 ureq::Error::Status(status, resp) => {
1080 let _ = resp;
1081 return Err(LinkError::Http {
1082 what: "registry home fetch",
1083 status,
1084 message: "the home node rejected the card request".to_string(),
1085 code: None,
1086 });
1087 }
1088 ureq::Error::Transport(err) => {
1089 return Err(LinkError::Transport {
1090 hub: url.to_string(),
1091 message: err.to_string(),
1092 });
1093 }
1094 },
1095 };
1096 let mut buf = Vec::new();
1097 resp.into_reader()
1098 .take(MAX_RESPONSE_BYTES + 1)
1099 .read_to_end(&mut buf)?;
1100 if buf.len() as u64 > MAX_RESPONSE_BYTES {
1101 return Err(LinkError::ResponseTooLarge);
1102 }
1103 serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
1104 message: "the home node returned invalid JSON".to_string(),
1105 })
1106}
1107
1108pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
1115 require_safe_ref(handle)?;
1116 let reg = request(
1117 cfg,
1118 "GET",
1119 &format!("/api/hub/registry/{handle}"),
1120 None,
1121 Auth::None,
1122 )?;
1123 if reg.status == 404 {
1124 return Ok(None);
1125 }
1126 let body = ensure_ok(reg, "registry resolve")?;
1127 let home = body
1128 .get("home")
1129 .and_then(Value::as_str)
1130 .ok_or_else(|| invalid_feed("registry entry has no home"))?;
1131 let brain = body
1132 .get("brain")
1133 .and_then(Value::as_str)
1134 .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
1135 let want_fp = body
1136 .get("identity")
1137 .and_then(|i| i.get("fingerprint"))
1138 .and_then(Value::as_str)
1139 .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
1140
1141 let home = home.trim_end_matches('/');
1142 let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
1143 let got_fp = card
1144 .get("identity")
1145 .and_then(|i| i.get("fingerprint"))
1146 .and_then(Value::as_str)
1147 .unwrap_or_default();
1148 if got_fp != want_fp {
1149 return Err(invalid_feed(
1150 "the home node served an identity that does not match the registry — refusing",
1151 ));
1152 }
1153 let mut out = card;
1154 if let Value::Object(map) = &mut out {
1155 map.insert("home".to_string(), Value::String(home.to_string()));
1156 map.insert(
1157 "resolvedVia".to_string(),
1158 Value::String("registry".to_string()),
1159 );
1160 }
1161 Ok(Some(out))
1162}
1163
1164pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
1165 require_safe_ref(&addr.brain)?;
1169 if let Some(target) = &addr.target {
1170 let (given, ok) = match target {
1171 AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
1172 AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
1173 };
1174 if !ok {
1175 return Err(LinkError::BadAddress {
1176 given: given.clone(),
1177 reason: BAD_TARGET_REASON.to_string(),
1178 });
1179 }
1180 }
1181
1182 let path = match &addr.target {
1183 None => format!("/api/hub/brains/{}", addr.brain),
1184 Some(AddressTarget::Id(id)) => {
1185 format!("/api/hub/brains/{}/resolve?id={id}", addr.brain)
1186 }
1187 Some(AddressTarget::Path(p)) => {
1188 format!("/api/hub/brains/{}/resolve?path={p}", addr.brain)
1189 }
1190 };
1191 let direct = request(cfg, "GET", &path, None, Auth::Required)?;
1196 if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
1197 if let Some(card) = resolve_registry(cfg, &addr.brain)? {
1198 return Ok(card);
1199 }
1200 }
1201 ensure_ok(direct, "resolve")
1202}
1203
1204#[derive(Debug, serde::Serialize)]
1210pub struct PullReport {
1211 pub brain: String,
1213 pub slug: String,
1215 #[serde(rename = "headSeq")]
1217 pub head_seq: u64,
1218 pub files: usize,
1220 pub dest: String,
1222 #[serde(rename = "extraLocal")]
1225 pub extra_local: Vec<String>,
1226}
1227
1228pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
1234 require_safe_ref(brain)?;
1235 let path = format!("/api/hub/brains/{brain}/export?format=pack");
1236 let body = ensure_ok(
1237 request(cfg, "GET", &path, None, Auth::Required)?,
1238 "sync pull",
1239 )?;
1240
1241 let remote_slug = body
1242 .get("slug")
1243 .and_then(Value::as_str)
1244 .filter(|slug| is_safe_slug(slug));
1245 let slug = remote_slug
1246 .or_else(|| is_safe_slug(brain).then_some(brain))
1247 .unwrap_or("brain")
1248 .to_string();
1249 let brain_id = body
1250 .get("brain")
1251 .and_then(Value::as_str)
1252 .unwrap_or(brain)
1253 .to_string();
1254 let head_seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
1255 let dest: PathBuf = match out {
1256 Some(p) => p.to_path_buf(),
1257 None => PathBuf::from(&slug),
1258 };
1259 let entries =
1260 if let Some(url) = body.get("url").and_then(Value::as_str) {
1261 let expected = body
1262 .get("sha256")
1263 .and_then(Value::as_str)
1264 .filter(|hash| {
1265 hash.len() == 64
1266 && hash
1267 .bytes()
1268 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1269 })
1270 .ok_or_else(|| LinkError::InvalidPack {
1271 message: "the hub returned an invalid SHA-256".to_string(),
1272 })?;
1273 let bytes = get_presigned(url)?;
1274 let actual = format!("{:x}", Sha256::digest(&bytes));
1275 if actual != expected {
1276 return Err(LinkError::InvalidPack {
1277 message: "SHA-256 verification failed".to_string(),
1278 });
1279 }
1280 parse_store_pack(bytes)?
1281 } else {
1282 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
1283 LinkError::InvalidPack {
1284 message: "the hub returned neither a pack nor a file manifest".to_string(),
1285 }
1286 })?;
1287 let mut entries = Vec::with_capacity(files.len());
1288 for file in files {
1289 let path = file.get("path").and_then(Value::as_str).ok_or_else(|| {
1290 LinkError::InvalidPack {
1291 message: "a file entry has no string path".to_string(),
1292 }
1293 })?;
1294 let content = file.get("content").and_then(Value::as_str).ok_or_else(|| {
1295 LinkError::InvalidPack {
1296 message: format!("file `{path}` has no string content"),
1297 }
1298 })?;
1299 entries.push((path.to_string(), content.as_bytes().to_vec()));
1300 }
1301 entries
1302 };
1303
1304 let mut seen = std::collections::HashSet::new();
1306 for (path, _) in &entries {
1307 if !safe_store_rel_path(path) {
1308 return Err(LinkError::UnsafePath { path: path.clone() });
1309 }
1310 if !seen.insert(path) {
1311 return Err(LinkError::InvalidPack {
1312 message: format!("duplicate path `{path}`"),
1313 });
1314 }
1315 }
1316 std::fs::create_dir_all(&dest)?;
1317 let real_dest = std::fs::canonicalize(&dest)?;
1318
1319 for (p, content) in &entries {
1320 let abs = dest.join(p);
1321 if let Some(parent) = abs.parent() {
1322 std::fs::create_dir_all(parent)?;
1323 let real_parent = std::fs::canonicalize(parent)?;
1324 if !real_parent.starts_with(&real_dest) {
1325 return Err(LinkError::UnsafePath { path: p.clone() });
1326 }
1327 }
1328 if std::fs::symlink_metadata(&abs).is_ok_and(|meta| meta.file_type().is_symlink()) {
1329 return Err(LinkError::UnsafePath { path: p.clone() });
1330 }
1331 write_atomic(&abs, content)?;
1332 }
1333
1334 let pulled: std::collections::BTreeSet<&str> =
1338 entries.iter().map(|(p, _)| p.as_str()).collect();
1339 let mut extra_local = Vec::new();
1340 if let Ok(store) = Store::open(&dest) {
1341 if let Ok(walked) = store.walk() {
1342 for rel in walked {
1343 let rel_str = rel.to_string_lossy().replace('\\', "/");
1344 if !pulled.contains(rel_str.as_str()) {
1345 extra_local.push(rel_str);
1346 }
1347 }
1348 }
1349 }
1350
1351 Ok(PullReport {
1352 brain: brain_id,
1353 slug,
1354 head_seq,
1355 files: entries.len(),
1356 dest: dest.to_string_lossy().into_owned(),
1357 extra_local,
1358 })
1359}
1360
1361fn is_safe_slug(slug: &str) -> bool {
1362 !slug.is_empty()
1363 && slug.len() <= 63
1364 && !slug.starts_with('-')
1365 && !slug.ends_with('-')
1366 && slug
1367 .bytes()
1368 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
1369}
1370
1371fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
1372 let mut archive =
1373 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
1374 message: format!("ZIP parse failed: {err}"),
1375 })?;
1376 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
1377 return Err(LinkError::InvalidPack {
1378 message: format!("invalid file count {}", archive.len()),
1379 });
1380 }
1381 let mut total = 0u64;
1382 let mut entries = Vec::with_capacity(archive.len());
1383 for index in 0..archive.len() {
1384 let mut file = archive
1385 .by_index(index)
1386 .map_err(|err| LinkError::InvalidPack {
1387 message: format!("ZIP entry failed: {err}"),
1388 })?;
1389 if file.is_dir() {
1390 continue;
1391 }
1392 let path = file.name().to_string();
1393 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
1394 return Err(LinkError::UnsafePath { path });
1395 }
1396 if file
1397 .unix_mode()
1398 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
1399 {
1400 return Err(LinkError::InvalidPack {
1401 message: format!("non-file entry `{path}`"),
1402 });
1403 }
1404 total = total.saturating_add(file.size());
1405 if total > MAX_STORE_BYTES {
1406 return Err(LinkError::InvalidPack {
1407 message: "expanded content exceeds the 512 MB limit".to_string(),
1408 });
1409 }
1410 let mut content = Vec::new();
1411 file.read_to_end(&mut content)
1412 .map_err(|err| LinkError::InvalidPack {
1413 message: format!("could not decompress `{path}`: {err}"),
1414 })?;
1415 if content.len() as u64 != file.size() {
1416 return Err(LinkError::InvalidPack {
1417 message: format!("length mismatch for `{path}`"),
1418 });
1419 }
1420 entries.push((path, content));
1421 }
1422 if entries.is_empty() {
1423 return Err(LinkError::InvalidPack {
1424 message: "pack contains no files".to_string(),
1425 });
1426 }
1427 Ok(entries)
1428}
1429
1430pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
1437 preflight_push_ownership(store)?;
1438 let mut out: Vec<(String, String)> = Vec::new();
1439
1440 let read_text = |rel: &str| -> LinkResult<String> {
1441 let abs = store.root.join(rel);
1442 let owned =
1446 crate::store::ensure_path_within_store(&store.root, &abs).map_err(LinkError::from)?;
1447 std::fs::read(&owned)
1448 .map_err(LinkError::from)
1449 .and_then(|bytes| {
1450 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
1451 path: rel.to_string(),
1452 })
1453 })
1454 };
1455
1456 out.push(("DB.md".to_string(), read_text("DB.md")?));
1457 if store.root.join("assets.jsonl").is_file() {
1458 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
1459 }
1460
1461 for rel in store.walk()? {
1462 let rel_str = rel.to_string_lossy().replace('\\', "/");
1463 if !safe_store_rel_path(&rel_str) {
1464 return Err(LinkError::UnsafePath { path: rel_str });
1467 }
1468 let content = read_text(&rel_str)?;
1469 out.push((rel_str, content));
1470 }
1471
1472 out.sort_by(|a, b| a.0.cmp(&b.0));
1473 Ok(out)
1474}
1475
1476fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
1480 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
1481 return Err(LinkError::from(std::io::Error::new(
1482 std::io::ErrorKind::PermissionDenied,
1483 format!("cannot push: nested db.md store at {}", nested.display()),
1484 )));
1485 }
1486
1487 for layer in crate::store::Layer::all() {
1488 let root = store.root.join(layer.dir_name());
1489 if !root.is_dir() {
1490 continue;
1491 }
1492 for entry in walkdir::WalkDir::new(&root)
1493 .follow_links(false)
1494 .into_iter()
1495 .filter_entry(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
1496 {
1497 let entry = entry.map_err(|err| {
1498 LinkError::from(std::io::Error::other(format!(
1499 "cannot inspect push path under {}: {err}",
1500 root.display()
1501 )))
1502 })?;
1503 if entry.file_type().is_symlink()
1504 && crate::store::ensure_path_within_store(&store.root, entry.path()).is_err()
1505 {
1506 return Err(LinkError::from(std::io::Error::new(
1507 std::io::ErrorKind::PermissionDenied,
1508 format!(
1509 "cannot push: {} resolves outside this store or into a nested store",
1510 entry.path().display()
1511 ),
1512 )));
1513 }
1514 }
1515 }
1516 Ok(())
1517}
1518
1519pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
1525 require_safe_ref(brain)?;
1526 if files.len() > MAX_PUSH_FILES {
1527 return Err(LinkError::PushTooLarge {
1528 detail: format!("{} files", files.len()),
1529 });
1530 }
1531 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
1532 if raw_total > MAX_STORE_BYTES {
1533 return Err(LinkError::PushTooLarge {
1534 detail: format!("{raw_total} uncompressed bytes"),
1535 });
1536 }
1537
1538 if cfg.brain_key.is_none() {
1542 let body = json!({
1543 "files": files
1544 .iter()
1545 .map(|(p, c)| json!({ "path": p, "content": c }))
1546 .collect::<Vec<_>>(),
1547 });
1548 if body.to_string().len() <= MAX_PUSH_BYTES {
1549 let path = format!("/api/hub/brains/{brain}/push");
1550 return ensure_ok(
1551 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
1552 "sync push",
1553 );
1554 }
1555 }
1556
1557 let pack = build_store_pack(files)?;
1558 if pack.len() as u64 > MAX_PACK_BYTES {
1559 return Err(LinkError::PushTooLarge {
1560 detail: format!("{} compressed bytes", pack.len()),
1561 });
1562 }
1563 let sha256 = format!("{:x}", Sha256::digest(&pack));
1564 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
1565 if let Some(key) = &cfg.brain_key {
1566 let current = head(cfg, brain)?;
1569 let mut manifest: Vec<WireFeedFile> = files
1570 .iter()
1571 .map(|(path, content)| WireFeedFile {
1572 path: path.clone(),
1573 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
1574 bytes: content.len() as u64,
1575 })
1576 .collect();
1577 manifest.sort_by(|a, b| a.path.cmp(&b.path));
1578 let ts = crate::now()
1579 .with_timezone(&chrono::Utc)
1580 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
1581 .to_string();
1582 let entry = self_custody_entry(
1583 key,
1584 current.seq + 1,
1585 ts,
1586 &sha256,
1587 &manifest,
1588 current.feed_hash.as_deref(),
1589 )?;
1590 meta["entry"] = Value::String(entry);
1591 }
1592 let presigned = ensure_ok(
1593 request(
1594 cfg,
1595 "POST",
1596 &format!("/api/hub/brains/{brain}/packs/presign"),
1597 Some(&meta),
1598 Auth::Required,
1599 )?,
1600 "prepare pack upload",
1601 )?;
1602 let url = presigned
1603 .get("url")
1604 .and_then(Value::as_str)
1605 .ok_or_else(|| LinkError::InvalidPack {
1606 message: "the hub returned no upload URL".to_string(),
1607 })?;
1608 put_presigned(url, presigned.get("headers").unwrap_or(&Value::Null), &pack)?;
1609 ensure_ok(
1610 request(
1611 cfg,
1612 "POST",
1613 &format!("/api/hub/brains/{brain}/packs/commit"),
1614 Some(&meta),
1615 Auth::Required,
1616 )?,
1617 "commit pack",
1618 )
1619}
1620
1621fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
1622 let mut sorted: Vec<_> = files.iter().collect();
1623 sorted.sort_by(|a, b| a.0.cmp(&b.0));
1624 let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
1625 let options = zip::write::SimpleFileOptions::default()
1626 .compression_method(zip::CompressionMethod::Deflated)
1627 .last_modified_time(zip::DateTime::default())
1628 .unix_permissions(0o600);
1629 for (path, content) in sorted {
1630 writer
1631 .start_file(path, options)
1632 .map_err(|err| LinkError::InvalidPack {
1633 message: format!("could not create ZIP entry `{path}`: {err}"),
1634 })?;
1635 writer.write_all(content.as_bytes())?;
1636 }
1637 writer
1638 .finish()
1639 .map(Cursor::into_inner)
1640 .map_err(|err| LinkError::InvalidPack {
1641 message: format!("could not finish ZIP: {err}"),
1642 })
1643}
1644
1645#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1651pub enum Capability {
1652 Read,
1654 Write,
1656}
1657
1658impl Capability {
1659 pub fn as_str(self) -> &'static str {
1661 match self {
1662 Capability::Read => "read",
1663 Capability::Write => "write",
1664 }
1665 }
1666}
1667
1668pub fn grant_issue(
1674 cfg: &HubConfig,
1675 brain: &str,
1676 grantee: &str,
1677 can: Capability,
1678 scope: Option<&str>,
1679 until: Option<&str>,
1680) -> LinkResult<Value> {
1681 require_safe_ref(brain)?;
1682 let is_key_grantee = URL_SAFE_NO_PAD
1687 .decode(grantee)
1688 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
1689 .unwrap_or(false);
1690 let mut body = if is_key_grantee {
1691 json!({ "keySpki": grantee, "capability": can.as_str() })
1692 } else {
1693 json!({ "email": grantee, "capability": can.as_str() })
1694 };
1695 if let Some(s) = scope {
1696 body["scopePrefix"] = json!(s);
1697 }
1698 if let Some(u) = until {
1699 body["expiresAt"] = json!(u);
1700 }
1701 let path = format!("/api/hub/brains/{brain}/grants");
1702 ensure_ok(
1703 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
1704 "grant issue",
1705 )
1706}
1707
1708pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
1710 require_safe_ref(brain)?;
1711 let path = format!("/api/hub/brains/{brain}/grants");
1712 ensure_ok(
1713 request(cfg, "GET", &path, None, Auth::Required)?,
1714 "grant list",
1715 )
1716}
1717
1718pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
1721 require_safe_ref(brain)?;
1722 require_safe_grant_id(grant_id)?;
1723 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
1724 ensure_ok(
1725 request(cfg, "DELETE", &path, None, Auth::Required)?,
1726 "grant revoke",
1727 )
1728}
1729
1730pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
1741 require_valid_handle(handle)?;
1742 if body.len() as u64 > MAX_PROPOSE_BYTES {
1743 return Err(LinkError::ProposeTooLarge {
1744 bytes: body.len() as u64,
1745 });
1746 }
1747 let payload = json!({ "app": app, "body": body });
1748 let (path, auth) = if crate::ulid::is_ulid(handle) {
1753 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
1754 } else {
1755 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
1756 };
1757 ensure_ok(
1758 request(cfg, "POST", &path, Some(&payload), auth)?,
1759 "propose",
1760 )
1761}
1762
1763#[derive(Debug, serde::Serialize)]
1769pub struct Head {
1770 pub brain: String,
1772 pub seq: u64,
1774 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
1776 pub updated_at: Option<String>,
1777 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
1779 pub feed_hash: Option<String>,
1780 pub verified: bool,
1783}
1784
1785#[derive(Debug, Deserialize, Serialize)]
1786struct FeedFile {
1787 path: String,
1788 sha256: String,
1789 bytes: u64,
1790}
1791
1792#[derive(Debug, Deserialize, Serialize)]
1793struct FeedEntry {
1794 v: u8,
1795 seq: u64,
1796 ts: String,
1797 brain: String,
1798 public_key: String,
1799 kind: String,
1800 op: String,
1801 pack_sha256: String,
1802 files: Vec<FeedFile>,
1803 removed: Vec<String>,
1804 prev_entry_hash: Option<String>,
1805 sig: String,
1806}
1807
1808#[derive(Serialize)]
1809struct UnsignedFeedEntry<'a> {
1810 v: u8,
1811 seq: u64,
1812 ts: &'a str,
1813 brain: &'a str,
1814 public_key: &'a str,
1815 kind: &'a str,
1816 op: &'a str,
1817 pack_sha256: &'a str,
1818 files: &'a [FeedFile],
1819 removed: &'a [String],
1820 prev_entry_hash: &'a Option<String>,
1821}
1822
1823#[derive(Debug, Deserialize)]
1824struct FeedItem {
1825 hash: String,
1826 entry: FeedEntry,
1827}
1828
1829#[derive(Debug, Deserialize)]
1830struct FeedIdentity {
1831 fingerprint: String,
1832 #[serde(rename = "publicKeySpki")]
1833 public_key_spki: String,
1834 #[serde(default)]
1838 previous: Vec<PreviousIdentity>,
1839}
1840
1841#[derive(Debug, Deserialize)]
1842struct PreviousIdentity {
1843 fingerprint: String,
1844 #[serde(rename = "publicKeySpki")]
1845 public_key_spki: String,
1846}
1847
1848#[derive(Debug, Deserialize)]
1849struct FeedResponse {
1850 #[serde(rename = "headSeq")]
1851 head_seq: u64,
1852 #[serde(rename = "feedHash")]
1853 feed_hash: Option<String>,
1854 identity: Option<FeedIdentity>,
1855 entries: Vec<FeedItem>,
1856 #[serde(rename = "scopeLimited")]
1857 scope_limited: bool,
1858}
1859
1860fn invalid_feed(message: impl Into<String>) -> LinkError {
1861 LinkError::InvalidFeed {
1862 message: message.into(),
1863 }
1864}
1865
1866fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
1867 const ED25519_SPKI_PREFIX: &[u8] = &[
1868 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1869 ];
1870 let entry = &item.entry;
1871 let public_der = URL_SAFE_NO_PAD
1872 .decode(&entry.public_key)
1873 .map_err(|_| invalid_feed("public key is not base64url"))?;
1874 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
1875 || !public_der.starts_with(ED25519_SPKI_PREFIX)
1876 {
1877 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
1878 }
1879 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
1880 if entry.brain != format!("ed25519:{fingerprint}") {
1881 return Err(invalid_feed(
1882 "brain fingerprint does not match its public key",
1883 ));
1884 }
1885 let known = fingerprint == identity.fingerprint
1888 || identity
1889 .previous
1890 .iter()
1891 .any(|p| p.fingerprint == fingerprint && p.public_key_spki == entry.public_key);
1892 if !known {
1893 return Err(invalid_feed(
1894 "entry signer is not this brain's identity (current or rotated-from)",
1895 ));
1896 }
1897 let unsigned = UnsignedFeedEntry {
1898 v: entry.v,
1899 seq: entry.seq,
1900 ts: &entry.ts,
1901 brain: &entry.brain,
1902 public_key: &entry.public_key,
1903 kind: &entry.kind,
1904 op: &entry.op,
1905 pack_sha256: &entry.pack_sha256,
1906 files: &entry.files,
1907 removed: &entry.removed,
1908 prev_entry_hash: &entry.prev_entry_hash,
1909 };
1910 let message =
1911 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
1912 let signature = URL_SAFE_NO_PAD
1913 .decode(&entry.sig)
1914 .map_err(|_| invalid_feed("signature is not base64url"))?;
1915 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
1916 .verify(&message, &signature)
1917 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
1918
1919 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
1920 exact.push(b'\n');
1921 let actual_hash = format!("{:x}", Sha256::digest(&exact));
1922 if actual_hash != item.hash {
1923 return Err(invalid_feed("entry SHA-256 does not match"));
1924 }
1925 Ok(())
1926}
1927
1928#[derive(Serialize)]
1934struct UnsignedRotation<'a> {
1935 v: u8,
1936 op: &'a str,
1937 brain: &'a str,
1938 public_key: &'a str,
1939 new_brain: &'a str,
1940 new_public_key: &'a str,
1941 ts: String,
1942}
1943
1944#[derive(Debug, Serialize)]
1946pub struct RotationReport {
1947 pub brain: String,
1949 pub multikey: String,
1951 #[serde(rename = "keyFile")]
1953 pub key_file: String,
1954 pub previous: Vec<String>,
1956}
1957
1958pub fn rotate_brain_key(
1964 cfg: &HubConfig,
1965 brain: &str,
1966 old_key: &AgentSigningKey,
1967 out: &Path,
1968) -> LinkResult<RotationReport> {
1969 require_safe_ref(brain)?;
1970 if out.exists() {
1971 return Err(bad_agent_key(
1972 "the output file already exists — refusing to overwrite a key",
1973 ));
1974 }
1975 let rng = ring::rand::SystemRandom::new();
1976 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1977 .map_err(|_| bad_agent_key("key generation failed"))?;
1978 let pair = agent_keypair(pkcs8.as_ref())?;
1979 let (new_spki, new_multikey) = public_identity_for(&pair);
1980
1981 let ts = crate::now()
1982 .with_timezone(&chrono::Utc)
1983 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
1984 .to_string();
1985 let unsigned = serde_json::to_string(&UnsignedRotation {
1986 v: 1,
1987 op: "rotate",
1988 brain: &old_key.multikey,
1989 public_key: &old_key.public_key_spki,
1990 new_brain: &new_multikey,
1991 new_public_key: &new_spki,
1992 ts,
1993 })
1994 .expect("serialize rotation");
1995 let old_pair = agent_keypair(&old_key.pkcs8)?;
1996 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
1997 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
1998
1999 let body = json!({ "statement": statement });
2000 let path = format!("/api/hub/brains/{brain}/rotate");
2001 let response = ensure_ok(
2002 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
2003 "key rotate",
2004 )?;
2005 let previous = response
2006 .get("identity")
2007 .and_then(|i| i.get("previous"))
2008 .and_then(Value::as_array)
2009 .map(|arr| {
2010 arr.iter()
2011 .filter_map(|p| p.get("fingerprint").and_then(Value::as_str))
2012 .map(|f| format!("ed25519:{f}"))
2013 .collect()
2014 })
2015 .unwrap_or_default();
2016
2017 std::fs::write(out, format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())))?;
2018 #[cfg(unix)]
2019 {
2020 use std::os::unix::fs::PermissionsExt as _;
2021 std::fs::set_permissions(out, std::fs::Permissions::from_mode(0o600))?;
2022 }
2023 Ok(RotationReport {
2024 brain: brain.to_string(),
2025 multikey: new_multikey,
2026 key_file: out.display().to_string(),
2027 previous,
2028 })
2029}
2030
2031#[derive(Debug, Serialize)]
2037pub struct MirrorReport {
2038 pub brain: String,
2040 #[serde(rename = "headSeq")]
2042 pub head_seq: u64,
2043 #[serde(rename = "feedHash")]
2045 pub feed_hash: Option<String>,
2046 pub entries: u64,
2048 pub pinned: String,
2050 pub files: usize,
2052}
2053
2054pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
2056
2057pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
2060 format!(
2061 "{:x}",
2062 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
2063 )
2064}
2065
2066fn config_pin(path: &Path) -> Option<String> {
2067 let text = std::fs::read_to_string(path).ok()?;
2068 for line in text.lines() {
2069 let line = line.trim();
2070 if let Some(rest) = line.strip_prefix("pin") {
2071 let rest = rest.trim_start();
2072 if let Some(value) = rest.strip_prefix('=') {
2073 let value = value.trim();
2074 if !value.is_empty() {
2075 return Some(value.to_string());
2076 }
2077 }
2078 }
2079 }
2080 None
2081}
2082
2083pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
2092 require_safe_ref(brain)?;
2093 let card = head(cfg, brain)?;
2094 let brain_id = card.brain.clone();
2095
2096 let mirror_dir = dest.join(MIRROR_REL_DIR);
2097 let feed_dir = mirror_dir.join("feed");
2098 std::fs::create_dir_all(&feed_dir)?;
2099
2100 let mut expected_seq: u64 = 1;
2101 let mut prev_hash: Option<String> = None;
2102 let mut identity: Option<FeedIdentity> = None;
2103 let mut stored: u64 = 0;
2104 let advertised: Option<String> = loop {
2105 let path = format!(
2106 "/api/hub/brains/{brain_id}/feed?after={}&limit=100",
2107 expected_seq - 1
2108 );
2109 let body = ensure_ok(request(cfg, "GET", &path, None, Auth::Required)?, "mirror")?;
2110 let page: FeedResponse = serde_json::from_value(body)
2111 .map_err(|_| invalid_feed("feed response did not parse"))?;
2112 if page.scope_limited {
2113 return Err(invalid_feed(
2114 "this grant is path-scoped — mirroring needs full-store read",
2115 ));
2116 }
2117 let page_identity = page
2118 .identity
2119 .ok_or_else(|| invalid_feed("feed response carried no identity"))?;
2120 if let Some(existing) = &identity {
2121 if existing.fingerprint != page_identity.fingerprint {
2122 return Err(invalid_feed("identity changed mid-mirror"));
2123 }
2124 }
2125 for item in &page.entries {
2126 if item.entry.seq != expected_seq {
2127 return Err(invalid_feed(format!(
2128 "expected entry {expected_seq}, feed served {}",
2129 item.entry.seq
2130 )));
2131 }
2132 if item.entry.prev_entry_hash != prev_hash {
2133 return Err(invalid_feed(format!(
2134 "entry {} does not chain to its predecessor",
2135 item.entry.seq
2136 )));
2137 }
2138 verify_feed_item(item, &page_identity)?;
2139 let mut exact = serde_json::to_vec(&item.entry)
2140 .map_err(|_| invalid_feed("could not serialize entry"))?;
2141 exact.push(b'\n');
2142 crate::fsx::write_atomic(&feed_dir.join(format!("{}.json", item.entry.seq)), &exact)?;
2143 prev_hash = Some(item.hash.clone());
2144 expected_seq += 1;
2145 stored += 1;
2146 }
2147 identity = Some(page_identity);
2148 if expected_seq > page.head_seq || page.entries.is_empty() {
2149 if expected_seq <= page.head_seq {
2150 return Err(invalid_feed("feed page was empty before the head"));
2151 }
2152 break page.feed_hash.clone();
2153 }
2154 };
2155 if prev_hash != advertised {
2156 return Err(invalid_feed(
2157 "the verified chain does not converge on the advertised head",
2158 ));
2159 }
2160 let identity = identity.ok_or_else(|| invalid_feed("brain has no identity"))?;
2161 let multikey = format!("ed25519:{}", identity.fingerprint);
2162
2163 let config_path = dest.join(CONFIG_REL_PATH);
2165 match config_pin(&config_path) {
2166 Some(pinned) if pinned != multikey => {
2167 return Err(invalid_feed(format!(
2168 "pinned identity {pinned} does not match served identity {multikey} — refusing"
2169 )));
2170 }
2171 Some(_) => {}
2172 None => {
2173 let mut text = std::fs::read_to_string(&config_path).unwrap_or_default();
2174 if !text.is_empty() && !text.ends_with('\n') {
2175 text.push('\n');
2176 }
2177 text.push_str(&format!("pin = {multikey}\n"));
2178 if let Some(parent) = config_path.parent() {
2179 std::fs::create_dir_all(parent)?;
2180 }
2181 crate::fsx::write_atomic(&config_path, text.as_bytes())?;
2182 }
2183 }
2184
2185 let previous: Vec<serde_json::Value> = identity
2186 .previous
2187 .iter()
2188 .map(|p| {
2189 serde_json::json!({
2190 "fingerprint": p.fingerprint,
2191 "publicKeySpki": p.public_key_spki,
2192 })
2193 })
2194 .collect();
2195 crate::fsx::write_atomic(
2196 &mirror_dir.join("identity.json"),
2197 format!(
2198 "{}\n",
2199 serde_json::json!({
2200 "fingerprint": identity.fingerprint,
2201 "publicKeySpki": identity.public_key_spki,
2202 "previous": previous,
2203 })
2204 )
2205 .as_bytes(),
2206 )?;
2207 crate::fsx::write_atomic(
2208 &mirror_dir.join("head.json"),
2209 format!(
2210 "{}\n",
2211 serde_json::json!({
2212 "brain": brain_id,
2213 "headSeq": card.seq,
2214 "feedHash": prev_hash,
2215 })
2216 )
2217 .as_bytes(),
2218 )?;
2219
2220 let pulled = sync_pull(cfg, &brain_id, Some(dest))?;
2221 Ok(MirrorReport {
2222 brain: brain_id,
2223 head_seq: card.seq,
2224 feed_hash: prev_hash,
2225 entries: stored,
2226 pinned: multikey,
2227 files: pulled.files,
2228 })
2229}
2230
2231pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
2235 require_safe_ref(brain)?;
2236 let path = format!("/api/hub/brains/{brain}");
2237 let body = ensure_ok(
2238 request(cfg, "GET", &path, None, Auth::Required)?,
2239 "subscribe",
2240 )?;
2241 let resolved_brain = body
2242 .get("id")
2243 .and_then(Value::as_str)
2244 .unwrap_or(brain)
2245 .to_string();
2246 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
2247 let advertised_hash = body
2248 .get("feedHash")
2249 .and_then(Value::as_str)
2250 .map(str::to_string);
2251 let updated_at = body
2252 .get("updatedAt")
2253 .and_then(Value::as_str)
2254 .map(str::to_string);
2255 if seq == 0 {
2256 return Ok(Head {
2257 brain: resolved_brain,
2258 seq,
2259 updated_at,
2260 feed_hash: None,
2261 verified: true,
2262 });
2263 }
2264
2265 let feed_value = ensure_ok(
2266 request(
2267 cfg,
2268 "GET",
2269 &format!("/api/hub/brains/{brain}/feed?after={}&limit=1", seq - 1),
2270 None,
2271 Auth::Required,
2272 )?,
2273 "subscribe feed",
2274 )?;
2275 let feed: FeedResponse = serde_json::from_value(feed_value)
2276 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
2277 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
2278 return Err(invalid_feed("brain card and feed head disagree"));
2279 }
2280 if feed.scope_limited {
2281 return Ok(Head {
2282 brain: resolved_brain,
2283 seq,
2284 updated_at,
2285 feed_hash: advertised_hash,
2286 verified: false,
2287 });
2288 }
2289 let identity = feed
2290 .identity
2291 .as_ref()
2292 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
2293 let item = feed
2294 .entries
2295 .first()
2296 .ok_or_else(|| invalid_feed("feed head entry is missing"))?;
2297 if item.entry.seq != seq || Some(&item.hash) != advertised_hash.as_ref() {
2298 return Err(invalid_feed(
2299 "advertised feed hash does not address the head entry",
2300 ));
2301 }
2302 verify_feed_item(item, identity)?;
2303 Ok(Head {
2304 brain: resolved_brain,
2305 seq,
2306 updated_at,
2307 feed_hash: advertised_hash,
2308 verified: true,
2309 })
2310}
2311
2312#[cfg(test)]
2313mod tests {
2314 use super::*;
2315
2316 #[cfg(unix)]
2317 #[test]
2318 fn collect_push_files_refuses_external_symlink_and_nested_store() {
2319 use std::os::unix::fs::symlink;
2320
2321 let root = tempfile::tempdir().unwrap();
2322 std::fs::write(
2323 root.path().join("DB.md"),
2324 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
2325 )
2326 .unwrap();
2327 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
2328
2329 let external = tempfile::tempdir().unwrap();
2330 let secret = external.path().join("secret.md");
2331 std::fs::write(&secret, "TOP SECRET").unwrap();
2332 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
2333
2334 let store = Store::open_strict(root.path()).unwrap();
2335 let err = collect_push_files(&store).unwrap_err().to_string();
2336 assert!(err.contains("cannot push"), "{err}");
2337 assert!(
2338 !err.contains("TOP SECRET"),
2339 "external bytes must never leak"
2340 );
2341
2342 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
2343 let nested = root.path().join("records/nested");
2344 std::fs::create_dir_all(&nested).unwrap();
2345 std::fs::write(
2346 nested.join("DB.md"),
2347 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
2348 )
2349 .unwrap();
2350 let err = collect_push_files(&store).unwrap_err().to_string();
2351 assert!(err.contains("nested db.md store"), "{err}");
2352 }
2353
2354 #[test]
2355 fn signed_feed_item_verifies_identity_hash_and_signature() {
2356 use ring::rand::SystemRandom;
2357 use ring::signature::{Ed25519KeyPair, KeyPair};
2358
2359 const PREFIX: &[u8] = &[
2360 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
2361 ];
2362 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
2363 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
2364 let mut spki = PREFIX.to_vec();
2365 spki.extend_from_slice(pair.public_key().as_ref());
2366 let public_key = URL_SAFE_NO_PAD.encode(&spki);
2367 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
2368 let mut entry = FeedEntry {
2369 v: 1,
2370 seq: 1,
2371 ts: "2026-07-14T00:00:00.000Z".to_string(),
2372 brain: format!("ed25519:{fingerprint}"),
2373 public_key: public_key.clone(),
2374 kind: "push".to_string(),
2375 op: "snapshot".to_string(),
2376 pack_sha256: "a".repeat(64),
2377 files: vec![FeedFile {
2378 path: "DB.md".to_string(),
2379 sha256: "b".repeat(64),
2380 bytes: 3,
2381 }],
2382 removed: vec![],
2383 prev_entry_hash: None,
2384 sig: String::new(),
2385 };
2386 let unsigned = UnsignedFeedEntry {
2387 v: entry.v,
2388 seq: entry.seq,
2389 ts: &entry.ts,
2390 brain: &entry.brain,
2391 public_key: &entry.public_key,
2392 kind: &entry.kind,
2393 op: &entry.op,
2394 pack_sha256: &entry.pack_sha256,
2395 files: &entry.files,
2396 removed: &entry.removed,
2397 prev_entry_hash: &entry.prev_entry_hash,
2398 };
2399 entry.sig =
2400 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
2401 let mut exact = serde_json::to_vec(&entry).unwrap();
2402 exact.push(b'\n');
2403 let item = FeedItem {
2404 hash: format!("{:x}", Sha256::digest(&exact)),
2405 entry,
2406 };
2407 let identity = FeedIdentity {
2408 fingerprint,
2409 public_key_spki: public_key,
2410 previous: Vec::new(),
2411 };
2412 assert!(verify_feed_item(&item, &identity).is_ok());
2413 let mut tampered = item;
2414 tampered.entry.pack_sha256 = "c".repeat(64);
2415 assert!(verify_feed_item(&tampered, &identity).is_err());
2416 }
2417
2418 #[test]
2419 fn a_self_custody_entry_verifies_like_any_hub_entry() {
2420 let rng = ring::rand::SystemRandom::new();
2421 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
2422 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
2423 let (spki, multikey) = public_identity_for(&pair);
2424 let key = AgentSigningKey {
2425 pkcs8: pkcs8.as_ref().to_vec(),
2426 multikey: multikey.clone(),
2427 public_key_spki: spki.clone(),
2428 };
2429 let files = vec![WireFeedFile {
2430 path: "DB.md".to_string(),
2431 sha256: "a".repeat(64),
2432 bytes: 3,
2433 }];
2434 let raw = self_custody_entry(
2435 &key,
2436 1,
2437 "2026-07-23T12:00:00.000Z".to_string(),
2438 &"c".repeat(64),
2439 &files,
2440 None,
2441 )
2442 .unwrap();
2443 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
2447 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
2448 let item = FeedItem { hash, entry };
2449 let identity = FeedIdentity {
2450 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
2451 public_key_spki: spki,
2452 previous: Vec::new(),
2453 };
2454 assert!(verify_feed_item(&item, &identity).is_ok());
2455 }
2456
2457 #[test]
2460 fn address_bare_brain_with_and_without_sigil() {
2461 for raw in ["@acme-ops", "acme-ops"] {
2462 let a = Address::parse(raw).expect(raw);
2463 assert_eq!(a.brain, "acme-ops");
2464 assert_eq!(a.target, None);
2465 }
2466 }
2467
2468 #[test]
2469 fn address_ulid_target_parses_as_id() {
2470 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
2471 assert_eq!(a.brain, "acme");
2472 assert_eq!(
2473 a.target,
2474 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
2475 );
2476 }
2477
2478 #[test]
2479 fn address_md_path_target_parses_as_path() {
2480 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
2481 assert_eq!(
2482 a.target,
2483 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
2484 );
2485 }
2486
2487 #[test]
2488 fn address_rejects_malformed_forms() {
2489 for raw in [
2490 "",
2491 "@",
2492 "@/x",
2493 "@acme/",
2494 "@acme/../etc/passwd",
2495 "@acme/records/.hidden.md",
2496 "@ACME", "@acme/notes/x.txt", "@a b", ] {
2500 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
2501 }
2502 }
2503
2504 #[test]
2507 fn safe_paths_accept_store_shapes_and_reject_escapes() {
2508 for ok in [
2509 "DB.md",
2510 "assets.jsonl",
2511 "records/clients/lumio.md",
2512 "sources/emails/2026/07/x.md",
2513 ] {
2514 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
2515 }
2516 for bad in [
2517 "",
2518 "/etc/passwd",
2519 "../up.md",
2520 "records/../../up.md",
2521 "records//x.md",
2522 ".dbmd/config",
2523 "records/.hidden/x.md",
2524 "records/a b.md",
2525 "records\\win.md",
2526 ] {
2527 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
2528 }
2529 }
2530
2531 #[test]
2535 fn hub_config_flag_beats_file_and_requires_some_source() {
2536 let dir = tempfile::tempdir().unwrap();
2537 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
2538 std::fs::write(
2539 dir.path().join(CONFIG_REL_PATH),
2540 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
2541 )
2542 .unwrap();
2543
2544 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
2545 assert_eq!(from_flag.hub, "https://flag.example.com");
2546
2547 let from_file = hub_config(None, dir.path()).unwrap();
2548 assert_eq!(from_file.hub, "https://file.example.com");
2549
2550 let none = hub_config(None, tempfile::tempdir().unwrap().path());
2551 assert!(matches!(none, Err(LinkError::NoHub)));
2552 }
2553
2554 #[test]
2555 fn https_guard_allows_loopback_only_for_plain_http() {
2556 assert!(assert_safe_hub("https://hub.example.com").is_ok());
2557 assert!(assert_safe_hub("http://localhost:3000").is_ok());
2558 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
2559 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
2560 assert!(matches!(
2561 assert_safe_hub("http://hub.example.com"),
2562 Err(LinkError::UnsafeHub { .. })
2563 ));
2564 assert!(matches!(
2565 assert_safe_hub("hub.example.com"),
2566 Err(LinkError::UnsafeHub { .. })
2567 ));
2568 assert!(matches!(
2569 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
2570 Err(LinkError::UnsafeHub { .. })
2571 ));
2572 assert!(matches!(
2573 assert_safe_hub("https://hub.example.com@attacker.example"),
2574 Err(LinkError::UnsafeHub { .. })
2575 ));
2576 }
2577
2578 #[test]
2579 fn https_guard_matches_the_scheme_case_insensitively() {
2580 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
2583 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
2584 assert!(matches!(
2586 assert_safe_hub("HTTP://hub.example.com"),
2587 Err(LinkError::UnsafeHub { .. })
2588 ));
2589 }
2590
2591 #[test]
2592 fn clean_key_refuses_paste_artifacts_without_echoing() {
2593 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
2594 for bad in ["vc account", "vc\naccount", "ключ", ""] {
2595 let err = clean_key(bad).unwrap_err();
2596 assert!(matches!(err, LinkError::BadKey));
2597 assert!(
2598 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
2599 "error must not echo the key"
2600 );
2601 }
2602 }
2603
2604 fn dead_hub() -> HubConfig {
2610 HubConfig {
2611 hub: "http://127.0.0.1:9".to_string(),
2612 key: Some("k".to_string()),
2613 agent_key: None,
2614 brain_key: None,
2615 }
2616 }
2617
2618 #[test]
2619 fn request_retries_a_connection_failure_before_sending() {
2620 use std::io::{Read as _, Write as _};
2621 use std::net::TcpListener;
2622 use std::thread;
2623 use std::time::Duration;
2624
2625 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
2626 let address = probe.local_addr().unwrap();
2627 drop(probe);
2628 let server = thread::spawn(move || {
2629 thread::sleep(Duration::from_millis(40));
2630 let listener = TcpListener::bind(address).unwrap();
2631 let (mut stream, _) = listener.accept().unwrap();
2632 let mut request_bytes = [0_u8; 1024];
2633 let _ = stream.read(&mut request_bytes).unwrap();
2634 stream
2635 .write_all(
2636 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
2637 )
2638 .unwrap();
2639 });
2640 let cfg = HubConfig {
2641 hub: format!("http://{address}"),
2642 key: None,
2643 agent_key: None,
2644 brain_key: None,
2645 };
2646
2647 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
2648 assert_eq!(response.status, 200);
2649 assert_eq!(response.body, Some(json!({ "ok": true })));
2650 server.join().unwrap();
2651 }
2652
2653 #[test]
2654 fn verb_entry_gates_accept_the_hub_ref_shapes() {
2655 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
2656 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
2657 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
2658 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
2659 }
2660 }
2661
2662 #[test]
2663 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
2664 let cfg = dead_hub();
2665 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
2666 assert!(
2667 matches!(
2668 sync_pull(&cfg, bad, None),
2669 Err(LinkError::BadAddress { .. })
2670 ),
2671 "sync_pull must refuse {bad:?}"
2672 );
2673 assert!(
2674 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
2675 "sync_push must refuse {bad:?}"
2676 );
2677 assert!(
2678 matches!(
2679 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
2680 Err(LinkError::BadAddress { .. })
2681 ),
2682 "grant_issue must refuse {bad:?}"
2683 );
2684 assert!(
2685 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
2686 "grant_list must refuse {bad:?}"
2687 );
2688 assert!(
2689 matches!(
2690 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
2691 Err(LinkError::BadAddress { .. })
2692 ),
2693 "grant_revoke must refuse brain {bad:?}"
2694 );
2695 assert!(
2696 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
2697 "head must refuse {bad:?}"
2698 );
2699 }
2700 }
2701
2702 #[test]
2703 fn grant_revoke_refuses_url_reshaping_grant_ids() {
2704 let cfg = dead_hub();
2705 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
2706 assert!(
2707 matches!(
2708 grant_revoke(&cfg, "acme", bad),
2709 Err(LinkError::BadGrantId { .. })
2710 ),
2711 "grant_revoke must refuse grant id {bad:?}"
2712 );
2713 }
2714 }
2715
2716 #[test]
2717 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
2718 let cfg = dead_hub();
2719 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
2720 assert!(
2721 matches!(
2722 propose(&cfg, bad, "intake", "hi"),
2723 Err(LinkError::BadAddress { .. })
2724 ),
2725 "propose must refuse handle {bad:?}"
2726 );
2727 }
2728 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
2729 assert!(matches!(
2730 propose(&cfg, "acme-site", "intake", &oversize),
2731 Err(LinkError::ProposeTooLarge { .. })
2732 ));
2733 assert!(matches!(
2736 propose(&cfg, "acme-site", "intake", "hi"),
2737 Err(LinkError::Transport { .. })
2738 ));
2739 }
2740
2741 #[test]
2742 fn resolve_refuses_a_hand_built_unsafe_address() {
2743 let cfg = dead_hub();
2744 for brain in ["../up", "a/b", "a?x", "a#f"] {
2745 let addr = Address {
2746 brain: brain.to_string(),
2747 target: None,
2748 };
2749 assert!(
2750 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
2751 "resolve must refuse brain {brain:?}"
2752 );
2753 }
2754 for target in [
2755 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
2756 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
2758 AddressTarget::Path("records/x.md#frag".to_string()),
2759 ] {
2760 let addr = Address {
2761 brain: "acme".to_string(),
2762 target: Some(target.clone()),
2763 };
2764 assert!(
2765 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
2766 "resolve must refuse target {target:?}"
2767 );
2768 }
2769 }
2770}