1use std::io::{Cursor, Read, Write};
61use std::path::{Path, PathBuf};
62
63use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
64use ring::signature::{UnparsedPublicKey, ED25519};
65use serde::{Deserialize, Serialize};
66use serde_json::{json, Value};
67use sha2::{Digest, Sha256};
68
69use crate::store::Store;
70
71pub const HUB_URL_ENV: &str = "DBMD_HUB_URL";
73
74pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
77
78pub const HUB_CREDENTIAL_ORIGIN_ENV: &str = "DBMD_HUB_CREDENTIAL_ORIGIN";
82
83pub const STATE_DIR_ENV: &str = "DBMD_STATE_DIR";
87
88pub const ALLOW_PRIVATE_REGISTRY_HOME_ENV: &str = "DBMD_ALLOW_PRIVATE_REGISTRY_HOME";
92
93pub const ALLOW_PRIVATE_OBJECT_URL_ENV: &str = "DBMD_ALLOW_PRIVATE_OBJECT_URL";
97
98pub const BRAIN_KEY_FILE_ENV: &str = "DBMD_BRAIN_KEY_FILE";
104
105pub const AGENT_KEY_FILE_ENV: &str = "DBMD_AGENT_KEY_FILE";
113
114pub const CONFIG_REL_PATH: &str = ".dbmd/config";
117
118const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024;
121const MAX_FEED_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
124const MAX_REGISTRY_CARD_BYTES: u64 = 1024 * 1024;
126
127const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
130
131const MAX_PUSH_FILES: usize = u16::MAX as usize;
133const MAX_STORE_PATH_BYTES: usize = 1_024;
134const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
135const MAX_PACK_BYTES: u64 =
138 MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
139const MAX_IDENTITY_ROTATIONS: usize = 1_024;
142const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
146const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
147const FEED_PAGE_LIMIT: usize = 100;
148
149pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
154
155const CONNECT_TIMEOUT_SECS: u64 = 10;
158const READ_TIMEOUT_SECS: u64 = 120;
159const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
163const CONNECT_ATTEMPTS: usize = 3;
164const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
165
166#[derive(Debug, thiserror::Error)]
170pub enum LinkError {
171 #[error(
173 "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
174 )]
175 NoHub,
176
177 #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
179 NoCredential,
180
181 #[error(
184 "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
185 )]
186 BadKey,
187
188 #[error(
194 "refusing to send an ambient credential to the hub selected by {CONFIG_REL_PATH} — set {HUB_CREDENTIAL_ORIGIN_ENV} to that exact origin, or choose the hub explicitly with --hub/{HUB_URL_ENV}"
195 )]
196 UnboundCredential,
197
198 #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
202 BadAgentKey {
203 message: String,
205 },
206
207 #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
209 UnsafeHub {
210 hub: String,
212 },
213
214 #[error("hub unreachable at {hub}: {message}")]
216 Transport {
217 hub: String,
219 message: String,
221 },
222
223 #[error("{what} failed (HTTP {status}): {message}")]
225 Http {
226 what: &'static str,
228 status: u16,
230 message: String,
232 code: Option<String>,
234 },
235
236 #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
239 NotJson {
240 what: &'static str,
242 status: u16,
244 },
245
246 #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
248 ResponseTooLarge {
249 limit_bytes: u64,
251 },
252
253 #[error("invalid address `{given}`: {reason}")]
255 BadAddress {
256 given: String,
258 reason: String,
260 },
261
262 #[error(
264 "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
265 )]
266 BadGrantId {
267 given: String,
269 },
270
271 #[error("refusing unsafe path from the hub: `{path}`")]
275 UnsafePath {
276 path: String,
278 },
279
280 #[error(
282 "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
283 MAX_STORE_BYTES / (1024 * 1024),
284 MAX_PACK_BYTES / (1024 * 1024)
285 )]
286 PushTooLarge {
287 detail: String,
289 },
290
291 #[error(
293 "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
294 MAX_PROPOSE_BYTES / 1024
295 )]
296 ProposeTooLarge {
297 bytes: u64,
299 },
300
301 #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
303 NotUtf8 {
304 path: String,
306 },
307
308 #[error("invalid store pack: {message}")]
310 InvalidPack {
311 message: String,
313 },
314
315 #[error("invalid signed feed: {message}")]
317 InvalidFeed {
318 message: String,
320 },
321
322 #[error(
325 "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
326 )]
327 UnsupportedPlatform {
328 operation: &'static str,
330 },
331
332 #[error(transparent)]
334 Io(#[from] std::io::Error),
335
336 #[error(transparent)]
338 Store(#[from] crate::StoreError),
339}
340
341pub type LinkResult<T> = std::result::Result<T, LinkError>;
343
344fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
349 #[cfg(any(target_os = "linux", target_os = "macos"))]
350 {
351 let _ = operation;
352 Ok(())
353 }
354 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
355 {
356 Err(LinkError::UnsupportedPlatform { operation })
357 }
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
366pub enum AddressTarget {
367 Id(String),
369 Path(String),
373}
374
375const BAD_BRAIN_REASON: &str =
378 "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
379
380const BAD_TARGET_REASON: &str =
383 "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
384
385#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct Address {
391 pub brain: String,
393 pub target: Option<AddressTarget>,
395}
396
397impl Address {
398 pub fn parse(raw: &str) -> LinkResult<Address> {
402 let bad = |reason: &str| LinkError::BadAddress {
403 given: raw.to_string(),
404 reason: reason.to_string(),
405 };
406
407 let trimmed = raw.trim();
408 let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
409 if body.is_empty() {
410 return Err(bad("empty address"));
411 }
412
413 let (brain, rest) = match body.split_once('/') {
414 Some((b, r)) => (b, Some(r)),
415 None => (body, None),
416 };
417
418 if brain.is_empty() {
419 return Err(bad("missing brain reference before `/`"));
420 }
421 if !is_safe_ref(brain) {
422 return Err(bad(BAD_BRAIN_REASON));
423 }
424
425 let target = match rest {
426 None => None,
427 Some("") => return Err(bad("trailing `/` with no record id or path")),
428 Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
429 Some(r) => {
430 if !safe_store_rel_path(r) || !r.ends_with(".md") {
431 return Err(bad(BAD_TARGET_REASON));
432 }
433 Some(AddressTarget::Path(r.to_string()))
434 }
435 };
436
437 Ok(Address {
438 brain: brain.to_string(),
439 target,
440 })
441 }
442}
443
444fn is_safe_ref(s: &str) -> bool {
447 !s.is_empty()
448 && s.len() <= 64
449 && s.bytes()
450 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
451}
452
453pub fn is_valid_handle(s: &str) -> bool {
456 is_safe_ref(s)
457}
458
459pub fn safe_store_rel_path(p: &str) -> bool {
465 if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
466 return false;
467 }
468 if !p
469 .bytes()
470 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
471 {
472 return false;
473 }
474 p.split('/')
475 .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
476}
477
478fn require_safe_ref(brain: &str) -> LinkResult<()> {
486 if is_safe_ref(brain) {
487 Ok(())
488 } else {
489 Err(LinkError::BadAddress {
490 given: brain.to_string(),
491 reason: BAD_BRAIN_REASON.to_string(),
492 })
493 }
494}
495
496fn require_valid_handle(handle: &str) -> LinkResult<()> {
498 if is_valid_handle(handle) {
499 Ok(())
500 } else {
501 Err(LinkError::BadAddress {
502 given: handle.to_string(),
503 reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
504 })
505 }
506}
507
508fn require_safe_grant_id(id: &str) -> LinkResult<()> {
512 if is_safe_ref(id) {
513 Ok(())
514 } else {
515 Err(LinkError::BadGrantId {
516 given: id.to_string(),
517 })
518 }
519}
520
521#[derive(Debug, Clone)]
527pub struct HubConfig {
528 pub hub: String,
530 pub key: Option<String>,
532 pub agent_key: Option<AgentSigningKey>,
535 pub brain_key: Option<AgentSigningKey>,
538 pub state_dir: PathBuf,
541 store_selected: bool,
544}
545
546#[derive(Clone)]
549pub struct AgentSigningKey {
550 pkcs8: Vec<u8>,
551 pub multikey: String,
553 pub public_key_spki: String,
555}
556
557impl std::fmt::Debug for AgentSigningKey {
558 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559 f.debug_struct("AgentSigningKey")
560 .field("multikey", &self.multikey)
561 .field("pkcs8", &"<redacted>")
562 .finish()
563 }
564}
565
566impl HubConfig {
567 pub fn require_key(&self) -> LinkResult<&str> {
570 self.key.as_deref().ok_or(LinkError::NoCredential)
571 }
572}
573
574pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
579 let explicit_hub = flag_hub
580 .map(str::to_string)
581 .or_else(|| env_nonempty(HUB_URL_ENV));
582 let selected_by_store = explicit_hub.is_none();
583 let hub = explicit_hub
584 .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
585 .ok_or(LinkError::NoHub)?;
586 let hub = hub.trim().trim_end_matches('/').to_string();
587 assert_safe_hub(&hub)?;
588 if selected_by_store {
589 let parsed =
590 url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
591 if !parsed.scheme().eq_ignore_ascii_case("https")
595 || (parsed.path() != "/" && !parsed.path().is_empty())
596 {
597 return Err(LinkError::UnsafeHub { hub });
598 }
599 }
600
601 let key = match env_nonempty(HUB_KEY_ENV) {
602 Some(raw) => Some(clean_key(&raw)?),
603 None => None,
604 };
605
606 let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
607 Some(path) => Some(load_agent_key(Path::new(&path))?),
608 None => None,
609 };
610
611 let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
612 Some(path) => Some(load_agent_key(Path::new(&path))?),
613 None => None,
614 };
615
616 if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
623 let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
624 .and_then(|value| normalized_origin(&value).ok());
625 let selected_origin = normalized_origin(&hub)?;
626 if bound.as_deref() != Some(selected_origin.as_str()) {
627 return Err(LinkError::UnboundCredential);
628 }
629 }
630
631 Ok(HubConfig {
632 hub,
633 key,
634 agent_key,
635 brain_key,
636 state_dir: toolkit_state_dir()?,
637 store_selected: selected_by_store,
638 })
639}
640
641fn toolkit_state_dir() -> LinkResult<PathBuf> {
642 if let Some(path) = env_nonempty(STATE_DIR_ENV) {
643 let path = PathBuf::from(path);
644 if !path.is_absolute() {
645 return Err(LinkError::UnsafePath {
646 path: path.display().to_string(),
647 });
648 }
649 return Ok(path);
650 }
651 #[cfg(windows)]
652 if let Some(base) = env_nonempty("LOCALAPPDATA") {
653 let base = PathBuf::from(base);
654 if base.is_absolute() {
655 return Ok(base.join("dbmd").join("state"));
656 }
657 }
658 #[cfg(not(windows))]
659 if let Some(base) = env_nonempty("XDG_STATE_HOME") {
660 let base = PathBuf::from(base);
661 if base.is_absolute() {
662 return Ok(base.join("dbmd"));
663 }
664 }
665 let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
666 LinkError::Io(std::io::Error::new(
667 std::io::ErrorKind::NotFound,
668 format!("cannot locate user state; set {STATE_DIR_ENV}"),
669 ))
670 })?);
671 if !home.is_absolute() {
672 return Err(LinkError::UnsafePath {
673 path: home.display().to_string(),
674 });
675 }
676 #[cfg(target_os = "macos")]
677 {
678 Ok(home
679 .join("Library")
680 .join("Application Support")
681 .join("dbmd")
682 .join("state"))
683 }
684 #[cfg(all(not(target_os = "macos"), not(windows)))]
685 {
686 Ok(home.join(".local").join("state").join("dbmd"))
687 }
688}
689
690fn normalized_origin(value: &str) -> LinkResult<String> {
691 let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
692 hub: value.to_string(),
693 })?;
694 if !(parsed.scheme().eq_ignore_ascii_case("https")
695 || parsed.scheme().eq_ignore_ascii_case("http"))
696 || !parsed.username().is_empty()
697 || parsed.password().is_some()
698 || (parsed.path() != "/" && !parsed.path().is_empty())
699 || parsed.query().is_some()
700 || parsed.fragment().is_some()
701 {
702 return Err(LinkError::UnsafeHub {
703 hub: value.to_string(),
704 });
705 }
706 let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
707 hub: value.to_string(),
708 })?;
709 let host = if host.contains(':') {
710 format!("[{host}]")
711 } else {
712 host.to_ascii_lowercase()
713 };
714 let port = parsed
715 .port_or_known_default()
716 .ok_or_else(|| LinkError::UnsafeHub {
717 hub: value.to_string(),
718 })?;
719 let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
720 || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
721 Ok(format!(
722 "{}://{}{}",
723 parsed.scheme().to_ascii_lowercase(),
724 host,
725 if default {
726 String::new()
727 } else {
728 format!(":{port}")
729 }
730 ))
731}
732
733const ED25519_SPKI_PREFIX: [u8; 12] = [
740 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
741];
742
743fn bad_agent_key(message: &str) -> LinkError {
744 LinkError::BadAgentKey {
745 message: message.to_string(),
746 }
747}
748
749fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
750 ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
754 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
755 .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
756}
757
758fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
760 use ring::signature::KeyPair as _;
761 let mut spki = Vec::with_capacity(44);
762 spki.extend_from_slice(&ED25519_SPKI_PREFIX);
763 spki.extend_from_slice(pair.public_key().as_ref());
764 (
765 URL_SAFE_NO_PAD.encode(&spki),
766 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
767 )
768}
769
770pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
774 load_agent_key(path)
775}
776
777fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
779 #[cfg(unix)]
780 let file = {
781 use std::os::fd::{AsRawFd as _, FromRawFd as _};
782 use std::os::unix::ffi::OsStrExt as _;
783 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
784 .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
785 let leaf = path
786 .file_name()
787 .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
788 let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
789 let fd = unsafe {
790 libc::openat(
791 parent.as_raw_fd(),
792 leaf.as_ptr(),
793 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
794 )
795 };
796 if fd < 0 {
797 return Err(bad_agent_key(
798 "the key path must be an existing regular file without symlink ancestors",
799 ));
800 }
801 unsafe { std::fs::File::from_raw_fd(fd) }
802 };
803 #[cfg(not(unix))]
804 let file = std::fs::File::open(path)
805 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
806 let metadata = file
807 .metadata()
808 .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
809 if !metadata.is_file() {
810 return Err(bad_agent_key("the key path must be a regular file"));
811 }
812 #[cfg(unix)]
813 {
814 use std::os::unix::fs::PermissionsExt as _;
815 if metadata.permissions().mode() & 0o077 != 0 {
816 return Err(bad_agent_key(
817 "the key file is accessible to group/other; set mode 0600",
818 ));
819 }
820 }
821 let mut text = String::new();
822 file.take(1024 * 1024 + 1)
823 .read_to_string(&mut text)
824 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
825 if text.len() > 1024 * 1024 {
826 return Err(bad_agent_key("the key file exceeds the size limit"));
827 }
828 let pkcs8 = URL_SAFE_NO_PAD
829 .decode(text.trim())
830 .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
831 let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
832 Ok(AgentSigningKey {
833 pkcs8,
834 multikey,
835 public_key_spki,
836 })
837}
838
839fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
845 #[cfg(unix)]
846 let (mut file, parent, leaf) = {
847 use std::os::fd::{AsRawFd as _, FromRawFd as _};
848 use std::os::unix::ffi::OsStrExt as _;
849 let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
850 let leaf_name = path
851 .file_name()
852 .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
853 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
854 let fd = unsafe {
855 libc::openat(
856 parent.as_raw_fd(),
857 leaf.as_ptr(),
858 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
859 0o600,
860 )
861 };
862 if fd < 0 {
863 let error = std::io::Error::last_os_error();
864 if error.kind() == std::io::ErrorKind::AlreadyExists {
865 return Err(bad_agent_key(
866 "the output file already exists — refusing to overwrite a key",
867 ));
868 }
869 return Err(error.into());
870 }
871 (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
872 };
873 #[cfg(not(unix))]
874 let mut file = std::fs::OpenOptions::new()
875 .write(true)
876 .create_new(true)
877 .open(path)
878 .map_err(|error| {
879 if error.kind() == std::io::ErrorKind::AlreadyExists {
880 bad_agent_key("the output file already exists — refusing to overwrite a key")
881 } else {
882 LinkError::Io(error)
883 }
884 })?;
885 if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
886 drop(file);
887 #[cfg(unix)]
888 let _ =
889 unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
890 #[cfg(not(unix))]
891 let _ = std::fs::remove_file(path);
892 return Err(LinkError::Io(error));
893 }
894 drop(file);
895 #[cfg(unix)]
896 parent.sync_all()?;
897 Ok(())
898}
899
900#[derive(Debug, Serialize)]
903pub struct GeneratedAgentKey {
904 pub multikey: String,
906 #[serde(rename = "publicKeySpki")]
908 pub public_key_spki: String,
909 #[serde(rename = "keyFile")]
911 pub key_file: String,
912}
913
914pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
919 require_hardened_filesystem("key generation")?;
920 let rng = ring::rand::SystemRandom::new();
921 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
922 .map_err(|_| bad_agent_key("key generation failed"))?;
923 let pair = agent_keypair(pkcs8.as_ref())?;
924 let (spki_b64u, multikey) = public_identity_for(&pair);
925
926 write_secret_new(
927 out,
928 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
929 )?;
930
931 Ok(GeneratedAgentKey {
932 multikey,
933 public_key_spki: spki_b64u,
934 key_file: out.display().to_string(),
935 })
936}
937
938fn linkmd_sig_header(
947 key: &AgentSigningKey,
948 origin: &str,
949 method: &str,
950 path: &str,
951 body: Option<&str>,
952) -> LinkResult<String> {
953 let ts = std::time::SystemTime::now()
954 .duration_since(std::time::UNIX_EPOCH)
955 .map_err(|_| bad_agent_key("system clock is before the epoch"))?
956 .as_secs();
957 let body_hash = match body {
958 Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
959 None => "-".to_string(),
960 };
961 let canonical = format!(
962 "v2\n{}\n{}\n{}\n{}\n{}",
963 origin,
964 method.to_uppercase(),
965 path,
966 ts,
967 body_hash
968 );
969 let pair = agent_keypair(&key.pkcs8)?;
970 let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
971 let fingerprint = key.multikey.trim_start_matches("ed25519:");
972 Ok(format!(
973 "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
974 ))
975}
976
977#[derive(Serialize)]
984struct WireFeedFile {
985 path: String,
986 sha256: String,
987 bytes: u64,
988}
989
990#[derive(Serialize)]
993struct UnsignedWireEntry<'a> {
994 v: u8,
995 seq: u64,
996 ts: String,
997 brain: &'a str,
998 public_key: &'a str,
999 kind: &'a str,
1000 op: &'a str,
1001 pack_sha256: &'a str,
1002 files: &'a [WireFeedFile],
1003 removed: &'a [String],
1004 prev_entry_hash: Option<&'a str>,
1005}
1006
1007fn self_custody_entry(
1013 key: &AgentSigningKey,
1014 seq: u64,
1015 ts: String,
1016 pack_sha256: &str,
1017 files: &[WireFeedFile],
1018 prev_entry_hash: Option<&str>,
1019) -> LinkResult<String> {
1020 let removed: [String; 0] = [];
1021 let unsigned = serde_json::to_string(&UnsignedWireEntry {
1022 v: 1,
1023 seq,
1024 ts,
1025 brain: &key.multikey,
1026 public_key: &key.public_key_spki,
1027 kind: "push",
1028 op: "snapshot",
1029 pack_sha256,
1030 files,
1031 removed: &removed,
1032 prev_entry_hash,
1033 })
1034 .expect("serialize feed entry");
1035 let pair = agent_keypair(&key.pkcs8)?;
1036 let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1037 Ok(format!(
1038 "{},\"sig\":\"{}\"}}",
1039 &unsigned[..unsigned.len() - 1],
1040 sig
1041 ))
1042}
1043
1044fn env_nonempty(name: &str) -> Option<String> {
1047 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1048}
1049
1050fn config_file_hub(path: &Path) -> Option<String> {
1055 const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1056 #[cfg(unix)]
1057 let file = {
1058 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1059 use std::os::unix::ffi::OsStrExt as _;
1060 let parent =
1061 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1062 let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1063 let fd = unsafe {
1064 libc::openat(
1065 parent.as_raw_fd(),
1066 leaf.as_ptr(),
1067 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1068 )
1069 };
1070 if fd < 0 {
1071 return None;
1072 }
1073 unsafe { std::fs::File::from_raw_fd(fd) }
1074 };
1075 #[cfg(not(unix))]
1076 let file = std::fs::File::open(path).ok()?;
1077 let metadata = file.metadata().ok()?;
1078 if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1079 return None;
1080 }
1081 let mut bytes = Vec::with_capacity(metadata.len() as usize);
1082 file.take(MAX_CONFIG_BYTES + 1)
1083 .read_to_end(&mut bytes)
1084 .ok()?;
1085 if bytes.len() as u64 > MAX_CONFIG_BYTES {
1086 return None;
1087 }
1088 let text = String::from_utf8(bytes).ok()?;
1089 for line in text.lines() {
1090 let line = line.trim();
1091 if line.is_empty() || line.starts_with('#') {
1092 continue;
1093 }
1094 if let Some((k, v)) = line.split_once('=') {
1095 if k.trim() == "hub" {
1096 let v = v.trim();
1097 if !v.is_empty() {
1098 return Some(v.to_string());
1099 }
1100 }
1101 }
1102 }
1103 None
1104}
1105
1106fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1109 let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1110 hub: hub.to_string(),
1111 })?;
1112 if !(parsed.scheme().eq_ignore_ascii_case("https")
1113 || parsed.scheme().eq_ignore_ascii_case("http"))
1114 || !parsed.username().is_empty()
1115 || parsed.password().is_some()
1116 || (parsed.path() != "/" && !parsed.path().is_empty())
1117 || parsed.query().is_some()
1118 || parsed.fragment().is_some()
1119 {
1120 return Err(LinkError::UnsafeHub {
1121 hub: hub.to_string(),
1122 });
1123 }
1124 let loopback = match parsed.host() {
1125 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1126 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1127 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1128 None => false,
1129 };
1130 if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1131 Ok(())
1132 } else {
1133 Err(LinkError::UnsafeHub {
1134 hub: hub.to_string(),
1135 })
1136 }
1137}
1138
1139fn clean_key(raw: &str) -> LinkResult<String> {
1144 let k = raw.trim();
1145 if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1146 return Err(LinkError::BadKey);
1147 }
1148 Ok(k.to_string())
1149}
1150
1151#[derive(Debug)]
1157pub struct HubResponse {
1158 pub status: u16,
1160 pub body: Option<Value>,
1162}
1163
1164struct RawHubResponse {
1165 status: u16,
1166 body: Vec<u8>,
1167}
1168
1169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1171enum Auth {
1172 Required,
1174 None,
1176 Optional,
1180}
1181
1182fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1183 ureq::AgentBuilder::new()
1184 .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1185 .redirects(0)
1189 .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1190 .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1191 .timeout_write(overall)
1192 .timeout(overall)
1193}
1194
1195fn agent_builder() -> ureq::AgentBuilder {
1196 agent_builder_with_timeout(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS))
1197}
1198
1199fn agent() -> ureq::Agent {
1200 agent_builder().build()
1201}
1202
1203fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1204 if !cfg.store_selected {
1205 return Ok(agent());
1206 }
1207 let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1208 hub: cfg.hub.clone(),
1209 })?;
1210 pinned_public_agent(&parsed, false, "store-selected hub")
1211}
1212
1213fn request_raw(
1218 cfg: &HubConfig,
1219 method: &str,
1220 path: &str,
1221 body: Option<&Value>,
1222 auth: Auth,
1223 max_response_bytes: u64,
1224) -> LinkResult<RawHubResponse> {
1225 let url = format!("{}{}", cfg.hub, path);
1226 let encoded_body = body.map(Value::to_string);
1227 let origin = normalized_origin(&cfg.hub)?;
1228 let credential = match auth {
1231 Auth::Required => Some(match &cfg.agent_key {
1232 Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1233 None => format!("Bearer {}", cfg.require_key()?),
1234 }),
1235 Auth::Optional => match &cfg.agent_key {
1236 Some(key) => Some(linkmd_sig_header(
1237 key,
1238 &origin,
1239 method,
1240 path,
1241 encoded_body.as_deref(),
1242 )?),
1243 None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1244 },
1245 Auth::None => None,
1246 };
1247 let http = hub_agent(cfg)?;
1248 let result = with_connect_retries(|| {
1249 let mut req = http.request(method, &url);
1250 if let Some(value) = &credential {
1251 req = req.set("authorization", value);
1252 }
1253 match &encoded_body {
1254 Some(value) => req
1255 .set("content-type", "application/json")
1256 .send_string(value)
1257 .map_err(Box::new),
1258 None => req.call().map_err(Box::new),
1259 }
1260 });
1261 let resp = match result {
1262 Ok(resp) => resp,
1263 Err(error) => match *error {
1264 ureq::Error::Status(_, resp) => resp,
1265 ureq::Error::Transport(error) => {
1266 return Err(LinkError::Transport {
1267 hub: cfg.hub.clone(),
1268 message: error.to_string(),
1269 });
1270 }
1271 },
1272 };
1273
1274 let status = resp.status();
1275 let mut buf = Vec::new();
1276 resp.into_reader()
1277 .take(max_response_bytes + 1)
1278 .read_to_end(&mut buf)?;
1279 if buf.len() as u64 > max_response_bytes {
1280 return Err(LinkError::ResponseTooLarge {
1281 limit_bytes: max_response_bytes,
1282 });
1283 }
1284 Ok(RawHubResponse { status, body: buf })
1285}
1286
1287fn request_capped(
1288 cfg: &HubConfig,
1289 method: &str,
1290 path: &str,
1291 body: Option<&Value>,
1292 auth: Auth,
1293 max_response_bytes: u64,
1294) -> LinkResult<HubResponse> {
1295 let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1296 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1297 Ok(HubResponse {
1298 status: raw.status,
1299 body: parsed,
1300 })
1301}
1302
1303fn request(
1304 cfg: &HubConfig,
1305 method: &str,
1306 path: &str,
1307 body: Option<&Value>,
1308 auth: Auth,
1309) -> LinkResult<HubResponse> {
1310 request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1311}
1312
1313fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1314 if (200..300).contains(&r.status) {
1315 return Ok(r.body);
1316 }
1317 ensure_ok(
1318 HubResponse {
1319 status: r.status,
1320 body: serde_json::from_slice(&r.body).ok(),
1321 },
1322 what,
1323 )
1324 .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1325}
1326
1327fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1332 matches!(
1333 kind,
1334 ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1335 )
1336}
1337
1338fn with_connect_retries(
1339 mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1340) -> Result<ureq::Response, Box<ureq::Error>> {
1341 let mut attempt = 0;
1342 loop {
1343 match send() {
1344 Err(error)
1345 if matches!(
1346 error.as_ref(),
1347 ureq::Error::Transport(transport)
1348 if is_pre_request_transport(transport.kind())
1349 ) && attempt + 1 < CONNECT_ATTEMPTS =>
1350 {
1351 std::thread::sleep(std::time::Duration::from_millis(
1352 CONNECT_RETRY_BACKOFF_MS[attempt],
1353 ));
1354 attempt += 1;
1355 }
1356 result => return result,
1357 }
1358 }
1359}
1360
1361fn hub_is_loopback(hub: &str) -> bool {
1362 url::Url::parse(hub).ok().is_some_and(|parsed| {
1363 parsed.host().is_some_and(|host| match host {
1364 url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1365 url::Host::Ipv4(ip) => ip.is_loopback(),
1366 url::Host::Ipv6(ip) => ip.is_loopback(),
1367 })
1368 })
1369}
1370
1371fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1372 let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1373 message: "the hub returned an invalid object-store URL".to_string(),
1374 })?;
1375 let allow_private = hub_is_loopback(&cfg.hub)
1376 || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1377 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1378 || !parsed.username().is_empty()
1379 || parsed.password().is_some()
1380 || parsed.fragment().is_some()
1381 {
1382 return Err(LinkError::InvalidPack {
1383 message: "the hub returned an unsafe object-store URL".to_string(),
1384 });
1385 }
1386 pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1387 LinkError::InvalidPack {
1388 message: "the hub returned an object-store URL with an unsafe network target"
1389 .to_string(),
1390 }
1391 })
1392}
1393
1394fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1395 let http = presigned_agent(cfg, raw)?;
1396 let result = with_connect_retries(|| {
1397 let mut req = http.put(raw);
1398 if let Some(map) = headers.as_object() {
1399 for (name, value) in map {
1400 if let Some(value) = value.as_str() {
1401 req = req.set(name, value);
1402 }
1403 }
1404 }
1405 req.send_bytes(bytes).map_err(Box::new)
1406 });
1407 match result {
1408 Ok(resp) if (200..300).contains(&resp.status()) => Ok(()),
1409 Ok(resp) => Err(LinkError::Http {
1410 what: "pack upload",
1411 status: resp.status(),
1412 message: "object store rejected the upload".to_string(),
1413 code: None,
1414 }),
1415 Err(error) => match *error {
1416 ureq::Error::Status(_, resp) => Err(LinkError::Http {
1417 what: "pack upload",
1418 status: resp.status(),
1419 message: "object store rejected the upload".to_string(),
1420 code: None,
1421 }),
1422 ureq::Error::Transport(err) => Err(LinkError::Transport {
1423 hub: "the object store".to_string(),
1424 message: err.to_string(),
1425 }),
1426 },
1427 }
1428}
1429
1430fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1431 max_bytes.checked_add(1)
1432}
1433
1434fn presigned_download_read_limit() -> u64 {
1435 one_past_bounded_limit(MAX_PACK_BYTES)
1436 .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1437}
1438
1439fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1440 let http = presigned_agent(cfg, raw)?;
1441 let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1442 Ok(resp) => resp,
1443 Err(error) => match *error {
1444 ureq::Error::Status(_, resp) => {
1445 return Err(LinkError::Http {
1446 what: "pack download",
1447 status: resp.status(),
1448 message: "object store rejected the download".to_string(),
1449 code: None,
1450 });
1451 }
1452 ureq::Error::Transport(err) => {
1453 return Err(LinkError::Transport {
1454 hub: "the object store".to_string(),
1455 message: err.to_string(),
1456 });
1457 }
1458 },
1459 };
1460 if !(200..300).contains(&resp.status()) {
1461 return Err(LinkError::Http {
1462 what: "pack download",
1463 status: resp.status(),
1464 message: "object store rejected the download".to_string(),
1465 code: None,
1466 });
1467 }
1468 let mut bytes = Vec::new();
1469 resp.into_reader()
1470 .take(presigned_download_read_limit())
1471 .read_to_end(&mut bytes)?;
1472 if bytes.len() as u64 > MAX_PACK_BYTES {
1473 return Err(LinkError::InvalidPack {
1474 message: "download exceeds the compressed-size limit".to_string(),
1475 });
1476 }
1477 Ok(bytes)
1478}
1479
1480fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
1484 if !(200..300).contains(&r.status) {
1485 let message = r
1486 .body
1487 .as_ref()
1488 .and_then(|b| b.get("error"))
1489 .and_then(Value::as_str)
1490 .unwrap_or("unknown error")
1491 .to_string();
1492 let code = r
1493 .body
1494 .as_ref()
1495 .and_then(|b| b.get("code"))
1496 .and_then(Value::as_str)
1497 .map(str::to_string);
1498 return Err(LinkError::Http {
1499 what,
1500 status: r.status,
1501 message,
1502 code,
1503 });
1504 }
1505 r.body.ok_or(LinkError::NotJson {
1506 what,
1507 status: r.status,
1508 })
1509}
1510
1511fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
1520 match ip {
1521 std::net::IpAddr::V4(ip) => {
1522 let [a, b, c, _] = ip.octets();
1523 !(a == 0
1524 || a == 10
1525 || a == 127
1526 || (a == 100 && (64..=127).contains(&b))
1527 || (a == 169 && b == 254)
1528 || (a == 172 && (16..=31).contains(&b))
1529 || (a == 192 && b == 0 && c == 0)
1530 || (a == 192 && b == 0 && c == 2)
1531 || (a == 192 && b == 88 && c == 99)
1532 || (a == 192 && b == 168)
1533 || (a == 198 && (b == 18 || b == 19))
1534 || (a == 198 && b == 51 && c == 100)
1535 || (a == 203 && b == 0 && c == 113)
1536 || a >= 224)
1537 }
1538 std::net::IpAddr::V6(ip) => {
1539 let segments = ip.segments();
1540 (segments[0] & 0xe000) == 0x2000
1545 && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
1546 && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
1547 && segments[0] != 0x2002
1548 && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
1549 }
1550 }
1551}
1552
1553#[derive(Clone)]
1554struct PinnedRegistryResolver {
1555 netloc: String,
1556 addresses: Vec<std::net::SocketAddr>,
1557}
1558
1559impl ureq::Resolver for PinnedRegistryResolver {
1560 fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
1561 if requested == self.netloc {
1562 Ok(self.addresses.clone())
1563 } else {
1564 Err(std::io::Error::new(
1565 std::io::ErrorKind::PermissionDenied,
1566 "registry request attempted to resolve an unvalidated authority",
1567 ))
1568 }
1569 }
1570}
1571
1572fn pinned_public_agent(
1573 url: &url::Url,
1574 allow_private: bool,
1575 label: &str,
1576) -> LinkResult<ureq::Agent> {
1577 let host = url
1578 .host_str()
1579 .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
1580 let port = url
1581 .port_or_known_default()
1582 .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
1583 let addresses = resolve_addresses_with_deadline(
1584 host,
1585 port,
1586 std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
1587 )
1588 .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
1589 if addresses.is_empty() {
1590 return Err(invalid_feed(format!("{label} DNS returned no addresses")));
1591 }
1592 if !allow_private
1593 && addresses
1594 .iter()
1595 .any(|address| !is_public_registry_ip(address.ip()))
1596 {
1597 return Err(invalid_feed(format!(
1598 "{label} resolves to a non-public address"
1599 )));
1600 }
1601 let netloc = if host.contains(':') {
1602 format!("[{host}]:{port}")
1603 } else {
1604 format!("{host}:{port}")
1605 };
1606 Ok(agent_builder()
1607 .resolver(PinnedRegistryResolver { netloc, addresses })
1608 .build())
1609}
1610
1611fn resolve_addresses_with_deadline(
1616 host: &str,
1617 port: u16,
1618 timeout: std::time::Duration,
1619) -> std::io::Result<Vec<std::net::SocketAddr>> {
1620 use std::net::ToSocketAddrs as _;
1621
1622 let host = host.to_string();
1623 let (send, receive) = std::sync::mpsc::sync_channel(1);
1624 std::thread::Builder::new()
1625 .name("dbmd-dns".to_string())
1626 .spawn(move || {
1627 let result = (host.as_str(), port)
1628 .to_socket_addrs()
1629 .map(|addresses| addresses.collect());
1630 let _ = send.send(result);
1631 })
1632 .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
1633 match receive.recv_timeout(timeout) {
1634 Ok(result) => result,
1635 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
1636 std::io::ErrorKind::TimedOut,
1637 "resolution exceeded its deadline",
1638 )),
1639 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
1640 "resolver stopped without returning a result",
1641 )),
1642 }
1643}
1644
1645fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
1646 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1647 pinned_public_agent(url, allow_private, "registry home")
1648}
1649
1650fn get_json_absolute(url: &str) -> LinkResult<Value> {
1655 let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
1656 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1657 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1658 || !parsed.username().is_empty()
1659 || parsed.password().is_some()
1660 || parsed.query().is_some()
1661 || parsed.fragment().is_some()
1662 {
1663 return Err(invalid_feed("unsafe registry home URL"));
1664 }
1665 let http = registry_agent(&parsed)?;
1666 let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
1667 Ok(resp) => resp,
1668 Err(error) => match *error {
1669 ureq::Error::Status(status, resp) => {
1670 let _ = resp;
1671 return Err(LinkError::Http {
1672 what: "registry home fetch",
1673 status,
1674 message: "the home node rejected the card request".to_string(),
1675 code: None,
1676 });
1677 }
1678 ureq::Error::Transport(err) => {
1679 return Err(LinkError::Transport {
1680 hub: url.to_string(),
1681 message: err.to_string(),
1682 });
1683 }
1684 },
1685 };
1686 if !(200..300).contains(&resp.status()) {
1687 return Err(LinkError::Http {
1688 what: "registry home fetch",
1689 status: resp.status(),
1690 message: "the home node returned a redirect or error".to_string(),
1691 code: None,
1692 });
1693 }
1694 let mut buf = Vec::new();
1695 resp.into_reader()
1696 .take(MAX_REGISTRY_CARD_BYTES + 1)
1697 .read_to_end(&mut buf)?;
1698 if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
1699 return Err(LinkError::ResponseTooLarge {
1700 limit_bytes: MAX_REGISTRY_CARD_BYTES,
1701 });
1702 }
1703 serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
1704 message: "the home node returned invalid JSON".to_string(),
1705 })
1706}
1707
1708pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
1715 require_safe_ref(handle)?;
1716 let trust_directory = open_trust_dir(cfg)?;
1720 let reg = request_capped(
1721 cfg,
1722 "GET",
1723 &format!("/api/hub/registry/{handle}"),
1724 None,
1725 Auth::None,
1726 MAX_REGISTRY_CARD_BYTES,
1727 )?;
1728 if reg.status == 404 {
1729 return Ok(None);
1730 }
1731 let body = ensure_ok(reg, "registry resolve")?;
1732 let home = body
1733 .get("home")
1734 .and_then(Value::as_str)
1735 .ok_or_else(|| invalid_feed("registry entry has no home"))?;
1736 let brain = body
1737 .get("brain")
1738 .and_then(Value::as_str)
1739 .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
1740 if !crate::ulid::is_ulid(brain) {
1741 return Err(invalid_feed(
1742 "registry entry brain is not a canonical lowercase ULID",
1743 ));
1744 }
1745 let want_fp = body
1746 .get("identity")
1747 .and_then(|i| i.get("fingerprint"))
1748 .and_then(Value::as_str)
1749 .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
1750
1751 let home = home.trim_end_matches('/');
1752 let origin = normalized_origin(home)?;
1753 if origin != home {
1754 return Err(invalid_feed(
1755 "registry home must be an origin without a path, query, or fragment",
1756 ));
1757 }
1758 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
1759 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
1760 if let Some(binding) = &alias_binding {
1761 if binding
1762 .home
1763 .as_deref()
1764 .is_some_and(|pinned_home| pinned_home != home)
1765 {
1766 return Err(invalid_feed(
1767 "registry relocated a pinned handle to a different home",
1768 ));
1769 }
1770 }
1771 let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
1772 if card.get("id").and_then(Value::as_str) != Some(brain) {
1773 return Err(invalid_feed(
1774 "the home node served a card for a different brain",
1775 ));
1776 }
1777 let identity: FeedIdentity = serde_json::from_value(
1778 card.get("identity")
1779 .cloned()
1780 .ok_or_else(|| invalid_feed("the home node served no identity"))?,
1781 )
1782 .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
1783 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
1784 let got_fp = card
1785 .get("identity")
1786 .and_then(|i| i.get("fingerprint"))
1787 .and_then(Value::as_str)
1788 .unwrap_or_default();
1789 if got_fp != want_fp {
1790 return Err(invalid_feed(
1791 "the home node served an identity that does not match the registry — refusing",
1792 ));
1793 }
1794 let current = format!("ed25519:{}", identity.fingerprint);
1795 let advertised_seq = card
1796 .get("headSeq")
1797 .and_then(Value::as_u64)
1798 .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
1799 let advertised_hash = card.get("feedHash").and_then(Value::as_str);
1800 if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
1801 || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
1802 {
1803 return Err(invalid_feed(
1804 "the home node served an invalid feed head boundary",
1805 ));
1806 }
1807 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
1811 let registry_alias = AliasBinding {
1812 v: 1,
1813 origin: normalized_origin(&cfg.hub)?,
1814 requested: handle.to_string(),
1815 brain: brain.to_string(),
1816 home: Some(home.to_string()),
1817 };
1818 save_canonical_pin_and_alias(
1819 cfg,
1820 &trust_directory,
1821 handle,
1822 brain,
1823 TrustState {
1824 v: 2,
1825 origin: normalized_origin(&cfg.hub)?,
1826 requested: brain.to_string(),
1827 brain: brain.to_string(),
1828 home: None,
1829 anchor,
1830 current,
1831 head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
1832 feed_hash: pinned
1833 .as_ref()
1834 .and_then(|checkpoint| checkpoint.feed_hash.clone()),
1835 rotations: identity.rotations.clone(),
1836 },
1837 Some(®istry_alias),
1838 )?;
1839 let mut out = card;
1840 if let Value::Object(map) = &mut out {
1841 map.insert("home".to_string(), Value::String(home.to_string()));
1842 map.insert(
1843 "resolvedVia".to_string(),
1844 Value::String("registry".to_string()),
1845 );
1846 }
1847 Ok(Some(out))
1848}
1849
1850pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
1851 require_safe_ref(&addr.brain)?;
1855 if let Some(target) = &addr.target {
1856 let (given, ok) = match target {
1857 AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
1858 AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
1859 };
1860 if !ok {
1861 return Err(LinkError::BadAddress {
1862 given: given.clone(),
1863 reason: BAD_TARGET_REASON.to_string(),
1864 });
1865 }
1866 }
1867
1868 if let Some(target) = &addr.target {
1874 let remote = verified_remote_head(cfg, &addr.brain, false)?;
1875 if !remote.head.verified {
1876 return Err(invalid_feed(
1877 "a path-scoped feed cannot prove a record against the full signed snapshot",
1878 ));
1879 }
1880 if remote.head.seq == 0 {
1881 return Err(LinkError::Http {
1882 what: "resolve",
1883 status: 404,
1884 message: "record not found".to_string(),
1885 code: Some("NOT_FOUND".to_string()),
1886 });
1887 }
1888 let brain = remote.head.brain.clone();
1889 let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
1890 return resolve_from_verified_pack(&brain, target, pack);
1891 }
1892
1893 let path = format!("/api/hub/brains/{}", addr.brain);
1894 let direct = request(cfg, "GET", &path, None, Auth::Required)?;
1899 if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
1900 if let Some(card) = resolve_registry(cfg, &addr.brain)? {
1901 return Ok(card);
1902 }
1903 }
1904 let resolved = ensure_ok(direct, "resolve")?;
1905 let remote = verified_remote_head(cfg, &addr.brain, false)?;
1909 if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
1910 || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
1911 || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
1912 {
1913 return Err(invalid_feed(
1914 "resolve card is not bound to the exact verified feed checkpoint",
1915 ));
1916 }
1917 let card_identity: FeedIdentity = serde_json::from_value(
1918 resolved
1919 .get("identity")
1920 .cloned()
1921 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
1922 )
1923 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
1924 if remote.identity.as_ref() != Some(&card_identity) {
1925 return Err(invalid_feed(
1926 "resolve card identity differs from the verified feed identity",
1927 ));
1928 }
1929 Ok(resolved)
1930}
1931
1932fn resolve_from_verified_pack(
1937 brain: &str,
1938 target: &AddressTarget,
1939 pack: Vec<u8>,
1940) -> LinkResult<Value> {
1941 let entries = parse_store_pack(pack)?;
1942 let mut matched: Option<(String, Vec<u8>)> = None;
1943
1944 for (path, bytes) in entries {
1945 let is_candidate = match target {
1946 AddressTarget::Path(want) => &path == want,
1947 AddressTarget::Id(_) => {
1948 path.ends_with(".md")
1949 && (path.starts_with("records/") || path.starts_with("sources/"))
1950 }
1951 };
1952 if !is_candidate {
1953 continue;
1954 }
1955 let text = std::str::from_utf8(&bytes)
1956 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
1957 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
1958 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
1959 if let AddressTarget::Id(want) = target {
1960 let frontmatter =
1961 crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
1962 .map_err(|_| {
1963 invalid_feed(format!("signed snapshot record `{path}` is malformed"))
1964 })?;
1965 if frontmatter.id.as_deref() != Some(want) {
1966 continue;
1967 }
1968 }
1969 if matched.is_some() {
1970 return Err(invalid_feed(
1971 "signed snapshot contains more than one record for the requested target",
1972 ));
1973 }
1974 matched = Some((path, bytes));
1975 }
1976
1977 let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
1978 what: "resolve",
1979 status: 404,
1980 message: "record not found".to_string(),
1981 code: Some("NOT_FOUND".to_string()),
1982 })?;
1983 let text = std::str::from_utf8(&bytes)
1984 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
1985 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
1986 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
1987 let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
1988 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
1989 let Value::Object(fields) = frontmatter else {
1990 return Err(invalid_feed(format!(
1991 "signed snapshot record `{path}` frontmatter is not a mapping"
1992 )));
1993 };
1994 let mut document = serde_json::Map::new();
1995 document.insert("path".to_string(), Value::String(path));
1996 for (key, value) in fields {
1997 document.insert(key, value);
1998 }
1999 document.insert("body".to_string(), Value::String(parsed.body));
2000 document.insert(
2001 "contentSha".to_string(),
2002 Value::String(content_sha256(&bytes)),
2003 );
2004 Ok(json!({
2005 "brain": brain,
2006 "document": Value::Object(document),
2007 }))
2008}
2009
2010#[derive(Debug, serde::Serialize)]
2016pub struct PullReport {
2017 pub brain: String,
2019 pub slug: String,
2021 #[serde(rename = "headSeq")]
2023 pub head_seq: u64,
2024 pub files: usize,
2026 pub dest: String,
2028 #[serde(rename = "extraLocal")]
2031 pub extra_local: Vec<String>,
2032}
2033
2034fn download_verified_snapshot_pack(
2035 cfg: &HubConfig,
2036 brain: &str,
2037 remote: &VerifiedRemote,
2038) -> LinkResult<Vec<u8>> {
2039 let feed_hash = remote
2040 .head
2041 .feed_hash
2042 .as_deref()
2043 .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2044 let signed_head = remote
2045 .head_entry
2046 .as_ref()
2047 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2048 let expected = &signed_head.entry.pack_sha256;
2049 if !is_sha256(expected) {
2050 return Err(invalid_feed(
2051 "signed head carries an invalid snapshot pack digest",
2052 ));
2053 }
2054 let path = format!(
2055 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2056 remote.head.seq
2057 );
2058 let body = ensure_ok(
2059 request(cfg, "GET", &path, None, Auth::Required)?,
2060 "sync pull",
2061 )?;
2062 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2063 || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2064 || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2065 || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2066 {
2067 return Err(invalid_feed(
2068 "export response is not bound to the exact verified snapshot",
2069 ));
2070 }
2071 let url = body
2072 .get("url")
2073 .and_then(Value::as_str)
2074 .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2075 let bytes = get_presigned(cfg, url)?;
2076 if content_sha256(&bytes) != *expected {
2077 return Err(LinkError::InvalidPack {
2078 message: "downloaded pack does not match the signed snapshot digest".to_string(),
2079 });
2080 }
2081 let entries = parse_store_pack(bytes.clone())?;
2082 if signed_head.entry.kind == "push" {
2083 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2084 }
2085 Ok(bytes)
2086}
2087
2088pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
2094 require_hardened_filesystem("sync pull")?;
2095 require_safe_ref(brain)?;
2096 let remote = verified_remote_head(cfg, brain, false)?;
2097 if !remote.head.verified {
2098 return Err(invalid_feed(
2099 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
2100 ));
2101 }
2102 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
2103 let path = format!(
2104 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
2105 remote.head.seq
2106 );
2107 let body = ensure_ok(
2108 request(cfg, "GET", &path, None, Auth::Required)?,
2109 "sync pull",
2110 )?;
2111 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2112 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2113 {
2114 return Err(invalid_feed(
2115 "export response is not bound to the verified snapshot token",
2116 ));
2117 }
2118
2119 let remote_slug = body
2120 .get("slug")
2121 .and_then(Value::as_str)
2122 .filter(|slug| is_safe_slug(slug));
2123 let slug = remote_slug
2124 .or_else(|| is_safe_slug(brain).then_some(brain))
2125 .unwrap_or("brain")
2126 .to_string();
2127 let brain_id = body
2128 .get("brain")
2129 .and_then(Value::as_str)
2130 .unwrap_or(&remote.head.brain)
2131 .to_string();
2132 if brain_id != remote.head.brain {
2133 return Err(invalid_feed(
2134 "export response names a different brain than the verified head",
2135 ));
2136 }
2137 let head_seq = remote.head.seq;
2138 let dest: PathBuf = match out {
2139 Some(p) => p.to_path_buf(),
2140 None => PathBuf::from(&slug),
2141 };
2142 let entries = if head_seq == 0 {
2143 let files = body
2144 .get("files")
2145 .and_then(Value::as_array)
2146 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
2147 if !files.is_empty() || body.get("url").is_some() {
2148 return Err(invalid_feed(
2149 "empty signed feed cannot authorize non-empty exported content",
2150 ));
2151 }
2152 Vec::new()
2153 } else {
2154 let signed_head = remote
2155 .head_entry
2156 .as_ref()
2157 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2158 let expected = &signed_head.entry.pack_sha256;
2159 if !is_sha256(expected) {
2160 return Err(invalid_feed(
2161 "signed head carries an invalid snapshot pack digest",
2162 ));
2163 }
2164 if let Some(url) = body.get("url").and_then(Value::as_str) {
2165 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
2166 return Err(invalid_feed(
2167 "export pack digest does not match the signed head entry",
2168 ));
2169 }
2170 let bytes = get_presigned(cfg, url)?;
2171 let actual = format!("{:x}", Sha256::digest(&bytes));
2172 if actual != *expected {
2173 return Err(LinkError::InvalidPack {
2174 message: "downloaded pack does not match the signed snapshot digest"
2175 .to_string(),
2176 });
2177 }
2178 let entries = parse_store_pack(bytes)?;
2179 if signed_head.entry.kind == "push" {
2180 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2181 }
2182 entries
2183 } else {
2184 if signed_head.entry.kind != "push" {
2185 return Err(invalid_feed(
2186 "delta snapshots must export the exact signed pack",
2187 ));
2188 }
2189 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
2190 invalid_feed("verified snapshot export carried neither a pack nor files")
2191 })?;
2192 let mut entries = Vec::with_capacity(files.len());
2193 for file in files {
2194 let path = file
2195 .get("path")
2196 .and_then(Value::as_str)
2197 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
2198 let content = file
2199 .get("content")
2200 .and_then(Value::as_str)
2201 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
2202 entries.push((path.to_string(), content.as_bytes().to_vec()));
2203 }
2204 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2205 entries
2206 }
2207 };
2208
2209 let mut seen = std::collections::HashSet::new();
2211 for (path, _) in &entries {
2212 if !safe_store_rel_path(path) {
2213 return Err(LinkError::UnsafePath { path: path.clone() });
2214 }
2215 if !seen.insert(path) {
2216 return Err(LinkError::InvalidPack {
2217 message: format!("duplicate path `{path}`"),
2218 });
2219 }
2220 }
2221 let pulled: std::collections::BTreeSet<&str> =
2224 entries.iter().map(|(p, _)| p.as_str()).collect();
2225 let mut extra_local = Vec::new();
2226 if let Ok(store) = Store::open(&dest) {
2227 if let Ok(walked) = store.walk() {
2228 for rel in walked {
2229 let rel_str = rel.to_string_lossy().replace('\\', "/");
2230 if !pulled.contains(rel_str.as_str()) {
2231 extra_local.push(rel_str);
2232 }
2233 }
2234 }
2235 }
2236 #[cfg(unix)]
2237 install_pulled_snapshot(&dest, &entries)?;
2238
2239 Ok(PullReport {
2240 brain: brain_id,
2241 slug,
2242 head_seq,
2243 files: entries.len(),
2244 dest: dest.to_string_lossy().into_owned(),
2245 extra_local,
2246 })
2247}
2248
2249#[cfg(unix)]
2250fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
2251 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
2252 path: display.to_string(),
2253 })
2254}
2255
2256#[cfg(unix)]
2257fn open_dir_at(
2258 parent: std::os::fd::RawFd,
2259 name: &std::ffi::CStr,
2260 display: &str,
2261) -> LinkResult<std::fs::File> {
2262 use std::os::fd::FromRawFd as _;
2263 let fd = unsafe {
2264 libc::openat(
2265 parent,
2266 name.as_ptr(),
2267 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
2268 )
2269 };
2270 if fd < 0 {
2271 return Err(LinkError::UnsafePath {
2272 path: display.to_string(),
2273 });
2274 }
2275 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
2276}
2277
2278#[cfg(unix)]
2282fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
2283 use std::os::fd::AsRawFd as _;
2284
2285 #[cfg(target_os = "macos")]
2289 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
2290 .into_iter()
2291 .find_map(|(alias, real)| {
2292 path.strip_prefix(alias)
2293 .ok()
2294 .map(|rest| Path::new(real).join(rest))
2295 })
2296 .unwrap_or_else(|| path.to_path_buf());
2297 #[cfg(not(target_os = "macos"))]
2298 let normalized = path.to_path_buf();
2299
2300 let start = if normalized.is_absolute() {
2301 std::fs::File::open("/")?
2302 } else {
2303 std::fs::File::open(".")?
2304 };
2305 let mut directory = start;
2306 for component in normalized.components() {
2307 use std::path::Component;
2308 let name = match component {
2309 Component::RootDir | Component::CurDir => continue,
2310 Component::Normal(name) => name,
2311 Component::ParentDir | Component::Prefix(_) => {
2312 return Err(LinkError::UnsafePath {
2313 path: path.display().to_string(),
2314 });
2315 }
2316 };
2317 use std::os::unix::ffi::OsStrExt as _;
2318 let name = c_name(name.as_bytes(), &path.display().to_string())?;
2319 if create {
2320 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
2321 if made != 0 {
2322 let error = std::io::Error::last_os_error();
2323 if error.raw_os_error() != Some(libc::EEXIST) {
2324 return Err(error.into());
2325 }
2326 }
2327 }
2328 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
2329 }
2330 Ok(directory)
2331}
2332
2333#[cfg(unix)]
2334fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
2335 open_dir_path_nofollow(path, true)
2336}
2337
2338#[cfg(unix)]
2339fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
2340 open_dir_path_nofollow(path, false)
2341}
2342
2343#[cfg(unix)]
2344fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
2345 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
2346 let result =
2347 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
2348 if result == 0 {
2349 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
2350 }
2351 let error = std::io::Error::last_os_error();
2352 if error.kind() == std::io::ErrorKind::NotFound {
2353 Ok(None)
2354 } else {
2355 Err(error.into())
2356 }
2357}
2358
2359#[cfg(unix)]
2360fn create_dir_exclusive_at(
2361 parent: std::os::fd::RawFd,
2362 name: &std::ffi::CStr,
2363 display: &str,
2364) -> LinkResult<std::fs::File> {
2365 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
2366 if made != 0 {
2367 return Err(LinkError::UnsafePath {
2368 path: display.to_string(),
2369 });
2370 }
2371 open_dir_at(parent, name, display)
2372}
2373
2374#[cfg(unix)]
2375fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
2376 use std::os::fd::AsRawFd as _;
2377
2378 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
2379 if duplicate < 0 {
2380 return Err(std::io::Error::last_os_error().into());
2381 }
2382 let stream = unsafe { libc::fdopendir(duplicate) };
2383 if stream.is_null() {
2384 let error = std::io::Error::last_os_error();
2385 unsafe {
2386 libc::close(duplicate);
2387 }
2388 return Err(error.into());
2389 }
2390 let mut names = Vec::new();
2391 loop {
2392 let entry = unsafe { libc::readdir(stream) };
2393 if entry.is_null() {
2394 break;
2395 }
2396 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
2397 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
2398 names.push(raw.to_owned());
2399 }
2400 }
2401 if unsafe { libc::closedir(stream) } != 0 {
2402 return Err(std::io::Error::last_os_error().into());
2403 }
2404 Ok(names)
2405}
2406
2407#[cfg(unix)]
2410fn remove_tree_at(
2411 parent: std::os::fd::RawFd,
2412 name: &std::ffi::CStr,
2413 display: &str,
2414) -> LinkResult<()> {
2415 use std::os::fd::AsRawFd as _;
2416
2417 match entry_is_dir_at(parent, name)? {
2418 None => return Ok(()),
2419 Some(false) => {
2420 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
2421 return Err(std::io::Error::last_os_error().into());
2422 }
2423 }
2424 Some(true) => {
2425 let directory = open_dir_at(parent, name, display)?;
2426 for child in directory_entry_names(&directory)? {
2427 let child_display =
2428 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
2429 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
2430 }
2431 drop(directory);
2432 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
2433 return Err(std::io::Error::last_os_error().into());
2434 }
2435 }
2436 }
2437 Ok(())
2438}
2439
2440#[cfg(unix)]
2444fn clone_tree_contents(
2445 source: &std::fs::File,
2446 destination: &std::fs::File,
2447 display: &str,
2448) -> LinkResult<()> {
2449 use std::os::fd::{AsRawFd as _, FromRawFd as _};
2450
2451 for name in directory_entry_names(source)? {
2452 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
2453 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
2454 if unsafe {
2455 libc::fstatat(
2456 source.as_raw_fd(),
2457 name.as_ptr(),
2458 &mut stat,
2459 libc::AT_SYMLINK_NOFOLLOW,
2460 )
2461 } != 0
2462 {
2463 return Err(std::io::Error::last_os_error().into());
2464 }
2465 match stat.st_mode & libc::S_IFMT {
2466 libc::S_IFDIR => {
2467 if unsafe {
2468 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
2469 } != 0
2470 {
2471 return Err(std::io::Error::last_os_error().into());
2472 }
2473 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
2474 let destination_child =
2475 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
2476 clone_tree_contents(&source_child, &destination_child, &child_display)?;
2477 destination_child.sync_all()?;
2478 }
2479 libc::S_IFREG => {
2480 let source_fd = unsafe {
2481 libc::openat(
2482 source.as_raw_fd(),
2483 name.as_ptr(),
2484 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
2485 )
2486 };
2487 if source_fd < 0 {
2488 return Err(std::io::Error::last_os_error().into());
2489 }
2490 let destination_fd = unsafe {
2491 libc::openat(
2492 destination.as_raw_fd(),
2493 name.as_ptr(),
2494 libc::O_WRONLY
2495 | libc::O_CREAT
2496 | libc::O_EXCL
2497 | libc::O_CLOEXEC
2498 | libc::O_NOFOLLOW,
2499 (stat.st_mode & 0o777) as libc::c_uint,
2500 )
2501 };
2502 if destination_fd < 0 {
2503 unsafe {
2504 libc::close(source_fd);
2505 }
2506 return Err(std::io::Error::last_os_error().into());
2507 }
2508 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
2509 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
2510 std::io::copy(&mut input, &mut output)?;
2511 output.sync_all()?;
2512 }
2513 libc::S_IFLNK => {
2514 let mut target = vec![0_u8; 4097];
2515 let length = unsafe {
2516 libc::readlinkat(
2517 source.as_raw_fd(),
2518 name.as_ptr(),
2519 target.as_mut_ptr().cast(),
2520 target.len(),
2521 )
2522 };
2523 if length < 0 || length as usize >= target.len() {
2524 return Err(LinkError::UnsafePath {
2525 path: child_display,
2526 });
2527 }
2528 target.truncate(length as usize);
2529 let target = c_name(&target, &child_display)?;
2530 if unsafe {
2531 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
2532 } != 0
2533 {
2534 return Err(std::io::Error::last_os_error().into());
2535 }
2536 }
2537 _ => {
2538 return Err(LinkError::UnsafePath {
2539 path: child_display,
2540 });
2541 }
2542 }
2543 }
2544 destination.sync_all()?;
2545 Ok(())
2546}
2547
2548#[cfg(target_os = "linux")]
2549fn install_stage_at(
2550 parent: std::os::fd::RawFd,
2551 stage: &std::ffi::CStr,
2552 dest: &std::ffi::CStr,
2553 dest_exists: bool,
2554) -> LinkResult<()> {
2555 let flags = if dest_exists {
2556 libc::RENAME_EXCHANGE
2557 } else {
2558 libc::RENAME_NOREPLACE
2559 };
2560 let result = unsafe {
2564 libc::syscall(
2565 libc::SYS_renameat2,
2566 parent,
2567 stage.as_ptr(),
2568 parent,
2569 dest.as_ptr(),
2570 flags,
2571 )
2572 };
2573 if result == 0 {
2574 Ok(())
2575 } else {
2576 Err(std::io::Error::last_os_error().into())
2577 }
2578}
2579
2580#[cfg(target_os = "macos")]
2581fn install_stage_at(
2582 parent: std::os::fd::RawFd,
2583 stage: &std::ffi::CStr,
2584 dest: &std::ffi::CStr,
2585 dest_exists: bool,
2586) -> LinkResult<()> {
2587 let flags = if dest_exists {
2588 libc::RENAME_SWAP
2589 } else {
2590 libc::RENAME_EXCL
2591 };
2592 let result =
2593 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
2594 if result == 0 {
2595 Ok(())
2596 } else {
2597 Err(std::io::Error::last_os_error().into())
2598 }
2599}
2600
2601#[cfg(unix)]
2602fn write_pull_entries_beneath_dir(
2603 root: &std::fs::File,
2604 entries: &[(String, Vec<u8>)],
2605) -> LinkResult<()> {
2606 use std::os::fd::{AsRawFd as _, FromRawFd as _};
2607
2608 for (path, content) in entries {
2609 let components: Vec<&str> = path.split('/').collect();
2610 let (leaf, parents) = components
2611 .split_last()
2612 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
2613 let mut directory = root.try_clone()?;
2614 for component in parents {
2615 let name = c_name(component.as_bytes(), path)?;
2616 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
2617 if made != 0 {
2618 let error = std::io::Error::last_os_error();
2619 if error.raw_os_error() != Some(libc::EEXIST) {
2620 return Err(error.into());
2621 }
2622 }
2623 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
2624 }
2625
2626 let leaf_name = c_name(leaf.as_bytes(), path)?;
2627 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
2628 let inspected = unsafe {
2629 libc::fstatat(
2630 directory.as_raw_fd(),
2631 leaf_name.as_ptr(),
2632 &mut existing,
2633 libc::AT_SYMLINK_NOFOLLOW,
2634 )
2635 };
2636 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
2637 return Err(LinkError::UnsafePath { path: path.clone() });
2638 }
2639
2640 let nonce = std::time::SystemTime::now()
2641 .duration_since(std::time::UNIX_EPOCH)
2642 .unwrap_or_default()
2643 .as_nanos();
2644 let temp_name = format!(
2645 ".dbmd-pull-{}-{nonce}-{}",
2646 std::process::id(),
2647 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
2648 );
2649 let temp = c_name(temp_name.as_bytes(), path)?;
2650 let fd = unsafe {
2651 libc::openat(
2652 directory.as_raw_fd(),
2653 temp.as_ptr(),
2654 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
2655 0o600,
2656 )
2657 };
2658 if fd < 0 {
2659 return Err(std::io::Error::last_os_error().into());
2660 }
2661 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
2662 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
2663 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
2664 return Err(error.into());
2665 }
2666 drop(file);
2667 let renamed = unsafe {
2668 libc::renameat(
2669 directory.as_raw_fd(),
2670 temp.as_ptr(),
2671 directory.as_raw_fd(),
2672 leaf_name.as_ptr(),
2673 )
2674 };
2675 if renamed != 0 {
2676 let error = std::io::Error::last_os_error();
2677 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
2678 return Err(error.into());
2679 }
2680 directory.sync_all()?;
2681 }
2682 root.sync_all()?;
2683 Ok(())
2684}
2685
2686#[cfg(unix)]
2687fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
2688 use ring::rand::SecureRandom as _;
2689 use std::os::fd::AsRawFd as _;
2690 use std::os::unix::ffi::OsStrExt as _;
2691
2692 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
2693 let name = dest
2694 .file_name()
2695 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
2696 .ok_or_else(|| LinkError::UnsafePath {
2697 path: dest.display().to_string(),
2698 })?;
2699 let parent_dir = open_or_create_dir_nofollow(parent)?;
2700 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
2701 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
2702 None => false,
2703 Some(true) => true,
2704 Some(false) => {
2705 return Err(LinkError::UnsafePath {
2706 path: dest.display().to_string(),
2707 });
2708 }
2709 };
2710
2711 let mut nonce = [0_u8; 16];
2712 ring::rand::SystemRandom::new()
2713 .fill(&mut nonce)
2714 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
2715 let stage_label = format!(
2716 ".{}.dbmd-pull-stage-{}",
2717 name.to_string_lossy(),
2718 URL_SAFE_NO_PAD.encode(nonce)
2719 );
2720 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
2721 let stage_dir = create_dir_exclusive_at(
2722 parent_dir.as_raw_fd(),
2723 &stage_name,
2724 &dest.display().to_string(),
2725 )?;
2726
2727 let prepared = (|| -> LinkResult<()> {
2728 if dest_exists {
2729 let live = open_dir_at(
2730 parent_dir.as_raw_fd(),
2731 &dest_name,
2732 &dest.display().to_string(),
2733 )?;
2734 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
2735 }
2736 write_pull_entries_beneath_dir(&stage_dir, entries)?;
2737 stage_dir.sync_all()?;
2738 Ok(())
2739 })();
2740 if let Err(error) = prepared {
2741 let _ = remove_tree_at(
2742 parent_dir.as_raw_fd(),
2743 &stage_name,
2744 &dest.display().to_string(),
2745 );
2746 return Err(error);
2747 }
2748
2749 if let Err(error) =
2750 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
2751 {
2752 let _ = remove_tree_at(
2753 parent_dir.as_raw_fd(),
2754 &stage_name,
2755 &dest.display().to_string(),
2756 );
2757 return Err(error);
2758 }
2759 parent_dir.sync_all()?;
2760 if dest_exists {
2761 let _ = remove_tree_at(
2765 parent_dir.as_raw_fd(),
2766 &stage_name,
2767 &dest.display().to_string(),
2768 );
2769 let _ = parent_dir.sync_all();
2770 }
2771 Ok(())
2772}
2773
2774fn is_safe_slug(slug: &str) -> bool {
2775 !slug.is_empty()
2776 && slug.len() <= 63
2777 && !slug.starts_with('-')
2778 && !slug.ends_with('-')
2779 && slug
2780 .bytes()
2781 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
2782}
2783
2784fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
2785 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
2786}
2787
2788fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
2789 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
2790}
2791
2792fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
2793 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
2794}
2795
2796fn preflight_zip_central_directory(
2797 bytes: &[u8],
2798 offset: usize,
2799 size: usize,
2800 count: u64,
2801) -> LinkResult<()> {
2802 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
2803 let end = offset
2804 .checked_add(size)
2805 .filter(|end| *end <= bytes.len())
2806 .ok_or_else(|| LinkError::InvalidPack {
2807 message: "ZIP central directory is out of bounds".to_string(),
2808 })?;
2809 let mut cursor = offset;
2810 for _ in 0..count {
2811 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
2812 return Err(LinkError::InvalidPack {
2813 message: "ZIP central directory entry count is inconsistent".to_string(),
2814 });
2815 }
2816 if le_u16(bytes, cursor + 34) != Some(0) {
2817 return Err(LinkError::InvalidPack {
2818 message: "multi-disk ZIP archives are not supported".to_string(),
2819 });
2820 }
2821 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
2822 total.checked_add(le_u16(bytes, cursor + at)? as usize)
2823 });
2824 cursor = cursor
2825 .checked_add(46)
2826 .and_then(|fixed| fixed.checked_add(variable?))
2827 .filter(|cursor| *cursor <= end)
2828 .ok_or_else(|| LinkError::InvalidPack {
2829 message: "ZIP central directory entry is truncated".to_string(),
2830 })?;
2831 }
2832 if cursor != end {
2833 return Err(LinkError::InvalidPack {
2834 message: "ZIP central directory size is inconsistent".to_string(),
2835 });
2836 }
2837 Ok(())
2838}
2839
2840fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
2844 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
2845 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
2846 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
2847 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
2848 let eocd = bytes[search_start..]
2849 .windows(4)
2850 .rposition(|window| window == EOCD_SIG)
2851 .map(|offset| search_start + offset)
2852 .ok_or_else(|| LinkError::InvalidPack {
2853 message: "ZIP has no end-of-central-directory record".to_string(),
2854 })?;
2855 let invalid_end = || LinkError::InvalidPack {
2856 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
2857 };
2858 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
2859 if eocd
2860 .checked_add(22)
2861 .and_then(|end| end.checked_add(comment_len))
2862 != Some(bytes.len())
2863 {
2864 return Err(invalid_end());
2868 }
2869 let disk = le_u16(bytes, eocd + 4);
2870 let central_disk = le_u16(bytes, eocd + 6);
2871 if disk != Some(0) || central_disk != Some(0) {
2872 return Err(LinkError::InvalidPack {
2873 message: "multi-disk ZIP archives are not supported".to_string(),
2874 });
2875 }
2876 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
2877 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
2878 if entries_on_disk != ordinary {
2879 return Err(LinkError::InvalidPack {
2880 message: "multi-disk ZIP archives are not supported".to_string(),
2881 });
2882 }
2883 let zip64_locator = eocd
2884 .checked_sub(20)
2885 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
2886 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
2887 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
2888 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
2889 if central_offset
2890 .checked_add(central_size)
2891 .filter(|end| *end == eocd)
2892 .is_none()
2893 {
2894 return Err(invalid_end());
2895 }
2896 (ordinary as u64, central_offset, central_size)
2897 } else {
2898 let Some(locator) = zip64_locator else {
2899 return Err(invalid_end());
2900 };
2901 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
2902 return Err(LinkError::InvalidPack {
2903 message: "multi-disk ZIP64 archives are not supported".to_string(),
2904 });
2905 }
2906 let record = le_u64(bytes, locator + 8)
2907 .and_then(|offset| usize::try_from(offset).ok())
2908 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
2909 .ok_or_else(|| LinkError::InvalidPack {
2910 message: "ZIP64 archive has an invalid end record".to_string(),
2911 })?;
2912 let record_size = le_u64(bytes, record + 4)
2913 .and_then(|size| usize::try_from(size).ok())
2914 .filter(|size| *size >= 44)
2915 .ok_or_else(invalid_end)?;
2916 if record
2917 .checked_add(12)
2918 .and_then(|end| end.checked_add(record_size))
2919 != Some(locator)
2920 || le_u32(bytes, record + 16) != Some(0)
2921 || le_u32(bytes, record + 20) != Some(0)
2922 {
2923 return Err(invalid_end());
2924 }
2925 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
2926 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
2927 let central_size = le_u64(bytes, record + 40)
2928 .and_then(|size| usize::try_from(size).ok())
2929 .ok_or_else(invalid_end)?;
2930 let central_offset = le_u64(bytes, record + 48)
2931 .and_then(|offset| usize::try_from(offset).ok())
2932 .ok_or_else(invalid_end)?;
2933 if zip64_on_disk != zip64_total
2934 || central_offset
2935 .checked_add(central_size)
2936 .filter(|end| *end == record)
2937 .is_none()
2938 {
2939 return Err(invalid_end());
2940 }
2941 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
2942 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
2943 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
2944 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
2945 {
2946 return Err(invalid_end());
2947 }
2948 (zip64_total, central_offset, central_size)
2949 };
2950 if count == 0 || count > max_entries as u64 {
2951 return Err(LinkError::InvalidPack {
2952 message: format!("invalid file count {count}"),
2953 });
2954 }
2955 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
2956 Ok(())
2957}
2958
2959fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
2960 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
2961 let mut archive =
2962 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
2963 message: format!("ZIP parse failed: {err}"),
2964 })?;
2965 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
2966 return Err(LinkError::InvalidPack {
2967 message: format!("invalid file count {}", archive.len()),
2968 });
2969 }
2970 let mut total = 0u64;
2971 let mut seen = std::collections::HashSet::new();
2972 let mut entries = Vec::with_capacity(archive.len());
2973 for index in 0..archive.len() {
2974 let mut file = archive
2975 .by_index(index)
2976 .map_err(|err| LinkError::InvalidPack {
2977 message: format!("ZIP entry failed: {err}"),
2978 })?;
2979 if file.is_dir() {
2980 continue;
2981 }
2982 let path = file.name().to_string();
2983 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
2984 return Err(LinkError::UnsafePath { path });
2985 }
2986 if file
2987 .unix_mode()
2988 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
2989 {
2990 return Err(LinkError::InvalidPack {
2991 message: format!("non-file entry `{path}`"),
2992 });
2993 }
2994 if !seen.insert(path.clone()) {
2995 return Err(LinkError::InvalidPack {
2996 message: format!("duplicate path `{path}`"),
2997 });
2998 }
2999 let remaining = MAX_STORE_BYTES.saturating_sub(total);
3000 if file.size() > remaining {
3001 return Err(LinkError::InvalidPack {
3002 message: "expanded content exceeds the 512 MB limit".to_string(),
3003 });
3004 }
3005 let mut content = Vec::new();
3006 (&mut file)
3007 .take(remaining + 1)
3008 .read_to_end(&mut content)
3009 .map_err(|err| LinkError::InvalidPack {
3010 message: format!("could not decompress `{path}`: {err}"),
3011 })?;
3012 if content.len() as u64 > remaining {
3013 return Err(LinkError::InvalidPack {
3014 message: "expanded content exceeds the 512 MB limit".to_string(),
3015 });
3016 }
3017 if content.len() as u64 != file.size() {
3018 return Err(LinkError::InvalidPack {
3019 message: format!("length mismatch for `{path}`"),
3020 });
3021 }
3022 total += content.len() as u64;
3023 entries.push((path, content));
3024 }
3025 if entries.is_empty() {
3026 return Err(LinkError::InvalidPack {
3027 message: "pack contains no files".to_string(),
3028 });
3029 }
3030 Ok(entries)
3031}
3032
3033fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
3034 let mut expected = std::collections::BTreeMap::new();
3035 for file in signed {
3036 if !safe_store_rel_path(&file.path) {
3037 return Err(LinkError::UnsafePath {
3038 path: file.path.clone(),
3039 });
3040 }
3041 if !is_sha256(&file.sha256)
3042 || expected
3043 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
3044 .is_some()
3045 {
3046 return Err(invalid_feed(
3047 "signed snapshot manifest contains an invalid or duplicate file",
3048 ));
3049 }
3050 }
3051 if expected.len() != entries.len() {
3052 return Err(invalid_feed(
3053 "downloaded pack file set differs from the signed snapshot manifest",
3054 ));
3055 }
3056 for (path, bytes) in entries {
3057 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
3058 return Err(invalid_feed(format!(
3059 "downloaded pack contains unsigned path `{path}`"
3060 )));
3061 };
3062 if *declared_bytes != bytes.len() as u64
3063 || *sha256 != format!("{:x}", Sha256::digest(bytes))
3064 {
3065 return Err(invalid_feed(format!(
3066 "downloaded file `{path}` differs from its signed manifest"
3067 )));
3068 }
3069 }
3070 Ok(())
3071}
3072
3073pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
3080 require_hardened_filesystem("sync push")?;
3081 preflight_push_ownership(store)?;
3082 let mut out: Vec<(String, String)> = Vec::new();
3083 let mut total = 0u64;
3084
3085 let mut read_text = |rel: &str| -> LinkResult<String> {
3086 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
3087 total = total
3088 .checked_add(bytes.len() as u64)
3089 .ok_or_else(|| LinkError::PushTooLarge {
3090 detail: "uncompressed byte count overflow".to_string(),
3091 })?;
3092 if total > MAX_STORE_BYTES {
3093 return Err(LinkError::PushTooLarge {
3094 detail: format!("{total} uncompressed bytes"),
3095 });
3096 }
3097 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
3098 path: rel.to_string(),
3099 })
3100 };
3101
3102 out.push(("DB.md".to_string(), read_text("DB.md")?));
3103 if store
3104 .regular_file_exists(Path::new("assets.jsonl"))
3105 .unwrap_or(false)
3106 {
3107 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
3108 }
3109
3110 for rel in store.walk()? {
3111 let rel_str = rel.to_string_lossy().replace('\\', "/");
3112 if !safe_store_rel_path(&rel_str) {
3113 return Err(LinkError::UnsafePath { path: rel_str });
3116 }
3117 let content = read_text(&rel_str)?;
3118 out.push((rel_str, content));
3119 }
3120
3121 out.sort_by(|a, b| a.0.cmp(&b.0));
3122 Ok(out)
3123}
3124
3125fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
3129 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
3130 return Err(LinkError::from(std::io::Error::new(
3131 std::io::ErrorKind::PermissionDenied,
3132 format!("cannot push: nested db.md store at {}", nested.display()),
3133 )));
3134 }
3135
3136 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
3137 return Err(LinkError::from(std::io::Error::new(
3138 std::io::ErrorKind::PermissionDenied,
3139 format!(
3140 "cannot push: {} is a symlink outside the store ownership model",
3141 symlink.display()
3142 ),
3143 )));
3144 }
3145 Ok(())
3146}
3147
3148pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
3154 require_safe_ref(brain)?;
3155 let remote = verified_remote_head(cfg, brain, false)?;
3156 if files.len() > MAX_PUSH_FILES {
3157 return Err(LinkError::PushTooLarge {
3158 detail: format!("{} files", files.len()),
3159 });
3160 }
3161 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
3162 if raw_total > MAX_STORE_BYTES {
3163 return Err(LinkError::PushTooLarge {
3164 detail: format!("{raw_total} uncompressed bytes"),
3165 });
3166 }
3167
3168 if cfg.brain_key.is_none() {
3172 let body = json!({
3173 "files": files
3174 .iter()
3175 .map(|(p, c)| json!({ "path": p, "content": c }))
3176 .collect::<Vec<_>>(),
3177 });
3178 if body.to_string().len() <= MAX_PUSH_BYTES {
3179 let path = format!("/api/hub/brains/{brain}/push");
3180 let pushed = ensure_ok(
3181 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
3182 "sync push",
3183 )?;
3184 return Ok(pushed);
3185 }
3186 }
3187
3188 let pack = build_store_pack(files)?;
3189 if pack.len() as u64 > MAX_PACK_BYTES {
3190 return Err(LinkError::PushTooLarge {
3191 detail: format!("{} pack bytes", pack.len()),
3192 });
3193 }
3194 let sha256 = format!("{:x}", Sha256::digest(&pack));
3195 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
3196 if let Some(key) = &cfg.brain_key {
3197 if !remote.head.verified {
3198 return Err(invalid_feed(
3199 "self-custody push requires a fully verified, unscoped feed head",
3200 ));
3201 }
3202 let identity = remote
3203 .identity
3204 .as_ref()
3205 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
3206 let current_multikey = format!("ed25519:{}", identity.fingerprint);
3207 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
3208 return Err(invalid_feed(
3209 "configured brain key is not the verified current brain identity",
3210 ));
3211 }
3212 let next_seq = remote
3215 .head
3216 .seq
3217 .checked_add(1)
3218 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
3219 let mut manifest: Vec<WireFeedFile> = files
3220 .iter()
3221 .map(|(path, content)| WireFeedFile {
3222 path: path.clone(),
3223 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
3224 bytes: content.len() as u64,
3225 })
3226 .collect();
3227 manifest.sort_by(|a, b| a.path.cmp(&b.path));
3228 let ts = crate::now()
3229 .with_timezone(&chrono::Utc)
3230 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
3231 .to_string();
3232 let entry = self_custody_entry(
3233 key,
3234 next_seq,
3235 ts,
3236 &sha256,
3237 &manifest,
3238 remote.head.feed_hash.as_deref(),
3239 )?;
3240 meta["entry"] = Value::String(entry);
3241 }
3242 let presigned = ensure_ok(
3243 request(
3244 cfg,
3245 "POST",
3246 &format!("/api/hub/brains/{brain}/packs/presign"),
3247 Some(&meta),
3248 Auth::Required,
3249 )?,
3250 "prepare pack upload",
3251 )?;
3252 let url = presigned
3253 .get("url")
3254 .and_then(Value::as_str)
3255 .ok_or_else(|| LinkError::InvalidPack {
3256 message: "the hub returned no upload URL".to_string(),
3257 })?;
3258 put_presigned(
3259 cfg,
3260 url,
3261 presigned.get("headers").unwrap_or(&Value::Null),
3262 &pack,
3263 )?;
3264 let committed = ensure_ok(
3265 request(
3266 cfg,
3267 "POST",
3268 &format!("/api/hub/brains/{brain}/packs/commit"),
3269 Some(&meta),
3270 Auth::Required,
3271 )?,
3272 "commit pack",
3273 )?;
3274 Ok(committed)
3275}
3276
3277fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
3278 const LOCAL_HEADER: u32 = 0x0403_4b50;
3279 const CENTRAL_HEADER: u32 = 0x0201_4b50;
3280 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
3281 const VERSION_20: u16 = 20;
3282 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
3283 const UTF8_FLAG: u16 = 1 << 11;
3284 const STORED: u16 = 0;
3285 const DOS_TIME_MIDNIGHT: u16 = 0;
3286 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
3287 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
3288
3289 struct CentralEntry<'a> {
3290 name: &'a [u8],
3291 crc32: u32,
3292 size: u32,
3293 local_offset: u32,
3294 }
3295
3296 fn push_u16(out: &mut Vec<u8>, value: u16) {
3297 out.extend_from_slice(&value.to_le_bytes());
3298 }
3299
3300 fn push_u32(out: &mut Vec<u8>, value: u32) {
3301 out.extend_from_slice(&value.to_le_bytes());
3302 }
3303
3304 if files.is_empty() {
3305 return Err(LinkError::InvalidPack {
3306 message: "cannot create an empty snapshot pack".to_string(),
3307 });
3308 }
3309 if files.len() > u16::MAX as usize {
3310 return Err(LinkError::PushTooLarge {
3311 detail: format!(
3312 "{} files (canonical ZIP32 packs cap at {})",
3313 files.len(),
3314 u16::MAX
3315 ),
3316 });
3317 }
3318
3319 let mut sorted: Vec<_> = files.iter().collect();
3320 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
3321 let mut previous: Option<&str> = None;
3322 for (path, content) in &sorted {
3323 if !safe_store_rel_path(path) {
3324 return Err(LinkError::UnsafePath {
3325 path: (*path).clone(),
3326 });
3327 }
3328 if previous == Some(path.as_str()) {
3329 return Err(LinkError::InvalidPack {
3330 message: format!("duplicate path `{path}`"),
3331 });
3332 }
3333 previous = Some(path.as_str());
3334 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
3335 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
3336 })?;
3337 }
3338
3339 let mut out = Vec::new();
3340 let mut central = Vec::with_capacity(sorted.len());
3341 for (path, content) in sorted {
3342 let name = path.as_bytes();
3343 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
3344 message: format!("ZIP entry name is too long: `{path}`"),
3345 })?;
3346 let bytes = content.as_bytes();
3347 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
3348 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
3349 })?;
3350 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
3351 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
3352 })?;
3353 let crc32 = crc32fast::hash(bytes);
3354
3355 push_u32(&mut out, LOCAL_HEADER);
3358 push_u16(&mut out, VERSION_20);
3359 push_u16(&mut out, UTF8_FLAG);
3360 push_u16(&mut out, STORED);
3361 push_u16(&mut out, DOS_TIME_MIDNIGHT);
3362 push_u16(&mut out, DOS_DATE_1980_01_01);
3363 push_u32(&mut out, crc32);
3364 push_u32(&mut out, size);
3365 push_u32(&mut out, size);
3366 push_u16(&mut out, name_len);
3367 push_u16(&mut out, 0); out.extend_from_slice(name);
3369 out.extend_from_slice(bytes);
3370
3371 central.push(CentralEntry {
3372 name,
3373 crc32,
3374 size,
3375 local_offset,
3376 });
3377 }
3378
3379 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
3380 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
3381 })?;
3382 for entry in ¢ral {
3383 push_u32(&mut out, CENTRAL_HEADER);
3384 push_u16(&mut out, MADE_BY_UNIX_20);
3385 push_u16(&mut out, VERSION_20);
3386 push_u16(&mut out, UTF8_FLAG);
3387 push_u16(&mut out, STORED);
3388 push_u16(&mut out, DOS_TIME_MIDNIGHT);
3389 push_u16(&mut out, DOS_DATE_1980_01_01);
3390 push_u32(&mut out, entry.crc32);
3391 push_u32(&mut out, entry.size);
3392 push_u32(&mut out, entry.size);
3393 push_u16(&mut out, entry.name.len() as u16);
3394 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, 0); push_u32(&mut out, UNIX_REGULAR_0600);
3399 push_u32(&mut out, entry.local_offset);
3400 out.extend_from_slice(entry.name);
3401 }
3402 let central_size = u32::try_from(out.len())
3403 .ok()
3404 .and_then(|end| end.checked_sub(central_offset))
3405 .ok_or_else(|| LinkError::PushTooLarge {
3406 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
3407 })?;
3408 let entry_count = central.len() as u16;
3409
3410 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
3411 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
3414 push_u16(&mut out, entry_count);
3415 push_u32(&mut out, central_size);
3416 push_u32(&mut out, central_offset);
3417 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
3420 return Err(LinkError::PushTooLarge {
3421 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
3422 });
3423 }
3424 Ok(out)
3425}
3426
3427#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3433pub enum Capability {
3434 Read,
3436 Write,
3438}
3439
3440impl Capability {
3441 pub fn as_str(self) -> &'static str {
3443 match self {
3444 Capability::Read => "read",
3445 Capability::Write => "write",
3446 }
3447 }
3448}
3449
3450pub fn grant_issue(
3456 cfg: &HubConfig,
3457 brain: &str,
3458 grantee: &str,
3459 can: Capability,
3460 scope: Option<&str>,
3461 until: Option<&str>,
3462) -> LinkResult<Value> {
3463 require_safe_ref(brain)?;
3464 let _ = verified_remote_head(cfg, brain, false)?;
3465 let is_key_grantee = URL_SAFE_NO_PAD
3470 .decode(grantee)
3471 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
3472 .unwrap_or(false);
3473 let mut body = if is_key_grantee {
3474 json!({ "keySpki": grantee, "capability": can.as_str() })
3475 } else {
3476 json!({ "email": grantee, "capability": can.as_str() })
3477 };
3478 if let Some(s) = scope {
3479 body["scopePrefix"] = json!(s);
3480 }
3481 if let Some(u) = until {
3482 body["expiresAt"] = json!(u);
3483 }
3484 let path = format!("/api/hub/brains/{brain}/grants");
3485 ensure_ok(
3486 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
3487 "grant issue",
3488 )
3489}
3490
3491pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
3493 require_safe_ref(brain)?;
3494 let _ = verified_remote_head(cfg, brain, false)?;
3495 let path = format!("/api/hub/brains/{brain}/grants");
3496 ensure_ok(
3497 request(cfg, "GET", &path, None, Auth::Required)?,
3498 "grant list",
3499 )
3500}
3501
3502pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
3505 require_safe_ref(brain)?;
3506 require_safe_grant_id(grant_id)?;
3507 let _ = verified_remote_head(cfg, brain, false)?;
3508 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
3509 ensure_ok(
3510 request(cfg, "DELETE", &path, None, Auth::Required)?,
3511 "grant revoke",
3512 )
3513}
3514
3515pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
3526 require_valid_handle(handle)?;
3527 if body.len() as u64 > MAX_PROPOSE_BYTES {
3528 return Err(LinkError::ProposeTooLarge {
3529 bytes: body.len() as u64,
3530 });
3531 }
3532 let payload = json!({ "app": app, "body": body });
3533 let (path, auth) = if crate::ulid::is_ulid(handle) {
3538 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
3539 } else {
3540 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
3541 };
3542 ensure_ok(
3543 request(cfg, "POST", &path, Some(&payload), auth)?,
3544 "propose",
3545 )
3546}
3547
3548#[derive(Debug, serde::Serialize)]
3554pub struct Head {
3555 pub brain: String,
3557 pub seq: u64,
3559 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
3561 pub updated_at: Option<String>,
3562 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
3564 pub feed_hash: Option<String>,
3565 pub verified: bool,
3568}
3569
3570struct BoundedVecVisitor<T, const MAX: usize> {
3571 label: &'static str,
3572 marker: std::marker::PhantomData<T>,
3573}
3574
3575impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
3576where
3577 T: Deserialize<'de>,
3578{
3579 type Value = Vec<T>;
3580
3581 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3582 write!(formatter, "at most {MAX} {}", self.label)
3583 }
3584
3585 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
3586 where
3587 A: serde::de::SeqAccess<'de>,
3588 {
3589 if sequence.size_hint().is_some_and(|size| size > MAX) {
3590 return Err(serde::de::Error::custom(format!(
3591 "{} exceeds the {MAX}-item limit",
3592 self.label
3593 )));
3594 }
3595 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
3596 while let Some(value) = sequence.next_element()? {
3597 if values.len() == MAX {
3598 return Err(serde::de::Error::custom(format!(
3599 "{} exceeds the {MAX}-item limit",
3600 self.label
3601 )));
3602 }
3603 values.push(value);
3604 }
3605 Ok(values)
3606 }
3607}
3608
3609fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
3610 deserializer: D,
3611 label: &'static str,
3612) -> Result<Vec<T>, D::Error>
3613where
3614 D: serde::Deserializer<'de>,
3615 T: Deserialize<'de>,
3616{
3617 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
3618 label,
3619 marker: std::marker::PhantomData,
3620 })
3621}
3622
3623fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
3624where
3625 D: serde::Deserializer<'de>,
3626{
3627 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
3628}
3629
3630fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
3631where
3632 D: serde::Deserializer<'de>,
3633{
3634 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
3635}
3636
3637fn deserialize_previous_identities<'de, D>(
3638 deserializer: D,
3639) -> Result<Vec<PreviousIdentity>, D::Error>
3640where
3641 D: serde::Deserializer<'de>,
3642{
3643 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
3644 deserializer,
3645 "previous identities",
3646 )
3647}
3648
3649fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
3650where
3651 D: serde::Deserializer<'de>,
3652{
3653 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
3654 deserializer,
3655 "rotation statements",
3656 )
3657}
3658
3659fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
3660where
3661 D: serde::Deserializer<'de>,
3662{
3663 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
3664}
3665
3666#[derive(Debug, Clone, Deserialize, Serialize)]
3667struct FeedFile {
3668 path: String,
3669 sha256: String,
3670 bytes: u64,
3671}
3672
3673#[derive(Debug, Clone, Deserialize, Serialize)]
3674struct FeedEntry {
3675 v: u8,
3676 seq: u64,
3677 ts: String,
3678 brain: String,
3679 public_key: String,
3680 kind: String,
3681 op: String,
3682 pack_sha256: String,
3683 #[serde(deserialize_with = "deserialize_feed_files")]
3684 files: Vec<FeedFile>,
3685 #[serde(deserialize_with = "deserialize_removed_paths")]
3686 removed: Vec<String>,
3687 prev_entry_hash: Option<String>,
3688 sig: String,
3689}
3690
3691#[derive(Serialize)]
3692struct UnsignedFeedEntry<'a> {
3693 v: u8,
3694 seq: u64,
3695 ts: &'a str,
3696 brain: &'a str,
3697 public_key: &'a str,
3698 kind: &'a str,
3699 op: &'a str,
3700 pack_sha256: &'a str,
3701 files: &'a [FeedFile],
3702 removed: &'a [String],
3703 prev_entry_hash: &'a Option<String>,
3704}
3705
3706#[derive(Debug, Clone, Deserialize, Serialize)]
3707struct FeedItem {
3708 hash: String,
3709 entry: FeedEntry,
3710}
3711
3712#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
3713struct FeedIdentity {
3714 fingerprint: String,
3715 #[serde(rename = "publicKeySpki")]
3716 public_key_spki: String,
3717 #[serde(default, deserialize_with = "deserialize_previous_identities")]
3721 previous: Vec<PreviousIdentity>,
3722 #[serde(default, deserialize_with = "deserialize_rotations")]
3725 rotations: Vec<String>,
3726}
3727
3728#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
3729struct PreviousIdentity {
3730 fingerprint: String,
3731 #[serde(rename = "publicKeySpki")]
3732 public_key_spki: String,
3733}
3734
3735#[derive(Debug, Deserialize)]
3736struct FeedResponse {
3737 #[serde(rename = "headSeq")]
3738 head_seq: u64,
3739 #[serde(rename = "feedHash")]
3740 feed_hash: Option<String>,
3741 identity: Option<FeedIdentity>,
3742 #[serde(deserialize_with = "deserialize_feed_items")]
3743 entries: Vec<FeedItem>,
3744 #[serde(rename = "scopeLimited")]
3745 scope_limited: bool,
3746}
3747
3748#[derive(Debug, Deserialize, Serialize)]
3749#[serde(deny_unknown_fields)]
3750struct RotationStatement {
3751 v: u8,
3752 op: String,
3753 brain: String,
3754 public_key: String,
3755 new_brain: String,
3756 new_public_key: String,
3757 prior_head_seq: u64,
3758 prior_feed_hash: Option<String>,
3759 ts: String,
3760 sig: String,
3761}
3762
3763#[derive(Debug, Clone, Deserialize, Serialize)]
3764struct TrustState {
3765 v: u8,
3766 origin: String,
3767 #[serde(default)]
3771 requested: String,
3772 brain: String,
3774 #[serde(default, skip_serializing_if = "Option::is_none")]
3777 home: Option<String>,
3778 anchor: String,
3779 current: String,
3780 #[serde(rename = "headSeq")]
3781 head_seq: u64,
3782 #[serde(rename = "feedHash")]
3783 feed_hash: Option<String>,
3784 #[serde(default)]
3788 rotations: Vec<String>,
3789}
3790
3791#[derive(Debug, Clone, Deserialize, Serialize)]
3792struct AliasBinding {
3793 v: u8,
3794 origin: String,
3795 requested: String,
3796 brain: String,
3797 #[serde(default, skip_serializing_if = "Option::is_none")]
3798 home: Option<String>,
3799}
3800
3801struct VerifiedRemote {
3802 head: Head,
3803 identity: Option<FeedIdentity>,
3804 head_entry: Option<FeedItem>,
3805 entries: Vec<FeedItem>,
3807 anchor: Option<String>,
3808}
3809
3810fn invalid_feed(message: impl Into<String>) -> LinkError {
3811 LinkError::InvalidFeed {
3812 message: message.into(),
3813 }
3814}
3815
3816fn is_sha256(value: &str) -> bool {
3817 value.len() == 64
3818 && value
3819 .bytes()
3820 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
3821}
3822
3823fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
3824 let der = URL_SAFE_NO_PAD
3825 .decode(public_key_spki)
3826 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
3827 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
3828 return Err(invalid_feed(
3829 "identity public key is not a valid Ed25519 SPKI",
3830 ));
3831 }
3832 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
3833}
3834
3835fn verify_identity_chain(
3839 identity: &FeedIdentity,
3840 pinned: Option<&TrustState>,
3841) -> LinkResult<String> {
3842 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
3843 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
3844 {
3845 return Err(invalid_feed(
3846 "identity rotation history exceeds the client cap",
3847 ));
3848 }
3849 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
3850 return Err(invalid_feed(
3851 "current identity fingerprint does not match its public key",
3852 ));
3853 }
3854 for previous in &identity.previous {
3855 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
3856 return Err(invalid_feed(
3857 "previous identity fingerprint does not match its public key",
3858 ));
3859 }
3860 }
3861 if identity.rotations.len() != identity.previous.len() {
3862 return Err(invalid_feed(
3863 "identity history is missing an old-key-signed rotation statement",
3864 ));
3865 }
3866
3867 let mut chain: Vec<(&str, &str)> = identity
3871 .previous
3872 .iter()
3873 .rev()
3874 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
3875 .collect();
3876 chain.push((&identity.fingerprint, &identity.public_key_spki));
3877
3878 for (index, raw) in identity.rotations.iter().enumerate() {
3879 let statement: RotationStatement = serde_json::from_str(raw)
3880 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
3881 let (old_fingerprint, old_spki) = chain[index];
3882 let (new_fingerprint, new_spki) = chain[index + 1];
3883 if statement.v != 1
3884 || statement.op != "rotate"
3885 || statement.brain != format!("ed25519:{old_fingerprint}")
3886 || statement.public_key != old_spki
3887 || statement.new_brain != format!("ed25519:{new_fingerprint}")
3888 || statement.new_public_key != new_spki
3889 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
3890 || (statement.prior_head_seq > 0
3891 && statement
3892 .prior_feed_hash
3893 .as_deref()
3894 .is_none_or(|hash| !is_sha256(hash)))
3895 {
3896 return Err(invalid_feed(
3897 "rotation statement does not connect adjacent identities",
3898 ));
3899 }
3900 let unsigned = serde_json::to_string(&UnsignedRotation {
3901 v: statement.v,
3902 op: &statement.op,
3903 brain: &statement.brain,
3904 public_key: &statement.public_key,
3905 new_brain: &statement.new_brain,
3906 new_public_key: &statement.new_public_key,
3907 prior_head_seq: statement.prior_head_seq,
3908 prior_feed_hash: statement.prior_feed_hash.as_deref(),
3909 ts: statement.ts.clone(),
3910 })
3911 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
3912 let exact = format!(
3913 "{},\"sig\":\"{}\"}}",
3914 &unsigned[..unsigned.len() - 1],
3915 statement.sig
3916 );
3917 if exact != *raw {
3918 return Err(invalid_feed(
3919 "rotation statement is not in normative serialization",
3920 ));
3921 }
3922 let der = URL_SAFE_NO_PAD
3923 .decode(old_spki)
3924 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
3925 let signature = URL_SAFE_NO_PAD
3926 .decode(&statement.sig)
3927 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
3928 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
3929 .verify(unsigned.as_bytes(), &signature)
3930 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
3931 if index > 0 {
3932 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
3933 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
3934 if statement.prior_head_seq < prior.prior_head_seq {
3935 return Err(invalid_feed("rotation feed boundaries move backward"));
3936 }
3937 }
3938 }
3939
3940 let anchor = format!("ed25519:{}", chain[0].0);
3941 let current = format!("ed25519:{}", identity.fingerprint);
3942 if let Some(pin) = pinned {
3943 if pin.anchor != anchor {
3944 return Err(invalid_feed(
3945 "served identity chain does not descend from the pinned anchor",
3946 ));
3947 }
3948 if !chain
3949 .iter()
3950 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
3951 {
3952 return Err(invalid_feed(
3953 "served identity chain forked away from the last pinned identity",
3954 ));
3955 }
3956 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
3957 return Err(invalid_feed("served identity discarded its rotation chain"));
3958 }
3959 if pin.v >= 2
3960 && (identity.rotations.len() < pin.rotations.len()
3961 || identity.rotations[..pin.rotations.len()] != pin.rotations)
3962 {
3963 return Err(invalid_feed(
3964 "served identity rewrote the locally accepted rotation history",
3965 ));
3966 }
3967 }
3968 Ok(anchor)
3969}
3970
3971fn verify_rotation_feed_boundaries(
3972 identity: &FeedIdentity,
3973 pinned: Option<&TrustState>,
3974 observed: &[FeedItem],
3975 advertised_seq: u64,
3976) -> LinkResult<()> {
3977 let mut chain: Vec<String> = identity
3978 .previous
3979 .iter()
3980 .rev()
3981 .map(|previous| format!("ed25519:{}", previous.fingerprint))
3982 .collect();
3983 chain.push(format!("ed25519:{}", identity.fingerprint));
3984 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
3985
3986 for (index, raw) in identity.rotations.iter().enumerate() {
3987 let rotation: RotationStatement = serde_json::from_str(raw)
3988 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
3989 if rotation.prior_head_seq > advertised_seq {
3990 return Err(invalid_feed(
3991 "rotation claims a feed boundary beyond the advertised head",
3992 ));
3993 }
3994 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
3995 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
3996 return Err(invalid_feed(
3997 "newly disclosed rotation predates the local feed checkpoint",
3998 ));
3999 }
4000 }
4001 let actual = if rotation.prior_head_seq == 0 {
4002 None
4003 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
4004 pinned.and_then(|pin| pin.feed_hash.as_deref())
4005 } else {
4006 observed
4007 .iter()
4008 .find(|item| item.entry.seq == rotation.prior_head_seq)
4009 .map(|item| item.hash.as_str())
4010 };
4011 if let Some(actual) = actual {
4012 if rotation.prior_feed_hash.as_deref() != Some(actual) {
4013 return Err(invalid_feed(
4014 "rotation statement does not commit the verified feed boundary",
4015 ));
4016 }
4017 } else if rotation.prior_head_seq == 0 {
4018 } else if pinned.is_some_and(|pin| {
4021 pinned_index.is_some_and(|pin_index| index >= pin_index)
4022 || rotation.prior_head_seq >= pin.head_seq
4023 }) {
4024 return Err(invalid_feed(
4025 "rotation feed boundary was not present in the verified chain",
4026 ));
4027 }
4028 }
4029 Ok(())
4030}
4031
4032fn reject_retired_signer_after_checkpoint(
4037 identity: &FeedIdentity,
4038 pinned: Option<&TrustState>,
4039 item: &FeedItem,
4040) -> LinkResult<()> {
4041 let Some(pin) = pinned else {
4042 return Ok(());
4043 };
4044 if item.entry.seq <= pin.head_seq {
4045 return Ok(());
4046 }
4047 let mut chain: Vec<String> = identity
4048 .previous
4049 .iter()
4050 .rev()
4051 .map(|previous| format!("ed25519:{}", previous.fingerprint))
4052 .collect();
4053 chain.push(format!("ed25519:{}", identity.fingerprint));
4054 let pinned_index = chain
4055 .iter()
4056 .position(|key| key == &pin.current)
4057 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
4058 let signer_index = chain
4059 .iter()
4060 .position(|key| key == &item.entry.brain)
4061 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
4062 if signer_index < pinned_index {
4063 return Err(invalid_feed(
4064 "a retired identity attempted to sign after the local checkpoint",
4065 ));
4066 }
4067 Ok(())
4068}
4069
4070fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
4071 let origin = normalized_origin(&cfg.hub)?;
4072 let key = format!(
4073 "{:x}",
4074 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
4075 );
4076 Ok(format!("{key}.json"))
4077}
4078
4079fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
4080 let origin = normalized_origin(&cfg.hub)?;
4081 let key = format!(
4082 "{:x}",
4083 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
4084 );
4085 Ok(format!("alias-{key}.json"))
4086}
4087
4088#[cfg(unix)]
4089struct TrustLock {
4090 _file: std::fs::File,
4091}
4092
4093#[cfg(unix)]
4094fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
4095 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4096
4097 let lock_string = format!(".{state_name}.lock");
4098 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
4099 let fd = unsafe {
4100 libc::openat(
4101 directory.as_raw_fd(),
4102 lock_name.as_ptr(),
4103 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4104 0o600,
4105 )
4106 };
4107 if fd < 0 {
4108 return Err(std::io::Error::last_os_error().into());
4109 }
4110 let file = unsafe { std::fs::File::from_raw_fd(fd) };
4111 if !file.metadata()?.is_file() {
4112 return Err(LinkError::UnsafePath { path: lock_string });
4113 }
4114 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
4115 return Err(std::io::Error::last_os_error().into());
4116 }
4117 Ok(TrustLock { _file: file })
4118}
4119
4120#[cfg(unix)]
4121fn lock_trust_many(
4122 cfg: &HubConfig,
4123 directory: &std::fs::File,
4124 refs: &[&str],
4125) -> LinkResult<Vec<TrustLock>> {
4126 let mut names = refs
4127 .iter()
4128 .map(|reference| trust_file_name(cfg, reference))
4129 .collect::<LinkResult<Vec<_>>>()?;
4130 names.sort();
4131 names.dedup();
4132 names
4133 .iter()
4134 .map(|name| lock_trust_name(directory, name))
4135 .collect()
4136}
4137
4138#[cfg(not(unix))]
4139fn lock_trust_many(
4140 _cfg: &HubConfig,
4141 _directory: &TrustDirectory,
4142 _refs: &[&str],
4143) -> LinkResult<Vec<()>> {
4144 Err(LinkError::UnsupportedPlatform {
4145 operation: "verified link.md state",
4146 })
4147}
4148
4149#[cfg(unix)]
4150type TrustDirectory = std::fs::File;
4151
4152#[cfg(not(unix))]
4153struct TrustDirectory;
4154
4155#[cfg(unix)]
4156fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
4157 use std::os::fd::AsRawFd as _;
4158
4159 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
4160 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
4161 return Err(std::io::Error::last_os_error().into());
4162 }
4163 directory.sync_all()?;
4164 Ok(directory)
4165}
4166
4167#[cfg(not(unix))]
4168fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
4169 Err(LinkError::UnsupportedPlatform {
4170 operation: "verified link.md state",
4171 })
4172}
4173
4174#[cfg(unix)]
4175fn load_trust_in(
4176 cfg: &HubConfig,
4177 directory: &TrustDirectory,
4178 requested: &str,
4179) -> LinkResult<Option<TrustState>> {
4180 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4181
4182 let name_string = trust_file_name(cfg, requested)?;
4183 let name = c_name(name_string.as_bytes(), &name_string)?;
4184 let fd = unsafe {
4185 libc::openat(
4186 directory.as_raw_fd(),
4187 name.as_ptr(),
4188 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4189 )
4190 };
4191 if fd < 0 {
4192 let error = std::io::Error::last_os_error();
4193 if error.kind() == std::io::ErrorKind::NotFound {
4194 return Ok(None);
4195 }
4196 return Err(LinkError::UnsafePath { path: name_string });
4197 }
4198 let file = unsafe { std::fs::File::from_raw_fd(fd) };
4199 if !file.metadata()?.is_file() {
4200 return Err(LinkError::UnsafePath { path: name_string });
4201 }
4202 let mut bytes = Vec::new();
4203 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
4204 if bytes.len() > 1024 * 1024 {
4205 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
4206 }
4207 let mut state: TrustState = serde_json::from_slice(&bytes)
4208 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
4209 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
4210 return Err(invalid_feed(
4211 "local identity/feed checkpoint does not match this hub and brain",
4212 ));
4213 }
4214 if state.v == 1 {
4215 if state.brain != requested {
4219 return Err(invalid_feed(
4220 "legacy checkpoint is not bound to the requested brain id",
4221 ));
4222 }
4223 state.requested = requested.to_string();
4224 } else if state.requested != requested {
4225 return Err(invalid_feed(
4226 "local identity/feed checkpoint is bound to a different requested ref",
4227 ));
4228 }
4229 Ok(Some(state))
4230}
4231
4232#[cfg(not(unix))]
4233fn load_trust_in(
4234 _cfg: &HubConfig,
4235 _directory: &TrustDirectory,
4236 _brain: &str,
4237) -> LinkResult<Option<TrustState>> {
4238 Err(LinkError::UnsupportedPlatform {
4239 operation: "verified link.md state",
4240 })
4241}
4242
4243#[cfg(all(test, unix))]
4244fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
4245 let directory = open_trust_dir(cfg)?;
4246 load_trust_in(cfg, &directory, requested)
4247}
4248
4249#[cfg(unix)]
4250fn save_trust_in(
4251 cfg: &HubConfig,
4252 directory: &TrustDirectory,
4253 state: &TrustState,
4254) -> LinkResult<()> {
4255 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4256
4257 let name_string = trust_file_name(cfg, &state.requested)?;
4258 let name = c_name(name_string.as_bytes(), &name_string)?;
4259 let mut bytes = serde_json::to_vec(state)
4260 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
4261 bytes.push(b'\n');
4262
4263 let nonce = std::time::SystemTime::now()
4264 .duration_since(std::time::UNIX_EPOCH)
4265 .unwrap_or_default()
4266 .as_nanos();
4267 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
4268 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
4269 let fd = unsafe {
4270 libc::openat(
4271 directory.as_raw_fd(),
4272 temp.as_ptr(),
4273 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4274 0o600,
4275 )
4276 };
4277 if fd < 0 {
4278 return Err(std::io::Error::last_os_error().into());
4279 }
4280 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
4281 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
4282 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4283 return Err(error.into());
4284 }
4285 drop(file);
4286 if unsafe {
4287 libc::renameat(
4288 directory.as_raw_fd(),
4289 temp.as_ptr(),
4290 directory.as_raw_fd(),
4291 name.as_ptr(),
4292 )
4293 } != 0
4294 {
4295 let error = std::io::Error::last_os_error();
4296 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4297 return Err(error.into());
4298 }
4299 directory.sync_all()?;
4300 Ok(())
4301}
4302
4303#[cfg(not(unix))]
4304fn save_trust_in(
4305 _cfg: &HubConfig,
4306 _directory: &TrustDirectory,
4307 _state: &TrustState,
4308) -> LinkResult<()> {
4309 Err(LinkError::UnsupportedPlatform {
4310 operation: "verified link.md state",
4311 })
4312}
4313
4314#[cfg(unix)]
4315fn load_alias_in(
4316 cfg: &HubConfig,
4317 directory: &TrustDirectory,
4318 requested: &str,
4319) -> LinkResult<Option<AliasBinding>> {
4320 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4321
4322 let name_string = alias_file_name(cfg, requested)?;
4323 let name = c_name(name_string.as_bytes(), &name_string)?;
4324 let fd = unsafe {
4325 libc::openat(
4326 directory.as_raw_fd(),
4327 name.as_ptr(),
4328 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4329 )
4330 };
4331 if fd < 0 {
4332 let error = std::io::Error::last_os_error();
4333 if error.kind() == std::io::ErrorKind::NotFound {
4334 return Ok(None);
4335 }
4336 return Err(LinkError::UnsafePath { path: name_string });
4337 }
4338 let file = unsafe { std::fs::File::from_raw_fd(fd) };
4339 if !file.metadata()?.is_file() {
4340 return Err(LinkError::UnsafePath { path: name_string });
4341 }
4342 let mut bytes = Vec::new();
4343 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
4344 if bytes.len() > 64 * 1024 {
4345 return Err(invalid_feed("local alias binding is oversized"));
4346 }
4347 let alias: AliasBinding = serde_json::from_slice(&bytes)
4348 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
4349 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
4350 {
4351 return Err(invalid_feed(
4352 "local alias binding does not match this hub and requested ref",
4353 ));
4354 }
4355 Ok(Some(alias))
4356}
4357
4358#[cfg(not(unix))]
4359fn load_alias_in(
4360 _cfg: &HubConfig,
4361 _directory: &TrustDirectory,
4362 _requested: &str,
4363) -> LinkResult<Option<AliasBinding>> {
4364 Err(LinkError::UnsupportedPlatform {
4365 operation: "verified link.md state",
4366 })
4367}
4368
4369#[cfg(unix)]
4370fn save_alias_in(
4371 cfg: &HubConfig,
4372 directory: &TrustDirectory,
4373 alias: &AliasBinding,
4374) -> LinkResult<()> {
4375 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4376
4377 let name_string = alias_file_name(cfg, &alias.requested)?;
4378 let name = c_name(name_string.as_bytes(), &name_string)?;
4379 let mut bytes = serde_json::to_vec(alias)
4380 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
4381 bytes.push(b'\n');
4382 let nonce = std::time::SystemTime::now()
4383 .duration_since(std::time::UNIX_EPOCH)
4384 .unwrap_or_default()
4385 .as_nanos();
4386 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
4387 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
4388 let fd = unsafe {
4389 libc::openat(
4390 directory.as_raw_fd(),
4391 temp.as_ptr(),
4392 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4393 0o600,
4394 )
4395 };
4396 if fd < 0 {
4397 return Err(std::io::Error::last_os_error().into());
4398 }
4399 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
4400 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
4401 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4402 return Err(error.into());
4403 }
4404 drop(file);
4405 if unsafe {
4406 libc::renameat(
4407 directory.as_raw_fd(),
4408 temp.as_ptr(),
4409 directory.as_raw_fd(),
4410 name.as_ptr(),
4411 )
4412 } != 0
4413 {
4414 let error = std::io::Error::last_os_error();
4415 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4416 return Err(error.into());
4417 }
4418 directory.sync_all()?;
4419 Ok(())
4420}
4421
4422#[cfg(not(unix))]
4423fn save_alias_in(
4424 _cfg: &HubConfig,
4425 _directory: &TrustDirectory,
4426 _alias: &AliasBinding,
4427) -> LinkResult<()> {
4428 Err(LinkError::UnsupportedPlatform {
4429 operation: "verified link.md state",
4430 })
4431}
4432
4433fn load_canonical_pin(
4438 cfg: &HubConfig,
4439 directory: &TrustDirectory,
4440 requested: &str,
4441 resolved_brain: &str,
4442) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
4443 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
4444 if requested == resolved_brain {
4445 return Ok((canonical, None));
4446 }
4447
4448 let mut alias = load_alias_in(cfg, directory, requested)?;
4449 if let Some(binding) = &alias {
4450 if binding.brain != resolved_brain {
4451 return Err(invalid_feed(
4452 "requested brain alias now resolves to a different canonical brain",
4453 ));
4454 }
4455 return Ok((canonical, alias));
4456 }
4457
4458 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
4462 if legacy.brain != resolved_brain {
4463 return Err(invalid_feed(
4464 "legacy alias checkpoint names a different canonical brain",
4465 ));
4466 }
4467 if let Some(existing) = &canonical {
4468 if existing.brain != legacy.brain
4469 || existing.anchor != legacy.anchor
4470 || existing.current != legacy.current
4471 || existing.head_seq != legacy.head_seq
4472 || existing.feed_hash != legacy.feed_hash
4473 || existing.rotations != legacy.rotations
4474 {
4475 return Err(invalid_feed(
4476 "legacy alias checkpoint conflicts with the canonical checkpoint",
4477 ));
4478 }
4479 } else {
4480 let mut promoted = legacy.clone();
4481 promoted.requested = resolved_brain.to_string();
4482 promoted.home = None;
4483 save_trust_in(cfg, directory, &promoted)?;
4484 canonical = Some(promoted);
4485 }
4486 alias = Some(AliasBinding {
4487 v: 1,
4488 origin: normalized_origin(&cfg.hub)?,
4489 requested: requested.to_string(),
4490 brain: resolved_brain.to_string(),
4491 home: legacy.home,
4492 });
4493 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
4494 }
4495 Ok((canonical, alias))
4496}
4497
4498fn save_canonical_pin_and_alias(
4499 cfg: &HubConfig,
4500 directory: &TrustDirectory,
4501 requested: &str,
4502 resolved_brain: &str,
4503 mut state: TrustState,
4504 existing_alias: Option<&AliasBinding>,
4505) -> LinkResult<()> {
4506 state.requested = resolved_brain.to_string();
4507 state.brain = resolved_brain.to_string();
4508 state.home = None;
4509 save_trust_in(cfg, directory, &state)?;
4510 if requested != resolved_brain {
4511 save_alias_in(
4512 cfg,
4513 directory,
4514 &AliasBinding {
4515 v: 1,
4516 origin: normalized_origin(&cfg.hub)?,
4517 requested: requested.to_string(),
4518 brain: resolved_brain.to_string(),
4519 home: existing_alias.and_then(|alias| alias.home.clone()),
4520 },
4521 )?;
4522 }
4523 Ok(())
4524}
4525
4526fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
4527 const ED25519_SPKI_PREFIX: &[u8] = &[
4528 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
4529 ];
4530 let entry = &item.entry;
4531 let public_der = URL_SAFE_NO_PAD
4532 .decode(&entry.public_key)
4533 .map_err(|_| invalid_feed("public key is not base64url"))?;
4534 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
4535 || !public_der.starts_with(ED25519_SPKI_PREFIX)
4536 {
4537 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
4538 }
4539 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
4540 if entry.brain != format!("ed25519:{fingerprint}") {
4541 return Err(invalid_feed(
4542 "brain fingerprint does not match its public key",
4543 ));
4544 }
4545 let _ = verify_identity_chain(identity, None)?;
4547 let mut chain: Vec<(&str, &str)> = identity
4548 .previous
4549 .iter()
4550 .rev()
4551 .map(|previous| {
4552 (
4553 previous.fingerprint.as_str(),
4554 previous.public_key_spki.as_str(),
4555 )
4556 })
4557 .collect();
4558 chain.push((&identity.fingerprint, &identity.public_key_spki));
4559 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
4560 *known_fingerprint == fingerprint && *spki == entry.public_key
4561 });
4562 let Some(signer_index) = signer_index else {
4563 return Err(invalid_feed(
4564 "entry signer is not this brain's identity (current or rotated-from)",
4565 ));
4566 };
4567 let lower_boundary = if signer_index == 0 {
4568 None
4569 } else {
4570 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
4571 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
4572 Some(prior.prior_head_seq)
4573 };
4574 let upper_boundary = if signer_index == identity.rotations.len() {
4575 None
4576 } else {
4577 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
4578 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
4579 Some(next.prior_head_seq)
4580 };
4581 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
4582 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
4583 {
4584 return Err(invalid_feed(
4585 "entry signer is outside its authenticated rotation epoch",
4586 ));
4587 }
4588 let unsigned = UnsignedFeedEntry {
4589 v: entry.v,
4590 seq: entry.seq,
4591 ts: &entry.ts,
4592 brain: &entry.brain,
4593 public_key: &entry.public_key,
4594 kind: &entry.kind,
4595 op: &entry.op,
4596 pack_sha256: &entry.pack_sha256,
4597 files: &entry.files,
4598 removed: &entry.removed,
4599 prev_entry_hash: &entry.prev_entry_hash,
4600 };
4601 let message =
4602 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
4603 let signature = URL_SAFE_NO_PAD
4604 .decode(&entry.sig)
4605 .map_err(|_| invalid_feed("signature is not base64url"))?;
4606 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
4607 .verify(&message, &signature)
4608 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
4609
4610 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
4611 exact.push(b'\n');
4612 let actual_hash = format!("{:x}", Sha256::digest(&exact));
4613 if actual_hash != item.hash {
4614 return Err(invalid_feed("entry SHA-256 does not match"));
4615 }
4616 Ok(())
4617}
4618
4619#[derive(Serialize)]
4625struct UnsignedRotation<'a> {
4626 v: u8,
4627 op: &'a str,
4628 brain: &'a str,
4629 public_key: &'a str,
4630 new_brain: &'a str,
4631 new_public_key: &'a str,
4632 prior_head_seq: u64,
4633 prior_feed_hash: Option<&'a str>,
4634 ts: String,
4635}
4636
4637#[derive(Debug, Deserialize, Serialize)]
4642#[serde(deny_unknown_fields)]
4643struct RotationJournal {
4644 v: u8,
4645 origin: String,
4646 brain: String,
4647 old_brain: String,
4648 new_brain: String,
4649 prior_head_seq: u64,
4650 prior_feed_hash: Option<String>,
4651 statement: String,
4652}
4653
4654fn rotation_journal_path(key_path: &Path) -> PathBuf {
4655 let mut path = key_path.as_os_str().to_os_string();
4656 path.push(".rotation.json");
4657 PathBuf::from(path)
4658}
4659
4660fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
4661 #[cfg(unix)]
4662 let file = {
4663 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4664 use std::os::unix::ffi::OsStrExt as _;
4665 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
4666 .map_err(|error| {
4667 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
4668 })?;
4669 let leaf_name = path
4670 .file_name()
4671 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
4672 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
4673 let fd = unsafe {
4674 libc::openat(
4675 parent.as_raw_fd(),
4676 leaf.as_ptr(),
4677 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4678 )
4679 };
4680 if fd < 0 {
4681 return Err(bad_agent_key(
4682 "the rotation journal must be an existing regular file without symlink ancestors",
4683 ));
4684 }
4685 unsafe { std::fs::File::from_raw_fd(fd) }
4686 };
4687 #[cfg(not(unix))]
4688 let file = std::fs::File::open(path)
4689 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
4690 let metadata = file
4691 .metadata()
4692 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
4693 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
4694 return Err(bad_agent_key(
4695 "the rotation journal must be a bounded regular file",
4696 ));
4697 }
4698 #[cfg(unix)]
4699 {
4700 use std::os::unix::fs::PermissionsExt as _;
4701 if metadata.permissions().mode() & 0o077 != 0 {
4702 return Err(bad_agent_key(
4703 "the rotation journal is accessible to group/other; set mode 0600",
4704 ));
4705 }
4706 }
4707 serde_json::from_reader(file)
4708 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
4709}
4710
4711fn remove_rotation_journal(path: &Path) {
4712 #[cfg(unix)]
4713 {
4714 use std::os::fd::AsRawFd as _;
4715 use std::os::unix::ffi::OsStrExt as _;
4716 let Ok(parent) =
4717 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
4718 else {
4719 return;
4720 };
4721 let Some(leaf_name) = path.file_name() else {
4722 return;
4723 };
4724 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
4725 return;
4726 };
4727 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
4728 let _ = parent.sync_all();
4729 }
4730 }
4731 #[cfg(not(unix))]
4732 {
4733 let _ = std::fs::remove_file(path);
4734 }
4735}
4736
4737fn validate_rotation_journal(
4738 journal: &RotationJournal,
4739 cfg: &HubConfig,
4740 canonical_brain: &str,
4741 old_key: &AgentSigningKey,
4742 new_key: &AgentSigningKey,
4743 head: &Head,
4744) -> LinkResult<()> {
4745 if journal.v != 1
4746 || journal.origin != normalized_origin(&cfg.hub)?
4747 || journal.brain != canonical_brain
4748 || journal.old_brain != old_key.multikey
4749 || journal.new_brain != new_key.multikey
4750 || journal.prior_head_seq != head.seq
4751 || journal.prior_feed_hash != head.feed_hash
4752 {
4753 return Err(invalid_feed(
4754 "rotation journal does not match the verified key and feed boundary",
4755 ));
4756 }
4757 let statement: RotationStatement = serde_json::from_str(&journal.statement)
4758 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
4759 if statement.prior_head_seq != journal.prior_head_seq
4760 || statement.prior_feed_hash != journal.prior_feed_hash
4761 || statement.brain != old_key.multikey
4762 || statement.public_key != old_key.public_key_spki
4763 || statement.new_brain != new_key.multikey
4764 || statement.new_public_key != new_key.public_key_spki
4765 {
4766 return Err(invalid_feed(
4767 "rotation journal statement does not match its durable intent",
4768 ));
4769 }
4770 let identity = FeedIdentity {
4771 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
4772 public_key_spki: new_key.public_key_spki.clone(),
4773 previous: vec![PreviousIdentity {
4774 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
4775 public_key_spki: old_key.public_key_spki.clone(),
4776 }],
4777 rotations: vec![journal.statement.clone()],
4778 };
4779 verify_identity_chain(&identity, None)?;
4780 Ok(())
4781}
4782
4783#[derive(Debug, Serialize)]
4785pub struct RotationReport {
4786 pub brain: String,
4788 pub multikey: String,
4790 #[serde(rename = "keyFile")]
4792 pub key_file: String,
4793 pub previous: Vec<String>,
4795}
4796
4797pub fn rotate_brain_key(
4803 cfg: &HubConfig,
4804 brain: &str,
4805 old_key: &AgentSigningKey,
4806 out: &Path,
4807) -> LinkResult<RotationReport> {
4808 require_hardened_filesystem("key rotation")?;
4809 require_safe_ref(brain)?;
4810 let new_key = if out.exists() {
4814 load_signing_key(out)?
4815 } else {
4816 let rng = ring::rand::SystemRandom::new();
4817 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
4818 .map_err(|_| bad_agent_key("key generation failed"))?;
4819 let pair = agent_keypair(pkcs8.as_ref())?;
4820 let (public_key_spki, multikey) = public_identity_for(&pair);
4821 write_secret_new(
4822 out,
4823 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
4824 )?;
4825 AgentSigningKey {
4826 pkcs8: pkcs8.as_ref().to_vec(),
4827 multikey,
4828 public_key_spki,
4829 }
4830 };
4831 let new_spki = new_key.public_key_spki.clone();
4832 let new_multikey = new_key.multikey.clone();
4833 let journal_path = rotation_journal_path(out);
4834 let before = verified_remote_head(cfg, brain, false)?;
4835 let served_identity = before
4836 .identity
4837 .as_ref()
4838 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
4839 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
4840 if served_multikey == new_multikey {
4841 remove_rotation_journal(&journal_path);
4842 return Ok(RotationReport {
4843 brain: brain.to_string(),
4844 multikey: new_multikey,
4845 key_file: out.display().to_string(),
4846 previous: served_identity
4847 .previous
4848 .iter()
4849 .map(|identity| format!("ed25519:{}", identity.fingerprint))
4850 .collect(),
4851 });
4852 }
4853 if served_multikey != old_key.multikey {
4854 return Err(invalid_feed(
4855 "the supplied old key is not the brain's verified current identity",
4856 ));
4857 }
4858
4859 let journal = if journal_path.exists() {
4860 read_rotation_journal(&journal_path)?
4861 } else {
4862 let ts = crate::now()
4863 .with_timezone(&chrono::Utc)
4864 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
4865 .to_string();
4866 let unsigned = serde_json::to_string(&UnsignedRotation {
4867 v: 1,
4868 op: "rotate",
4869 brain: &old_key.multikey,
4870 public_key: &old_key.public_key_spki,
4871 new_brain: &new_multikey,
4872 new_public_key: &new_spki,
4873 prior_head_seq: before.head.seq,
4874 prior_feed_hash: before.head.feed_hash.as_deref(),
4875 ts,
4876 })
4877 .expect("serialize rotation");
4878 let old_pair = agent_keypair(&old_key.pkcs8)?;
4879 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
4880 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
4881 let journal = RotationJournal {
4882 v: 1,
4883 origin: normalized_origin(&cfg.hub)?,
4884 brain: before.head.brain.clone(),
4885 old_brain: old_key.multikey.clone(),
4886 new_brain: new_multikey.clone(),
4887 prior_head_seq: before.head.seq,
4888 prior_feed_hash: before.head.feed_hash.clone(),
4889 statement,
4890 };
4891 let mut exact = serde_json::to_vec(&journal)
4892 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
4893 exact.push(b'\n');
4894 if write_secret_new(&journal_path, &exact).is_err() {
4895 read_rotation_journal(&journal_path)?
4898 } else {
4899 journal
4900 }
4901 };
4902 validate_rotation_journal(
4903 &journal,
4904 cfg,
4905 &before.head.brain,
4906 old_key,
4907 &new_key,
4908 &before.head,
4909 )?;
4910
4911 let body = json!({ "statement": journal.statement });
4912 let path = format!("/api/hub/brains/{brain}/rotate");
4913 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
4914 let attempted_failure = match attempted {
4915 Ok(response) if (200..300).contains(&response.status) => None,
4916 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
4917 Err(error) => Some(error),
4918 };
4919
4920 let after = match verified_remote_head(cfg, brain, false) {
4924 Ok(after) => after,
4925 Err(error) => return Err(attempted_failure.unwrap_or(error)),
4926 };
4927 let identity = after
4928 .identity
4929 .as_ref()
4930 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?;
4931 if format!("ed25519:{}", identity.fingerprint) != new_multikey
4932 || identity.public_key_spki != new_spki
4933 {
4934 return Err(attempted_failure.unwrap_or_else(|| {
4935 invalid_feed("hub acknowledged rotation without committing the verified new identity")
4936 }));
4937 }
4938 let previous = identity
4939 .previous
4940 .iter()
4941 .map(|prior| format!("ed25519:{}", prior.fingerprint))
4942 .collect();
4943 remove_rotation_journal(&journal_path);
4944
4945 Ok(RotationReport {
4946 brain: brain.to_string(),
4947 multikey: new_multikey,
4948 key_file: out.display().to_string(),
4949 previous,
4950 })
4951}
4952
4953#[derive(Debug, Serialize)]
4959pub struct MirrorReport {
4960 pub brain: String,
4962 #[serde(rename = "headSeq")]
4964 pub head_seq: u64,
4965 #[serde(rename = "feedHash")]
4967 pub feed_hash: Option<String>,
4968 pub entries: u64,
4970 pub pinned: String,
4972 pub files: usize,
4974}
4975
4976pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
4978
4979#[derive(Debug)]
4981pub struct VerifiedMirrorMaterial {
4982 pub brain: String,
4983 pub head_seq: u64,
4984 pub feed_hash: Option<String>,
4985 pub identity: serde_json::Value,
4986 pub entries: Vec<(u64, String, String)>,
4988 pub pack_sha256: Option<String>,
4989}
4990
4991#[derive(Deserialize)]
4992#[serde(deny_unknown_fields)]
4993struct StoredMirrorHead {
4994 brain: String,
4995 #[serde(rename = "headSeq")]
4996 head_seq: u64,
4997 #[serde(rename = "feedHash")]
4998 feed_hash: Option<String>,
4999}
5000
5001pub fn verify_mirror_material(
5004 head_bytes: &[u8],
5005 identity_bytes: &[u8],
5006 feed_bytes: &[Vec<u8>],
5007 snapshot_pack: Option<&[u8]>,
5008 expected_anchor: &str,
5009) -> LinkResult<VerifiedMirrorMaterial> {
5010 let snapshot_hash = snapshot_pack
5011 .filter(|pack| !pack.is_empty())
5012 .map(content_sha256);
5013 verify_mirror_material_with_pack_hash(
5014 head_bytes,
5015 identity_bytes,
5016 feed_bytes,
5017 snapshot_hash.as_deref(),
5018 expected_anchor,
5019 )
5020}
5021
5022pub fn verify_mirror_material_with_pack_hash(
5026 head_bytes: &[u8],
5027 identity_bytes: &[u8],
5028 feed_bytes: &[Vec<u8>],
5029 snapshot_pack_sha256: Option<&str>,
5030 expected_anchor: &str,
5031) -> LinkResult<VerifiedMirrorMaterial> {
5032 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
5033 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
5034 require_safe_ref(&head.brain)?;
5035 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
5036 return Err(invalid_feed(
5037 "stored mirror feed count does not match its bounded head sequence",
5038 ));
5039 }
5040 let aggregate = feed_bytes
5041 .iter()
5042 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
5043 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
5044 if aggregate > MAX_FEED_REPLAY_BYTES {
5045 return Err(invalid_feed(
5046 "stored mirror feed metadata exceeds the aggregate limit",
5047 ));
5048 }
5049 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
5050 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
5051 let anchor = verify_identity_chain(&identity, None)?;
5052 if anchor != expected_anchor {
5053 return Err(invalid_feed(
5054 "stored mirror identity does not descend from the explicitly trusted anchor",
5055 ));
5056 }
5057
5058 let mut entries = Vec::with_capacity(feed_bytes.len());
5059 let mut items = Vec::with_capacity(feed_bytes.len());
5060 let mut previous_hash = None;
5061 let mut pack_sha256 = None;
5062 for (index, bytes) in feed_bytes.iter().enumerate() {
5063 let exact = bytes
5064 .strip_suffix(b"\n")
5065 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
5066 if exact.ends_with(b"\n") {
5067 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
5068 }
5069 let entry: FeedEntry = serde_json::from_slice(exact)
5070 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
5071 let expected_seq = index as u64 + 1;
5072 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
5073 return Err(invalid_feed(
5074 "stored mirror feed is not contiguous and hash-chained",
5075 ));
5076 }
5077 let canonical = serde_json::to_vec(&entry)
5078 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
5079 if canonical != exact {
5080 return Err(invalid_feed(
5081 "stored feed entry is not in normative serialization",
5082 ));
5083 }
5084 let hash = content_sha256(bytes);
5085 let item = FeedItem {
5086 hash: hash.clone(),
5087 entry,
5088 };
5089 verify_feed_item(&item, &identity)?;
5090 previous_hash = Some(hash.clone());
5091 if expected_seq == head.head_seq {
5092 pack_sha256 = Some(item.entry.pack_sha256.clone());
5093 }
5094 entries.push((
5095 expected_seq,
5096 std::str::from_utf8(exact)
5097 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
5098 .to_string(),
5099 hash,
5100 ));
5101 items.push(item);
5102 }
5103 if previous_hash != head.feed_hash {
5104 return Err(invalid_feed(
5105 "stored mirror feed does not converge on its advertised head",
5106 ));
5107 }
5108 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
5109 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
5110 (0, None, None) => {}
5111 (_, Some(actual), Some(expected)) if actual == expected => {}
5112 _ => {
5113 return Err(LinkError::InvalidPack {
5114 message: "stored snapshot pack does not match the signed head digest".to_string(),
5115 });
5116 }
5117 }
5118 let identity_value = serde_json::to_value(&identity)
5119 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
5120 Ok(VerifiedMirrorMaterial {
5121 brain: head.brain,
5122 head_seq: head.head_seq,
5123 feed_hash: head.feed_hash,
5124 identity: identity_value,
5125 entries,
5126 pack_sha256,
5127 })
5128}
5129
5130pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
5133 format!(
5134 "{:x}",
5135 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
5136 )
5137}
5138
5139pub fn content_sha256(bytes: &[u8]) -> String {
5142 format!("{:x}", Sha256::digest(bytes))
5143}
5144
5145pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
5147 let mut digest = Sha256::new();
5148 let mut buffer = [0u8; 64 * 1024];
5149 loop {
5150 let read = reader.read(&mut buffer)?;
5151 if read == 0 {
5152 break;
5153 }
5154 digest.update(&buffer[..read]);
5155 }
5156 Ok(format!("{:x}", digest.finalize()))
5157}
5158
5159pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
5167 require_hardened_filesystem("mirror")?;
5168 require_safe_ref(brain)?;
5169 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
5170 let name = dest
5171 .file_name()
5172 .and_then(|name| name.to_str())
5173 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
5174 .ok_or_else(|| LinkError::UnsafePath {
5175 path: dest.display().to_string(),
5176 })?;
5177 #[cfg(unix)]
5178 let parent_dir = open_or_create_dir_nofollow(parent)?;
5179 #[cfg(unix)]
5180 use std::os::fd::AsRawFd as _;
5181 #[cfg(unix)]
5182 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
5183 #[cfg(unix)]
5184 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
5185 None => false,
5186 Some(true) => true,
5187 Some(false) => {
5188 return Err(LinkError::UnsafePath {
5189 path: dest.display().to_string(),
5190 });
5191 }
5192 };
5193
5194 #[cfg(unix)]
5197 let legacy_backup_name = c_name(
5198 format!(".{name}.dbmd-backup").as_bytes(),
5199 &dest.display().to_string(),
5200 )?;
5201 #[cfg(unix)]
5202 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
5203 return Err(LinkError::UnsafePath {
5204 path: parent
5205 .join(format!(".{name}.dbmd-backup"))
5206 .display()
5207 .to_string(),
5208 });
5209 }
5210
5211 let nonce = std::time::SystemTime::now()
5212 .duration_since(std::time::UNIX_EPOCH)
5213 .unwrap_or_default()
5214 .as_nanos();
5215 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
5216 #[cfg(unix)]
5217 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
5218 #[cfg(unix)]
5219 let stage_dir = create_dir_exclusive_at(
5220 parent_dir.as_raw_fd(),
5221 &stage_name,
5222 &dest.display().to_string(),
5223 )?;
5224
5225 let assembled = (|| -> LinkResult<MirrorReport> {
5226 let remote = verified_remote_head(cfg, brain, true)?;
5227 let brain_id = remote.head.brain.clone();
5228 let identity = remote
5229 .identity
5230 .as_ref()
5231 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
5232 let anchor = remote
5233 .anchor
5234 .clone()
5235 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
5236 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
5237 let snapshot_entries = parse_store_pack(pack.clone())?;
5238 let snapshot_count = snapshot_entries.len();
5239 let mut staged_entries = snapshot_entries;
5240 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
5241 for item in &remote.entries {
5242 let mut exact = serde_json::to_vec(&item.entry)
5243 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
5244 exact.push(b'\n');
5245 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
5246 return Err(invalid_feed(
5247 "serialized mirror entry differs from its verified hash",
5248 ));
5249 }
5250 staged_entries.push((
5251 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
5252 exact,
5253 ));
5254 }
5255 let mut identity_bytes = serde_json::to_vec(identity)
5256 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
5257 identity_bytes.push(b'\n');
5258 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
5259 let mut head_bytes = serde_json::to_vec(&json!({
5260 "brain": brain_id,
5261 "headSeq": remote.head.seq,
5262 "feedHash": remote.head.feed_hash,
5263 }))
5264 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
5265 head_bytes.push(b'\n');
5266 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
5267 staged_entries.push((
5268 CONFIG_REL_PATH.to_string(),
5269 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
5270 ));
5271 #[cfg(unix)]
5272 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
5273
5274 Ok(MirrorReport {
5275 brain: brain_id,
5276 head_seq: remote.head.seq,
5277 feed_hash: remote.head.feed_hash,
5278 entries: remote.entries.len() as u64,
5279 pinned: anchor,
5280 files: snapshot_count,
5281 })
5282 })();
5283
5284 let report = match assembled {
5285 Ok(report) => report,
5286 Err(error) => {
5287 #[cfg(unix)]
5288 let _ = remove_tree_at(
5289 parent_dir.as_raw_fd(),
5290 &stage_name,
5291 &dest.display().to_string(),
5292 );
5293 return Err(error);
5294 }
5295 };
5296
5297 #[cfg(unix)]
5298 if let Err(error) =
5299 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
5300 {
5301 let _ = remove_tree_at(
5302 parent_dir.as_raw_fd(),
5303 &stage_name,
5304 &dest.display().to_string(),
5305 );
5306 return Err(error);
5307 }
5308 #[cfg(unix)]
5311 if dest_exists {
5312 remove_tree_at(
5313 parent_dir.as_raw_fd(),
5314 &stage_name,
5315 &dest.display().to_string(),
5316 )?;
5317 }
5318 #[cfg(unix)]
5319 parent_dir.sync_all()?;
5320 Ok(report)
5321}
5322
5323fn verified_remote_head(
5324 cfg: &HubConfig,
5325 brain: &str,
5326 require_full_chain: bool,
5327) -> LinkResult<VerifiedRemote> {
5328 require_hardened_filesystem("verified link.md state")?;
5329 require_safe_ref(brain)?;
5330 let trust_directory = open_trust_dir(cfg)?;
5334 let path = format!("/api/hub/brains/{brain}");
5335 let body = ensure_ok(
5336 request(cfg, "GET", &path, None, Auth::Required)?,
5337 "subscribe",
5338 )?;
5339 let resolved_brain = body
5340 .get("id")
5341 .and_then(Value::as_str)
5342 .filter(|id| crate::ulid::is_ulid(id))
5343 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
5344 .to_string();
5345 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
5346 return Err(invalid_feed(
5347 "brain card id differs from the explicitly requested brain id",
5348 ));
5349 }
5350 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
5355 let (pinned, alias_binding) =
5356 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
5357 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
5358 let advertised_hash = body
5359 .get("feedHash")
5360 .and_then(Value::as_str)
5361 .map(str::to_string);
5362 let updated_at = body
5363 .get("updatedAt")
5364 .and_then(Value::as_str)
5365 .map(str::to_string);
5366 if let Some(pin) = &pinned {
5367 if seq < pin.head_seq {
5368 return Err(invalid_feed(format!(
5369 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
5370 pin.head_seq
5371 )));
5372 }
5373 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
5374 return Err(invalid_feed(
5375 "feed equivocation: the checkpoint sequence now has a different hash",
5376 ));
5377 }
5378 }
5379 if seq == 0 {
5380 if advertised_hash.is_some() {
5381 return Err(invalid_feed("an empty feed advertised a head hash"));
5382 }
5383 let identity: FeedIdentity = serde_json::from_value(
5384 body.get("identity")
5385 .cloned()
5386 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
5387 )
5388 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
5389 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
5390 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
5395 save_canonical_pin_and_alias(
5396 cfg,
5397 &trust_directory,
5398 brain,
5399 &resolved_brain,
5400 TrustState {
5401 v: 2,
5402 origin: normalized_origin(&cfg.hub)?,
5403 requested: resolved_brain.clone(),
5404 brain: resolved_brain.clone(),
5405 home: None,
5406 anchor: anchor.clone(),
5407 current: format!("ed25519:{}", identity.fingerprint),
5408 head_seq: 0,
5409 feed_hash: None,
5410 rotations: identity.rotations.clone(),
5411 },
5412 alias_binding.as_ref(),
5413 )?;
5414 return Ok(VerifiedRemote {
5415 head: Head {
5416 brain: resolved_brain,
5417 seq,
5418 updated_at,
5419 feed_hash: None,
5420 verified: true,
5421 },
5422 identity: Some(identity),
5423 head_entry: None,
5424 entries: Vec::new(),
5425 anchor: Some(anchor),
5426 });
5427 }
5428 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
5429 return Err(invalid_feed(
5430 "non-empty feed did not advertise a valid SHA-256 head",
5431 ));
5432 }
5433
5434 let replay_head_only = !require_full_chain
5438 && pinned
5439 .as_ref()
5440 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
5441 let mut after = if replay_head_only {
5442 seq - 1
5443 } else if require_full_chain || pinned.is_none() {
5444 0
5445 } else {
5446 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
5447 };
5448 let mut expected_seq = after + 1;
5449 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
5450 None
5451 } else {
5452 pinned
5453 .as_ref()
5454 .and_then(|checkpoint| checkpoint.feed_hash.clone())
5455 };
5456 let mut identity: Option<FeedIdentity> = None;
5457 let mut anchor: Option<String> = None;
5458 let mut head_entry: Option<FeedItem> = None;
5459 let mut all_entries = Vec::new();
5460 let mut observed_entries = Vec::new();
5461 let replay_count = seq
5462 .checked_sub(after)
5463 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
5464 if replay_count > MAX_FEED_REPLAY_ENTRIES {
5465 return Err(invalid_feed(format!(
5466 "feed replay requires {replay_count} entries, over the client cap"
5467 )));
5468 }
5469 let mut replay_bytes = 0u64;
5470
5471 loop {
5472 let feed_bytes = ensure_raw_ok(
5473 request_raw(
5474 cfg,
5475 "GET",
5476 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
5477 None,
5478 Auth::Required,
5479 MAX_FEED_RESPONSE_BYTES,
5480 )?,
5481 "subscribe feed",
5482 )?;
5483 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
5484 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
5485 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
5486 return Err(invalid_feed("brain card and feed head disagree"));
5487 }
5488 if feed.entries.len() > FEED_PAGE_LIMIT {
5489 return Err(invalid_feed("feed page exceeds the requested entry limit"));
5490 }
5491 if feed.scope_limited {
5492 if require_full_chain {
5493 return Err(invalid_feed(
5494 "path-scoped grants cannot verify a full snapshot chain",
5495 ));
5496 }
5497 return Ok(VerifiedRemote {
5498 head: Head {
5499 brain: resolved_brain,
5500 seq,
5501 updated_at,
5502 feed_hash: advertised_hash,
5503 verified: false,
5504 },
5505 identity: None,
5506 head_entry: None,
5507 entries: Vec::new(),
5508 anchor: None,
5509 });
5510 }
5511 let page_identity = feed
5512 .identity
5513 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
5514 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
5515 if identity
5516 .as_ref()
5517 .is_some_and(|existing| existing != &page_identity)
5518 {
5519 return Err(invalid_feed("identity changed while reading the feed"));
5520 }
5521 if anchor
5522 .as_ref()
5523 .is_some_and(|existing| existing != &page_anchor)
5524 {
5525 return Err(invalid_feed(
5526 "identity anchor changed while reading the feed",
5527 ));
5528 }
5529 identity = Some(page_identity.clone());
5530 if anchor.is_none() {
5531 anchor = Some(page_anchor);
5532 }
5533 if feed.entries.is_empty() {
5534 return Err(invalid_feed("feed page was empty before the signed head"));
5535 }
5536
5537 for item in feed.entries {
5538 if item.entry.seq != expected_seq {
5539 return Err(invalid_feed(format!(
5540 "expected entry {expected_seq}, feed served {}",
5541 item.entry.seq
5542 )));
5543 }
5544 if item.entry.seq > seq {
5545 return Err(invalid_feed("feed advanced past the card snapshot"));
5546 }
5547 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
5548 return Err(invalid_feed(format!(
5549 "entry {} does not chain to the local checkpoint",
5550 item.entry.seq
5551 )));
5552 }
5553 verify_feed_item(&item, &page_identity)?;
5554 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
5555 replay_bytes = replay_bytes.saturating_add(
5556 serde_json::to_vec(&item)
5557 .map_err(|_| invalid_feed("could not size feed entry"))?
5558 .len() as u64,
5559 );
5560 if replay_bytes > MAX_FEED_REPLAY_BYTES {
5561 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
5562 }
5563 previous_hash = Some(item.hash.clone());
5564 after = item.entry.seq;
5565 expected_seq = expected_seq
5566 .checked_add(1)
5567 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
5568 if require_full_chain {
5569 all_entries.push(item.clone());
5570 }
5571 observed_entries.push(item.clone());
5572 head_entry = Some(item);
5573 }
5574 if after == seq {
5575 break;
5576 }
5577 }
5578
5579 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
5580 return Err(invalid_feed(
5581 "verified chain does not converge on the advertised head",
5582 ));
5583 }
5584 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
5585 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
5586 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
5587 save_canonical_pin_and_alias(
5588 cfg,
5589 &trust_directory,
5590 brain,
5591 &resolved_brain,
5592 TrustState {
5593 v: 2,
5594 origin: normalized_origin(&cfg.hub)?,
5595 requested: resolved_brain.clone(),
5596 brain: resolved_brain.clone(),
5597 home: None,
5598 anchor: anchor.clone(),
5599 current: format!("ed25519:{}", identity.fingerprint),
5600 head_seq: seq,
5601 feed_hash: advertised_hash.clone(),
5602 rotations: identity.rotations.clone(),
5603 },
5604 alias_binding.as_ref(),
5605 )?;
5606 Ok(VerifiedRemote {
5607 head: Head {
5608 brain: resolved_brain,
5609 seq,
5610 updated_at,
5611 feed_hash: advertised_hash,
5612 verified: true,
5613 },
5614 identity: Some(identity),
5615 head_entry,
5616 entries: all_entries,
5617 anchor: Some(anchor),
5618 })
5619}
5620
5621pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
5626 Ok(verified_remote_head(cfg, brain, false)?.head)
5627}
5628
5629#[cfg(test)]
5630mod tests {
5631 use super::*;
5632
5633 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
5634
5635 #[cfg(target_os = "linux")]
5636 #[test]
5637 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
5638 use std::os::fd::AsRawFd as _;
5639
5640 let sandbox = tempfile::TempDir::new().unwrap();
5641 let parent = std::fs::File::open(sandbox.path()).unwrap();
5642 let stage = std::ffi::CString::new("stage").unwrap();
5643 let destination = std::ffi::CString::new("brain").unwrap();
5644
5645 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
5646 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
5647 install_stage_at(
5648 parent.as_raw_fd(),
5649 stage.as_c_str(),
5650 destination.as_c_str(),
5651 false,
5652 )
5653 .unwrap();
5654 assert!(!sandbox.path().join("stage").exists());
5655 assert_eq!(
5656 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
5657 b"created"
5658 );
5659
5660 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
5661 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
5662 install_stage_at(
5663 parent.as_raw_fd(),
5664 stage.as_c_str(),
5665 destination.as_c_str(),
5666 true,
5667 )
5668 .unwrap();
5669 assert_eq!(
5670 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
5671 b"replacement"
5672 );
5673 assert_eq!(
5674 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
5675 b"created",
5676 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
5677 );
5678 }
5679
5680 struct SignedRemoteFixture {
5681 card: String,
5682 feed: String,
5683 key: AgentSigningKey,
5684 identity: FeedIdentity,
5685 }
5686
5687 fn signed_remote_fixture() -> SignedRemoteFixture {
5688 let rng = ring::rand::SystemRandom::new();
5689 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
5690 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
5691 let (public_key, multikey) = public_identity_for(&pair);
5692 let identity = FeedIdentity {
5693 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
5694 public_key_spki: public_key.clone(),
5695 previous: Vec::new(),
5696 rotations: Vec::new(),
5697 };
5698 let mut entry = FeedEntry {
5699 v: 1,
5700 seq: 1,
5701 ts: "2026-07-30T12:00:00.000Z".to_string(),
5702 brain: multikey.clone(),
5703 public_key: public_key.clone(),
5704 kind: "push".to_string(),
5705 op: "snapshot".to_string(),
5706 pack_sha256: "a".repeat(64),
5707 files: Vec::new(),
5708 removed: Vec::new(),
5709 prev_entry_hash: None,
5710 sig: String::new(),
5711 };
5712 let unsigned = UnsignedFeedEntry {
5713 v: entry.v,
5714 seq: entry.seq,
5715 ts: &entry.ts,
5716 brain: &entry.brain,
5717 public_key: &entry.public_key,
5718 kind: &entry.kind,
5719 op: &entry.op,
5720 pack_sha256: &entry.pack_sha256,
5721 files: &entry.files,
5722 removed: &entry.removed,
5723 prev_entry_hash: &entry.prev_entry_hash,
5724 };
5725 entry.sig =
5726 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
5727 let mut exact = serde_json::to_vec(&entry).unwrap();
5728 exact.push(b'\n');
5729 let hash = content_sha256(&exact);
5730 let card = json!({
5731 "id": TEST_BRAIN_ID,
5732 "headSeq": 1,
5733 "feedHash": hash,
5734 "identity": identity.clone(),
5735 })
5736 .to_string();
5737 let feed = json!({
5738 "headSeq": 1,
5739 "feedHash": hash,
5740 "identity": identity.clone(),
5741 "entries": [{"hash": hash, "entry": entry}],
5742 "scopeLimited": false,
5743 })
5744 .to_string();
5745 SignedRemoteFixture {
5746 card,
5747 feed,
5748 key: AgentSigningKey {
5749 pkcs8: pkcs8.as_ref().to_vec(),
5750 multikey,
5751 public_key_spki: public_key,
5752 },
5753 identity,
5754 }
5755 }
5756
5757 #[test]
5758 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
5759 let fixture = signed_remote_fixture();
5760 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
5761 let item = feed["entries"][0].to_string();
5762 let oversized_page = format!(
5763 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
5764 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
5765 .collect::<Vec<_>>()
5766 .join(",")
5767 );
5768 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
5769
5770 let oversized_identity = format!(
5771 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
5772 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
5773 .collect::<Vec<_>>()
5774 .join(",")
5775 );
5776 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
5777
5778 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
5779 let oversized_entry = format!(
5780 "{{\"v\":1,\"seq\":1,\"ts\":\"t\",\"brain\":\"b\",\"public_key\":\"k\",\"kind\":\"push\",\"op\":\"snapshot\",\"pack_sha256\":\"{}\",\"files\":[{}],\"removed\":[],\"prev_entry_hash\":null,\"sig\":\"s\"}}",
5781 "a".repeat(64),
5782 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
5783 .collect::<Vec<_>>()
5784 .join(",")
5785 );
5786 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
5787 }
5788
5789 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
5790 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
5791 use std::net::TcpListener;
5792
5793 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
5794 let url = format!("http://{}", listener.local_addr().unwrap());
5795 let handle = std::thread::spawn(move || {
5796 for (status, body) in responses {
5797 let (stream, _) = listener.accept().unwrap();
5798 let mut reader = BufReader::new(stream);
5799 let mut line = String::new();
5800 reader.read_line(&mut line).unwrap();
5801 let mut content_length = 0usize;
5802 loop {
5803 line.clear();
5804 reader.read_line(&mut line).unwrap();
5805 if line == "\r\n" || line == "\n" || line.is_empty() {
5806 break;
5807 }
5808 if let Some((name, value)) = line.split_once(':') {
5809 if name.eq_ignore_ascii_case("content-length") {
5810 content_length = value.trim().parse().unwrap();
5811 }
5812 }
5813 }
5814 let mut request_body = vec![0_u8; content_length];
5815 reader.read_exact(&mut request_body).unwrap();
5816 let response = format!(
5817 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
5818 body.len()
5819 );
5820 reader.get_mut().write_all(response.as_bytes()).unwrap();
5821 }
5822 });
5823 (url, handle)
5824 }
5825
5826 fn routed_json_hub(
5827 requests: usize,
5828 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
5829 ) -> (String, std::thread::JoinHandle<()>) {
5830 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
5831 use std::net::TcpListener;
5832
5833 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
5834 let url = format!("http://{}", listener.local_addr().unwrap());
5835 let handle = std::thread::spawn(move || {
5836 for _ in 0..requests {
5837 let (stream, _) = listener.accept().unwrap();
5838 let mut reader = BufReader::new(stream);
5839 let mut line = String::new();
5840 reader.read_line(&mut line).unwrap();
5841 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
5842 let mut content_length = 0usize;
5843 loop {
5844 line.clear();
5845 reader.read_line(&mut line).unwrap();
5846 if line == "\r\n" || line == "\n" || line.is_empty() {
5847 break;
5848 }
5849 if let Some((name, value)) = line.split_once(':') {
5850 if name.eq_ignore_ascii_case("content-length") {
5851 content_length = value.trim().parse().unwrap();
5852 }
5853 }
5854 }
5855 let mut request_body = vec![0_u8; content_length];
5856 reader.read_exact(&mut request_body).unwrap();
5857 let (status, body) = respond(&path);
5858 let response = format!(
5859 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
5860 body.len()
5861 );
5862 reader.get_mut().write_all(response.as_bytes()).unwrap();
5863 }
5864 });
5865 (url, handle)
5866 }
5867
5868 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
5869 HubConfig {
5870 hub,
5871 key: Some("test-key".to_string()),
5872 agent_key: None,
5873 brain_key: None,
5874 state_dir,
5875 store_selected: false,
5876 }
5877 }
5878
5879 #[test]
5880 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
5881 use ring::signature::KeyPair as _;
5882
5883 let rng = ring::rand::SystemRandom::new();
5884 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
5885 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
5886 let (spki, multikey) = public_identity_for(&pair);
5887 let key = AgentSigningKey {
5888 pkcs8: pkcs8.as_ref().to_vec(),
5889 multikey,
5890 public_key_spki: spki,
5891 };
5892 let header = linkmd_sig_header(
5893 &key,
5894 "https://hub-a.example",
5895 "post",
5896 "/api/hub/brains/brain/push?mode=exact",
5897 Some("{\"ok\":true}"),
5898 )
5899 .unwrap();
5900 assert!(header.starts_with("LinkMD-Sig v2,"));
5901 let ts = header
5902 .split(",ts=")
5903 .nth(1)
5904 .unwrap()
5905 .split(',')
5906 .next()
5907 .unwrap();
5908 let signature = URL_SAFE_NO_PAD
5909 .decode(header.rsplit(",sig=").next().unwrap())
5910 .unwrap();
5911 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
5912 let accepted = format!(
5913 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
5914 );
5915 let replayed = format!(
5916 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
5917 );
5918 let public = pair.public_key().as_ref();
5919 assert!(UnparsedPublicKey::new(&ED25519, public)
5920 .verify(accepted.as_bytes(), &signature)
5921 .is_ok());
5922 assert!(
5923 UnparsedPublicKey::new(&ED25519, public)
5924 .verify(replayed.as_bytes(), &signature)
5925 .is_err(),
5926 "a proof captured at hub A must not authenticate at hub B"
5927 );
5928 }
5929
5930 #[test]
5931 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
5932 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
5933 let card = json!({
5934 "id": other,
5935 "headSeq": 0,
5936 "identity": signed_remote_fixture().identity,
5937 })
5938 .to_string();
5939 let (hub, server) = scripted_json_hub(vec![(200, card)]);
5940 let state = tempfile::tempdir().unwrap();
5941 let cfg = test_hub_config(hub, state.path().to_path_buf());
5942 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
5943 assert!(
5944 error.contains("differs from the explicitly requested"),
5945 "{error}"
5946 );
5947 server.join().unwrap();
5948 }
5949
5950 #[test]
5951 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
5952 let first = signed_remote_fixture().identity;
5953 let second = signed_remote_fixture().identity;
5954 let card = |identity: FeedIdentity| {
5955 json!({
5956 "id": TEST_BRAIN_ID,
5957 "headSeq": 0,
5958 "identity": identity,
5959 })
5960 .to_string()
5961 };
5962 let (hub, server) = scripted_json_hub(vec![(200, card(first)), (200, card(second))]);
5963 let state = tempfile::tempdir().unwrap();
5964 let cfg = test_hub_config(hub, state.path().to_path_buf());
5965 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
5966 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
5967 assert!(
5968 error.contains("pinned anchor") || error.contains("forked away"),
5969 "{error}"
5970 );
5971 server.join().unwrap();
5972 }
5973
5974 #[test]
5975 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
5976 let old = signed_remote_fixture();
5977 let new = signed_remote_fixture();
5978 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
5979 let unsigned = serde_json::to_string(&UnsignedRotation {
5980 v: 1,
5981 op: "rotate",
5982 brain: &old.key.multikey,
5983 public_key: &old.key.public_key_spki,
5984 new_brain: &new.key.multikey,
5985 new_public_key: &new.key.public_key_spki,
5986 prior_head_seq: 1,
5987 prior_feed_hash: Some(&"a".repeat(64)),
5988 ts: "2026-07-30T12:00:00.000Z".to_string(),
5989 })
5990 .unwrap();
5991 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
5992 let rotation = format!(
5993 "{},\"sig\":\"{}\"}}",
5994 &unsigned[..unsigned.len() - 1],
5995 signature
5996 );
5997 let identity = FeedIdentity {
5998 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
5999 public_key_spki: new.key.public_key_spki,
6000 previous: vec![PreviousIdentity {
6001 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
6002 public_key_spki: old.key.public_key_spki,
6003 }],
6004 rotations: vec![rotation],
6005 };
6006 let card = json!({
6007 "id": TEST_BRAIN_ID,
6008 "headSeq": 0,
6009 "feedHash": null,
6010 "identity": identity,
6011 })
6012 .to_string();
6013 let (hub, server) = scripted_json_hub(vec![(200, card)]);
6014 let state = tempfile::tempdir().unwrap();
6015 let cfg = test_hub_config(hub, state.path().to_path_buf());
6016 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
6017 assert!(
6018 error.contains("rotation claims a feed boundary beyond the advertised head"),
6019 "{error}"
6020 );
6021 assert!(
6022 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
6023 "an inconsistent empty-head identity must not become the TOFU checkpoint"
6024 );
6025 server.join().unwrap();
6026 }
6027
6028 #[test]
6029 fn trust_checkpoint_rejects_a_later_fork() {
6030 let fixture = signed_remote_fixture();
6031 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
6032 fork["feedHash"] = Value::String("b".repeat(64));
6033 let (hub, server) = scripted_json_hub(vec![
6034 (200, fixture.card),
6035 (200, fixture.feed),
6036 (200, fork.to_string()),
6037 ]);
6038 let state = tempfile::tempdir().unwrap();
6039 let cfg = test_hub_config(hub, state.path().to_path_buf());
6040 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
6041 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
6042 server.join().unwrap();
6043 }
6044
6045 #[test]
6046 fn alias_and_canonical_id_share_one_identity_checkpoint() {
6047 let trusted = signed_remote_fixture();
6048 let attacker = signed_remote_fixture();
6049 let (hub, server) = scripted_json_hub(vec![
6050 (200, trusted.card),
6051 (200, trusted.feed),
6052 (200, attacker.card),
6053 ]);
6054 let state = tempfile::tempdir().unwrap();
6055 let cfg = test_hub_config(hub, state.path().to_path_buf());
6056 assert!(head(&cfg, "trusted-slug").unwrap().verified);
6057 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
6058 assert!(
6059 error.contains("equivocation")
6060 || error.contains("pinned")
6061 || error.contains("identity"),
6062 "{error}"
6063 );
6064 server.join().unwrap();
6065 }
6066
6067 #[test]
6068 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
6069 let alpha = signed_remote_fixture();
6070 let beta = signed_remote_fixture();
6071 let alpha_card = alpha.card.clone();
6072 let alpha_feed = alpha.feed.clone();
6073 let beta_card = beta.card.clone();
6074 let beta_feed = beta.feed.clone();
6075 let (hub, server) = routed_json_hub(3, move |path| {
6076 if path.contains("/alpha/feed?") {
6077 (200, alpha_feed.clone())
6078 } else if path.contains("/beta/feed?") {
6079 (200, beta_feed.clone())
6080 } else if path.ends_with("/alpha") {
6081 (200, alpha_card.clone())
6082 } else if path.ends_with("/beta") {
6083 (200, beta_card.clone())
6084 } else {
6085 (500, r#"{"error":"unexpected path"}"#.to_string())
6086 }
6087 });
6088 let state = tempfile::tempdir().unwrap();
6089 let cfg = test_hub_config(hub, state.path().to_path_buf());
6090 let alpha_cfg = cfg.clone();
6091 let beta_cfg = cfg;
6092 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
6093 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
6094 let results = [first.join().unwrap(), second.join().unwrap()];
6095 assert_eq!(
6096 results.iter().filter(|result| result.is_ok()).count(),
6097 1,
6098 "only one alias identity may establish canonical TOFU: {results:?}"
6099 );
6100 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
6101 server.join().unwrap();
6102 }
6103
6104 #[cfg(unix)]
6105 #[test]
6106 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
6107 use std::os::unix::fs::symlink;
6108
6109 let fixture = signed_remote_fixture();
6110 let card = json!({
6111 "id": TEST_BRAIN_ID,
6112 "headSeq": 0,
6113 "feedHash": Value::Null,
6114 "identity": fixture.identity,
6115 })
6116 .to_string();
6117 let work = tempfile::tempdir().unwrap();
6118 let outside = tempfile::tempdir().unwrap();
6119 let state = work.path().join("state");
6120 let moved = work.path().join("state-held");
6121 let swap_state = state.clone();
6122 let swap_moved = moved.clone();
6123 let outside_path = outside.path().to_path_buf();
6124 let (hub, server) = routed_json_hub(1, move |_| {
6125 std::fs::rename(&swap_state, &swap_moved).unwrap();
6127 symlink(&outside_path, &swap_state).unwrap();
6128 (200, card.clone())
6129 });
6130 let cfg = test_hub_config(hub, state);
6131
6132 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
6133 assert_eq!(verified.head.seq, 0);
6134 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
6135 assert!(std::fs::read_dir(moved.join("trust"))
6136 .unwrap()
6137 .flatten()
6138 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
6139 server.join().unwrap();
6140 }
6141
6142 #[test]
6143 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
6144 let remote = signed_remote_fixture();
6145 let unrelated = signed_remote_fixture().key;
6146 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
6147 let state = tempfile::tempdir().unwrap();
6148 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
6149 cfg.brain_key = Some(unrelated);
6150 let error = sync_push(
6151 &cfg,
6152 TEST_BRAIN_ID,
6153 &[("DB.md".to_string(), "signed local content".to_string())],
6154 )
6155 .unwrap_err()
6156 .to_string();
6157 assert!(
6158 error.contains("not the verified current brain identity"),
6159 "{error}"
6160 );
6161 server.join().unwrap();
6162 }
6163
6164 #[test]
6165 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
6166 let remote = signed_remote_fixture();
6167 let new = signed_remote_fixture().key;
6168 let state = tempfile::tempdir().unwrap();
6169 let new_file = state.path().join("new.key");
6170 std::fs::write(
6171 &new_file,
6172 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
6173 )
6174 .unwrap();
6175 #[cfg(unix)]
6176 {
6177 use std::os::unix::fs::PermissionsExt as _;
6178 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
6179 }
6180 let forged = json!({
6181 "brain": TEST_BRAIN_ID,
6182 "identity": {
6183 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
6184 "publicKeySpki": new.public_key_spki,
6185 }
6186 })
6187 .to_string();
6188 let (hub, server) = scripted_json_hub(vec![
6189 (200, remote.card.clone()),
6190 (200, remote.feed.clone()),
6191 (200, forged),
6192 (200, remote.card),
6193 (200, remote.feed),
6194 ]);
6195 let cfg = test_hub_config(hub, state.path().to_path_buf());
6196 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
6197 .unwrap_err()
6198 .to_string();
6199 assert!(
6200 error.contains("without committing the verified new identity"),
6201 "{error}"
6202 );
6203 server.join().unwrap();
6204 }
6205
6206 #[test]
6207 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
6208 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
6209 let raw = format!(
6210 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
6211 );
6212 let pack = build_store_pack(&[
6213 (
6214 "DB.md".to_string(),
6215 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
6216 ),
6217 ("records/clients/truth.md".to_string(), raw.clone()),
6218 ])
6219 .unwrap();
6220 let by_id = resolve_from_verified_pack(
6221 "01j5qc3v9k4ym8rwbn2tqe6f7d",
6222 &AddressTarget::Id(record_id.to_string()),
6223 pack.clone(),
6224 )
6225 .unwrap();
6226 assert_eq!(by_id["document"]["summary"], "Signed truth");
6227 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
6228 assert_eq!(
6229 by_id["document"]["contentSha"],
6230 content_sha256(raw.as_bytes())
6231 );
6232
6233 let by_path = resolve_from_verified_pack(
6234 "01j5qc3v9k4ym8rwbn2tqe6f7d",
6235 &AddressTarget::Path("records/clients/truth.md".to_string()),
6236 pack,
6237 )
6238 .unwrap();
6239 assert_eq!(by_path["document"]["id"], record_id);
6240 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
6241 }
6242
6243 #[test]
6244 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
6245 let unsorted = vec![
6246 ("records/a.md".to_string(), "alpha\n".to_string()),
6247 ("DB.md".to_string(), "# db\n".to_string()),
6248 ];
6249 let sorted = vec![
6250 ("DB.md".to_string(), "# db\n".to_string()),
6251 ("records/a.md".to_string(), "alpha\n".to_string()),
6252 ];
6253 let pack = build_store_pack(&unsorted).unwrap();
6254
6255 assert_eq!(pack.len(), 219);
6260 assert_eq!(
6261 content_sha256(&pack),
6262 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
6263 );
6264 assert_eq!(pack, build_store_pack(&sorted).unwrap());
6265 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
6266 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
6267 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
6268
6269 assert_eq!(
6270 parse_store_pack(pack).unwrap(),
6271 vec![
6272 ("DB.md".to_string(), b"# db\n".to_vec()),
6273 ("records/a.md".to_string(), b"alpha\n".to_vec()),
6274 ]
6275 );
6276 }
6277
6278 #[test]
6279 fn canonical_store_pack_validates_every_path_before_writing() {
6280 let duplicate = vec![
6281 ("DB.md".to_string(), "first".to_string()),
6282 ("DB.md".to_string(), "second".to_string()),
6283 ];
6284 assert!(build_store_pack(&duplicate)
6285 .unwrap_err()
6286 .to_string()
6287 .contains("duplicate path"));
6288 assert!(matches!(
6289 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
6290 Err(LinkError::UnsafePath { .. })
6291 ));
6292 }
6293
6294 #[test]
6295 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
6296 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
6297 let mut bytes = vec![0_u8];
6300 let zip64_offset = bytes.len() as u64;
6301 bytes.extend_from_slice(b"PK\x06\x06");
6302 bytes.extend_from_slice(&44_u64.to_le_bytes());
6303 bytes.extend_from_slice(&[0_u8; 12]);
6304 bytes.extend_from_slice(&COUNT.to_le_bytes());
6305 bytes.extend_from_slice(&COUNT.to_le_bytes());
6306 bytes.extend_from_slice(&1_u64.to_le_bytes());
6307 bytes.extend_from_slice(&0_u64.to_le_bytes());
6308 bytes.extend_from_slice(b"PK\x06\x07");
6309 bytes.extend_from_slice(&0_u32.to_le_bytes());
6310 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
6311 bytes.extend_from_slice(&1_u32.to_le_bytes());
6312 bytes.extend_from_slice(b"PK\x05\x06");
6313 bytes.extend_from_slice(&0_u16.to_le_bytes());
6314 bytes.extend_from_slice(&0_u16.to_le_bytes());
6315 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
6316 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
6317 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
6318 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
6319 bytes.extend_from_slice(&0_u16.to_le_bytes());
6320
6321 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
6322 .unwrap_err()
6323 .to_string();
6324 assert!(error.contains("invalid file count"), "{error}");
6325 }
6326
6327 #[test]
6328 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
6329 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
6330 let mut bytes = vec![0_u8];
6331 let zip64_offset = bytes.len() as u64;
6332 bytes.extend_from_slice(b"PK\x06\x06");
6333 bytes.extend_from_slice(&44_u64.to_le_bytes());
6334 bytes.extend_from_slice(&[0_u8; 12]);
6335 bytes.extend_from_slice(&COUNT.to_le_bytes());
6336 bytes.extend_from_slice(&COUNT.to_le_bytes());
6337 bytes.extend_from_slice(&1_u64.to_le_bytes());
6338 bytes.extend_from_slice(&0_u64.to_le_bytes());
6339 bytes.extend_from_slice(b"PK\x06\x07");
6340 bytes.extend_from_slice(&0_u32.to_le_bytes());
6341 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
6342 bytes.extend_from_slice(&1_u32.to_le_bytes());
6343 bytes.extend_from_slice(b"PK\x05\x06");
6344 bytes.extend_from_slice(&0_u16.to_le_bytes());
6345 bytes.extend_from_slice(&0_u16.to_le_bytes());
6346 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
6347 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
6348 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
6349 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
6350 bytes.extend_from_slice(&0_u16.to_le_bytes());
6351 let fake_eocd = bytes.len() as u32;
6355 bytes.extend_from_slice(b"PK\x05\x06");
6356 bytes.extend_from_slice(&0_u16.to_le_bytes());
6357 bytes.extend_from_slice(&0_u16.to_le_bytes());
6358 bytes.extend_from_slice(&1_u16.to_le_bytes());
6359 bytes.extend_from_slice(&1_u16.to_le_bytes());
6360 bytes.extend_from_slice(&0_u32.to_le_bytes());
6361 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
6362 bytes.extend_from_slice(&0_u16.to_le_bytes());
6363
6364 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
6365 .unwrap_err()
6366 .to_string();
6367 assert!(error.contains("central directory"), "{error}");
6368 }
6369
6370 #[test]
6371 fn strict_http_status_handling_rejects_redirects_without_panicking() {
6372 let error = ensure_ok(
6373 HubResponse {
6374 status: 302,
6375 body: Some(json!({"redirect": "/elsewhere"})),
6376 },
6377 "mutation",
6378 )
6379 .unwrap_err();
6380 assert!(matches!(error, LinkError::Http { status: 302, .. }));
6381
6382 let error = ensure_raw_ok(
6383 RawHubResponse {
6384 status: 302,
6385 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
6386 },
6387 "feed",
6388 )
6389 .unwrap_err();
6390 assert!(matches!(error, LinkError::Http { status: 302, .. }));
6391 }
6392
6393 #[cfg(unix)]
6394 #[test]
6395 fn collect_push_files_refuses_external_symlink_and_nested_store() {
6396 use std::os::unix::fs::symlink;
6397
6398 let root = tempfile::tempdir().unwrap();
6399 std::fs::write(
6400 root.path().join("DB.md"),
6401 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
6402 )
6403 .unwrap();
6404 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
6405
6406 let external = tempfile::tempdir().unwrap();
6407 let secret = external.path().join("secret.md");
6408 std::fs::write(&secret, "TOP SECRET").unwrap();
6409 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
6410
6411 let store = Store::open_strict(root.path()).unwrap();
6412 let err = collect_push_files(&store).unwrap_err().to_string();
6413 assert!(err.contains("cannot push"), "{err}");
6414 assert!(
6415 !err.contains("TOP SECRET"),
6416 "external bytes must never leak"
6417 );
6418
6419 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
6420 let nested = root.path().join("records/nested");
6421 std::fs::create_dir_all(&nested).unwrap();
6422 std::fs::write(
6423 nested.join("DB.md"),
6424 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
6425 )
6426 .unwrap();
6427 let err = collect_push_files(&store).unwrap_err().to_string();
6428 assert!(err.contains("nested db.md store"), "{err}");
6429 }
6430
6431 #[cfg(unix)]
6432 #[test]
6433 fn remote_push_uses_opened_root_after_path_replacement() {
6434 use std::os::unix::fs::symlink;
6435
6436 let sandbox = tempfile::tempdir().unwrap();
6437 let root = sandbox.path().join("store");
6438 std::fs::create_dir_all(root.join("records/notes")).unwrap();
6439 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
6440 std::fs::write(
6441 root.join("records/notes/owned.md"),
6442 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
6443 )
6444 .unwrap();
6445 let store = Store::open_strict(&root).unwrap();
6446 let detached = sandbox.path().join("detached");
6447 std::fs::rename(&root, &detached).unwrap();
6448
6449 let replacement = sandbox.path().join("replacement");
6450 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
6451 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
6452 std::fs::write(
6453 replacement.join("records/notes/secret.md"),
6454 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
6455 )
6456 .unwrap();
6457 symlink(&replacement, &root).unwrap();
6458
6459 let files = collect_push_files(&store).unwrap();
6460 let wire_text = files
6461 .iter()
6462 .map(|(path, content)| format!("{path}\n{content}"))
6463 .collect::<Vec<_>>()
6464 .join("\n");
6465 assert!(wire_text.contains("owned upload"));
6466 assert!(!wire_text.contains("replacement sentinel"));
6467 assert!(!wire_text.contains("records/notes/secret.md"));
6468
6469 let remote = signed_remote_fixture();
6470 let (hub, server) = scripted_json_hub(vec![
6471 (200, remote.card),
6472 (200, remote.feed),
6473 (200, json!({"ok": true}).to_string()),
6474 ]);
6475 let state = tempfile::tempdir().unwrap();
6476 let cfg = test_hub_config(hub, state.path().to_path_buf());
6477 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
6478 assert_eq!(pushed, json!({"ok": true}));
6479 server.join().unwrap();
6480 }
6481
6482 #[test]
6483 fn signed_feed_item_verifies_identity_hash_and_signature() {
6484 use ring::rand::SystemRandom;
6485 use ring::signature::{Ed25519KeyPair, KeyPair};
6486
6487 const PREFIX: &[u8] = &[
6488 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
6489 ];
6490 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
6491 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
6492 let mut spki = PREFIX.to_vec();
6493 spki.extend_from_slice(pair.public_key().as_ref());
6494 let public_key = URL_SAFE_NO_PAD.encode(&spki);
6495 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
6496 let mut entry = FeedEntry {
6497 v: 1,
6498 seq: 1,
6499 ts: "2026-07-14T00:00:00.000Z".to_string(),
6500 brain: format!("ed25519:{fingerprint}"),
6501 public_key: public_key.clone(),
6502 kind: "push".to_string(),
6503 op: "snapshot".to_string(),
6504 pack_sha256: "a".repeat(64),
6505 files: vec![FeedFile {
6506 path: "DB.md".to_string(),
6507 sha256: "b".repeat(64),
6508 bytes: 3,
6509 }],
6510 removed: vec![],
6511 prev_entry_hash: None,
6512 sig: String::new(),
6513 };
6514 let unsigned = UnsignedFeedEntry {
6515 v: entry.v,
6516 seq: entry.seq,
6517 ts: &entry.ts,
6518 brain: &entry.brain,
6519 public_key: &entry.public_key,
6520 kind: &entry.kind,
6521 op: &entry.op,
6522 pack_sha256: &entry.pack_sha256,
6523 files: &entry.files,
6524 removed: &entry.removed,
6525 prev_entry_hash: &entry.prev_entry_hash,
6526 };
6527 entry.sig =
6528 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
6529 let mut exact = serde_json::to_vec(&entry).unwrap();
6530 exact.push(b'\n');
6531 let item = FeedItem {
6532 hash: format!("{:x}", Sha256::digest(&exact)),
6533 entry,
6534 };
6535 let identity = FeedIdentity {
6536 fingerprint,
6537 public_key_spki: public_key,
6538 previous: Vec::new(),
6539 rotations: Vec::new(),
6540 };
6541 assert!(verify_feed_item(&item, &identity).is_ok());
6542 let mut tampered = item;
6543 tampered.entry.pack_sha256 = "c".repeat(64);
6544 assert!(verify_feed_item(&tampered, &identity).is_err());
6545 }
6546
6547 #[test]
6548 fn a_self_custody_entry_verifies_like_any_hub_entry() {
6549 let rng = ring::rand::SystemRandom::new();
6550 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
6551 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
6552 let (spki, multikey) = public_identity_for(&pair);
6553 let key = AgentSigningKey {
6554 pkcs8: pkcs8.as_ref().to_vec(),
6555 multikey: multikey.clone(),
6556 public_key_spki: spki.clone(),
6557 };
6558 let files = vec![WireFeedFile {
6559 path: "DB.md".to_string(),
6560 sha256: "a".repeat(64),
6561 bytes: 3,
6562 }];
6563 let raw = self_custody_entry(
6564 &key,
6565 1,
6566 "2026-07-23T12:00:00.000Z".to_string(),
6567 &"c".repeat(64),
6568 &files,
6569 None,
6570 )
6571 .unwrap();
6572 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
6576 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
6577 let item = FeedItem { hash, entry };
6578 let identity = FeedIdentity {
6579 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
6580 public_key_spki: spki,
6581 previous: Vec::new(),
6582 rotations: Vec::new(),
6583 };
6584 assert!(verify_feed_item(&item, &identity).is_ok());
6585 }
6586
6587 #[test]
6588 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
6589 let rng = ring::rand::SystemRandom::new();
6590 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
6591 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
6592 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
6593 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
6594 let (old_spki, old_multikey) = public_identity_for(&old);
6595 let (new_spki, new_multikey) = public_identity_for(&new);
6596 let unsigned = serde_json::to_string(&UnsignedRotation {
6597 v: 1,
6598 op: "rotate",
6599 brain: &old_multikey,
6600 public_key: &old_spki,
6601 new_brain: &new_multikey,
6602 new_public_key: &new_spki,
6603 prior_head_seq: 1,
6604 prior_feed_hash: Some(&"a".repeat(64)),
6605 ts: "2026-07-30T12:00:00.000Z".to_string(),
6606 })
6607 .unwrap();
6608 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
6609 let rotation = format!(
6610 "{},\"sig\":\"{}\"}}",
6611 &unsigned[..unsigned.len() - 1],
6612 signature
6613 );
6614 let identity = FeedIdentity {
6615 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
6616 public_key_spki: new_spki,
6617 previous: vec![PreviousIdentity {
6618 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
6619 public_key_spki: old_spki,
6620 }],
6621 rotations: vec![rotation],
6622 };
6623 let pin = TrustState {
6624 v: 2,
6625 origin: "https://hub.example".to_string(),
6626 requested: "brain".to_string(),
6627 brain: "brain".to_string(),
6628 home: None,
6629 anchor: old_multikey.clone(),
6630 current: old_multikey.clone(),
6631 head_seq: 1,
6632 feed_hash: Some("a".repeat(64)),
6633 rotations: Vec::new(),
6634 };
6635 assert_eq!(
6636 verify_identity_chain(&identity, Some(&pin)).unwrap(),
6637 old_multikey
6638 );
6639 let mut accepted = pin.clone();
6640 accepted.current = new_multikey.clone();
6641 accepted.rotations = identity.rotations.clone();
6642 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
6643 v: 1,
6644 op: "rotate",
6645 brain: &old_multikey,
6646 public_key: &identity.previous[0].public_key_spki,
6647 new_brain: &new_multikey,
6648 new_public_key: &identity.public_key_spki,
6649 prior_head_seq: 1,
6650 prior_feed_hash: Some(&"a".repeat(64)),
6651 ts: "2026-07-30T12:00:01.000Z".to_string(),
6652 })
6653 .unwrap();
6654 let alternate_signature =
6655 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
6656 let mut rewritten = identity.clone();
6657 rewritten.rotations[0] = format!(
6658 "{},\"sig\":\"{}\"}}",
6659 &alternate_unsigned[..alternate_unsigned.len() - 1],
6660 alternate_signature
6661 );
6662 assert!(
6663 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
6664 "an alternate valid statement must not rewrite accepted history"
6665 );
6666
6667 let mut stale_entry = FeedEntry {
6668 v: 1,
6669 seq: 2,
6670 ts: "2026-07-30T12:01:00.000Z".to_string(),
6671 brain: pin.current.clone(),
6672 public_key: identity.previous[0].public_key_spki.clone(),
6673 kind: "push".to_string(),
6674 op: "snapshot".to_string(),
6675 pack_sha256: "b".repeat(64),
6676 files: Vec::new(),
6677 removed: Vec::new(),
6678 prev_entry_hash: pin.feed_hash.clone(),
6679 sig: String::new(),
6680 };
6681 let stale_unsigned = UnsignedFeedEntry {
6682 v: stale_entry.v,
6683 seq: stale_entry.seq,
6684 ts: &stale_entry.ts,
6685 brain: &stale_entry.brain,
6686 public_key: &stale_entry.public_key,
6687 kind: &stale_entry.kind,
6688 op: &stale_entry.op,
6689 pack_sha256: &stale_entry.pack_sha256,
6690 files: &stale_entry.files,
6691 removed: &stale_entry.removed,
6692 prev_entry_hash: &stale_entry.prev_entry_hash,
6693 };
6694 stale_entry.sig = URL_SAFE_NO_PAD.encode(
6695 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
6696 .as_ref(),
6697 );
6698 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
6699 stale_exact.push(b'\n');
6700 let stale_item = FeedItem {
6701 hash: content_sha256(&stale_exact),
6702 entry: stale_entry,
6703 };
6704 assert!(
6705 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
6706 .is_err(),
6707 "a key retired before the checkpoint must never append after it"
6708 );
6709 assert!(
6710 verify_feed_item(&stale_item, &identity).is_err(),
6711 "an old key must never append after its signed rotation boundary"
6712 );
6713
6714 let mut missing = identity.clone();
6715 missing.rotations.clear();
6716 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
6717
6718 let mut tampered = identity;
6719 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
6720 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
6721 }
6722
6723 #[cfg(unix)]
6724 #[test]
6725 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
6726 use std::os::unix::fs::symlink;
6727
6728 let dir = tempfile::tempdir().unwrap();
6729 let target = dir.path().join("valuable.txt");
6730 let planted = dir.path().join("agent.key");
6731 std::fs::write(&target, "do not overwrite").unwrap();
6732 symlink(&target, &planted).unwrap();
6733
6734 assert!(matches!(
6735 generate_agent_key(&planted),
6736 Err(LinkError::BadAgentKey { .. })
6737 ));
6738 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
6739 }
6740
6741 #[cfg(unix)]
6742 #[test]
6743 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
6744 use std::os::unix::fs::symlink;
6745
6746 let root = tempfile::tempdir().unwrap();
6747 let outside = tempfile::tempdir().unwrap();
6748 symlink(outside.path(), root.path().join("redirect")).unwrap();
6749
6750 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
6751 assert!(!outside.path().join("agent.key").exists());
6752 }
6753
6754 #[test]
6757 fn address_bare_brain_with_and_without_sigil() {
6758 for raw in ["@acme-ops", "acme-ops"] {
6759 let a = Address::parse(raw).expect(raw);
6760 assert_eq!(a.brain, "acme-ops");
6761 assert_eq!(a.target, None);
6762 }
6763 }
6764
6765 #[test]
6766 fn address_ulid_target_parses_as_id() {
6767 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
6768 assert_eq!(a.brain, "acme");
6769 assert_eq!(
6770 a.target,
6771 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
6772 );
6773 }
6774
6775 #[test]
6776 fn address_md_path_target_parses_as_path() {
6777 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
6778 assert_eq!(
6779 a.target,
6780 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
6781 );
6782 }
6783
6784 #[test]
6785 fn address_rejects_malformed_forms() {
6786 for raw in [
6787 "",
6788 "@",
6789 "@/x",
6790 "@acme/",
6791 "@acme/../etc/passwd",
6792 "@acme/records/.hidden.md",
6793 "@ACME", "@acme/notes/x.txt", "@a b", ] {
6797 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
6798 }
6799 }
6800
6801 #[test]
6804 fn safe_paths_accept_store_shapes_and_reject_escapes() {
6805 for ok in [
6806 "DB.md",
6807 "assets.jsonl",
6808 "records/clients/lumio.md",
6809 "sources/emails/2026/07/x.md",
6810 ] {
6811 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
6812 }
6813 for bad in [
6814 "",
6815 "/etc/passwd",
6816 "../up.md",
6817 "records/../../up.md",
6818 "records//x.md",
6819 ".dbmd/config",
6820 "records/.hidden/x.md",
6821 "records/a b.md",
6822 "records\\win.md",
6823 ] {
6824 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
6825 }
6826 }
6827
6828 #[cfg(unix)]
6829 #[test]
6830 fn opened_destination_capability_survives_an_ancestor_path_swap() {
6831 use std::os::unix::fs::symlink;
6832
6833 let work = tempfile::tempdir().unwrap();
6834 let outside = tempfile::tempdir().unwrap();
6835 let original = work.path().join("destination");
6836 let moved = work.path().join("destination-moved");
6837 let directory = open_or_create_dir_nofollow(&original).unwrap();
6838
6839 std::fs::rename(&original, &moved).unwrap();
6840 symlink(outside.path(), &original).unwrap();
6841 write_pull_entries_beneath_dir(
6842 &directory,
6843 &[("records/note.md".to_string(), b"held inode".to_vec())],
6844 )
6845 .unwrap();
6846
6847 assert_eq!(
6848 std::fs::read(moved.join("records/note.md")).unwrap(),
6849 b"held inode"
6850 );
6851 assert!(!outside.path().join("records/note.md").exists());
6852 }
6853
6854 #[test]
6858 fn hub_config_flag_beats_file_and_requires_some_source() {
6859 let dir = tempfile::tempdir().unwrap();
6860 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
6861 std::fs::write(
6862 dir.path().join(CONFIG_REL_PATH),
6863 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
6864 )
6865 .unwrap();
6866
6867 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
6868 assert_eq!(from_flag.hub, "https://flag.example.com");
6869
6870 let from_file = hub_config(None, dir.path()).unwrap();
6871 assert_eq!(from_file.hub, "https://file.example.com");
6872
6873 let none = hub_config(None, tempfile::tempdir().unwrap().path());
6874 assert!(matches!(none, Err(LinkError::NoHub)));
6875 }
6876
6877 #[test]
6878 fn https_guard_allows_loopback_only_for_plain_http() {
6879 assert!(assert_safe_hub("https://hub.example.com").is_ok());
6880 assert!(assert_safe_hub("http://localhost:3000").is_ok());
6881 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
6882 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
6883 assert!(matches!(
6884 assert_safe_hub("http://hub.example.com"),
6885 Err(LinkError::UnsafeHub { .. })
6886 ));
6887 assert!(matches!(
6888 assert_safe_hub("hub.example.com"),
6889 Err(LinkError::UnsafeHub { .. })
6890 ));
6891 assert!(matches!(
6892 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
6893 Err(LinkError::UnsafeHub { .. })
6894 ));
6895 assert!(matches!(
6896 assert_safe_hub("https://hub.example.com@attacker.example"),
6897 Err(LinkError::UnsafeHub { .. })
6898 ));
6899 assert!(matches!(
6900 assert_safe_hub("https://hub.example.com/base"),
6901 Err(LinkError::UnsafeHub { .. })
6902 ));
6903 }
6904
6905 #[test]
6906 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
6907 for blocked in [
6908 "127.0.0.1",
6909 "10.0.0.1",
6910 "100.64.0.1",
6911 "169.254.169.254",
6912 "172.16.0.1",
6913 "192.168.0.1",
6914 "192.88.99.1",
6915 "198.18.0.1",
6916 "203.0.113.1",
6917 "::1",
6918 "fe80::1",
6919 "fd00::1",
6920 "2001:db8::1",
6921 "2001:1::1",
6922 "2002:7f00:1::",
6923 "3fff::1",
6924 ] {
6925 assert!(
6926 !is_public_registry_ip(blocked.parse().unwrap()),
6927 "must block {blocked}"
6928 );
6929 }
6930 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
6931 assert!(is_public_registry_ip(
6932 "2606:4700:4700::1111".parse().unwrap()
6933 ));
6934 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
6935 }
6936
6937 #[test]
6938 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
6939 use ureq::Resolver as _;
6940
6941 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
6942 let resolver = PinnedRegistryResolver {
6943 netloc: "home.example:443".to_string(),
6944 addresses: vec![pinned],
6945 };
6946 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
6947 assert!(resolver.resolve("127.0.0.1:443").is_err());
6948 assert_eq!(
6949 resolver.resolve("home.example:443").unwrap(),
6950 vec![pinned],
6951 "subsequent connects reuse the validated answer instead of DNS"
6952 );
6953 }
6954
6955 #[test]
6956 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
6957 let cfg = HubConfig {
6958 hub: "https://hub.example".to_string(),
6959 key: None,
6960 agent_key: None,
6961 brain_key: None,
6962 state_dir: tempfile::tempdir().unwrap().keep(),
6963 store_selected: false,
6964 };
6965 assert!(
6966 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
6967 "a production hub must not turn its presigned URL into an SSRF primitive"
6968 );
6969
6970 let store_selected = HubConfig {
6971 hub: "https://127.0.0.1".to_string(),
6972 store_selected: true,
6973 ..cfg
6974 };
6975 assert!(
6976 hub_agent(&store_selected).is_err(),
6977 "bytes in a cloned store must not select a private-network hub"
6978 );
6979 }
6980
6981 #[test]
6982 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
6983 assert_eq!(
6984 one_past_bounded_limit(MAX_PACK_BYTES),
6985 Some(MAX_PACK_BYTES + 1),
6986 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
6987 );
6988 assert_eq!(
6989 presigned_download_read_limit(),
6990 MAX_PACK_BYTES + 1,
6991 "the presigned reader is capped by the client constant, not a hub response"
6992 );
6993 assert_eq!(
6994 one_past_bounded_limit(u64::MAX),
6995 None,
6996 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
6997 );
6998 }
6999
7000 #[test]
7001 fn https_guard_matches_the_scheme_case_insensitively() {
7002 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
7005 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
7006 assert!(matches!(
7008 assert_safe_hub("HTTP://hub.example.com"),
7009 Err(LinkError::UnsafeHub { .. })
7010 ));
7011 }
7012
7013 #[test]
7014 fn clean_key_refuses_paste_artifacts_without_echoing() {
7015 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
7016 for bad in ["vc account", "vc\naccount", "ключ", ""] {
7017 let err = clean_key(bad).unwrap_err();
7018 assert!(matches!(err, LinkError::BadKey));
7019 assert!(
7020 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
7021 "error must not echo the key"
7022 );
7023 }
7024 }
7025
7026 fn dead_hub() -> HubConfig {
7032 HubConfig {
7033 hub: "http://127.0.0.1:9".to_string(),
7034 key: Some("k".to_string()),
7035 agent_key: None,
7036 brain_key: None,
7037 state_dir: PathBuf::from("."),
7038 store_selected: false,
7039 }
7040 }
7041
7042 #[test]
7043 fn request_retries_a_connection_failure_before_sending() {
7044 use std::io::{Read as _, Write as _};
7045 use std::net::TcpListener;
7046 use std::thread;
7047 use std::time::Duration;
7048
7049 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
7050 let address = probe.local_addr().unwrap();
7051 drop(probe);
7052 let server = thread::spawn(move || {
7053 thread::sleep(Duration::from_millis(40));
7054 let listener = TcpListener::bind(address).unwrap();
7055 let (mut stream, _) = listener.accept().unwrap();
7056 let mut request_bytes = [0_u8; 1024];
7057 let _ = stream.read(&mut request_bytes).unwrap();
7058 stream
7059 .write_all(
7060 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
7061 )
7062 .unwrap();
7063 });
7064 let cfg = HubConfig {
7065 hub: format!("http://{address}"),
7066 key: None,
7067 agent_key: None,
7068 brain_key: None,
7069 state_dir: tempfile::tempdir().unwrap().keep(),
7070 store_selected: false,
7071 };
7072
7073 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
7074 assert_eq!(response.status, 200);
7075 assert_eq!(response.body, Some(json!({ "ok": true })));
7076 server.join().unwrap();
7077 }
7078
7079 #[test]
7080 fn endpoint_cap_refuses_a_body_before_json_parsing() {
7081 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
7082 let cfg = HubConfig {
7083 hub,
7084 key: None,
7085 agent_key: None,
7086 brain_key: None,
7087 state_dir: tempfile::tempdir().unwrap().keep(),
7088 store_selected: false,
7089 };
7090
7091 assert!(matches!(
7092 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
7093 Err(LinkError::ResponseTooLarge { .. })
7094 ));
7095 server.join().unwrap();
7096 }
7097
7098 #[test]
7099 fn overall_deadline_stops_a_dribbled_response_body() {
7100 use std::io::{Read as _, Write as _};
7101 use std::net::TcpListener;
7102 use std::time::{Duration, Instant};
7103
7104 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
7105 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
7106 let server = std::thread::spawn(move || {
7107 let (mut stream, _) = listener.accept().unwrap();
7108 let mut request = [0_u8; 1024];
7109 let _ = stream.read(&mut request);
7110 stream
7111 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
7112 .unwrap();
7113 for byte in [b'x'; 32] {
7114 if stream.write_all(&[byte]).is_err() {
7115 break;
7116 }
7117 std::thread::sleep(Duration::from_millis(40));
7118 }
7119 });
7120 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
7121 let started = Instant::now();
7122 let response = http.get(&url).call().unwrap();
7123 let mut body = Vec::new();
7124 let error = response
7125 .into_reader()
7126 .read_to_end(&mut body)
7127 .expect_err("per-read progress must not reset the overall deadline");
7128 assert!(
7129 started.elapsed() < Duration::from_millis(700),
7130 "dribbled body exceeded the wall-clock budget: {error}"
7131 );
7132 server.join().unwrap();
7133 }
7134
7135 #[test]
7136 fn overall_deadline_stops_a_stalled_upload() {
7137 use std::net::TcpListener;
7138 use std::time::{Duration, Instant};
7139
7140 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
7141 let url = format!("http://{}/upload", listener.local_addr().unwrap());
7142 let server = std::thread::spawn(move || {
7143 let (_stream, _) = listener.accept().unwrap();
7144 std::thread::sleep(Duration::from_millis(600));
7147 });
7148 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
7149 let body = vec![0x5a; 32 * 1024 * 1024];
7150 let started = Instant::now();
7151 let error = http
7152 .put(&url)
7153 .send_bytes(&body)
7154 .expect_err("stalled request-body writes must time out");
7155 assert!(
7156 started.elapsed() < Duration::from_millis(700),
7157 "stalled upload exceeded the wall-clock budget: {error}"
7158 );
7159 server.join().unwrap();
7160 }
7161
7162 #[test]
7163 fn verb_entry_gates_accept_the_hub_ref_shapes() {
7164 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
7165 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
7166 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
7167 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
7168 }
7169 }
7170
7171 #[test]
7172 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
7173 let cfg = dead_hub();
7174 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
7175 assert!(
7176 matches!(
7177 sync_pull(&cfg, bad, None),
7178 Err(LinkError::BadAddress { .. })
7179 ),
7180 "sync_pull must refuse {bad:?}"
7181 );
7182 assert!(
7183 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
7184 "sync_push must refuse {bad:?}"
7185 );
7186 assert!(
7187 matches!(
7188 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
7189 Err(LinkError::BadAddress { .. })
7190 ),
7191 "grant_issue must refuse {bad:?}"
7192 );
7193 assert!(
7194 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
7195 "grant_list must refuse {bad:?}"
7196 );
7197 assert!(
7198 matches!(
7199 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
7200 Err(LinkError::BadAddress { .. })
7201 ),
7202 "grant_revoke must refuse brain {bad:?}"
7203 );
7204 assert!(
7205 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
7206 "head must refuse {bad:?}"
7207 );
7208 }
7209 }
7210
7211 #[test]
7212 fn grant_revoke_refuses_url_reshaping_grant_ids() {
7213 let cfg = dead_hub();
7214 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
7215 assert!(
7216 matches!(
7217 grant_revoke(&cfg, "acme", bad),
7218 Err(LinkError::BadGrantId { .. })
7219 ),
7220 "grant_revoke must refuse grant id {bad:?}"
7221 );
7222 }
7223 }
7224
7225 #[test]
7226 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
7227 let cfg = dead_hub();
7228 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
7229 assert!(
7230 matches!(
7231 propose(&cfg, bad, "intake", "hi"),
7232 Err(LinkError::BadAddress { .. })
7233 ),
7234 "propose must refuse handle {bad:?}"
7235 );
7236 }
7237 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
7238 assert!(matches!(
7239 propose(&cfg, "acme-site", "intake", &oversize),
7240 Err(LinkError::ProposeTooLarge { .. })
7241 ));
7242 assert!(matches!(
7245 propose(&cfg, "acme-site", "intake", "hi"),
7246 Err(LinkError::Transport { .. })
7247 ));
7248 }
7249
7250 #[test]
7251 fn resolve_refuses_a_hand_built_unsafe_address() {
7252 let cfg = dead_hub();
7253 for brain in ["../up", "a/b", "a?x", "a#f"] {
7254 let addr = Address {
7255 brain: brain.to_string(),
7256 target: None,
7257 };
7258 assert!(
7259 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
7260 "resolve must refuse brain {brain:?}"
7261 );
7262 }
7263 for target in [
7264 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
7265 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
7267 AddressTarget::Path("records/x.md#frag".to_string()),
7268 ] {
7269 let addr = Address {
7270 brain: "acme".to_string(),
7271 target: Some(target.clone()),
7272 };
7273 assert!(
7274 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
7275 "resolve must refuse target {target:?}"
7276 );
7277 }
7278 }
7279}