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;
103
104#[derive(Debug, thiserror::Error)]
108pub enum LinkError {
109 #[error(
111 "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
112 )]
113 NoHub,
114
115 #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
117 NoCredential,
118
119 #[error(
122 "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
123 )]
124 BadKey,
125
126 #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
128 UnsafeHub {
129 hub: String,
131 },
132
133 #[error("hub unreachable at {hub}: {message}")]
135 Transport {
136 hub: String,
138 message: String,
140 },
141
142 #[error("{what} failed (HTTP {status}): {message}")]
144 Http {
145 what: &'static str,
147 status: u16,
149 message: String,
151 code: Option<String>,
153 },
154
155 #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
158 NotJson {
159 what: &'static str,
161 status: u16,
163 },
164
165 #[error("hub response exceeded {} MB — refusing to buffer it", MAX_RESPONSE_BYTES / (1024 * 1024))]
167 ResponseTooLarge,
168
169 #[error("invalid address `{given}`: {reason}")]
171 BadAddress {
172 given: String,
174 reason: String,
176 },
177
178 #[error(
180 "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
181 )]
182 BadGrantId {
183 given: String,
185 },
186
187 #[error("refusing unsafe path from the hub: `{path}`")]
191 UnsafePath {
192 path: String,
194 },
195
196 #[error(
198 "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB compressed, and {MAX_PUSH_FILES} files",
199 MAX_STORE_BYTES / (1024 * 1024),
200 MAX_PACK_BYTES / (1024 * 1024)
201 )]
202 PushTooLarge {
203 detail: String,
205 },
206
207 #[error(
209 "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
210 MAX_PROPOSE_BYTES / 1024
211 )]
212 ProposeTooLarge {
213 bytes: u64,
215 },
216
217 #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
219 NotUtf8 {
220 path: String,
222 },
223
224 #[error("invalid store pack: {message}")]
226 InvalidPack {
227 message: String,
229 },
230
231 #[error("invalid signed feed: {message}")]
233 InvalidFeed {
234 message: String,
236 },
237
238 #[error(transparent)]
240 Io(#[from] std::io::Error),
241
242 #[error(transparent)]
244 Store(#[from] crate::StoreError),
245}
246
247pub type LinkResult<T> = std::result::Result<T, LinkError>;
249
250#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum AddressTarget {
257 Id(String),
259 Path(String),
263}
264
265const BAD_BRAIN_REASON: &str =
268 "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
269
270const BAD_TARGET_REASON: &str =
273 "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
274
275#[derive(Debug, Clone, PartialEq, Eq)]
280pub struct Address {
281 pub brain: String,
283 pub target: Option<AddressTarget>,
285}
286
287impl Address {
288 pub fn parse(raw: &str) -> LinkResult<Address> {
292 let bad = |reason: &str| LinkError::BadAddress {
293 given: raw.to_string(),
294 reason: reason.to_string(),
295 };
296
297 let trimmed = raw.trim();
298 let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
299 if body.is_empty() {
300 return Err(bad("empty address"));
301 }
302
303 let (brain, rest) = match body.split_once('/') {
304 Some((b, r)) => (b, Some(r)),
305 None => (body, None),
306 };
307
308 if brain.is_empty() {
309 return Err(bad("missing brain reference before `/`"));
310 }
311 if !is_safe_ref(brain) {
312 return Err(bad(BAD_BRAIN_REASON));
313 }
314
315 let target = match rest {
316 None => None,
317 Some("") => return Err(bad("trailing `/` with no record id or path")),
318 Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
319 Some(r) => {
320 if !safe_store_rel_path(r) || !r.ends_with(".md") {
321 return Err(bad(BAD_TARGET_REASON));
322 }
323 Some(AddressTarget::Path(r.to_string()))
324 }
325 };
326
327 Ok(Address {
328 brain: brain.to_string(),
329 target,
330 })
331 }
332}
333
334fn is_safe_ref(s: &str) -> bool {
337 !s.is_empty()
338 && s.len() <= 64
339 && s.bytes()
340 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
341}
342
343pub fn is_valid_handle(s: &str) -> bool {
346 is_safe_ref(s)
347}
348
349pub fn safe_store_rel_path(p: &str) -> bool {
355 if p.is_empty() || p.len() > 512 || p.starts_with('/') {
356 return false;
357 }
358 if !p
359 .bytes()
360 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
361 {
362 return false;
363 }
364 p.split('/')
365 .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
366}
367
368fn require_safe_ref(brain: &str) -> LinkResult<()> {
376 if is_safe_ref(brain) {
377 Ok(())
378 } else {
379 Err(LinkError::BadAddress {
380 given: brain.to_string(),
381 reason: BAD_BRAIN_REASON.to_string(),
382 })
383 }
384}
385
386fn require_valid_handle(handle: &str) -> LinkResult<()> {
388 if is_valid_handle(handle) {
389 Ok(())
390 } else {
391 Err(LinkError::BadAddress {
392 given: handle.to_string(),
393 reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
394 })
395 }
396}
397
398fn require_safe_grant_id(id: &str) -> LinkResult<()> {
402 if is_safe_ref(id) {
403 Ok(())
404 } else {
405 Err(LinkError::BadGrantId {
406 given: id.to_string(),
407 })
408 }
409}
410
411#[derive(Debug, Clone)]
417pub struct HubConfig {
418 pub hub: String,
420 pub key: Option<String>,
422}
423
424impl HubConfig {
425 pub fn require_key(&self) -> LinkResult<&str> {
428 self.key.as_deref().ok_or(LinkError::NoCredential)
429 }
430}
431
432pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
437 let hub = flag_hub
438 .map(str::to_string)
439 .or_else(|| env_nonempty(HUB_URL_ENV))
440 .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
441 .ok_or(LinkError::NoHub)?;
442 let hub = hub.trim().trim_end_matches('/').to_string();
443 assert_safe_hub(&hub)?;
444
445 let key = match env_nonempty(HUB_KEY_ENV) {
446 Some(raw) => Some(clean_key(&raw)?),
447 None => None,
448 };
449
450 Ok(HubConfig { hub, key })
451}
452
453fn env_nonempty(name: &str) -> Option<String> {
456 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
457}
458
459fn config_file_hub(path: &Path) -> Option<String> {
464 let text = std::fs::read_to_string(path).ok()?;
465 for line in text.lines() {
466 let line = line.trim();
467 if line.is_empty() || line.starts_with('#') {
468 continue;
469 }
470 if let Some((k, v)) = line.split_once('=') {
471 if k.trim() == "hub" {
472 let v = v.trim();
473 if !v.is_empty() {
474 return Some(v.to_string());
475 }
476 }
477 }
478 }
479 None
480}
481
482fn assert_safe_hub(hub: &str) -> LinkResult<()> {
485 let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
486 hub: hub.to_string(),
487 })?;
488 if !parsed.username().is_empty()
489 || parsed.password().is_some()
490 || parsed.query().is_some()
491 || parsed.fragment().is_some()
492 {
493 return Err(LinkError::UnsafeHub {
494 hub: hub.to_string(),
495 });
496 }
497 let loopback = match parsed.host() {
498 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
499 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
500 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
501 None => false,
502 };
503 if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
504 Ok(())
505 } else {
506 Err(LinkError::UnsafeHub {
507 hub: hub.to_string(),
508 })
509 }
510}
511
512fn clean_key(raw: &str) -> LinkResult<String> {
517 let k = raw.trim();
518 if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
519 return Err(LinkError::BadKey);
520 }
521 Ok(k.to_string())
522}
523
524#[derive(Debug)]
530pub struct HubResponse {
531 pub status: u16,
533 pub body: Option<Value>,
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq)]
539enum Auth {
540 Required,
542 None,
544}
545
546fn agent() -> ureq::Agent {
547 ureq::AgentBuilder::new()
548 .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
549 .redirects(0)
553 .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
554 .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
555 .build()
556}
557
558fn request(
563 cfg: &HubConfig,
564 method: &str,
565 path: &str,
566 body: Option<&Value>,
567 auth: Auth,
568) -> LinkResult<HubResponse> {
569 let url = format!("{}{}", cfg.hub, path);
570 let mut req = agent().request(method, &url);
571 if auth == Auth::Required {
572 req = req.set("authorization", &format!("Bearer {}", cfg.require_key()?));
573 }
574
575 let result = match body {
576 Some(v) => req
577 .set("content-type", "application/json")
578 .send_string(&v.to_string()),
579 None => req.call(),
580 };
581
582 let resp = match result {
583 Ok(resp) => resp,
584 Err(ureq::Error::Status(_, resp)) => resp,
585 Err(ureq::Error::Transport(t)) => {
586 return Err(LinkError::Transport {
587 hub: cfg.hub.clone(),
588 message: t.to_string(),
589 })
590 }
591 };
592
593 let status = resp.status();
594 let mut buf = Vec::new();
595 resp.into_reader()
596 .take(MAX_RESPONSE_BYTES + 1)
597 .read_to_end(&mut buf)?;
598 if buf.len() as u64 > MAX_RESPONSE_BYTES {
599 return Err(LinkError::ResponseTooLarge);
600 }
601 let parsed: Option<Value> = serde_json::from_slice(&buf).ok();
602 Ok(HubResponse {
603 status,
604 body: parsed,
605 })
606}
607
608fn assert_safe_presigned_url(raw: &str) -> LinkResult<()> {
609 let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
610 message: "the hub returned an invalid object-store URL".to_string(),
611 })?;
612 if !parsed.scheme().eq_ignore_ascii_case("https")
613 || !parsed.username().is_empty()
614 || parsed.password().is_some()
615 || parsed.fragment().is_some()
616 {
617 return Err(LinkError::InvalidPack {
618 message: "the hub returned an unsafe object-store URL".to_string(),
619 });
620 }
621 Ok(())
622}
623
624fn put_presigned(raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
625 assert_safe_presigned_url(raw)?;
626 let mut req = agent().put(raw);
627 if let Some(map) = headers.as_object() {
628 for (name, value) in map {
629 if let Some(value) = value.as_str() {
630 req = req.set(name, value);
631 }
632 }
633 }
634 match req.send_bytes(bytes) {
635 Ok(resp) if resp.status() < 300 => Ok(()),
636 Ok(resp) | Err(ureq::Error::Status(_, resp)) => Err(LinkError::Http {
637 what: "pack upload",
638 status: resp.status(),
639 message: "object store rejected the upload".to_string(),
640 code: None,
641 }),
642 Err(ureq::Error::Transport(err)) => Err(LinkError::Transport {
643 hub: "the object store".to_string(),
644 message: err.to_string(),
645 }),
646 }
647}
648
649fn get_presigned(raw: &str) -> LinkResult<Vec<u8>> {
650 assert_safe_presigned_url(raw)?;
651 let resp = match agent().get(raw).call() {
652 Ok(resp) => resp,
653 Err(ureq::Error::Status(_, resp)) => {
654 return Err(LinkError::Http {
655 what: "pack download",
656 status: resp.status(),
657 message: "object store rejected the download".to_string(),
658 code: None,
659 })
660 }
661 Err(ureq::Error::Transport(err)) => {
662 return Err(LinkError::Transport {
663 hub: "the object store".to_string(),
664 message: err.to_string(),
665 })
666 }
667 };
668 let mut bytes = Vec::new();
669 resp.into_reader()
670 .take(MAX_PACK_BYTES + 1)
671 .read_to_end(&mut bytes)?;
672 if bytes.len() as u64 > MAX_PACK_BYTES {
673 return Err(LinkError::InvalidPack {
674 message: "download exceeds the compressed-size limit".to_string(),
675 });
676 }
677 Ok(bytes)
678}
679
680fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
684 if r.status >= 400 {
685 let message = r
686 .body
687 .as_ref()
688 .and_then(|b| b.get("error"))
689 .and_then(Value::as_str)
690 .unwrap_or("unknown error")
691 .to_string();
692 let code = r
693 .body
694 .as_ref()
695 .and_then(|b| b.get("code"))
696 .and_then(Value::as_str)
697 .map(str::to_string);
698 return Err(LinkError::Http {
699 what,
700 status: r.status,
701 message,
702 code,
703 });
704 }
705 r.body.ok_or(LinkError::NotJson {
706 what,
707 status: r.status,
708 })
709}
710
711pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
720 require_safe_ref(&addr.brain)?;
724 if let Some(target) = &addr.target {
725 let (given, ok) = match target {
726 AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
727 AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
728 };
729 if !ok {
730 return Err(LinkError::BadAddress {
731 given: given.clone(),
732 reason: BAD_TARGET_REASON.to_string(),
733 });
734 }
735 }
736
737 let path = match &addr.target {
738 None => format!("/api/hub/brains/{}", addr.brain),
739 Some(AddressTarget::Id(id)) => {
740 format!("/api/hub/brains/{}/resolve?id={id}", addr.brain)
741 }
742 Some(AddressTarget::Path(p)) => {
743 format!("/api/hub/brains/{}/resolve?path={p}", addr.brain)
744 }
745 };
746 ensure_ok(request(cfg, "GET", &path, None, Auth::Required)?, "resolve")
747}
748
749#[derive(Debug, serde::Serialize)]
755pub struct PullReport {
756 pub brain: String,
758 pub slug: String,
760 #[serde(rename = "headSeq")]
762 pub head_seq: u64,
763 pub files: usize,
765 pub dest: String,
767 #[serde(rename = "extraLocal")]
770 pub extra_local: Vec<String>,
771}
772
773pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
779 require_safe_ref(brain)?;
780 let path = format!("/api/hub/brains/{brain}/export?format=pack");
781 let body = ensure_ok(
782 request(cfg, "GET", &path, None, Auth::Required)?,
783 "sync pull",
784 )?;
785
786 let remote_slug = body
787 .get("slug")
788 .and_then(Value::as_str)
789 .filter(|slug| is_safe_slug(slug));
790 let slug = remote_slug
791 .or_else(|| is_safe_slug(brain).then_some(brain))
792 .unwrap_or("brain")
793 .to_string();
794 let brain_id = body
795 .get("brain")
796 .and_then(Value::as_str)
797 .unwrap_or(brain)
798 .to_string();
799 let head_seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
800 let dest: PathBuf = match out {
801 Some(p) => p.to_path_buf(),
802 None => PathBuf::from(&slug),
803 };
804 let entries =
805 if let Some(url) = body.get("url").and_then(Value::as_str) {
806 let expected = body
807 .get("sha256")
808 .and_then(Value::as_str)
809 .filter(|hash| {
810 hash.len() == 64
811 && hash
812 .bytes()
813 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
814 })
815 .ok_or_else(|| LinkError::InvalidPack {
816 message: "the hub returned an invalid SHA-256".to_string(),
817 })?;
818 let bytes = get_presigned(url)?;
819 let actual = format!("{:x}", Sha256::digest(&bytes));
820 if actual != expected {
821 return Err(LinkError::InvalidPack {
822 message: "SHA-256 verification failed".to_string(),
823 });
824 }
825 parse_store_pack(bytes)?
826 } else {
827 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
828 LinkError::InvalidPack {
829 message: "the hub returned neither a pack nor a file manifest".to_string(),
830 }
831 })?;
832 let mut entries = Vec::with_capacity(files.len());
833 for file in files {
834 let path = file.get("path").and_then(Value::as_str).ok_or_else(|| {
835 LinkError::InvalidPack {
836 message: "a file entry has no string path".to_string(),
837 }
838 })?;
839 let content = file.get("content").and_then(Value::as_str).ok_or_else(|| {
840 LinkError::InvalidPack {
841 message: format!("file `{path}` has no string content"),
842 }
843 })?;
844 entries.push((path.to_string(), content.as_bytes().to_vec()));
845 }
846 entries
847 };
848
849 let mut seen = std::collections::HashSet::new();
851 for (path, _) in &entries {
852 if !safe_store_rel_path(path) {
853 return Err(LinkError::UnsafePath { path: path.clone() });
854 }
855 if !seen.insert(path) {
856 return Err(LinkError::InvalidPack {
857 message: format!("duplicate path `{path}`"),
858 });
859 }
860 }
861 std::fs::create_dir_all(&dest)?;
862 let real_dest = std::fs::canonicalize(&dest)?;
863
864 for (p, content) in &entries {
865 let abs = dest.join(p);
866 if let Some(parent) = abs.parent() {
867 std::fs::create_dir_all(parent)?;
868 let real_parent = std::fs::canonicalize(parent)?;
869 if !real_parent.starts_with(&real_dest) {
870 return Err(LinkError::UnsafePath { path: p.clone() });
871 }
872 }
873 if std::fs::symlink_metadata(&abs).is_ok_and(|meta| meta.file_type().is_symlink()) {
874 return Err(LinkError::UnsafePath { path: p.clone() });
875 }
876 write_atomic(&abs, content)?;
877 }
878
879 let pulled: std::collections::BTreeSet<&str> =
883 entries.iter().map(|(p, _)| p.as_str()).collect();
884 let mut extra_local = Vec::new();
885 if let Ok(store) = Store::open(&dest) {
886 if let Ok(walked) = store.walk() {
887 for rel in walked {
888 let rel_str = rel.to_string_lossy().replace('\\', "/");
889 if !pulled.contains(rel_str.as_str()) {
890 extra_local.push(rel_str);
891 }
892 }
893 }
894 }
895
896 Ok(PullReport {
897 brain: brain_id,
898 slug,
899 head_seq,
900 files: entries.len(),
901 dest: dest.to_string_lossy().into_owned(),
902 extra_local,
903 })
904}
905
906fn is_safe_slug(slug: &str) -> bool {
907 !slug.is_empty()
908 && slug.len() <= 63
909 && !slug.starts_with('-')
910 && !slug.ends_with('-')
911 && slug
912 .bytes()
913 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
914}
915
916fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
917 let mut archive =
918 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
919 message: format!("ZIP parse failed: {err}"),
920 })?;
921 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
922 return Err(LinkError::InvalidPack {
923 message: format!("invalid file count {}", archive.len()),
924 });
925 }
926 let mut total = 0u64;
927 let mut entries = Vec::with_capacity(archive.len());
928 for index in 0..archive.len() {
929 let mut file = archive
930 .by_index(index)
931 .map_err(|err| LinkError::InvalidPack {
932 message: format!("ZIP entry failed: {err}"),
933 })?;
934 if file.is_dir() {
935 continue;
936 }
937 let path = file.name().to_string();
938 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
939 return Err(LinkError::UnsafePath { path });
940 }
941 if file
942 .unix_mode()
943 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
944 {
945 return Err(LinkError::InvalidPack {
946 message: format!("non-file entry `{path}`"),
947 });
948 }
949 total = total.saturating_add(file.size());
950 if total > MAX_STORE_BYTES {
951 return Err(LinkError::InvalidPack {
952 message: "expanded content exceeds the 512 MB limit".to_string(),
953 });
954 }
955 let mut content = Vec::new();
956 file.read_to_end(&mut content)
957 .map_err(|err| LinkError::InvalidPack {
958 message: format!("could not decompress `{path}`: {err}"),
959 })?;
960 if content.len() as u64 != file.size() {
961 return Err(LinkError::InvalidPack {
962 message: format!("length mismatch for `{path}`"),
963 });
964 }
965 entries.push((path, content));
966 }
967 if entries.is_empty() {
968 return Err(LinkError::InvalidPack {
969 message: "pack contains no files".to_string(),
970 });
971 }
972 Ok(entries)
973}
974
975pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
982 let mut out: Vec<(String, String)> = Vec::new();
983
984 let read_text = |rel: &str| -> LinkResult<String> {
985 let abs = store.root.join(rel);
986 std::fs::read(&abs)
987 .map_err(LinkError::from)
988 .and_then(|bytes| {
989 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
990 path: rel.to_string(),
991 })
992 })
993 };
994
995 out.push(("DB.md".to_string(), read_text("DB.md")?));
996 if store.root.join("assets.jsonl").is_file() {
997 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
998 }
999
1000 for rel in store.walk()? {
1001 let rel_str = rel.to_string_lossy().replace('\\', "/");
1002 if !safe_store_rel_path(&rel_str) {
1003 return Err(LinkError::UnsafePath { path: rel_str });
1006 }
1007 let content = read_text(&rel_str)?;
1008 out.push((rel_str, content));
1009 }
1010
1011 out.sort_by(|a, b| a.0.cmp(&b.0));
1012 Ok(out)
1013}
1014
1015pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
1021 require_safe_ref(brain)?;
1022 if files.len() > MAX_PUSH_FILES {
1023 return Err(LinkError::PushTooLarge {
1024 detail: format!("{} files", files.len()),
1025 });
1026 }
1027 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
1028 if raw_total > MAX_STORE_BYTES {
1029 return Err(LinkError::PushTooLarge {
1030 detail: format!("{raw_total} uncompressed bytes"),
1031 });
1032 }
1033
1034 let body = json!({
1035 "files": files
1036 .iter()
1037 .map(|(p, c)| json!({ "path": p, "content": c }))
1038 .collect::<Vec<_>>(),
1039 });
1040 if body.to_string().len() <= MAX_PUSH_BYTES {
1041 let path = format!("/api/hub/brains/{brain}/push");
1042 return ensure_ok(
1043 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
1044 "sync push",
1045 );
1046 }
1047
1048 let pack = build_store_pack(files)?;
1049 if pack.len() as u64 > MAX_PACK_BYTES {
1050 return Err(LinkError::PushTooLarge {
1051 detail: format!("{} compressed bytes", pack.len()),
1052 });
1053 }
1054 let sha256 = format!("{:x}", Sha256::digest(&pack));
1055 let meta = json!({ "sha256": sha256, "bytes": pack.len() });
1056 let presigned = ensure_ok(
1057 request(
1058 cfg,
1059 "POST",
1060 &format!("/api/hub/brains/{brain}/packs/presign"),
1061 Some(&meta),
1062 Auth::Required,
1063 )?,
1064 "prepare pack upload",
1065 )?;
1066 let url = presigned
1067 .get("url")
1068 .and_then(Value::as_str)
1069 .ok_or_else(|| LinkError::InvalidPack {
1070 message: "the hub returned no upload URL".to_string(),
1071 })?;
1072 put_presigned(url, presigned.get("headers").unwrap_or(&Value::Null), &pack)?;
1073 ensure_ok(
1074 request(
1075 cfg,
1076 "POST",
1077 &format!("/api/hub/brains/{brain}/packs/commit"),
1078 Some(&meta),
1079 Auth::Required,
1080 )?,
1081 "commit pack",
1082 )
1083}
1084
1085fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
1086 let mut sorted: Vec<_> = files.iter().collect();
1087 sorted.sort_by(|a, b| a.0.cmp(&b.0));
1088 let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
1089 let options = zip::write::SimpleFileOptions::default()
1090 .compression_method(zip::CompressionMethod::Deflated)
1091 .last_modified_time(zip::DateTime::default())
1092 .unix_permissions(0o600);
1093 for (path, content) in sorted {
1094 writer
1095 .start_file(path, options)
1096 .map_err(|err| LinkError::InvalidPack {
1097 message: format!("could not create ZIP entry `{path}`: {err}"),
1098 })?;
1099 writer.write_all(content.as_bytes())?;
1100 }
1101 writer
1102 .finish()
1103 .map(Cursor::into_inner)
1104 .map_err(|err| LinkError::InvalidPack {
1105 message: format!("could not finish ZIP: {err}"),
1106 })
1107}
1108
1109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1115pub enum Capability {
1116 Read,
1118 Write,
1120}
1121
1122impl Capability {
1123 pub fn as_str(self) -> &'static str {
1125 match self {
1126 Capability::Read => "read",
1127 Capability::Write => "write",
1128 }
1129 }
1130}
1131
1132pub fn grant_issue(
1138 cfg: &HubConfig,
1139 brain: &str,
1140 grantee: &str,
1141 can: Capability,
1142 scope: Option<&str>,
1143 until: Option<&str>,
1144) -> LinkResult<Value> {
1145 require_safe_ref(brain)?;
1146 let mut body = json!({
1147 "email": grantee,
1148 "capability": can.as_str(),
1149 });
1150 if let Some(s) = scope {
1151 body["scopePrefix"] = json!(s);
1152 }
1153 if let Some(u) = until {
1154 body["expiresAt"] = json!(u);
1155 }
1156 let path = format!("/api/hub/brains/{brain}/grants");
1157 ensure_ok(
1158 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
1159 "grant issue",
1160 )
1161}
1162
1163pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
1165 require_safe_ref(brain)?;
1166 let path = format!("/api/hub/brains/{brain}/grants");
1167 ensure_ok(
1168 request(cfg, "GET", &path, None, Auth::Required)?,
1169 "grant list",
1170 )
1171}
1172
1173pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
1176 require_safe_ref(brain)?;
1177 require_safe_grant_id(grant_id)?;
1178 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
1179 ensure_ok(
1180 request(cfg, "DELETE", &path, None, Auth::Required)?,
1181 "grant revoke",
1182 )
1183}
1184
1185pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
1196 require_valid_handle(handle)?;
1197 if body.len() as u64 > MAX_PROPOSE_BYTES {
1198 return Err(LinkError::ProposeTooLarge {
1199 bytes: body.len() as u64,
1200 });
1201 }
1202 let payload = json!({ "app": app, "body": body });
1203 let path = format!("/api/hub/sites/{handle}/inbox");
1204 ensure_ok(
1205 request(cfg, "POST", &path, Some(&payload), Auth::None)?,
1206 "propose",
1207 )
1208}
1209
1210#[derive(Debug, serde::Serialize)]
1216pub struct Head {
1217 pub brain: String,
1219 pub seq: u64,
1221 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
1223 pub updated_at: Option<String>,
1224 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
1226 pub feed_hash: Option<String>,
1227 pub verified: bool,
1230}
1231
1232#[derive(Debug, Deserialize, Serialize)]
1233struct FeedFile {
1234 path: String,
1235 sha256: String,
1236 bytes: u64,
1237}
1238
1239#[derive(Debug, Deserialize, Serialize)]
1240struct FeedEntry {
1241 v: u8,
1242 seq: u64,
1243 ts: String,
1244 brain: String,
1245 public_key: String,
1246 kind: String,
1247 op: String,
1248 pack_sha256: String,
1249 files: Vec<FeedFile>,
1250 removed: Vec<String>,
1251 prev_entry_hash: Option<String>,
1252 sig: String,
1253}
1254
1255#[derive(Serialize)]
1256struct UnsignedFeedEntry<'a> {
1257 v: u8,
1258 seq: u64,
1259 ts: &'a str,
1260 brain: &'a str,
1261 public_key: &'a str,
1262 kind: &'a str,
1263 op: &'a str,
1264 pack_sha256: &'a str,
1265 files: &'a [FeedFile],
1266 removed: &'a [String],
1267 prev_entry_hash: &'a Option<String>,
1268}
1269
1270#[derive(Debug, Deserialize)]
1271struct FeedItem {
1272 hash: String,
1273 entry: FeedEntry,
1274}
1275
1276#[derive(Debug, Deserialize)]
1277struct FeedIdentity {
1278 fingerprint: String,
1279 #[serde(rename = "publicKeySpki")]
1280 public_key_spki: String,
1281}
1282
1283#[derive(Debug, Deserialize)]
1284struct FeedResponse {
1285 #[serde(rename = "headSeq")]
1286 head_seq: u64,
1287 #[serde(rename = "feedHash")]
1288 feed_hash: Option<String>,
1289 identity: Option<FeedIdentity>,
1290 entries: Vec<FeedItem>,
1291 #[serde(rename = "scopeLimited")]
1292 scope_limited: bool,
1293}
1294
1295fn invalid_feed(message: impl Into<String>) -> LinkError {
1296 LinkError::InvalidFeed {
1297 message: message.into(),
1298 }
1299}
1300
1301fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
1302 const ED25519_SPKI_PREFIX: &[u8] = &[
1303 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1304 ];
1305 let entry = &item.entry;
1306 let public_der = URL_SAFE_NO_PAD
1307 .decode(&entry.public_key)
1308 .map_err(|_| invalid_feed("public key is not base64url"))?;
1309 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
1310 || !public_der.starts_with(ED25519_SPKI_PREFIX)
1311 || identity.public_key_spki != entry.public_key
1312 {
1313 return Err(invalid_feed("public key does not match the brain card"));
1314 }
1315 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
1316 if fingerprint != identity.fingerprint || entry.brain != format!("ed25519:{fingerprint}") {
1317 return Err(invalid_feed(
1318 "brain fingerprint does not match its public key",
1319 ));
1320 }
1321 let unsigned = UnsignedFeedEntry {
1322 v: entry.v,
1323 seq: entry.seq,
1324 ts: &entry.ts,
1325 brain: &entry.brain,
1326 public_key: &entry.public_key,
1327 kind: &entry.kind,
1328 op: &entry.op,
1329 pack_sha256: &entry.pack_sha256,
1330 files: &entry.files,
1331 removed: &entry.removed,
1332 prev_entry_hash: &entry.prev_entry_hash,
1333 };
1334 let message =
1335 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
1336 let signature = URL_SAFE_NO_PAD
1337 .decode(&entry.sig)
1338 .map_err(|_| invalid_feed("signature is not base64url"))?;
1339 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
1340 .verify(&message, &signature)
1341 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
1342
1343 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
1344 exact.push(b'\n');
1345 let actual_hash = format!("{:x}", Sha256::digest(&exact));
1346 if actual_hash != item.hash {
1347 return Err(invalid_feed("entry SHA-256 does not match"));
1348 }
1349 Ok(())
1350}
1351
1352pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
1356 require_safe_ref(brain)?;
1357 let path = format!("/api/hub/brains/{brain}");
1358 let body = ensure_ok(
1359 request(cfg, "GET", &path, None, Auth::Required)?,
1360 "subscribe",
1361 )?;
1362 let resolved_brain = body
1363 .get("id")
1364 .and_then(Value::as_str)
1365 .unwrap_or(brain)
1366 .to_string();
1367 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
1368 let advertised_hash = body
1369 .get("feedHash")
1370 .and_then(Value::as_str)
1371 .map(str::to_string);
1372 let updated_at = body
1373 .get("updatedAt")
1374 .and_then(Value::as_str)
1375 .map(str::to_string);
1376 if seq == 0 {
1377 return Ok(Head {
1378 brain: resolved_brain,
1379 seq,
1380 updated_at,
1381 feed_hash: None,
1382 verified: true,
1383 });
1384 }
1385
1386 let feed_value = ensure_ok(
1387 request(
1388 cfg,
1389 "GET",
1390 &format!("/api/hub/brains/{brain}/feed?after={}&limit=1", seq - 1),
1391 None,
1392 Auth::Required,
1393 )?,
1394 "subscribe feed",
1395 )?;
1396 let feed: FeedResponse = serde_json::from_value(feed_value)
1397 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
1398 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
1399 return Err(invalid_feed("brain card and feed head disagree"));
1400 }
1401 if feed.scope_limited {
1402 return Ok(Head {
1403 brain: resolved_brain,
1404 seq,
1405 updated_at,
1406 feed_hash: advertised_hash,
1407 verified: false,
1408 });
1409 }
1410 let identity = feed
1411 .identity
1412 .as_ref()
1413 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
1414 let item = feed
1415 .entries
1416 .first()
1417 .ok_or_else(|| invalid_feed("feed head entry is missing"))?;
1418 if item.entry.seq != seq || Some(&item.hash) != advertised_hash.as_ref() {
1419 return Err(invalid_feed(
1420 "advertised feed hash does not address the head entry",
1421 ));
1422 }
1423 verify_feed_item(item, identity)?;
1424 Ok(Head {
1425 brain: resolved_brain,
1426 seq,
1427 updated_at,
1428 feed_hash: advertised_hash,
1429 verified: true,
1430 })
1431}
1432
1433#[cfg(test)]
1434mod tests {
1435 use super::*;
1436
1437 #[test]
1438 fn signed_feed_item_verifies_identity_hash_and_signature() {
1439 use ring::rand::SystemRandom;
1440 use ring::signature::{Ed25519KeyPair, KeyPair};
1441
1442 const PREFIX: &[u8] = &[
1443 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1444 ];
1445 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
1446 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
1447 let mut spki = PREFIX.to_vec();
1448 spki.extend_from_slice(pair.public_key().as_ref());
1449 let public_key = URL_SAFE_NO_PAD.encode(&spki);
1450 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
1451 let mut entry = FeedEntry {
1452 v: 1,
1453 seq: 1,
1454 ts: "2026-07-14T00:00:00.000Z".to_string(),
1455 brain: format!("ed25519:{fingerprint}"),
1456 public_key: public_key.clone(),
1457 kind: "push".to_string(),
1458 op: "snapshot".to_string(),
1459 pack_sha256: "a".repeat(64),
1460 files: vec![FeedFile {
1461 path: "DB.md".to_string(),
1462 sha256: "b".repeat(64),
1463 bytes: 3,
1464 }],
1465 removed: vec![],
1466 prev_entry_hash: None,
1467 sig: String::new(),
1468 };
1469 let unsigned = UnsignedFeedEntry {
1470 v: entry.v,
1471 seq: entry.seq,
1472 ts: &entry.ts,
1473 brain: &entry.brain,
1474 public_key: &entry.public_key,
1475 kind: &entry.kind,
1476 op: &entry.op,
1477 pack_sha256: &entry.pack_sha256,
1478 files: &entry.files,
1479 removed: &entry.removed,
1480 prev_entry_hash: &entry.prev_entry_hash,
1481 };
1482 entry.sig =
1483 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
1484 let mut exact = serde_json::to_vec(&entry).unwrap();
1485 exact.push(b'\n');
1486 let item = FeedItem {
1487 hash: format!("{:x}", Sha256::digest(&exact)),
1488 entry,
1489 };
1490 let identity = FeedIdentity {
1491 fingerprint,
1492 public_key_spki: public_key,
1493 };
1494 assert!(verify_feed_item(&item, &identity).is_ok());
1495 let mut tampered = item;
1496 tampered.entry.pack_sha256 = "c".repeat(64);
1497 assert!(verify_feed_item(&tampered, &identity).is_err());
1498 }
1499
1500 #[test]
1503 fn address_bare_brain_with_and_without_sigil() {
1504 for raw in ["@acme-ops", "acme-ops"] {
1505 let a = Address::parse(raw).expect(raw);
1506 assert_eq!(a.brain, "acme-ops");
1507 assert_eq!(a.target, None);
1508 }
1509 }
1510
1511 #[test]
1512 fn address_ulid_target_parses_as_id() {
1513 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
1514 assert_eq!(a.brain, "acme");
1515 assert_eq!(
1516 a.target,
1517 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
1518 );
1519 }
1520
1521 #[test]
1522 fn address_md_path_target_parses_as_path() {
1523 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
1524 assert_eq!(
1525 a.target,
1526 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
1527 );
1528 }
1529
1530 #[test]
1531 fn address_rejects_malformed_forms() {
1532 for raw in [
1533 "",
1534 "@",
1535 "@/x",
1536 "@acme/",
1537 "@acme/../etc/passwd",
1538 "@acme/records/.hidden.md",
1539 "@ACME", "@acme/notes/x.txt", "@a b", ] {
1543 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
1544 }
1545 }
1546
1547 #[test]
1550 fn safe_paths_accept_store_shapes_and_reject_escapes() {
1551 for ok in [
1552 "DB.md",
1553 "assets.jsonl",
1554 "records/clients/lumio.md",
1555 "sources/emails/2026/07/x.md",
1556 ] {
1557 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
1558 }
1559 for bad in [
1560 "",
1561 "/etc/passwd",
1562 "../up.md",
1563 "records/../../up.md",
1564 "records//x.md",
1565 ".dbmd/config",
1566 "records/.hidden/x.md",
1567 "records/a b.md",
1568 "records\\win.md",
1569 ] {
1570 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
1571 }
1572 }
1573
1574 #[test]
1578 fn hub_config_flag_beats_file_and_requires_some_source() {
1579 let dir = tempfile::tempdir().unwrap();
1580 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
1581 std::fs::write(
1582 dir.path().join(CONFIG_REL_PATH),
1583 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
1584 )
1585 .unwrap();
1586
1587 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
1588 assert_eq!(from_flag.hub, "https://flag.example.com");
1589
1590 let from_file = hub_config(None, dir.path()).unwrap();
1591 assert_eq!(from_file.hub, "https://file.example.com");
1592
1593 let none = hub_config(None, tempfile::tempdir().unwrap().path());
1594 assert!(matches!(none, Err(LinkError::NoHub)));
1595 }
1596
1597 #[test]
1598 fn https_guard_allows_loopback_only_for_plain_http() {
1599 assert!(assert_safe_hub("https://hub.example.com").is_ok());
1600 assert!(assert_safe_hub("http://localhost:3000").is_ok());
1601 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
1602 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
1603 assert!(matches!(
1604 assert_safe_hub("http://hub.example.com"),
1605 Err(LinkError::UnsafeHub { .. })
1606 ));
1607 assert!(matches!(
1608 assert_safe_hub("hub.example.com"),
1609 Err(LinkError::UnsafeHub { .. })
1610 ));
1611 assert!(matches!(
1612 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
1613 Err(LinkError::UnsafeHub { .. })
1614 ));
1615 assert!(matches!(
1616 assert_safe_hub("https://hub.example.com@attacker.example"),
1617 Err(LinkError::UnsafeHub { .. })
1618 ));
1619 }
1620
1621 #[test]
1622 fn https_guard_matches_the_scheme_case_insensitively() {
1623 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
1626 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
1627 assert!(matches!(
1629 assert_safe_hub("HTTP://hub.example.com"),
1630 Err(LinkError::UnsafeHub { .. })
1631 ));
1632 }
1633
1634 #[test]
1635 fn clean_key_refuses_paste_artifacts_without_echoing() {
1636 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
1637 for bad in ["vc account", "vc\naccount", "ключ", ""] {
1638 let err = clean_key(bad).unwrap_err();
1639 assert!(matches!(err, LinkError::BadKey));
1640 assert!(
1641 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
1642 "error must not echo the key"
1643 );
1644 }
1645 }
1646
1647 fn dead_hub() -> HubConfig {
1653 HubConfig {
1654 hub: "http://127.0.0.1:9".to_string(),
1655 key: Some("k".to_string()),
1656 }
1657 }
1658
1659 #[test]
1660 fn verb_entry_gates_accept_the_hub_ref_shapes() {
1661 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
1662 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
1663 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
1664 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
1665 }
1666 }
1667
1668 #[test]
1669 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
1670 let cfg = dead_hub();
1671 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
1672 assert!(
1673 matches!(
1674 sync_pull(&cfg, bad, None),
1675 Err(LinkError::BadAddress { .. })
1676 ),
1677 "sync_pull must refuse {bad:?}"
1678 );
1679 assert!(
1680 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
1681 "sync_push must refuse {bad:?}"
1682 );
1683 assert!(
1684 matches!(
1685 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
1686 Err(LinkError::BadAddress { .. })
1687 ),
1688 "grant_issue must refuse {bad:?}"
1689 );
1690 assert!(
1691 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
1692 "grant_list must refuse {bad:?}"
1693 );
1694 assert!(
1695 matches!(
1696 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
1697 Err(LinkError::BadAddress { .. })
1698 ),
1699 "grant_revoke must refuse brain {bad:?}"
1700 );
1701 assert!(
1702 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
1703 "head must refuse {bad:?}"
1704 );
1705 }
1706 }
1707
1708 #[test]
1709 fn grant_revoke_refuses_url_reshaping_grant_ids() {
1710 let cfg = dead_hub();
1711 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
1712 assert!(
1713 matches!(
1714 grant_revoke(&cfg, "acme", bad),
1715 Err(LinkError::BadGrantId { .. })
1716 ),
1717 "grant_revoke must refuse grant id {bad:?}"
1718 );
1719 }
1720 }
1721
1722 #[test]
1723 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
1724 let cfg = dead_hub();
1725 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
1726 assert!(
1727 matches!(
1728 propose(&cfg, bad, "intake", "hi"),
1729 Err(LinkError::BadAddress { .. })
1730 ),
1731 "propose must refuse handle {bad:?}"
1732 );
1733 }
1734 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
1735 assert!(matches!(
1736 propose(&cfg, "acme-site", "intake", &oversize),
1737 Err(LinkError::ProposeTooLarge { .. })
1738 ));
1739 assert!(matches!(
1742 propose(&cfg, "acme-site", "intake", "hi"),
1743 Err(LinkError::Transport { .. })
1744 ));
1745 }
1746
1747 #[test]
1748 fn resolve_refuses_a_hand_built_unsafe_address() {
1749 let cfg = dead_hub();
1750 for brain in ["../up", "a/b", "a?x", "a#f"] {
1751 let addr = Address {
1752 brain: brain.to_string(),
1753 target: None,
1754 };
1755 assert!(
1756 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
1757 "resolve must refuse brain {brain:?}"
1758 );
1759 }
1760 for target in [
1761 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
1762 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
1764 AddressTarget::Path("records/x.md#frag".to_string()),
1765 ] {
1766 let addr = Address {
1767 brain: "acme".to_string(),
1768 target: Some(target.clone()),
1769 };
1770 assert!(
1771 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
1772 "resolve must refuse target {target:?}"
1773 );
1774 }
1775 }
1776}