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 CONFIG_REL_PATH: &str = ".dbmd/config";
78
79const MAX_RESPONSE_BYTES: u64 = 256 * 1024 * 1024;
83
84const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
87
88const MAX_PUSH_FILES: usize = 100_000;
90const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
91const MAX_PACK_BYTES: u64 = 256 * 1024 * 1024;
92
93pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
98
99const CONNECT_TIMEOUT_SECS: u64 = 10;
102const READ_TIMEOUT_SECS: u64 = 120;
103const CONNECT_ATTEMPTS: usize = 3;
104const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
105
106#[derive(Debug, thiserror::Error)]
110pub enum LinkError {
111 #[error(
113 "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
114 )]
115 NoHub,
116
117 #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
119 NoCredential,
120
121 #[error(
124 "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
125 )]
126 BadKey,
127
128 #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
130 UnsafeHub {
131 hub: String,
133 },
134
135 #[error("hub unreachable at {hub}: {message}")]
137 Transport {
138 hub: String,
140 message: String,
142 },
143
144 #[error("{what} failed (HTTP {status}): {message}")]
146 Http {
147 what: &'static str,
149 status: u16,
151 message: String,
153 code: Option<String>,
155 },
156
157 #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
160 NotJson {
161 what: &'static str,
163 status: u16,
165 },
166
167 #[error("hub response exceeded {} MB — refusing to buffer it", MAX_RESPONSE_BYTES / (1024 * 1024))]
169 ResponseTooLarge,
170
171 #[error("invalid address `{given}`: {reason}")]
173 BadAddress {
174 given: String,
176 reason: String,
178 },
179
180 #[error(
182 "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
183 )]
184 BadGrantId {
185 given: String,
187 },
188
189 #[error("refusing unsafe path from the hub: `{path}`")]
193 UnsafePath {
194 path: String,
196 },
197
198 #[error(
200 "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB compressed, and {MAX_PUSH_FILES} files",
201 MAX_STORE_BYTES / (1024 * 1024),
202 MAX_PACK_BYTES / (1024 * 1024)
203 )]
204 PushTooLarge {
205 detail: String,
207 },
208
209 #[error(
211 "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
212 MAX_PROPOSE_BYTES / 1024
213 )]
214 ProposeTooLarge {
215 bytes: u64,
217 },
218
219 #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
221 NotUtf8 {
222 path: String,
224 },
225
226 #[error("invalid store pack: {message}")]
228 InvalidPack {
229 message: String,
231 },
232
233 #[error("invalid signed feed: {message}")]
235 InvalidFeed {
236 message: String,
238 },
239
240 #[error(transparent)]
242 Io(#[from] std::io::Error),
243
244 #[error(transparent)]
246 Store(#[from] crate::StoreError),
247}
248
249pub type LinkResult<T> = std::result::Result<T, LinkError>;
251
252#[derive(Debug, Clone, PartialEq, Eq)]
258pub enum AddressTarget {
259 Id(String),
261 Path(String),
265}
266
267const BAD_BRAIN_REASON: &str =
270 "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
271
272const BAD_TARGET_REASON: &str =
275 "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
276
277#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct Address {
283 pub brain: String,
285 pub target: Option<AddressTarget>,
287}
288
289impl Address {
290 pub fn parse(raw: &str) -> LinkResult<Address> {
294 let bad = |reason: &str| LinkError::BadAddress {
295 given: raw.to_string(),
296 reason: reason.to_string(),
297 };
298
299 let trimmed = raw.trim();
300 let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
301 if body.is_empty() {
302 return Err(bad("empty address"));
303 }
304
305 let (brain, rest) = match body.split_once('/') {
306 Some((b, r)) => (b, Some(r)),
307 None => (body, None),
308 };
309
310 if brain.is_empty() {
311 return Err(bad("missing brain reference before `/`"));
312 }
313 if !is_safe_ref(brain) {
314 return Err(bad(BAD_BRAIN_REASON));
315 }
316
317 let target = match rest {
318 None => None,
319 Some("") => return Err(bad("trailing `/` with no record id or path")),
320 Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
321 Some(r) => {
322 if !safe_store_rel_path(r) || !r.ends_with(".md") {
323 return Err(bad(BAD_TARGET_REASON));
324 }
325 Some(AddressTarget::Path(r.to_string()))
326 }
327 };
328
329 Ok(Address {
330 brain: brain.to_string(),
331 target,
332 })
333 }
334}
335
336fn is_safe_ref(s: &str) -> bool {
339 !s.is_empty()
340 && s.len() <= 64
341 && s.bytes()
342 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
343}
344
345pub fn is_valid_handle(s: &str) -> bool {
348 is_safe_ref(s)
349}
350
351pub fn safe_store_rel_path(p: &str) -> bool {
357 if p.is_empty() || p.len() > 512 || p.starts_with('/') {
358 return false;
359 }
360 if !p
361 .bytes()
362 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
363 {
364 return false;
365 }
366 p.split('/')
367 .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
368}
369
370fn require_safe_ref(brain: &str) -> LinkResult<()> {
378 if is_safe_ref(brain) {
379 Ok(())
380 } else {
381 Err(LinkError::BadAddress {
382 given: brain.to_string(),
383 reason: BAD_BRAIN_REASON.to_string(),
384 })
385 }
386}
387
388fn require_valid_handle(handle: &str) -> LinkResult<()> {
390 if is_valid_handle(handle) {
391 Ok(())
392 } else {
393 Err(LinkError::BadAddress {
394 given: handle.to_string(),
395 reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
396 })
397 }
398}
399
400fn require_safe_grant_id(id: &str) -> LinkResult<()> {
404 if is_safe_ref(id) {
405 Ok(())
406 } else {
407 Err(LinkError::BadGrantId {
408 given: id.to_string(),
409 })
410 }
411}
412
413#[derive(Debug, Clone)]
419pub struct HubConfig {
420 pub hub: String,
422 pub key: Option<String>,
424}
425
426impl HubConfig {
427 pub fn require_key(&self) -> LinkResult<&str> {
430 self.key.as_deref().ok_or(LinkError::NoCredential)
431 }
432}
433
434pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
439 let hub = flag_hub
440 .map(str::to_string)
441 .or_else(|| env_nonempty(HUB_URL_ENV))
442 .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
443 .ok_or(LinkError::NoHub)?;
444 let hub = hub.trim().trim_end_matches('/').to_string();
445 assert_safe_hub(&hub)?;
446
447 let key = match env_nonempty(HUB_KEY_ENV) {
448 Some(raw) => Some(clean_key(&raw)?),
449 None => None,
450 };
451
452 Ok(HubConfig { hub, key })
453}
454
455fn env_nonempty(name: &str) -> Option<String> {
458 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
459}
460
461fn config_file_hub(path: &Path) -> Option<String> {
466 let text = std::fs::read_to_string(path).ok()?;
467 for line in text.lines() {
468 let line = line.trim();
469 if line.is_empty() || line.starts_with('#') {
470 continue;
471 }
472 if let Some((k, v)) = line.split_once('=') {
473 if k.trim() == "hub" {
474 let v = v.trim();
475 if !v.is_empty() {
476 return Some(v.to_string());
477 }
478 }
479 }
480 }
481 None
482}
483
484fn assert_safe_hub(hub: &str) -> LinkResult<()> {
487 let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
488 hub: hub.to_string(),
489 })?;
490 if !parsed.username().is_empty()
491 || parsed.password().is_some()
492 || parsed.query().is_some()
493 || parsed.fragment().is_some()
494 {
495 return Err(LinkError::UnsafeHub {
496 hub: hub.to_string(),
497 });
498 }
499 let loopback = match parsed.host() {
500 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
501 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
502 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
503 None => false,
504 };
505 if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
506 Ok(())
507 } else {
508 Err(LinkError::UnsafeHub {
509 hub: hub.to_string(),
510 })
511 }
512}
513
514fn clean_key(raw: &str) -> LinkResult<String> {
519 let k = raw.trim();
520 if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
521 return Err(LinkError::BadKey);
522 }
523 Ok(k.to_string())
524}
525
526#[derive(Debug)]
532pub struct HubResponse {
533 pub status: u16,
535 pub body: Option<Value>,
537}
538
539#[derive(Debug, Clone, Copy, PartialEq, Eq)]
541enum Auth {
542 Required,
544 None,
546}
547
548fn agent() -> ureq::Agent {
549 ureq::AgentBuilder::new()
550 .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
551 .redirects(0)
555 .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
556 .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
557 .build()
558}
559
560fn request(
565 cfg: &HubConfig,
566 method: &str,
567 path: &str,
568 body: Option<&Value>,
569 auth: Auth,
570) -> LinkResult<HubResponse> {
571 let url = format!("{}{}", cfg.hub, path);
572 let credential = match auth {
573 Auth::Required => Some(format!("Bearer {}", cfg.require_key()?)),
574 Auth::None => None,
575 };
576 let encoded_body = body.map(Value::to_string);
577 let http = agent();
578 let result = with_connect_retries(|| {
579 let mut req = http.request(method, &url);
580 if let Some(value) = &credential {
581 req = req.set("authorization", value);
582 }
583 match &encoded_body {
584 Some(value) => req
585 .set("content-type", "application/json")
586 .send_string(value)
587 .map_err(Box::new),
588 None => req.call().map_err(Box::new),
589 }
590 });
591 let resp = match result {
592 Ok(resp) => resp,
593 Err(error) => match *error {
594 ureq::Error::Status(_, resp) => resp,
595 ureq::Error::Transport(error) => {
596 return Err(LinkError::Transport {
597 hub: cfg.hub.clone(),
598 message: error.to_string(),
599 });
600 }
601 },
602 };
603
604 let status = resp.status();
605 let mut buf = Vec::new();
606 resp.into_reader()
607 .take(MAX_RESPONSE_BYTES + 1)
608 .read_to_end(&mut buf)?;
609 if buf.len() as u64 > MAX_RESPONSE_BYTES {
610 return Err(LinkError::ResponseTooLarge);
611 }
612 let parsed: Option<Value> = serde_json::from_slice(&buf).ok();
613 Ok(HubResponse {
614 status,
615 body: parsed,
616 })
617}
618
619fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
624 matches!(
625 kind,
626 ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
627 )
628}
629
630fn with_connect_retries(
631 mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
632) -> Result<ureq::Response, Box<ureq::Error>> {
633 let mut attempt = 0;
634 loop {
635 match send() {
636 Err(error)
637 if matches!(
638 error.as_ref(),
639 ureq::Error::Transport(transport)
640 if is_pre_request_transport(transport.kind())
641 ) && attempt + 1 < CONNECT_ATTEMPTS =>
642 {
643 std::thread::sleep(std::time::Duration::from_millis(
644 CONNECT_RETRY_BACKOFF_MS[attempt],
645 ));
646 attempt += 1;
647 }
648 result => return result,
649 }
650 }
651}
652
653fn assert_safe_presigned_url(raw: &str) -> LinkResult<()> {
654 let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
655 message: "the hub returned an invalid object-store URL".to_string(),
656 })?;
657 if !parsed.scheme().eq_ignore_ascii_case("https")
658 || !parsed.username().is_empty()
659 || parsed.password().is_some()
660 || parsed.fragment().is_some()
661 {
662 return Err(LinkError::InvalidPack {
663 message: "the hub returned an unsafe object-store URL".to_string(),
664 });
665 }
666 Ok(())
667}
668
669fn put_presigned(raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
670 assert_safe_presigned_url(raw)?;
671 let http = agent();
672 let result = with_connect_retries(|| {
673 let mut req = http.put(raw);
674 if let Some(map) = headers.as_object() {
675 for (name, value) in map {
676 if let Some(value) = value.as_str() {
677 req = req.set(name, value);
678 }
679 }
680 }
681 req.send_bytes(bytes).map_err(Box::new)
682 });
683 match result {
684 Ok(resp) if resp.status() < 300 => Ok(()),
685 Ok(resp) => Err(LinkError::Http {
686 what: "pack upload",
687 status: resp.status(),
688 message: "object store rejected the upload".to_string(),
689 code: None,
690 }),
691 Err(error) => match *error {
692 ureq::Error::Status(_, resp) => Err(LinkError::Http {
693 what: "pack upload",
694 status: resp.status(),
695 message: "object store rejected the upload".to_string(),
696 code: None,
697 }),
698 ureq::Error::Transport(err) => Err(LinkError::Transport {
699 hub: "the object store".to_string(),
700 message: err.to_string(),
701 }),
702 },
703 }
704}
705
706fn get_presigned(raw: &str) -> LinkResult<Vec<u8>> {
707 assert_safe_presigned_url(raw)?;
708 let http = agent();
709 let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
710 Ok(resp) => resp,
711 Err(error) => match *error {
712 ureq::Error::Status(_, resp) => {
713 return Err(LinkError::Http {
714 what: "pack download",
715 status: resp.status(),
716 message: "object store rejected the download".to_string(),
717 code: None,
718 });
719 }
720 ureq::Error::Transport(err) => {
721 return Err(LinkError::Transport {
722 hub: "the object store".to_string(),
723 message: err.to_string(),
724 });
725 }
726 },
727 };
728 let mut bytes = Vec::new();
729 resp.into_reader()
730 .take(MAX_PACK_BYTES + 1)
731 .read_to_end(&mut bytes)?;
732 if bytes.len() as u64 > MAX_PACK_BYTES {
733 return Err(LinkError::InvalidPack {
734 message: "download exceeds the compressed-size limit".to_string(),
735 });
736 }
737 Ok(bytes)
738}
739
740fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
744 if r.status >= 400 {
745 let message = r
746 .body
747 .as_ref()
748 .and_then(|b| b.get("error"))
749 .and_then(Value::as_str)
750 .unwrap_or("unknown error")
751 .to_string();
752 let code = r
753 .body
754 .as_ref()
755 .and_then(|b| b.get("code"))
756 .and_then(Value::as_str)
757 .map(str::to_string);
758 return Err(LinkError::Http {
759 what,
760 status: r.status,
761 message,
762 code,
763 });
764 }
765 r.body.ok_or(LinkError::NotJson {
766 what,
767 status: r.status,
768 })
769}
770
771pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
780 require_safe_ref(&addr.brain)?;
784 if let Some(target) = &addr.target {
785 let (given, ok) = match target {
786 AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
787 AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
788 };
789 if !ok {
790 return Err(LinkError::BadAddress {
791 given: given.clone(),
792 reason: BAD_TARGET_REASON.to_string(),
793 });
794 }
795 }
796
797 let path = match &addr.target {
798 None => format!("/api/hub/brains/{}", addr.brain),
799 Some(AddressTarget::Id(id)) => {
800 format!("/api/hub/brains/{}/resolve?id={id}", addr.brain)
801 }
802 Some(AddressTarget::Path(p)) => {
803 format!("/api/hub/brains/{}/resolve?path={p}", addr.brain)
804 }
805 };
806 ensure_ok(request(cfg, "GET", &path, None, Auth::Required)?, "resolve")
807}
808
809#[derive(Debug, serde::Serialize)]
815pub struct PullReport {
816 pub brain: String,
818 pub slug: String,
820 #[serde(rename = "headSeq")]
822 pub head_seq: u64,
823 pub files: usize,
825 pub dest: String,
827 #[serde(rename = "extraLocal")]
830 pub extra_local: Vec<String>,
831}
832
833pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
839 require_safe_ref(brain)?;
840 let path = format!("/api/hub/brains/{brain}/export?format=pack");
841 let body = ensure_ok(
842 request(cfg, "GET", &path, None, Auth::Required)?,
843 "sync pull",
844 )?;
845
846 let remote_slug = body
847 .get("slug")
848 .and_then(Value::as_str)
849 .filter(|slug| is_safe_slug(slug));
850 let slug = remote_slug
851 .or_else(|| is_safe_slug(brain).then_some(brain))
852 .unwrap_or("brain")
853 .to_string();
854 let brain_id = body
855 .get("brain")
856 .and_then(Value::as_str)
857 .unwrap_or(brain)
858 .to_string();
859 let head_seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
860 let dest: PathBuf = match out {
861 Some(p) => p.to_path_buf(),
862 None => PathBuf::from(&slug),
863 };
864 let entries =
865 if let Some(url) = body.get("url").and_then(Value::as_str) {
866 let expected = body
867 .get("sha256")
868 .and_then(Value::as_str)
869 .filter(|hash| {
870 hash.len() == 64
871 && hash
872 .bytes()
873 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
874 })
875 .ok_or_else(|| LinkError::InvalidPack {
876 message: "the hub returned an invalid SHA-256".to_string(),
877 })?;
878 let bytes = get_presigned(url)?;
879 let actual = format!("{:x}", Sha256::digest(&bytes));
880 if actual != expected {
881 return Err(LinkError::InvalidPack {
882 message: "SHA-256 verification failed".to_string(),
883 });
884 }
885 parse_store_pack(bytes)?
886 } else {
887 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
888 LinkError::InvalidPack {
889 message: "the hub returned neither a pack nor a file manifest".to_string(),
890 }
891 })?;
892 let mut entries = Vec::with_capacity(files.len());
893 for file in files {
894 let path = file.get("path").and_then(Value::as_str).ok_or_else(|| {
895 LinkError::InvalidPack {
896 message: "a file entry has no string path".to_string(),
897 }
898 })?;
899 let content = file.get("content").and_then(Value::as_str).ok_or_else(|| {
900 LinkError::InvalidPack {
901 message: format!("file `{path}` has no string content"),
902 }
903 })?;
904 entries.push((path.to_string(), content.as_bytes().to_vec()));
905 }
906 entries
907 };
908
909 let mut seen = std::collections::HashSet::new();
911 for (path, _) in &entries {
912 if !safe_store_rel_path(path) {
913 return Err(LinkError::UnsafePath { path: path.clone() });
914 }
915 if !seen.insert(path) {
916 return Err(LinkError::InvalidPack {
917 message: format!("duplicate path `{path}`"),
918 });
919 }
920 }
921 std::fs::create_dir_all(&dest)?;
922 let real_dest = std::fs::canonicalize(&dest)?;
923
924 for (p, content) in &entries {
925 let abs = dest.join(p);
926 if let Some(parent) = abs.parent() {
927 std::fs::create_dir_all(parent)?;
928 let real_parent = std::fs::canonicalize(parent)?;
929 if !real_parent.starts_with(&real_dest) {
930 return Err(LinkError::UnsafePath { path: p.clone() });
931 }
932 }
933 if std::fs::symlink_metadata(&abs).is_ok_and(|meta| meta.file_type().is_symlink()) {
934 return Err(LinkError::UnsafePath { path: p.clone() });
935 }
936 write_atomic(&abs, content)?;
937 }
938
939 let pulled: std::collections::BTreeSet<&str> =
943 entries.iter().map(|(p, _)| p.as_str()).collect();
944 let mut extra_local = Vec::new();
945 if let Ok(store) = Store::open(&dest) {
946 if let Ok(walked) = store.walk() {
947 for rel in walked {
948 let rel_str = rel.to_string_lossy().replace('\\', "/");
949 if !pulled.contains(rel_str.as_str()) {
950 extra_local.push(rel_str);
951 }
952 }
953 }
954 }
955
956 Ok(PullReport {
957 brain: brain_id,
958 slug,
959 head_seq,
960 files: entries.len(),
961 dest: dest.to_string_lossy().into_owned(),
962 extra_local,
963 })
964}
965
966fn is_safe_slug(slug: &str) -> bool {
967 !slug.is_empty()
968 && slug.len() <= 63
969 && !slug.starts_with('-')
970 && !slug.ends_with('-')
971 && slug
972 .bytes()
973 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
974}
975
976fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
977 let mut archive =
978 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
979 message: format!("ZIP parse failed: {err}"),
980 })?;
981 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
982 return Err(LinkError::InvalidPack {
983 message: format!("invalid file count {}", archive.len()),
984 });
985 }
986 let mut total = 0u64;
987 let mut entries = Vec::with_capacity(archive.len());
988 for index in 0..archive.len() {
989 let mut file = archive
990 .by_index(index)
991 .map_err(|err| LinkError::InvalidPack {
992 message: format!("ZIP entry failed: {err}"),
993 })?;
994 if file.is_dir() {
995 continue;
996 }
997 let path = file.name().to_string();
998 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
999 return Err(LinkError::UnsafePath { path });
1000 }
1001 if file
1002 .unix_mode()
1003 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
1004 {
1005 return Err(LinkError::InvalidPack {
1006 message: format!("non-file entry `{path}`"),
1007 });
1008 }
1009 total = total.saturating_add(file.size());
1010 if total > MAX_STORE_BYTES {
1011 return Err(LinkError::InvalidPack {
1012 message: "expanded content exceeds the 512 MB limit".to_string(),
1013 });
1014 }
1015 let mut content = Vec::new();
1016 file.read_to_end(&mut content)
1017 .map_err(|err| LinkError::InvalidPack {
1018 message: format!("could not decompress `{path}`: {err}"),
1019 })?;
1020 if content.len() as u64 != file.size() {
1021 return Err(LinkError::InvalidPack {
1022 message: format!("length mismatch for `{path}`"),
1023 });
1024 }
1025 entries.push((path, content));
1026 }
1027 if entries.is_empty() {
1028 return Err(LinkError::InvalidPack {
1029 message: "pack contains no files".to_string(),
1030 });
1031 }
1032 Ok(entries)
1033}
1034
1035pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
1042 let mut out: Vec<(String, String)> = Vec::new();
1043
1044 let read_text = |rel: &str| -> LinkResult<String> {
1045 let abs = store.root.join(rel);
1046 std::fs::read(&abs)
1047 .map_err(LinkError::from)
1048 .and_then(|bytes| {
1049 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
1050 path: rel.to_string(),
1051 })
1052 })
1053 };
1054
1055 out.push(("DB.md".to_string(), read_text("DB.md")?));
1056 if store.root.join("assets.jsonl").is_file() {
1057 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
1058 }
1059
1060 for rel in store.walk()? {
1061 let rel_str = rel.to_string_lossy().replace('\\', "/");
1062 if !safe_store_rel_path(&rel_str) {
1063 return Err(LinkError::UnsafePath { path: rel_str });
1066 }
1067 let content = read_text(&rel_str)?;
1068 out.push((rel_str, content));
1069 }
1070
1071 out.sort_by(|a, b| a.0.cmp(&b.0));
1072 Ok(out)
1073}
1074
1075pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
1081 require_safe_ref(brain)?;
1082 if files.len() > MAX_PUSH_FILES {
1083 return Err(LinkError::PushTooLarge {
1084 detail: format!("{} files", files.len()),
1085 });
1086 }
1087 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
1088 if raw_total > MAX_STORE_BYTES {
1089 return Err(LinkError::PushTooLarge {
1090 detail: format!("{raw_total} uncompressed bytes"),
1091 });
1092 }
1093
1094 let body = json!({
1095 "files": files
1096 .iter()
1097 .map(|(p, c)| json!({ "path": p, "content": c }))
1098 .collect::<Vec<_>>(),
1099 });
1100 if body.to_string().len() <= MAX_PUSH_BYTES {
1101 let path = format!("/api/hub/brains/{brain}/push");
1102 return ensure_ok(
1103 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
1104 "sync push",
1105 );
1106 }
1107
1108 let pack = build_store_pack(files)?;
1109 if pack.len() as u64 > MAX_PACK_BYTES {
1110 return Err(LinkError::PushTooLarge {
1111 detail: format!("{} compressed bytes", pack.len()),
1112 });
1113 }
1114 let sha256 = format!("{:x}", Sha256::digest(&pack));
1115 let meta = json!({ "sha256": sha256, "bytes": pack.len() });
1116 let presigned = ensure_ok(
1117 request(
1118 cfg,
1119 "POST",
1120 &format!("/api/hub/brains/{brain}/packs/presign"),
1121 Some(&meta),
1122 Auth::Required,
1123 )?,
1124 "prepare pack upload",
1125 )?;
1126 let url = presigned
1127 .get("url")
1128 .and_then(Value::as_str)
1129 .ok_or_else(|| LinkError::InvalidPack {
1130 message: "the hub returned no upload URL".to_string(),
1131 })?;
1132 put_presigned(url, presigned.get("headers").unwrap_or(&Value::Null), &pack)?;
1133 ensure_ok(
1134 request(
1135 cfg,
1136 "POST",
1137 &format!("/api/hub/brains/{brain}/packs/commit"),
1138 Some(&meta),
1139 Auth::Required,
1140 )?,
1141 "commit pack",
1142 )
1143}
1144
1145fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
1146 let mut sorted: Vec<_> = files.iter().collect();
1147 sorted.sort_by(|a, b| a.0.cmp(&b.0));
1148 let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
1149 let options = zip::write::SimpleFileOptions::default()
1150 .compression_method(zip::CompressionMethod::Deflated)
1151 .last_modified_time(zip::DateTime::default())
1152 .unix_permissions(0o600);
1153 for (path, content) in sorted {
1154 writer
1155 .start_file(path, options)
1156 .map_err(|err| LinkError::InvalidPack {
1157 message: format!("could not create ZIP entry `{path}`: {err}"),
1158 })?;
1159 writer.write_all(content.as_bytes())?;
1160 }
1161 writer
1162 .finish()
1163 .map(Cursor::into_inner)
1164 .map_err(|err| LinkError::InvalidPack {
1165 message: format!("could not finish ZIP: {err}"),
1166 })
1167}
1168
1169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1175pub enum Capability {
1176 Read,
1178 Write,
1180}
1181
1182impl Capability {
1183 pub fn as_str(self) -> &'static str {
1185 match self {
1186 Capability::Read => "read",
1187 Capability::Write => "write",
1188 }
1189 }
1190}
1191
1192pub fn grant_issue(
1198 cfg: &HubConfig,
1199 brain: &str,
1200 grantee: &str,
1201 can: Capability,
1202 scope: Option<&str>,
1203 until: Option<&str>,
1204) -> LinkResult<Value> {
1205 require_safe_ref(brain)?;
1206 let mut body = json!({
1207 "email": grantee,
1208 "capability": can.as_str(),
1209 });
1210 if let Some(s) = scope {
1211 body["scopePrefix"] = json!(s);
1212 }
1213 if let Some(u) = until {
1214 body["expiresAt"] = json!(u);
1215 }
1216 let path = format!("/api/hub/brains/{brain}/grants");
1217 ensure_ok(
1218 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
1219 "grant issue",
1220 )
1221}
1222
1223pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
1225 require_safe_ref(brain)?;
1226 let path = format!("/api/hub/brains/{brain}/grants");
1227 ensure_ok(
1228 request(cfg, "GET", &path, None, Auth::Required)?,
1229 "grant list",
1230 )
1231}
1232
1233pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
1236 require_safe_ref(brain)?;
1237 require_safe_grant_id(grant_id)?;
1238 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
1239 ensure_ok(
1240 request(cfg, "DELETE", &path, None, Auth::Required)?,
1241 "grant revoke",
1242 )
1243}
1244
1245pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
1256 require_valid_handle(handle)?;
1257 if body.len() as u64 > MAX_PROPOSE_BYTES {
1258 return Err(LinkError::ProposeTooLarge {
1259 bytes: body.len() as u64,
1260 });
1261 }
1262 let payload = json!({ "app": app, "body": body });
1263 let path = format!("/api/hub/sites/{handle}/inbox");
1264 ensure_ok(
1265 request(cfg, "POST", &path, Some(&payload), Auth::None)?,
1266 "propose",
1267 )
1268}
1269
1270#[derive(Debug, serde::Serialize)]
1276pub struct Head {
1277 pub brain: String,
1279 pub seq: u64,
1281 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
1283 pub updated_at: Option<String>,
1284 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
1286 pub feed_hash: Option<String>,
1287 pub verified: bool,
1290}
1291
1292#[derive(Debug, Deserialize, Serialize)]
1293struct FeedFile {
1294 path: String,
1295 sha256: String,
1296 bytes: u64,
1297}
1298
1299#[derive(Debug, Deserialize, Serialize)]
1300struct FeedEntry {
1301 v: u8,
1302 seq: u64,
1303 ts: String,
1304 brain: String,
1305 public_key: String,
1306 kind: String,
1307 op: String,
1308 pack_sha256: String,
1309 files: Vec<FeedFile>,
1310 removed: Vec<String>,
1311 prev_entry_hash: Option<String>,
1312 sig: String,
1313}
1314
1315#[derive(Serialize)]
1316struct UnsignedFeedEntry<'a> {
1317 v: u8,
1318 seq: u64,
1319 ts: &'a str,
1320 brain: &'a str,
1321 public_key: &'a str,
1322 kind: &'a str,
1323 op: &'a str,
1324 pack_sha256: &'a str,
1325 files: &'a [FeedFile],
1326 removed: &'a [String],
1327 prev_entry_hash: &'a Option<String>,
1328}
1329
1330#[derive(Debug, Deserialize)]
1331struct FeedItem {
1332 hash: String,
1333 entry: FeedEntry,
1334}
1335
1336#[derive(Debug, Deserialize)]
1337struct FeedIdentity {
1338 fingerprint: String,
1339 #[serde(rename = "publicKeySpki")]
1340 public_key_spki: String,
1341}
1342
1343#[derive(Debug, Deserialize)]
1344struct FeedResponse {
1345 #[serde(rename = "headSeq")]
1346 head_seq: u64,
1347 #[serde(rename = "feedHash")]
1348 feed_hash: Option<String>,
1349 identity: Option<FeedIdentity>,
1350 entries: Vec<FeedItem>,
1351 #[serde(rename = "scopeLimited")]
1352 scope_limited: bool,
1353}
1354
1355fn invalid_feed(message: impl Into<String>) -> LinkError {
1356 LinkError::InvalidFeed {
1357 message: message.into(),
1358 }
1359}
1360
1361fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
1362 const ED25519_SPKI_PREFIX: &[u8] = &[
1363 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1364 ];
1365 let entry = &item.entry;
1366 let public_der = URL_SAFE_NO_PAD
1367 .decode(&entry.public_key)
1368 .map_err(|_| invalid_feed("public key is not base64url"))?;
1369 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
1370 || !public_der.starts_with(ED25519_SPKI_PREFIX)
1371 || identity.public_key_spki != entry.public_key
1372 {
1373 return Err(invalid_feed("public key does not match the brain card"));
1374 }
1375 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
1376 if fingerprint != identity.fingerprint || entry.brain != format!("ed25519:{fingerprint}") {
1377 return Err(invalid_feed(
1378 "brain fingerprint does not match its public key",
1379 ));
1380 }
1381 let unsigned = UnsignedFeedEntry {
1382 v: entry.v,
1383 seq: entry.seq,
1384 ts: &entry.ts,
1385 brain: &entry.brain,
1386 public_key: &entry.public_key,
1387 kind: &entry.kind,
1388 op: &entry.op,
1389 pack_sha256: &entry.pack_sha256,
1390 files: &entry.files,
1391 removed: &entry.removed,
1392 prev_entry_hash: &entry.prev_entry_hash,
1393 };
1394 let message =
1395 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
1396 let signature = URL_SAFE_NO_PAD
1397 .decode(&entry.sig)
1398 .map_err(|_| invalid_feed("signature is not base64url"))?;
1399 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
1400 .verify(&message, &signature)
1401 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
1402
1403 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
1404 exact.push(b'\n');
1405 let actual_hash = format!("{:x}", Sha256::digest(&exact));
1406 if actual_hash != item.hash {
1407 return Err(invalid_feed("entry SHA-256 does not match"));
1408 }
1409 Ok(())
1410}
1411
1412pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
1416 require_safe_ref(brain)?;
1417 let path = format!("/api/hub/brains/{brain}");
1418 let body = ensure_ok(
1419 request(cfg, "GET", &path, None, Auth::Required)?,
1420 "subscribe",
1421 )?;
1422 let resolved_brain = body
1423 .get("id")
1424 .and_then(Value::as_str)
1425 .unwrap_or(brain)
1426 .to_string();
1427 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
1428 let advertised_hash = body
1429 .get("feedHash")
1430 .and_then(Value::as_str)
1431 .map(str::to_string);
1432 let updated_at = body
1433 .get("updatedAt")
1434 .and_then(Value::as_str)
1435 .map(str::to_string);
1436 if seq == 0 {
1437 return Ok(Head {
1438 brain: resolved_brain,
1439 seq,
1440 updated_at,
1441 feed_hash: None,
1442 verified: true,
1443 });
1444 }
1445
1446 let feed_value = ensure_ok(
1447 request(
1448 cfg,
1449 "GET",
1450 &format!("/api/hub/brains/{brain}/feed?after={}&limit=1", seq - 1),
1451 None,
1452 Auth::Required,
1453 )?,
1454 "subscribe feed",
1455 )?;
1456 let feed: FeedResponse = serde_json::from_value(feed_value)
1457 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
1458 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
1459 return Err(invalid_feed("brain card and feed head disagree"));
1460 }
1461 if feed.scope_limited {
1462 return Ok(Head {
1463 brain: resolved_brain,
1464 seq,
1465 updated_at,
1466 feed_hash: advertised_hash,
1467 verified: false,
1468 });
1469 }
1470 let identity = feed
1471 .identity
1472 .as_ref()
1473 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
1474 let item = feed
1475 .entries
1476 .first()
1477 .ok_or_else(|| invalid_feed("feed head entry is missing"))?;
1478 if item.entry.seq != seq || Some(&item.hash) != advertised_hash.as_ref() {
1479 return Err(invalid_feed(
1480 "advertised feed hash does not address the head entry",
1481 ));
1482 }
1483 verify_feed_item(item, identity)?;
1484 Ok(Head {
1485 brain: resolved_brain,
1486 seq,
1487 updated_at,
1488 feed_hash: advertised_hash,
1489 verified: true,
1490 })
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495 use super::*;
1496
1497 #[test]
1498 fn signed_feed_item_verifies_identity_hash_and_signature() {
1499 use ring::rand::SystemRandom;
1500 use ring::signature::{Ed25519KeyPair, KeyPair};
1501
1502 const PREFIX: &[u8] = &[
1503 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1504 ];
1505 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
1506 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
1507 let mut spki = PREFIX.to_vec();
1508 spki.extend_from_slice(pair.public_key().as_ref());
1509 let public_key = URL_SAFE_NO_PAD.encode(&spki);
1510 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
1511 let mut entry = FeedEntry {
1512 v: 1,
1513 seq: 1,
1514 ts: "2026-07-14T00:00:00.000Z".to_string(),
1515 brain: format!("ed25519:{fingerprint}"),
1516 public_key: public_key.clone(),
1517 kind: "push".to_string(),
1518 op: "snapshot".to_string(),
1519 pack_sha256: "a".repeat(64),
1520 files: vec![FeedFile {
1521 path: "DB.md".to_string(),
1522 sha256: "b".repeat(64),
1523 bytes: 3,
1524 }],
1525 removed: vec![],
1526 prev_entry_hash: None,
1527 sig: String::new(),
1528 };
1529 let unsigned = UnsignedFeedEntry {
1530 v: entry.v,
1531 seq: entry.seq,
1532 ts: &entry.ts,
1533 brain: &entry.brain,
1534 public_key: &entry.public_key,
1535 kind: &entry.kind,
1536 op: &entry.op,
1537 pack_sha256: &entry.pack_sha256,
1538 files: &entry.files,
1539 removed: &entry.removed,
1540 prev_entry_hash: &entry.prev_entry_hash,
1541 };
1542 entry.sig =
1543 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
1544 let mut exact = serde_json::to_vec(&entry).unwrap();
1545 exact.push(b'\n');
1546 let item = FeedItem {
1547 hash: format!("{:x}", Sha256::digest(&exact)),
1548 entry,
1549 };
1550 let identity = FeedIdentity {
1551 fingerprint,
1552 public_key_spki: public_key,
1553 };
1554 assert!(verify_feed_item(&item, &identity).is_ok());
1555 let mut tampered = item;
1556 tampered.entry.pack_sha256 = "c".repeat(64);
1557 assert!(verify_feed_item(&tampered, &identity).is_err());
1558 }
1559
1560 #[test]
1563 fn address_bare_brain_with_and_without_sigil() {
1564 for raw in ["@acme-ops", "acme-ops"] {
1565 let a = Address::parse(raw).expect(raw);
1566 assert_eq!(a.brain, "acme-ops");
1567 assert_eq!(a.target, None);
1568 }
1569 }
1570
1571 #[test]
1572 fn address_ulid_target_parses_as_id() {
1573 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
1574 assert_eq!(a.brain, "acme");
1575 assert_eq!(
1576 a.target,
1577 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
1578 );
1579 }
1580
1581 #[test]
1582 fn address_md_path_target_parses_as_path() {
1583 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
1584 assert_eq!(
1585 a.target,
1586 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
1587 );
1588 }
1589
1590 #[test]
1591 fn address_rejects_malformed_forms() {
1592 for raw in [
1593 "",
1594 "@",
1595 "@/x",
1596 "@acme/",
1597 "@acme/../etc/passwd",
1598 "@acme/records/.hidden.md",
1599 "@ACME", "@acme/notes/x.txt", "@a b", ] {
1603 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
1604 }
1605 }
1606
1607 #[test]
1610 fn safe_paths_accept_store_shapes_and_reject_escapes() {
1611 for ok in [
1612 "DB.md",
1613 "assets.jsonl",
1614 "records/clients/lumio.md",
1615 "sources/emails/2026/07/x.md",
1616 ] {
1617 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
1618 }
1619 for bad in [
1620 "",
1621 "/etc/passwd",
1622 "../up.md",
1623 "records/../../up.md",
1624 "records//x.md",
1625 ".dbmd/config",
1626 "records/.hidden/x.md",
1627 "records/a b.md",
1628 "records\\win.md",
1629 ] {
1630 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
1631 }
1632 }
1633
1634 #[test]
1638 fn hub_config_flag_beats_file_and_requires_some_source() {
1639 let dir = tempfile::tempdir().unwrap();
1640 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
1641 std::fs::write(
1642 dir.path().join(CONFIG_REL_PATH),
1643 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
1644 )
1645 .unwrap();
1646
1647 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
1648 assert_eq!(from_flag.hub, "https://flag.example.com");
1649
1650 let from_file = hub_config(None, dir.path()).unwrap();
1651 assert_eq!(from_file.hub, "https://file.example.com");
1652
1653 let none = hub_config(None, tempfile::tempdir().unwrap().path());
1654 assert!(matches!(none, Err(LinkError::NoHub)));
1655 }
1656
1657 #[test]
1658 fn https_guard_allows_loopback_only_for_plain_http() {
1659 assert!(assert_safe_hub("https://hub.example.com").is_ok());
1660 assert!(assert_safe_hub("http://localhost:3000").is_ok());
1661 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
1662 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
1663 assert!(matches!(
1664 assert_safe_hub("http://hub.example.com"),
1665 Err(LinkError::UnsafeHub { .. })
1666 ));
1667 assert!(matches!(
1668 assert_safe_hub("hub.example.com"),
1669 Err(LinkError::UnsafeHub { .. })
1670 ));
1671 assert!(matches!(
1672 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
1673 Err(LinkError::UnsafeHub { .. })
1674 ));
1675 assert!(matches!(
1676 assert_safe_hub("https://hub.example.com@attacker.example"),
1677 Err(LinkError::UnsafeHub { .. })
1678 ));
1679 }
1680
1681 #[test]
1682 fn https_guard_matches_the_scheme_case_insensitively() {
1683 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
1686 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
1687 assert!(matches!(
1689 assert_safe_hub("HTTP://hub.example.com"),
1690 Err(LinkError::UnsafeHub { .. })
1691 ));
1692 }
1693
1694 #[test]
1695 fn clean_key_refuses_paste_artifacts_without_echoing() {
1696 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
1697 for bad in ["vc account", "vc\naccount", "ключ", ""] {
1698 let err = clean_key(bad).unwrap_err();
1699 assert!(matches!(err, LinkError::BadKey));
1700 assert!(
1701 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
1702 "error must not echo the key"
1703 );
1704 }
1705 }
1706
1707 fn dead_hub() -> HubConfig {
1713 HubConfig {
1714 hub: "http://127.0.0.1:9".to_string(),
1715 key: Some("k".to_string()),
1716 }
1717 }
1718
1719 #[test]
1720 fn request_retries_a_connection_failure_before_sending() {
1721 use std::io::{Read as _, Write as _};
1722 use std::net::TcpListener;
1723 use std::thread;
1724 use std::time::Duration;
1725
1726 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
1727 let address = probe.local_addr().unwrap();
1728 drop(probe);
1729 let server = thread::spawn(move || {
1730 thread::sleep(Duration::from_millis(40));
1731 let listener = TcpListener::bind(address).unwrap();
1732 let (mut stream, _) = listener.accept().unwrap();
1733 let mut request_bytes = [0_u8; 1024];
1734 let _ = stream.read(&mut request_bytes).unwrap();
1735 stream
1736 .write_all(
1737 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
1738 )
1739 .unwrap();
1740 });
1741 let cfg = HubConfig {
1742 hub: format!("http://{address}"),
1743 key: None,
1744 };
1745
1746 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
1747 assert_eq!(response.status, 200);
1748 assert_eq!(response.body, Some(json!({ "ok": true })));
1749 server.join().unwrap();
1750 }
1751
1752 #[test]
1753 fn verb_entry_gates_accept_the_hub_ref_shapes() {
1754 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
1755 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
1756 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
1757 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
1758 }
1759 }
1760
1761 #[test]
1762 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
1763 let cfg = dead_hub();
1764 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
1765 assert!(
1766 matches!(
1767 sync_pull(&cfg, bad, None),
1768 Err(LinkError::BadAddress { .. })
1769 ),
1770 "sync_pull must refuse {bad:?}"
1771 );
1772 assert!(
1773 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
1774 "sync_push must refuse {bad:?}"
1775 );
1776 assert!(
1777 matches!(
1778 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
1779 Err(LinkError::BadAddress { .. })
1780 ),
1781 "grant_issue must refuse {bad:?}"
1782 );
1783 assert!(
1784 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
1785 "grant_list must refuse {bad:?}"
1786 );
1787 assert!(
1788 matches!(
1789 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
1790 Err(LinkError::BadAddress { .. })
1791 ),
1792 "grant_revoke must refuse brain {bad:?}"
1793 );
1794 assert!(
1795 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
1796 "head must refuse {bad:?}"
1797 );
1798 }
1799 }
1800
1801 #[test]
1802 fn grant_revoke_refuses_url_reshaping_grant_ids() {
1803 let cfg = dead_hub();
1804 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
1805 assert!(
1806 matches!(
1807 grant_revoke(&cfg, "acme", bad),
1808 Err(LinkError::BadGrantId { .. })
1809 ),
1810 "grant_revoke must refuse grant id {bad:?}"
1811 );
1812 }
1813 }
1814
1815 #[test]
1816 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
1817 let cfg = dead_hub();
1818 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
1819 assert!(
1820 matches!(
1821 propose(&cfg, bad, "intake", "hi"),
1822 Err(LinkError::BadAddress { .. })
1823 ),
1824 "propose must refuse handle {bad:?}"
1825 );
1826 }
1827 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
1828 assert!(matches!(
1829 propose(&cfg, "acme-site", "intake", &oversize),
1830 Err(LinkError::ProposeTooLarge { .. })
1831 ));
1832 assert!(matches!(
1835 propose(&cfg, "acme-site", "intake", "hi"),
1836 Err(LinkError::Transport { .. })
1837 ));
1838 }
1839
1840 #[test]
1841 fn resolve_refuses_a_hand_built_unsafe_address() {
1842 let cfg = dead_hub();
1843 for brain in ["../up", "a/b", "a?x", "a#f"] {
1844 let addr = Address {
1845 brain: brain.to_string(),
1846 target: None,
1847 };
1848 assert!(
1849 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
1850 "resolve must refuse brain {brain:?}"
1851 );
1852 }
1853 for target in [
1854 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
1855 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
1857 AddressTarget::Path("records/x.md#frag".to_string()),
1858 ] {
1859 let addr = Address {
1860 brain: "acme".to_string(),
1861 target: Some(target.clone()),
1862 };
1863 assert!(
1864 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
1865 "resolve must refuse target {target:?}"
1866 );
1867 }
1868 }
1869}