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];
165const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
169
170#[derive(Debug, thiserror::Error)]
174pub enum LinkError {
175 #[error(
177 "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
178 )]
179 NoHub,
180
181 #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
183 NoCredential,
184
185 #[error(
188 "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
189 )]
190 BadKey,
191
192 #[error(
198 "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}"
199 )]
200 UnboundCredential,
201
202 #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
206 BadAgentKey {
207 message: String,
209 },
210
211 #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
213 UnsafeHub {
214 hub: String,
216 },
217
218 #[error("hub unreachable at {hub}: {message}")]
220 Transport {
221 hub: String,
223 message: String,
225 },
226
227 #[error("{what} failed (HTTP {status}): {message}")]
229 Http {
230 what: &'static str,
232 status: u16,
234 message: String,
236 code: Option<String>,
238 details: Option<Value>,
240 },
241
242 #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
245 NotJson {
246 what: &'static str,
248 status: u16,
250 },
251
252 #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
254 ResponseTooLarge {
255 limit_bytes: u64,
257 },
258
259 #[error("invalid address `{given}`: {reason}")]
261 BadAddress {
262 given: String,
264 reason: String,
266 },
267
268 #[error(
270 "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
271 )]
272 BadGrantId {
273 given: String,
275 },
276
277 #[error("refusing unsafe path from the hub: `{path}`")]
281 UnsafePath {
282 path: String,
284 },
285
286 #[error(
288 "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
289 MAX_STORE_BYTES / (1024 * 1024),
290 MAX_PACK_BYTES / (1024 * 1024)
291 )]
292 PushTooLarge {
293 detail: String,
295 },
296
297 #[error(
299 "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
300 MAX_PROPOSE_BYTES / 1024
301 )]
302 ProposeTooLarge {
303 bytes: u64,
305 },
306
307 #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
309 NotUtf8 {
310 path: String,
312 },
313
314 #[error("invalid store pack: {message}")]
316 InvalidPack {
317 message: String,
319 },
320
321 #[error("invalid signed feed: {message}")]
323 InvalidFeed {
324 message: String,
326 },
327
328 #[error("sync conflict on {paths:?} — resolve the named files and retry")]
331 Conflict {
332 paths: Vec<String>,
334 },
335
336 #[error(
340 "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
341 )]
342 LocalPolicyTransition {
343 paths: Vec<String>,
345 },
346
347 #[error(
350 "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
351 )]
352 ScopedProjectionModified,
353
354 #[error(
358 "the checkout's permission scope changed — clone into a new directory to accept the new view"
359 )]
360 ScopedViewChanged,
361
362 #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
365 BrainUnavailable,
366
367 #[error(
370 "the remote brain advanced during sync — retry to converge from the new verified head"
371 )]
372 RemoteAdvancedDuringSync,
373
374 #[error(
377 "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
378 )]
379 UnsupportedPlatform {
380 operation: &'static str,
382 },
383
384 #[error(transparent)]
386 Io(#[from] std::io::Error),
387
388 #[error(transparent)]
390 Store(#[from] crate::StoreError),
391}
392
393pub type LinkResult<T> = std::result::Result<T, LinkError>;
395
396fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
401 #[cfg(any(target_os = "linux", target_os = "macos"))]
402 {
403 let _ = operation;
404 Ok(())
405 }
406 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
407 {
408 Err(LinkError::UnsupportedPlatform { operation })
409 }
410}
411
412#[derive(Debug, Clone, PartialEq, Eq)]
418pub enum AddressTarget {
419 Id(String),
421 Path(String),
425}
426
427const BAD_BRAIN_REASON: &str =
430 "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
431
432const BAD_TARGET_REASON: &str =
435 "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
436
437#[derive(Debug, Clone, PartialEq, Eq)]
442pub struct Address {
443 pub brain: String,
445 pub target: Option<AddressTarget>,
447}
448
449impl Address {
450 pub fn parse(raw: &str) -> LinkResult<Address> {
454 let bad = |reason: &str| LinkError::BadAddress {
455 given: raw.to_string(),
456 reason: reason.to_string(),
457 };
458
459 let trimmed = raw.trim();
460 let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
461 if body.is_empty() {
462 return Err(bad("empty address"));
463 }
464
465 let (brain, rest) = match body.split_once('/') {
466 Some((b, r)) => (b, Some(r)),
467 None => (body, None),
468 };
469
470 if brain.is_empty() {
471 return Err(bad("missing brain reference before `/`"));
472 }
473 if !is_safe_ref(brain) {
474 return Err(bad(BAD_BRAIN_REASON));
475 }
476
477 let target = match rest {
478 None => None,
479 Some("") => return Err(bad("trailing `/` with no record id or path")),
480 Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
481 Some(r) => {
482 if !safe_store_rel_path(r) || !r.ends_with(".md") {
483 return Err(bad(BAD_TARGET_REASON));
484 }
485 Some(AddressTarget::Path(r.to_string()))
486 }
487 };
488
489 Ok(Address {
490 brain: brain.to_string(),
491 target,
492 })
493 }
494}
495
496fn is_safe_ref(s: &str) -> bool {
499 !s.is_empty()
500 && s.len() <= 64
501 && s.bytes()
502 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
503}
504
505pub fn is_valid_handle(s: &str) -> bool {
508 is_safe_ref(s)
509}
510
511pub fn safe_store_rel_path(p: &str) -> bool {
517 if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
518 return false;
519 }
520 if !p
521 .bytes()
522 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
523 {
524 return false;
525 }
526 p.split('/')
527 .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
528}
529
530fn require_safe_ref(brain: &str) -> LinkResult<()> {
538 if is_safe_ref(brain) {
539 Ok(())
540 } else {
541 Err(LinkError::BadAddress {
542 given: brain.to_string(),
543 reason: BAD_BRAIN_REASON.to_string(),
544 })
545 }
546}
547
548fn require_valid_handle(handle: &str) -> LinkResult<()> {
550 if is_valid_handle(handle) {
551 Ok(())
552 } else {
553 Err(LinkError::BadAddress {
554 given: handle.to_string(),
555 reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
556 })
557 }
558}
559
560fn require_safe_grant_id(id: &str) -> LinkResult<()> {
564 if is_safe_ref(id) {
565 Ok(())
566 } else {
567 Err(LinkError::BadGrantId {
568 given: id.to_string(),
569 })
570 }
571}
572
573#[derive(Debug, Clone)]
579pub struct HubConfig {
580 pub hub: String,
582 pub key: Option<String>,
584 pub agent_key: Option<AgentSigningKey>,
587 pub brain_key: Option<AgentSigningKey>,
590 pub state_dir: PathBuf,
593 store_selected: bool,
596}
597
598#[derive(Clone)]
601pub struct AgentSigningKey {
602 pkcs8: Vec<u8>,
603 pub multikey: String,
605 pub public_key_spki: String,
607}
608
609impl std::fmt::Debug for AgentSigningKey {
610 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
611 f.debug_struct("AgentSigningKey")
612 .field("multikey", &self.multikey)
613 .field("pkcs8", &"<redacted>")
614 .finish()
615 }
616}
617
618impl HubConfig {
619 pub fn require_key(&self) -> LinkResult<&str> {
622 self.key.as_deref().ok_or(LinkError::NoCredential)
623 }
624}
625
626pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
631 let explicit_hub = flag_hub
632 .map(str::to_string)
633 .or_else(|| env_nonempty(HUB_URL_ENV));
634 let selected_by_store = explicit_hub.is_none();
635 let hub = explicit_hub
636 .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
637 .ok_or(LinkError::NoHub)?;
638 let hub = hub.trim().trim_end_matches('/').to_string();
639 assert_safe_hub(&hub)?;
640 if selected_by_store {
641 let parsed =
642 url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
643 if !parsed.scheme().eq_ignore_ascii_case("https")
647 || (parsed.path() != "/" && !parsed.path().is_empty())
648 {
649 return Err(LinkError::UnsafeHub { hub });
650 }
651 }
652
653 let key = match env_nonempty(HUB_KEY_ENV) {
654 Some(raw) => Some(clean_key(&raw)?),
655 None => None,
656 };
657
658 let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
659 Some(path) => Some(load_agent_key(Path::new(&path))?),
660 None => None,
661 };
662
663 let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
664 Some(path) => Some(load_agent_key(Path::new(&path))?),
665 None => None,
666 };
667
668 if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
675 let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
676 .and_then(|value| normalized_origin(&value).ok());
677 let selected_origin = normalized_origin(&hub)?;
678 if bound.as_deref() != Some(selected_origin.as_str()) {
679 return Err(LinkError::UnboundCredential);
680 }
681 }
682
683 Ok(HubConfig {
684 hub,
685 key,
686 agent_key,
687 brain_key,
688 state_dir: toolkit_state_dir()?,
689 store_selected: selected_by_store,
690 })
691}
692
693fn toolkit_state_dir() -> LinkResult<PathBuf> {
694 if let Some(path) = env_nonempty(STATE_DIR_ENV) {
695 let path = PathBuf::from(path);
696 if !path.is_absolute() {
697 return Err(LinkError::UnsafePath {
698 path: path.display().to_string(),
699 });
700 }
701 return Ok(path);
702 }
703 #[cfg(windows)]
704 if let Some(base) = env_nonempty("LOCALAPPDATA") {
705 let base = PathBuf::from(base);
706 if base.is_absolute() {
707 return Ok(base.join("dbmd").join("state"));
708 }
709 }
710 #[cfg(not(windows))]
711 if let Some(base) = env_nonempty("XDG_STATE_HOME") {
712 let base = PathBuf::from(base);
713 if base.is_absolute() {
714 return Ok(base.join("dbmd"));
715 }
716 }
717 let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
718 LinkError::Io(std::io::Error::new(
719 std::io::ErrorKind::NotFound,
720 format!("cannot locate user state; set {STATE_DIR_ENV}"),
721 ))
722 })?);
723 if !home.is_absolute() {
724 return Err(LinkError::UnsafePath {
725 path: home.display().to_string(),
726 });
727 }
728 #[cfg(target_os = "macos")]
729 {
730 Ok(home
731 .join("Library")
732 .join("Application Support")
733 .join("dbmd")
734 .join("state"))
735 }
736 #[cfg(all(not(target_os = "macos"), not(windows)))]
737 {
738 Ok(home.join(".local").join("state").join("dbmd"))
739 }
740}
741
742fn normalized_origin(value: &str) -> LinkResult<String> {
743 let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
744 hub: value.to_string(),
745 })?;
746 if !(parsed.scheme().eq_ignore_ascii_case("https")
747 || parsed.scheme().eq_ignore_ascii_case("http"))
748 || !parsed.username().is_empty()
749 || parsed.password().is_some()
750 || (parsed.path() != "/" && !parsed.path().is_empty())
751 || parsed.query().is_some()
752 || parsed.fragment().is_some()
753 {
754 return Err(LinkError::UnsafeHub {
755 hub: value.to_string(),
756 });
757 }
758 let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
759 hub: value.to_string(),
760 })?;
761 let host = if host.contains(':') {
762 format!("[{host}]")
763 } else {
764 host.to_ascii_lowercase()
765 };
766 let port = parsed
767 .port_or_known_default()
768 .ok_or_else(|| LinkError::UnsafeHub {
769 hub: value.to_string(),
770 })?;
771 let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
772 || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
773 Ok(format!(
774 "{}://{}{}",
775 parsed.scheme().to_ascii_lowercase(),
776 host,
777 if default {
778 String::new()
779 } else {
780 format!(":{port}")
781 }
782 ))
783}
784
785const ED25519_SPKI_PREFIX: [u8; 12] = [
792 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
793];
794
795fn bad_agent_key(message: &str) -> LinkError {
796 LinkError::BadAgentKey {
797 message: message.to_string(),
798 }
799}
800
801fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
802 ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
806 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
807 .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
808}
809
810fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
812 use ring::signature::KeyPair as _;
813 let mut spki = Vec::with_capacity(44);
814 spki.extend_from_slice(&ED25519_SPKI_PREFIX);
815 spki.extend_from_slice(pair.public_key().as_ref());
816 (
817 URL_SAFE_NO_PAD.encode(&spki),
818 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
819 )
820}
821
822pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
826 load_agent_key(path)
827}
828
829fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
831 #[cfg(unix)]
832 let file = {
833 use std::os::fd::{AsRawFd as _, FromRawFd as _};
834 use std::os::unix::ffi::OsStrExt as _;
835 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
836 .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
837 let leaf = path
838 .file_name()
839 .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
840 let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
841 let fd = unsafe {
842 libc::openat(
843 parent.as_raw_fd(),
844 leaf.as_ptr(),
845 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
846 )
847 };
848 if fd < 0 {
849 return Err(bad_agent_key(
850 "the key path must be an existing regular file without symlink ancestors",
851 ));
852 }
853 unsafe { std::fs::File::from_raw_fd(fd) }
854 };
855 #[cfg(not(unix))]
856 let file = std::fs::File::open(path)
857 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
858 let metadata = file
859 .metadata()
860 .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
861 if !metadata.is_file() {
862 return Err(bad_agent_key("the key path must be a regular file"));
863 }
864 #[cfg(unix)]
865 {
866 use std::os::unix::fs::PermissionsExt as _;
867 if metadata.permissions().mode() & 0o077 != 0 {
868 return Err(bad_agent_key(
869 "the key file is accessible to group/other; set mode 0600",
870 ));
871 }
872 }
873 let mut text = String::new();
874 file.take(1024 * 1024 + 1)
875 .read_to_string(&mut text)
876 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
877 if text.len() > 1024 * 1024 {
878 return Err(bad_agent_key("the key file exceeds the size limit"));
879 }
880 let pkcs8 = URL_SAFE_NO_PAD
881 .decode(text.trim())
882 .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
883 let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
884 Ok(AgentSigningKey {
885 pkcs8,
886 multikey,
887 public_key_spki,
888 })
889}
890
891fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
897 #[cfg(unix)]
898 let (mut file, parent, leaf) = {
899 use std::os::fd::{AsRawFd as _, FromRawFd as _};
900 use std::os::unix::ffi::OsStrExt as _;
901 let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
902 let leaf_name = path
903 .file_name()
904 .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
905 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
906 let fd = unsafe {
907 libc::openat(
908 parent.as_raw_fd(),
909 leaf.as_ptr(),
910 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
911 0o600,
912 )
913 };
914 if fd < 0 {
915 let error = std::io::Error::last_os_error();
916 if error.kind() == std::io::ErrorKind::AlreadyExists {
917 return Err(bad_agent_key(
918 "the output file already exists — refusing to overwrite a key",
919 ));
920 }
921 return Err(error.into());
922 }
923 (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
924 };
925 #[cfg(not(unix))]
926 let mut file = std::fs::OpenOptions::new()
927 .write(true)
928 .create_new(true)
929 .open(path)
930 .map_err(|error| {
931 if error.kind() == std::io::ErrorKind::AlreadyExists {
932 bad_agent_key("the output file already exists — refusing to overwrite a key")
933 } else {
934 LinkError::Io(error)
935 }
936 })?;
937 if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
938 drop(file);
939 #[cfg(unix)]
940 let _ =
941 unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
942 #[cfg(not(unix))]
943 let _ = std::fs::remove_file(path);
944 return Err(LinkError::Io(error));
945 }
946 drop(file);
947 #[cfg(unix)]
948 parent.sync_all()?;
949 Ok(())
950}
951
952#[derive(Debug, Serialize)]
955pub struct GeneratedAgentKey {
956 pub multikey: String,
958 #[serde(rename = "publicKeySpki")]
960 pub public_key_spki: String,
961 #[serde(rename = "keyFile")]
963 pub key_file: String,
964}
965
966pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
971 require_hardened_filesystem("key generation")?;
972 let rng = ring::rand::SystemRandom::new();
973 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
974 .map_err(|_| bad_agent_key("key generation failed"))?;
975 let pair = agent_keypair(pkcs8.as_ref())?;
976 let (spki_b64u, multikey) = public_identity_for(&pair);
977
978 write_secret_new(
979 out,
980 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
981 )?;
982
983 Ok(GeneratedAgentKey {
984 multikey,
985 public_key_spki: spki_b64u,
986 key_file: out.display().to_string(),
987 })
988}
989
990fn linkmd_sig_header(
999 key: &AgentSigningKey,
1000 origin: &str,
1001 method: &str,
1002 path: &str,
1003 body: Option<&str>,
1004) -> LinkResult<String> {
1005 let ts = std::time::SystemTime::now()
1006 .duration_since(std::time::UNIX_EPOCH)
1007 .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1008 .as_secs();
1009 let body_hash = match body {
1010 Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1011 None => "-".to_string(),
1012 };
1013 let canonical = format!(
1014 "v2\n{}\n{}\n{}\n{}\n{}",
1015 origin,
1016 method.to_uppercase(),
1017 path,
1018 ts,
1019 body_hash
1020 );
1021 let pair = agent_keypair(&key.pkcs8)?;
1022 let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1023 let fingerprint = key.multikey.trim_start_matches("ed25519:");
1024 Ok(format!(
1025 "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1026 ))
1027}
1028
1029#[derive(Serialize)]
1036struct WireFeedFile {
1037 path: String,
1038 sha256: String,
1039 bytes: u64,
1040}
1041
1042#[derive(Serialize)]
1045struct UnsignedWireEntry<'a> {
1046 v: u8,
1047 seq: u64,
1048 ts: String,
1049 brain: &'a str,
1050 public_key: &'a str,
1051 kind: &'a str,
1052 op: &'a str,
1053 pack_sha256: &'a str,
1054 files: &'a [WireFeedFile],
1055 removed: &'a [String],
1056 prev_entry_hash: Option<&'a str>,
1057}
1058
1059fn self_custody_entry(
1065 key: &AgentSigningKey,
1066 seq: u64,
1067 ts: String,
1068 pack_sha256: &str,
1069 files: &[WireFeedFile],
1070 prev_entry_hash: Option<&str>,
1071) -> LinkResult<String> {
1072 let removed: [String; 0] = [];
1073 let unsigned = serde_json::to_string(&UnsignedWireEntry {
1074 v: 1,
1075 seq,
1076 ts,
1077 brain: &key.multikey,
1078 public_key: &key.public_key_spki,
1079 kind: "push",
1080 op: "snapshot",
1081 pack_sha256,
1082 files,
1083 removed: &removed,
1084 prev_entry_hash,
1085 })
1086 .expect("serialize feed entry");
1087 let pair = agent_keypair(&key.pkcs8)?;
1088 let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1089 Ok(format!(
1090 "{},\"sig\":\"{}\"}}",
1091 &unsigned[..unsigned.len() - 1],
1092 sig
1093 ))
1094}
1095
1096fn env_nonempty(name: &str) -> Option<String> {
1099 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1100}
1101
1102fn config_file_hub(path: &Path) -> Option<String> {
1107 const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1108 #[cfg(unix)]
1109 let file = {
1110 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1111 use std::os::unix::ffi::OsStrExt as _;
1112 let parent =
1113 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1114 let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1115 let fd = unsafe {
1116 libc::openat(
1117 parent.as_raw_fd(),
1118 leaf.as_ptr(),
1119 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1120 )
1121 };
1122 if fd < 0 {
1123 return None;
1124 }
1125 unsafe { std::fs::File::from_raw_fd(fd) }
1126 };
1127 #[cfg(not(unix))]
1128 let file = std::fs::File::open(path).ok()?;
1129 let metadata = file.metadata().ok()?;
1130 if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1131 return None;
1132 }
1133 let mut bytes = Vec::with_capacity(metadata.len() as usize);
1134 file.take(MAX_CONFIG_BYTES + 1)
1135 .read_to_end(&mut bytes)
1136 .ok()?;
1137 if bytes.len() as u64 > MAX_CONFIG_BYTES {
1138 return None;
1139 }
1140 let text = String::from_utf8(bytes).ok()?;
1141 for line in text.lines() {
1142 let line = line.trim();
1143 if line.is_empty() || line.starts_with('#') {
1144 continue;
1145 }
1146 if let Some((k, v)) = line.split_once('=') {
1147 if k.trim() == "hub" {
1148 let v = v.trim();
1149 if !v.is_empty() {
1150 return Some(v.to_string());
1151 }
1152 }
1153 }
1154 }
1155 None
1156}
1157
1158fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1161 let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1162 hub: hub.to_string(),
1163 })?;
1164 if !(parsed.scheme().eq_ignore_ascii_case("https")
1165 || parsed.scheme().eq_ignore_ascii_case("http"))
1166 || !parsed.username().is_empty()
1167 || parsed.password().is_some()
1168 || (parsed.path() != "/" && !parsed.path().is_empty())
1169 || parsed.query().is_some()
1170 || parsed.fragment().is_some()
1171 {
1172 return Err(LinkError::UnsafeHub {
1173 hub: hub.to_string(),
1174 });
1175 }
1176 let loopback = match parsed.host() {
1177 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1178 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1179 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1180 None => false,
1181 };
1182 if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1183 Ok(())
1184 } else {
1185 Err(LinkError::UnsafeHub {
1186 hub: hub.to_string(),
1187 })
1188 }
1189}
1190
1191fn clean_key(raw: &str) -> LinkResult<String> {
1196 let k = raw.trim();
1197 if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1198 return Err(LinkError::BadKey);
1199 }
1200 Ok(k.to_string())
1201}
1202
1203#[derive(Debug)]
1209pub struct HubResponse {
1210 pub status: u16,
1212 pub body: Option<Value>,
1214}
1215
1216struct RawHubResponse {
1217 status: u16,
1218 body: Vec<u8>,
1219}
1220
1221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1223enum Auth {
1224 Required,
1226 None,
1228 Optional,
1232}
1233
1234fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1235 ureq::AgentBuilder::new()
1236 .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1237 .redirects(0)
1241 .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1242 .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1243 .timeout_write(overall)
1244 .timeout(overall)
1245}
1246
1247fn agent_builder() -> ureq::AgentBuilder {
1248 agent_builder_with_timeout(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS))
1249}
1250
1251fn agent() -> ureq::Agent {
1252 agent_builder().build()
1253}
1254
1255fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1256 if !cfg.store_selected {
1257 return Ok(agent());
1258 }
1259 let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1260 hub: cfg.hub.clone(),
1261 })?;
1262 pinned_public_agent(&parsed, false, "store-selected hub")
1263}
1264
1265fn request_raw(
1270 cfg: &HubConfig,
1271 method: &str,
1272 path: &str,
1273 body: Option<&Value>,
1274 auth: Auth,
1275 max_response_bytes: u64,
1276) -> LinkResult<RawHubResponse> {
1277 let http = hub_agent(cfg)?;
1278 request_raw_with_agent(cfg, &http, method, path, body, auth, max_response_bytes)
1279}
1280
1281fn request_raw_with_agent(
1282 cfg: &HubConfig,
1283 http: &ureq::Agent,
1284 method: &str,
1285 path: &str,
1286 body: Option<&Value>,
1287 auth: Auth,
1288 max_response_bytes: u64,
1289) -> LinkResult<RawHubResponse> {
1290 let url = format!("{}{}", cfg.hub, path);
1291 let encoded_body = body.map(Value::to_string);
1292 let origin = normalized_origin(&cfg.hub)?;
1293 let credential = match auth {
1296 Auth::Required => Some(match &cfg.agent_key {
1297 Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1298 None => format!("Bearer {}", cfg.require_key()?),
1299 }),
1300 Auth::Optional => match &cfg.agent_key {
1301 Some(key) => Some(linkmd_sig_header(
1302 key,
1303 &origin,
1304 method,
1305 path,
1306 encoded_body.as_deref(),
1307 )?),
1308 None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1309 },
1310 Auth::None => None,
1311 };
1312 let result = with_connect_retries(|| {
1313 let mut req = http.request(method, &url);
1314 if let Some(value) = &credential {
1315 req = req.set("authorization", value);
1316 }
1317 match &encoded_body {
1318 Some(value) => req
1319 .set("content-type", "application/json")
1320 .send_string(value)
1321 .map_err(Box::new),
1322 None => req.call().map_err(Box::new),
1323 }
1324 });
1325 let resp = match result {
1326 Ok(resp) => resp,
1327 Err(error) => match *error {
1328 ureq::Error::Status(_, resp) => resp,
1329 ureq::Error::Transport(error) => {
1330 return Err(LinkError::Transport {
1331 hub: cfg.hub.clone(),
1332 message: error.to_string(),
1333 });
1334 }
1335 },
1336 };
1337
1338 let status = resp.status();
1339 let mut buf = Vec::new();
1340 resp.into_reader()
1341 .take(max_response_bytes + 1)
1342 .read_to_end(&mut buf)?;
1343 if buf.len() as u64 > max_response_bytes {
1344 return Err(LinkError::ResponseTooLarge {
1345 limit_bytes: max_response_bytes,
1346 });
1347 }
1348 Ok(RawHubResponse { status, body: buf })
1349}
1350
1351fn request_capped(
1352 cfg: &HubConfig,
1353 method: &str,
1354 path: &str,
1355 body: Option<&Value>,
1356 auth: Auth,
1357 max_response_bytes: u64,
1358) -> LinkResult<HubResponse> {
1359 let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1360 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1361 Ok(HubResponse {
1362 status: raw.status,
1363 body: parsed,
1364 })
1365}
1366
1367fn request(
1368 cfg: &HubConfig,
1369 method: &str,
1370 path: &str,
1371 body: Option<&Value>,
1372 auth: Auth,
1373) -> LinkResult<HubResponse> {
1374 request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1375}
1376
1377fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1378 if (200..300).contains(&r.status) {
1379 return Ok(r.body);
1380 }
1381 ensure_ok(
1382 HubResponse {
1383 status: r.status,
1384 body: serde_json::from_slice(&r.body).ok(),
1385 },
1386 what,
1387 )
1388 .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1389}
1390
1391fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1396 matches!(
1397 kind,
1398 ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1399 )
1400}
1401
1402fn with_connect_retries(
1403 mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1404) -> Result<ureq::Response, Box<ureq::Error>> {
1405 let mut attempt = 0;
1406 loop {
1407 match send() {
1408 Err(error)
1409 if matches!(
1410 error.as_ref(),
1411 ureq::Error::Transport(transport)
1412 if is_pre_request_transport(transport.kind())
1413 ) && attempt + 1 < CONNECT_ATTEMPTS =>
1414 {
1415 std::thread::sleep(std::time::Duration::from_millis(
1416 CONNECT_RETRY_BACKOFF_MS[attempt],
1417 ));
1418 attempt += 1;
1419 }
1420 result => return result,
1421 }
1422 }
1423}
1424
1425fn hub_is_loopback(hub: &str) -> bool {
1426 url::Url::parse(hub).ok().is_some_and(|parsed| {
1427 parsed.host().is_some_and(|host| match host {
1428 url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1429 url::Host::Ipv4(ip) => ip.is_loopback(),
1430 url::Host::Ipv6(ip) => ip.is_loopback(),
1431 })
1432 })
1433}
1434
1435fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1436 let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1437 message: "the hub returned an invalid object-store URL".to_string(),
1438 })?;
1439 let allow_private = hub_is_loopback(&cfg.hub)
1440 || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1441 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1442 || !parsed.username().is_empty()
1443 || parsed.password().is_some()
1444 || parsed.fragment().is_some()
1445 {
1446 return Err(LinkError::InvalidPack {
1447 message: "the hub returned an unsafe object-store URL".to_string(),
1448 });
1449 }
1450 pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1451 LinkError::InvalidPack {
1452 message: "the hub returned an object-store URL with an unsafe network target"
1453 .to_string(),
1454 }
1455 })
1456}
1457
1458fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1459 let http = presigned_agent(cfg, raw)?;
1460 let result = with_connect_retries(|| {
1461 let mut req = http.put(raw);
1462 if let Some(map) = headers.as_object() {
1463 for (name, value) in map {
1464 if let Some(value) = value.as_str() {
1465 req = req.set(name, value);
1466 }
1467 }
1468 }
1469 req.send_bytes(bytes).map_err(Box::new)
1470 });
1471 match result {
1472 Ok(resp) if (200..300).contains(&resp.status()) => Ok(()),
1473 Ok(resp) => Err(LinkError::Http {
1474 what: "pack upload",
1475 status: resp.status(),
1476 message: "object store rejected the upload".to_string(),
1477 code: None,
1478 details: None,
1479 }),
1480 Err(error) => match *error {
1481 ureq::Error::Status(412, _) => Ok(()),
1486 ureq::Error::Status(_, resp) => Err(LinkError::Http {
1487 what: "pack upload",
1488 status: resp.status(),
1489 message: "object store rejected the upload".to_string(),
1490 code: None,
1491 details: None,
1492 }),
1493 ureq::Error::Transport(err) => Err(LinkError::Transport {
1494 hub: "the object store".to_string(),
1495 message: err.to_string(),
1496 }),
1497 },
1498 }
1499}
1500
1501fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1502 max_bytes.checked_add(1)
1503}
1504
1505fn presigned_download_read_limit() -> u64 {
1506 one_past_bounded_limit(MAX_PACK_BYTES)
1507 .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1508}
1509
1510fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1511 let http = presigned_agent(cfg, raw)?;
1512 let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1513 Ok(resp) => resp,
1514 Err(error) => match *error {
1515 ureq::Error::Status(_, resp) => {
1516 return Err(LinkError::Http {
1517 what: "pack download",
1518 status: resp.status(),
1519 message: "object store rejected the download".to_string(),
1520 code: None,
1521 details: None,
1522 });
1523 }
1524 ureq::Error::Transport(err) => {
1525 return Err(LinkError::Transport {
1526 hub: "the object store".to_string(),
1527 message: err.to_string(),
1528 });
1529 }
1530 },
1531 };
1532 if !(200..300).contains(&resp.status()) {
1533 return Err(LinkError::Http {
1534 what: "pack download",
1535 status: resp.status(),
1536 message: "object store rejected the download".to_string(),
1537 code: None,
1538 details: None,
1539 });
1540 }
1541 let mut bytes = Vec::new();
1542 resp.into_reader()
1543 .take(presigned_download_read_limit())
1544 .read_to_end(&mut bytes)?;
1545 if bytes.len() as u64 > MAX_PACK_BYTES {
1546 return Err(LinkError::InvalidPack {
1547 message: "download exceeds the compressed-size limit".to_string(),
1548 });
1549 }
1550 Ok(bytes)
1551}
1552
1553fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
1557 if !(200..300).contains(&r.status) {
1558 let message = r
1559 .body
1560 .as_ref()
1561 .and_then(|b| b.get("error"))
1562 .and_then(Value::as_str)
1563 .unwrap_or("unknown error")
1564 .to_string();
1565 let code = r
1566 .body
1567 .as_ref()
1568 .and_then(|b| b.get("code"))
1569 .and_then(Value::as_str)
1570 .map(str::to_string);
1571 let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
1572 return Err(LinkError::Http {
1573 what,
1574 status: r.status,
1575 message,
1576 code,
1577 details,
1578 });
1579 }
1580 r.body.ok_or(LinkError::NotJson {
1581 what,
1582 status: r.status,
1583 })
1584}
1585
1586fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
1595 match ip {
1596 std::net::IpAddr::V4(ip) => {
1597 let [a, b, c, _] = ip.octets();
1598 !(a == 0
1599 || a == 10
1600 || a == 127
1601 || (a == 100 && (64..=127).contains(&b))
1602 || (a == 169 && b == 254)
1603 || (a == 172 && (16..=31).contains(&b))
1604 || (a == 192 && b == 0 && c == 0)
1605 || (a == 192 && b == 0 && c == 2)
1606 || (a == 192 && b == 88 && c == 99)
1607 || (a == 192 && b == 168)
1608 || (a == 198 && (b == 18 || b == 19))
1609 || (a == 198 && b == 51 && c == 100)
1610 || (a == 203 && b == 0 && c == 113)
1611 || a >= 224)
1612 }
1613 std::net::IpAddr::V6(ip) => {
1614 let segments = ip.segments();
1615 (segments[0] & 0xe000) == 0x2000
1620 && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
1621 && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
1622 && segments[0] != 0x2002
1623 && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
1624 }
1625 }
1626}
1627
1628#[derive(Clone)]
1629struct PinnedRegistryResolver {
1630 netloc: String,
1631 addresses: Vec<std::net::SocketAddr>,
1632}
1633
1634impl ureq::Resolver for PinnedRegistryResolver {
1635 fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
1636 if requested == self.netloc {
1637 Ok(self.addresses.clone())
1638 } else {
1639 Err(std::io::Error::new(
1640 std::io::ErrorKind::PermissionDenied,
1641 "registry request attempted to resolve an unvalidated authority",
1642 ))
1643 }
1644 }
1645}
1646
1647fn pinned_public_agent(
1648 url: &url::Url,
1649 allow_private: bool,
1650 label: &str,
1651) -> LinkResult<ureq::Agent> {
1652 let host = url
1653 .host_str()
1654 .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
1655 let port = url
1656 .port_or_known_default()
1657 .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
1658 let addresses = resolve_addresses_with_deadline(
1659 host,
1660 port,
1661 std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
1662 )
1663 .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
1664 if addresses.is_empty() {
1665 return Err(invalid_feed(format!("{label} DNS returned no addresses")));
1666 }
1667 if !allow_private
1668 && addresses
1669 .iter()
1670 .any(|address| !is_public_registry_ip(address.ip()))
1671 {
1672 return Err(invalid_feed(format!(
1673 "{label} resolves to a non-public address"
1674 )));
1675 }
1676 let netloc = if host.contains(':') {
1677 format!("[{host}]:{port}")
1678 } else {
1679 format!("{host}:{port}")
1680 };
1681 Ok(agent_builder()
1682 .resolver(PinnedRegistryResolver { netloc, addresses })
1683 .build())
1684}
1685
1686fn resolve_addresses_with_deadline(
1691 host: &str,
1692 port: u16,
1693 timeout: std::time::Duration,
1694) -> std::io::Result<Vec<std::net::SocketAddr>> {
1695 use std::net::ToSocketAddrs as _;
1696
1697 let host = host.to_string();
1698 let (send, receive) = std::sync::mpsc::sync_channel(1);
1699 std::thread::Builder::new()
1700 .name("dbmd-dns".to_string())
1701 .spawn(move || {
1702 let result = (host.as_str(), port)
1703 .to_socket_addrs()
1704 .map(|addresses| addresses.collect());
1705 let _ = send.send(result);
1706 })
1707 .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
1708 match receive.recv_timeout(timeout) {
1709 Ok(result) => result,
1710 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
1711 std::io::ErrorKind::TimedOut,
1712 "resolution exceeded its deadline",
1713 )),
1714 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
1715 "resolver stopped without returning a result",
1716 )),
1717 }
1718}
1719
1720fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
1721 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1722 pinned_public_agent(url, allow_private, "registry home")
1723}
1724
1725fn get_json_absolute(url: &str) -> LinkResult<Value> {
1730 let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
1731 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1732 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1733 || !parsed.username().is_empty()
1734 || parsed.password().is_some()
1735 || parsed.query().is_some()
1736 || parsed.fragment().is_some()
1737 {
1738 return Err(invalid_feed("unsafe registry home URL"));
1739 }
1740 let http = registry_agent(&parsed)?;
1741 let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
1742 Ok(resp) => resp,
1743 Err(error) => match *error {
1744 ureq::Error::Status(status, resp) => {
1745 let _ = resp;
1746 return Err(LinkError::Http {
1747 what: "registry home fetch",
1748 status,
1749 message: "the home node rejected the card request".to_string(),
1750 code: None,
1751 details: None,
1752 });
1753 }
1754 ureq::Error::Transport(err) => {
1755 return Err(LinkError::Transport {
1756 hub: url.to_string(),
1757 message: err.to_string(),
1758 });
1759 }
1760 },
1761 };
1762 if !(200..300).contains(&resp.status()) {
1763 return Err(LinkError::Http {
1764 what: "registry home fetch",
1765 status: resp.status(),
1766 message: "the home node returned a redirect or error".to_string(),
1767 code: None,
1768 details: None,
1769 });
1770 }
1771 let mut buf = Vec::new();
1772 resp.into_reader()
1773 .take(MAX_REGISTRY_CARD_BYTES + 1)
1774 .read_to_end(&mut buf)?;
1775 if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
1776 return Err(LinkError::ResponseTooLarge {
1777 limit_bytes: MAX_REGISTRY_CARD_BYTES,
1778 });
1779 }
1780 serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
1781 message: "the home node returned invalid JSON".to_string(),
1782 })
1783}
1784
1785pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
1792 require_safe_ref(handle)?;
1793 let trust_directory = open_trust_dir(cfg)?;
1797 let reg = request_capped(
1798 cfg,
1799 "GET",
1800 &format!("/api/hub/registry/{handle}"),
1801 None,
1802 Auth::None,
1803 MAX_REGISTRY_CARD_BYTES,
1804 )?;
1805 if reg.status == 404 {
1806 return Ok(None);
1807 }
1808 let body = ensure_ok(reg, "registry resolve")?;
1809 let home = body
1810 .get("home")
1811 .and_then(Value::as_str)
1812 .ok_or_else(|| invalid_feed("registry entry has no home"))?;
1813 let brain = body
1814 .get("brain")
1815 .and_then(Value::as_str)
1816 .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
1817 if !crate::ulid::is_ulid(brain) {
1818 return Err(invalid_feed(
1819 "registry entry brain is not a canonical lowercase ULID",
1820 ));
1821 }
1822 let want_fp = body
1823 .get("identity")
1824 .and_then(|i| i.get("fingerprint"))
1825 .and_then(Value::as_str)
1826 .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
1827
1828 let home = home.trim_end_matches('/');
1829 let origin = normalized_origin(home)?;
1830 if origin != home {
1831 return Err(invalid_feed(
1832 "registry home must be an origin without a path, query, or fragment",
1833 ));
1834 }
1835 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
1836 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
1837 if let Some(binding) = &alias_binding {
1838 if binding
1839 .home
1840 .as_deref()
1841 .is_some_and(|pinned_home| pinned_home != home)
1842 {
1843 return Err(invalid_feed(
1844 "registry relocated a pinned handle to a different home",
1845 ));
1846 }
1847 }
1848 let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
1849 if card.get("id").and_then(Value::as_str) != Some(brain) {
1850 return Err(invalid_feed(
1851 "the home node served a card for a different brain",
1852 ));
1853 }
1854 let identity: FeedIdentity = serde_json::from_value(
1855 card.get("identity")
1856 .cloned()
1857 .ok_or_else(|| invalid_feed("the home node served no identity"))?,
1858 )
1859 .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
1860 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
1861 let got_fp = card
1862 .get("identity")
1863 .and_then(|i| i.get("fingerprint"))
1864 .and_then(Value::as_str)
1865 .unwrap_or_default();
1866 if got_fp != want_fp {
1867 return Err(invalid_feed(
1868 "the home node served an identity that does not match the registry — refusing",
1869 ));
1870 }
1871 let current = format!("ed25519:{}", identity.fingerprint);
1872 let advertised_seq = card
1873 .get("headSeq")
1874 .and_then(Value::as_u64)
1875 .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
1876 let advertised_hash = card.get("feedHash").and_then(Value::as_str);
1877 if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
1878 || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
1879 {
1880 return Err(invalid_feed(
1881 "the home node served an invalid feed head boundary",
1882 ));
1883 }
1884 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
1888 let registry_alias = AliasBinding {
1889 v: 1,
1890 origin: normalized_origin(&cfg.hub)?,
1891 requested: handle.to_string(),
1892 brain: brain.to_string(),
1893 home: Some(home.to_string()),
1894 };
1895 save_canonical_pin_and_alias(
1896 cfg,
1897 &trust_directory,
1898 handle,
1899 brain,
1900 TrustState {
1901 v: 2,
1902 origin: normalized_origin(&cfg.hub)?,
1903 requested: brain.to_string(),
1904 brain: brain.to_string(),
1905 home: None,
1906 anchor,
1907 current,
1908 head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
1909 feed_hash: pinned
1910 .as_ref()
1911 .and_then(|checkpoint| checkpoint.feed_hash.clone()),
1912 rotations: identity.rotations.clone(),
1913 hub_signer: None,
1914 protocol_profile: None,
1915 },
1916 Some(®istry_alias),
1917 )?;
1918 let mut out = card;
1919 if let Value::Object(map) = &mut out {
1920 map.insert("home".to_string(), Value::String(home.to_string()));
1921 map.insert(
1922 "resolvedVia".to_string(),
1923 Value::String("registry".to_string()),
1924 );
1925 }
1926 Ok(Some(out))
1927}
1928
1929pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
1930 require_safe_ref(&addr.brain)?;
1934 if let Some(target) = &addr.target {
1935 let (given, ok) = match target {
1936 AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
1937 AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
1938 };
1939 if !ok {
1940 return Err(LinkError::BadAddress {
1941 given: given.clone(),
1942 reason: BAD_TARGET_REASON.to_string(),
1943 });
1944 }
1945 }
1946
1947 if let Some(target) = &addr.target {
1953 let remote = verified_remote_head(cfg, &addr.brain, false)?;
1954 if !remote.head.verified {
1955 return Err(invalid_feed(
1956 "a path-scoped feed cannot prove a record against the full signed snapshot",
1957 ));
1958 }
1959 if remote.head.seq == 0 {
1960 return Err(LinkError::Http {
1961 what: "resolve",
1962 status: 404,
1963 message: "record not found".to_string(),
1964 code: Some("NOT_FOUND".to_string()),
1965 details: None,
1966 });
1967 }
1968 let brain = remote.head.brain.clone();
1969 let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
1970 return resolve_from_verified_pack(&brain, target, pack);
1971 }
1972
1973 let path = format!("/api/hub/brains/{}", addr.brain);
1974 let direct = request(cfg, "GET", &path, None, Auth::Required)?;
1979 if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
1980 if let Some(card) = resolve_registry(cfg, &addr.brain)? {
1981 return Ok(card);
1982 }
1983 }
1984 let resolved = ensure_ok(direct, "resolve")?;
1985 let remote = verified_remote_head(cfg, &addr.brain, false)?;
1989 if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
1990 || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
1991 || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
1992 {
1993 return Err(invalid_feed(
1994 "resolve card is not bound to the exact verified feed checkpoint",
1995 ));
1996 }
1997 let card_identity: FeedIdentity = serde_json::from_value(
1998 resolved
1999 .get("identity")
2000 .cloned()
2001 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2002 )
2003 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2004 if remote.identity.as_ref() != Some(&card_identity) {
2005 return Err(invalid_feed(
2006 "resolve card identity differs from the verified feed identity",
2007 ));
2008 }
2009 Ok(resolved)
2010}
2011
2012fn resolve_from_verified_pack(
2017 brain: &str,
2018 target: &AddressTarget,
2019 pack: Vec<u8>,
2020) -> LinkResult<Value> {
2021 let entries = parse_store_pack(pack)?;
2022 let mut matched: Option<(String, Vec<u8>)> = None;
2023
2024 for (path, bytes) in entries {
2025 let is_candidate = match target {
2026 AddressTarget::Path(want) => &path == want,
2027 AddressTarget::Id(_) => {
2028 path.ends_with(".md")
2029 && (path.starts_with("records/") || path.starts_with("sources/"))
2030 }
2031 };
2032 if !is_candidate {
2033 continue;
2034 }
2035 let text = std::str::from_utf8(&bytes)
2036 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2037 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2038 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2039 if let AddressTarget::Id(want) = target {
2040 let frontmatter =
2041 crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2042 .map_err(|_| {
2043 invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2044 })?;
2045 if frontmatter.id.as_deref() != Some(want) {
2046 continue;
2047 }
2048 }
2049 if matched.is_some() {
2050 return Err(invalid_feed(
2051 "signed snapshot contains more than one record for the requested target",
2052 ));
2053 }
2054 matched = Some((path, bytes));
2055 }
2056
2057 let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2058 what: "resolve",
2059 status: 404,
2060 message: "record not found".to_string(),
2061 code: Some("NOT_FOUND".to_string()),
2062 details: None,
2063 })?;
2064 let text = std::str::from_utf8(&bytes)
2065 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2066 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2067 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2068 let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2069 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2070 let Value::Object(fields) = frontmatter else {
2071 return Err(invalid_feed(format!(
2072 "signed snapshot record `{path}` frontmatter is not a mapping"
2073 )));
2074 };
2075 let mut document = serde_json::Map::new();
2076 document.insert("path".to_string(), Value::String(path));
2077 for (key, value) in fields {
2078 document.insert(key, value);
2079 }
2080 document.insert("body".to_string(), Value::String(parsed.body));
2081 document.insert(
2082 "contentSha".to_string(),
2083 Value::String(content_sha256(&bytes)),
2084 );
2085 Ok(json!({
2086 "brain": brain,
2087 "document": Value::Object(document),
2088 }))
2089}
2090
2091#[derive(Debug, serde::Serialize)]
2097pub struct PullReport {
2098 pub brain: String,
2100 pub slug: String,
2102 #[serde(rename = "headSeq")]
2104 pub head_seq: u64,
2105 pub files: usize,
2107 pub dest: String,
2109 #[serde(rename = "extraLocal")]
2112 pub extra_local: Vec<String>,
2113 #[serde(rename = "syncStatus")]
2115 pub sync_status: String,
2116}
2117
2118fn download_verified_snapshot_pack(
2119 cfg: &HubConfig,
2120 brain: &str,
2121 remote: &VerifiedRemote,
2122) -> LinkResult<Vec<u8>> {
2123 let feed_hash = remote
2124 .head
2125 .feed_hash
2126 .as_deref()
2127 .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2128 let signed_head = remote
2129 .head_entry
2130 .as_ref()
2131 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2132 let expected = &signed_head.entry.pack_sha256;
2133 if !is_sha256(expected) {
2134 return Err(invalid_feed(
2135 "signed head carries an invalid snapshot pack digest",
2136 ));
2137 }
2138 let path = format!(
2139 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2140 remote.head.seq
2141 );
2142 let body = ensure_ok(
2143 request(cfg, "GET", &path, None, Auth::Required)?,
2144 "sync pull",
2145 )?;
2146 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2147 || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2148 || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2149 || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2150 {
2151 return Err(invalid_feed(
2152 "export response is not bound to the exact verified snapshot",
2153 ));
2154 }
2155 let url = body
2156 .get("url")
2157 .and_then(Value::as_str)
2158 .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2159 let bytes = get_presigned(cfg, url)?;
2160 if content_sha256(&bytes) != *expected {
2161 return Err(LinkError::InvalidPack {
2162 message: "downloaded pack does not match the signed snapshot digest".to_string(),
2163 });
2164 }
2165 let entries = parse_store_pack(bytes.clone())?;
2166 if signed_head.entry.kind == "push" {
2167 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2168 }
2169 Ok(bytes)
2170}
2171
2172#[derive(Debug, Clone, Deserialize, Serialize)]
2173struct V2PointerBody {
2174 v: u8,
2175 brain: String,
2176 seq: u64,
2177 commit_hash: String,
2178 feed_hash: String,
2179 content_root: Option<String>,
2180 asset_root: Option<String>,
2181 materializer: String,
2182 signer_epoch: u64,
2183 control_revision: String,
2184 backup_preparation: String,
2185 prior_pointer_hash: Option<String>,
2186 signed_at: String,
2187}
2188
2189#[derive(Debug, Clone, Deserialize)]
2190struct V2SignedPointer {
2191 pointer: V2PointerBody,
2192 hub_public_key: String,
2193 hub_fingerprint: String,
2194 sig: String,
2195}
2196
2197#[derive(Debug, Clone, Deserialize)]
2198struct V2HeadIdentity {
2199 fingerprint: String,
2200 public_key_spki: String,
2201 #[serde(default)]
2202 previous: Vec<V2PreviousIdentity>,
2203 #[serde(default)]
2204 rotations: Vec<String>,
2205}
2206
2207#[derive(Debug, Clone, Deserialize)]
2208struct V2PreviousIdentity {
2209 fingerprint: String,
2210 public_key_spki: String,
2211}
2212
2213#[derive(Debug, Deserialize)]
2214struct V2HeadResponse {
2215 v: u8,
2216 brain_id: String,
2217 profile: String,
2218 view: Option<V2HeadView>,
2219 pointer: Option<V2SignedPointer>,
2220 identity: Option<V2HeadIdentity>,
2221}
2222
2223#[derive(Debug, Clone, Deserialize)]
2224struct V2HeadView {
2225 kind: String,
2226 control_revision: String,
2227}
2228
2229#[derive(Debug, Clone)]
2230struct V2VerifiedHead {
2231 requested: String,
2232 brain_id: String,
2233 view_kind: String,
2234 view_revision: String,
2235 pointer: Option<V2PointerBody>,
2236 trust: TrustState,
2237 alias: Option<AliasBinding>,
2238}
2239
2240fn verify_v2_spki_signature(
2241 public_key: &str,
2242 message: &[u8],
2243 signature: &str,
2244) -> LinkResult<Vec<u8>> {
2245 let der = URL_SAFE_NO_PAD
2246 .decode(public_key)
2247 .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2248 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2249 return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2250 }
2251 let sig = URL_SAFE_NO_PAD
2252 .decode(signature)
2253 .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2254 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2255 .verify(message, &sig)
2256 .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2257 Ok(der)
2258}
2259
2260fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2261 if pointer.pointer.v != 2
2262 || pointer.pointer.brain != expected_brain
2263 || pointer.pointer.seq == 0
2264 || !is_sha256(&pointer.pointer.commit_hash)
2265 || !is_sha256(&pointer.pointer.feed_hash)
2266 || pointer
2267 .pointer
2268 .content_root
2269 .as_deref()
2270 .is_some_and(|hash| !is_sha256(hash))
2271 || !is_sha256(&pointer.pointer.backup_preparation)
2272 {
2273 return Err(invalid_feed("v2 pointer fields are invalid"));
2274 }
2275 let value = serde_json::to_value(&pointer.pointer)
2276 .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2277 let message = crate::linkmd_v2::canonical_bytes(&value)
2278 .map_err(|error| invalid_feed(error.to_string()))?;
2279 let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2280 let fingerprint = format!("{:x}", Sha256::digest(&der));
2281 if fingerprint != pointer.hub_fingerprint {
2282 return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2283 }
2284 Ok(format!(
2285 "{}:{}",
2286 pointer.hub_fingerprint, pointer.hub_public_key
2287 ))
2288}
2289
2290fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2291 FeedIdentity {
2292 fingerprint: identity.fingerprint.clone(),
2293 public_key_spki: identity.public_key_spki.clone(),
2294 previous: identity
2295 .previous
2296 .iter()
2297 .map(|previous| PreviousIdentity {
2298 fingerprint: previous.fingerprint.clone(),
2299 public_key_spki: previous.public_key_spki.clone(),
2300 })
2301 .collect(),
2302 rotations: identity.rotations.clone(),
2303 }
2304}
2305
2306fn verified_v2_commit_object(
2307 raw: &[u8],
2308 identity: &V2HeadIdentity,
2309) -> LinkResult<serde_json::Map<String, Value>> {
2310 let mut value: Value =
2311 serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2312 let canonical = crate::linkmd_v2::canonical_bytes(&value)
2313 .map_err(|error| invalid_feed(error.to_string()))?;
2314 if canonical != raw {
2315 return Err(invalid_feed("v2 commit is not canonical JSON"));
2316 }
2317 let object = value
2318 .as_object_mut()
2319 .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
2320 let sig = object
2321 .remove("sig")
2322 .and_then(|value| value.as_str().map(str::to_string))
2323 .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
2324 let public_key = object
2325 .get("public_key")
2326 .and_then(Value::as_str)
2327 .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
2328 if public_key != identity.public_key_spki {
2329 return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
2330 }
2331 let der = URL_SAFE_NO_PAD
2332 .decode(public_key)
2333 .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
2334 let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
2335 if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str())
2336 || identity.fingerprint != expected_multikey.trim_start_matches("ed25519:")
2337 {
2338 return Err(invalid_feed("v2 commit brain identity mismatch"));
2339 }
2340 let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
2341 .map_err(|error| invalid_feed(error.to_string()))?;
2342 verify_v2_spki_signature(public_key, &unsigned, &sig)?;
2343 Ok(object.clone())
2344}
2345
2346#[derive(Debug, Deserialize)]
2347struct V2FeedWireEntry {
2348 seq: u64,
2349 commit_hash: String,
2350 feed_hash: String,
2351 bytes_base64: String,
2352}
2353
2354#[derive(Debug, Deserialize)]
2355struct V2FeedPage {
2356 v: u8,
2357 head_seq: u64,
2358 head_commit_hash: String,
2359 head_feed_hash: String,
2360 entries: Vec<V2FeedWireEntry>,
2361 next_after: u64,
2362 complete: bool,
2363}
2364
2365fn replay_v2_feed(
2366 cfg: &HubConfig,
2367 brain: &str,
2368 pointer: &V2PointerBody,
2369 identity: &V2HeadIdentity,
2370 start_after: u64,
2371 start_feed: Option<String>,
2372) -> LinkResult<()> {
2373 let mut after = start_after;
2374 let mut prior_feed = start_feed;
2375 let mut final_object = None;
2376 let mut replayed_entries = 0_u64;
2377 let mut replayed_bytes = 0_u64;
2378 while after < pointer.seq {
2379 let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
2380 let value = ensure_ok(
2381 request_capped(
2382 cfg,
2383 "GET",
2384 &path,
2385 None,
2386 Auth::Required,
2387 MAX_FEED_REPLAY_BYTES,
2388 )?,
2389 "v2 feed replay",
2390 )?;
2391 let page: V2FeedPage = serde_json::from_value(value)
2392 .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
2393 if page.v != 2
2394 || page.head_seq != pointer.seq
2395 || page.head_commit_hash != pointer.commit_hash
2396 || page.head_feed_hash != pointer.feed_hash
2397 || page.entries.is_empty()
2398 || page.entries.len() > FEED_PAGE_LIMIT
2399 {
2400 return Err(invalid_feed("v2 feed page differs from the signed head"));
2401 }
2402 for entry in page.entries {
2403 if entry.seq != after + 1
2404 || !is_sha256(&entry.commit_hash)
2405 || !is_sha256(&entry.feed_hash)
2406 {
2407 return Err(invalid_feed("v2 feed sequence is not contiguous"));
2408 }
2409 let raw = base64::engine::general_purpose::STANDARD
2410 .decode(&entry.bytes_base64)
2411 .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
2412 replayed_entries = replayed_entries
2413 .checked_add(1)
2414 .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
2415 replayed_bytes = replayed_bytes
2416 .checked_add(raw.len() as u64)
2417 .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
2418 if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
2419 {
2420 return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
2421 }
2422 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2423 .map_err(|error| invalid_feed(error.to_string()))?
2424 != entry.commit_hash
2425 || content_sha256(&raw) != entry.feed_hash
2426 {
2427 return Err(invalid_feed("v2 feed entry address mismatch"));
2428 }
2429 let object = verified_v2_commit_object(&raw, identity)?;
2430 if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
2431 || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
2432 {
2433 return Err(invalid_feed(
2434 "v2 feed entry does not extend its predecessor",
2435 ));
2436 }
2437 after = entry.seq;
2438 prior_feed = Some(entry.feed_hash);
2439 final_object = Some((entry.commit_hash, object));
2440 }
2441 if page.next_after != after || (page.complete != (after == pointer.seq)) {
2442 return Err(invalid_feed("v2 feed page cursor is inconsistent"));
2443 }
2444 }
2445 let (final_hash, object) =
2446 final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
2447 if final_hash != pointer.commit_hash
2448 || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
2449 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2450 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2451 || object.get("control_revision").and_then(Value::as_str)
2452 != Some(pointer.control_revision.as_str())
2453 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2454 {
2455 return Err(invalid_feed(
2456 "v2 replay did not converge on the signed pointer",
2457 ));
2458 }
2459 Ok(())
2460}
2461
2462fn verify_v2_commit(
2463 cfg: &HubConfig,
2464 brain: &str,
2465 pointer: &V2PointerBody,
2466 identity: &V2HeadIdentity,
2467 pinned: Option<&TrustState>,
2468) -> LinkResult<()> {
2469 let path = format!(
2470 "/api/hub/brains/{brain}/v2/commit?commit={}",
2471 pointer.commit_hash
2472 );
2473 let raw = ensure_raw_ok(
2474 request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
2475 "v2 commit",
2476 )?;
2477 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2478 .map_err(|error| invalid_feed(error.to_string()))?
2479 != pointer.commit_hash
2480 || content_sha256(&raw) != pointer.feed_hash
2481 {
2482 return Err(invalid_feed("v2 commit address differs from the pointer"));
2483 }
2484 let object = verified_v2_commit_object(&raw, identity)?;
2485 if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
2486 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2487 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2488 || object.get("control_revision").and_then(Value::as_str)
2489 != Some(pointer.control_revision.as_str())
2490 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2491 {
2492 return Err(invalid_feed("v2 commit fields differ from the pointer"));
2493 }
2494 if let Some(checkpoint) = pinned {
2495 if pointer.seq == checkpoint.head_seq + 1
2496 && object.get("prev_entry_hash").and_then(Value::as_str)
2497 != checkpoint.feed_hash.as_deref()
2498 {
2499 return Err(invalid_feed(
2500 "v2 commit does not extend the pinned feed hash",
2501 ));
2502 }
2503 if pointer.seq > checkpoint.head_seq + 1 {
2504 return replay_v2_feed(
2505 cfg,
2506 brain,
2507 pointer,
2508 identity,
2509 checkpoint.head_seq,
2510 checkpoint.feed_hash.clone(),
2511 );
2512 }
2513 } else if pointer.seq > 1 {
2514 return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
2515 }
2516 Ok(())
2517}
2518
2519fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
2520 require_hardened_filesystem("verified link.md v2 state")?;
2521 require_safe_ref(brain)?;
2522 let path = format!("/api/hub/brains/{brain}/v2/head");
2523 let response = request(cfg, "GET", &path, None, Auth::Required)?;
2524 if response.status == 404 {
2525 if has_accepted_v2_ref(cfg, brain)? {
2526 return Err(LinkError::BrainUnavailable);
2527 }
2528 return Ok(None);
2529 }
2530 let body = ensure_ok(response, "v2 head")?;
2531 let head: V2HeadResponse = serde_json::from_value(body)
2532 .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
2533 if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
2534 return Err(invalid_feed("v2 head has no canonical brain id"));
2535 }
2536 if crate::ulid::is_ulid(brain) && head.brain_id != brain {
2537 return Err(invalid_feed("v2 head resolved a different brain id"));
2538 }
2539 if head.profile == "v1" {
2540 return Ok(None);
2541 }
2542 if head.profile != "v2" && head.profile != "v2-empty" {
2543 return Err(invalid_feed("v2 head advertised an unknown profile"));
2544 }
2545 let view = head
2546 .view
2547 .as_ref()
2548 .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
2549 if !matches!(view.kind.as_str(), "full" | "scoped") || !is_sha256(&view.control_revision) {
2550 return Err(invalid_feed("v2 head has an invalid permission view"));
2551 }
2552 let view_kind = view.kind.clone();
2553 let view_revision = view.control_revision.clone();
2554 let identity = head
2555 .identity
2556 .as_ref()
2557 .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
2558 let trust_directory = open_trust_dir(cfg)?;
2559 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
2560 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
2561 let feed_identity = v2_identity(identity);
2562 let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
2563 let (seq, feed_hash, hub_signer) = match &head.pointer {
2564 None => {
2565 if head.profile != "v2-empty" {
2566 return Err(invalid_feed("initialized v2 head has no pointer"));
2567 }
2568 (
2569 0,
2570 None,
2571 pinned.as_ref().and_then(|state| state.hub_signer.clone()),
2572 )
2573 }
2574 Some(signed) => {
2575 let signer = verify_v2_pointer(signed, &head.brain_id)?;
2576 if pinned
2577 .as_ref()
2578 .and_then(|state| state.hub_signer.as_ref())
2579 .is_some_and(|known| known != &signer)
2580 {
2581 return Err(invalid_feed(
2582 "v2 hub pointer signer changed without a trust transition",
2583 ));
2584 }
2585 if let Some(checkpoint) = &pinned {
2586 if signed.pointer.seq < checkpoint.head_seq
2587 || (signed.pointer.seq == checkpoint.head_seq
2588 && checkpoint.feed_hash.as_deref()
2589 != Some(signed.pointer.feed_hash.as_str()))
2590 {
2591 return Err(invalid_feed("v2 pointer rolled back or equivocated"));
2592 }
2593 }
2594 verify_v2_commit(
2595 cfg,
2596 &head.brain_id,
2597 &signed.pointer,
2598 identity,
2599 pinned.as_ref(),
2600 )?;
2601 (
2602 signed.pointer.seq,
2603 Some(signed.pointer.feed_hash.clone()),
2604 Some(signer),
2605 )
2606 }
2607 };
2608 let trust = TrustState {
2609 v: 2,
2610 origin: normalized_origin(&cfg.hub)?,
2611 requested: head.brain_id.clone(),
2612 brain: head.brain_id.clone(),
2613 home: None,
2614 anchor,
2615 current: format!("ed25519:{}", identity.fingerprint),
2616 head_seq: seq,
2617 feed_hash,
2618 rotations: identity.rotations.clone(),
2619 hub_signer,
2620 protocol_profile: Some("link-v2".to_string()),
2621 };
2622 Ok(Some(V2VerifiedHead {
2623 requested: brain.to_string(),
2624 brain_id: head.brain_id,
2625 view_kind,
2626 view_revision,
2627 pointer: head.pointer.map(|signed| signed.pointer),
2628 trust,
2629 alias: alias_binding,
2630 }))
2631}
2632
2633fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
2634 let directory = open_trust_dir(cfg)?;
2635 let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
2636 let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
2637 if let Some(current) = current {
2638 if head.trust.head_seq < current.head_seq
2639 || (head.trust.head_seq == current.head_seq
2640 && head.trust.feed_hash != current.feed_hash)
2641 || head.trust.anchor != current.anchor
2642 || !head.trust.rotations.starts_with(¤t.rotations)
2643 || current
2644 .hub_signer
2645 .as_ref()
2646 .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
2647 {
2648 return Err(invalid_feed(
2649 "v2 head cannot advance the currently accepted trust checkpoint",
2650 ));
2651 }
2652 }
2653 save_canonical_pin_and_alias(
2654 cfg,
2655 &directory,
2656 &head.requested,
2657 &head.brain_id,
2658 head.trust.clone(),
2659 alias.as_ref().or(head.alias.as_ref()),
2660 )
2661}
2662
2663#[derive(Debug, Clone, Deserialize, Serialize)]
2664struct V2BaselineFile {
2665 sha256: String,
2666 bytes: u64,
2667 #[serde(skip)]
2668 proof: Option<Vec<V2ProofStep>>,
2669}
2670
2671#[derive(Debug, Clone, Deserialize, Serialize)]
2672struct V2SyncBaseline {
2673 v: u8,
2674 origin: String,
2675 brain: String,
2676 commit_hash: Option<String>,
2677 content_root: Option<String>,
2678 #[serde(default)]
2679 view_kind: Option<String>,
2680 #[serde(default)]
2681 view_revision: Option<String>,
2682 #[serde(default)]
2683 projection_sha256: Option<String>,
2684 files: std::collections::BTreeMap<String, V2BaselineFile>,
2685 #[serde(default)]
2686 local_policy_digest: Option<String>,
2687 #[serde(default)]
2688 local_eligibility: std::collections::BTreeMap<String, bool>,
2689 #[serde(default)]
2690 remote_copy_remains: std::collections::BTreeMap<String, String>,
2691}
2692
2693struct V2LocalView {
2694 riding: std::collections::BTreeMap<String, (String, Vec<u8>)>,
2695 eligibility: std::collections::BTreeMap<String, bool>,
2696 policy: crate::linkmd_sync_policy::SyncPolicy,
2697}
2698
2699#[derive(Debug, Clone, Deserialize, Serialize)]
2700struct V2ProofStep {
2701 directory_root: String,
2702 component: String,
2703 proof: crate::linkmd_v2::HamtProof,
2704}
2705
2706#[derive(Debug, Deserialize)]
2707struct V2ManifestFile {
2708 path: String,
2709 sha256: String,
2710 bytes: u64,
2711 proof: Vec<V2ProofStep>,
2712}
2713
2714#[derive(Debug, Deserialize)]
2715struct V2ManifestPage {
2716 v: u8,
2717 commit: String,
2718 content_root: Option<String>,
2719 files: Vec<V2ManifestFile>,
2720 next_cursor: Option<String>,
2721}
2722
2723fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
2724 let normalized = crate::linkmd_v2::normalize_path(&file.path)
2725 .map_err(|error| invalid_feed(error.to_string()))?;
2726 let components = normalized.split('/').collect::<Vec<_>>();
2727 if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
2728 return Err(invalid_feed("v2 file proof has the wrong shape"));
2729 }
2730 let mut directory_root = root.to_string();
2731 for (index, step) in file.proof.iter().enumerate() {
2732 if step.directory_root != directory_root || step.component != components[index] {
2733 return Err(invalid_feed(
2734 "v2 file proof path chain differs from its manifest",
2735 ));
2736 }
2737 if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
2738 .map_err(|error| invalid_feed(error.to_string()))?
2739 {
2740 return Err(invalid_feed("v2 file proof failed verification"));
2741 }
2742 let entry = match &step.proof {
2743 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
2744 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
2745 return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
2746 }
2747 };
2748 if index + 1 == components.len() {
2749 if entry.kind != crate::linkmd_v2::EntryKind::Blob
2750 || entry.child_hash != file.sha256
2751 || entry.bytes != Some(file.bytes)
2752 {
2753 return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
2754 }
2755 } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
2756 return Err(invalid_feed("v2 file proof traversed a non-directory"));
2757 } else {
2758 directory_root = entry.child_hash.clone();
2759 }
2760 }
2761 Ok(())
2762}
2763
2764fn v2_manifest(
2765 cfg: &HubConfig,
2766 brain: &str,
2767 pointer: Option<&V2PointerBody>,
2768) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
2769 let Some(pointer) = pointer else {
2770 return Ok(std::collections::BTreeMap::new());
2771 };
2772 let Some(root) = pointer.content_root.as_deref() else {
2773 return Ok(std::collections::BTreeMap::new());
2774 };
2775 let mut files = std::collections::BTreeMap::new();
2776 let mut after = String::new();
2777 loop {
2778 let encoded_after: String =
2779 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
2780 let path = format!(
2781 "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
2782 pointer.commit_hash
2783 );
2784 let value = ensure_ok(
2785 request_capped(
2786 cfg,
2787 "GET",
2788 &path,
2789 None,
2790 Auth::Required,
2791 MAX_FEED_RESPONSE_BYTES,
2792 )?,
2793 "v2 file manifest",
2794 )?;
2795 let page: V2ManifestPage = serde_json::from_value(value)
2796 .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
2797 if page.v != 2
2798 || page.commit != pointer.commit_hash
2799 || page.content_root.as_deref() != Some(root)
2800 || page.files.len() > 500
2801 {
2802 return Err(invalid_feed(
2803 "v2 file manifest is not bound to the verified head",
2804 ));
2805 }
2806 for file in page.files {
2807 verify_v2_file_proof(root, &file)?;
2808 if files
2809 .insert(
2810 file.path.clone(),
2811 V2BaselineFile {
2812 sha256: file.sha256,
2813 bytes: file.bytes,
2814 proof: Some(file.proof),
2815 },
2816 )
2817 .is_some()
2818 {
2819 return Err(invalid_feed("v2 file manifest repeats a path"));
2820 }
2821 if files.len() > MAX_PUSH_FILES {
2822 return Err(invalid_feed(
2823 "v2 file manifest exceeds the file-count bound",
2824 ));
2825 }
2826 }
2827 match page.next_cursor {
2828 None => break,
2829 Some(next) if next > after => after = next,
2830 Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
2831 }
2832 }
2833 Ok(files)
2834}
2835
2836fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
2837 let origin = normalized_origin(&cfg.hub)?;
2838 let absolute = if checkout.is_absolute() {
2839 checkout.to_path_buf()
2840 } else {
2841 std::env::current_dir()?.join(checkout)
2842 };
2843 Ok(format!(
2844 "sync-{}.json",
2845 content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
2846 ))
2847}
2848
2849#[cfg(unix)]
2850fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
2851 let directory = open_trust_dir(cfg)?;
2852 let origin = normalized_origin(&cfg.hub)?;
2853 let name = format!(
2854 "operation-{}.lock",
2855 content_sha256(format!("{origin}\0{brain}").as_bytes())
2856 );
2857 lock_trust_name(&directory, &name)
2858}
2859
2860#[cfg(not(unix))]
2861fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
2862 Err(LinkError::UnsupportedPlatform {
2863 operation: "serialized link.md v2 sync",
2864 })
2865}
2866
2867fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
2868 left.brain_id == right.brain_id
2869 && left.view_kind == right.view_kind
2870 && left.view_revision == right.view_revision
2871 && match (&left.pointer, &right.pointer) {
2872 (None, None) => true,
2873 (Some(left), Some(right)) => {
2874 left.seq == right.seq
2875 && left.commit_hash == right.commit_hash
2876 && left.content_root == right.content_root
2877 && left.feed_hash == right.feed_hash
2878 }
2879 _ => false,
2880 }
2881}
2882
2883fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
2884 format!(
2885 "---\ntype: db-md\nscope: company\nowner: link.md scoped view\n---\n\n# Scoped brain view\n\nThis DB.md is generated locally by dbmd. It is not the brain's canonical contract and is never uploaded.\n\nCanonical brain: @{brain}\n"
2886 )
2887 .into_bytes()
2888}
2889
2890fn scoped_projection_sha256(brain: &str) -> String {
2891 content_sha256(&scoped_projection_bytes(brain))
2892}
2893
2894#[derive(Deserialize)]
2895struct LocalScopedViewMarker {
2896 v: u8,
2897 kind: String,
2898 authoritative: bool,
2899 brain: String,
2900 projection_sha256: String,
2901}
2902
2903pub fn has_verified_local_scoped_view(store: &Store) -> bool {
2907 let marker = store
2908 .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
2909 .ok()
2910 .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
2911 let Some(marker) = marker else {
2912 return false;
2913 };
2914 if marker.v != 1
2915 || marker.kind != "link.md-scoped-view"
2916 || marker.authoritative
2917 || !crate::ulid::is_ulid(&marker.brain)
2918 || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
2919 {
2920 return false;
2921 }
2922 store
2923 .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
2924 .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
2925}
2926
2927fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
2928 let mut bytes = serde_json::to_vec_pretty(&json!({
2929 "v": 1,
2930 "kind": "link.md-scoped-view",
2931 "authoritative": false,
2932 "brain": head.brain_id,
2933 "view_revision": head.view_revision,
2934 "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
2935 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
2936 "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
2937 "visible_files": files,
2938 "projection_sha256": scoped_projection_sha256(&head.brain_id),
2939 }))
2940 .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
2941 bytes.push(b'\n');
2942 Ok(bytes)
2943}
2944
2945fn refresh_scoped_view_marker(
2946 store: &Store,
2947 head: &V2VerifiedHead,
2948 files: usize,
2949) -> LinkResult<()> {
2950 if head.view_kind == "scoped" {
2951 store.write_atomic(
2952 Path::new(".dbmd/view.json"),
2953 &scoped_view_metadata(head, files)?,
2954 )?;
2955 }
2956 Ok(())
2957}
2958
2959fn ensure_v2_view_compatible(
2960 head: &V2VerifiedHead,
2961 baseline: Option<&V2SyncBaseline>,
2962) -> LinkResult<()> {
2963 let Some(baseline) = baseline else {
2964 return Ok(());
2965 };
2966 match (
2967 baseline.view_kind.as_deref(),
2968 baseline.view_revision.as_deref(),
2969 ) {
2970 (None, None) if head.view_kind == "full" => Ok(()),
2971 (Some(kind), Some(revision))
2972 if kind == head.view_kind && revision == head.view_revision =>
2973 {
2974 Ok(())
2975 }
2976 _ => Err(LinkError::ScopedViewChanged),
2977 }
2978}
2979
2980fn remove_scoped_projection(
2981 head: &V2VerifiedHead,
2982 baseline: Option<&V2SyncBaseline>,
2983 view: &mut V2LocalView,
2984) -> LinkResult<()> {
2985 if head.view_kind != "scoped" {
2986 return Ok(());
2987 }
2988 let expected = scoped_projection_sha256(&head.brain_id);
2989 if baseline
2990 .and_then(|state| state.projection_sha256.as_deref())
2991 .is_some_and(|pinned| pinned != expected)
2992 {
2993 return Err(LinkError::ScopedViewChanged);
2994 }
2995 if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
2996 return Err(LinkError::ScopedProjectionModified);
2997 }
2998 view.riding.remove("DB.md");
2999 view.eligibility.remove("DB.md");
3000 Ok(())
3001}
3002
3003fn files_for_v2_view(
3004 head: &V2VerifiedHead,
3005 mut files: std::collections::BTreeMap<String, V2BaselineFile>,
3006) -> std::collections::BTreeMap<String, V2BaselineFile> {
3007 if head.view_kind == "scoped" {
3008 files.remove("DB.md");
3012 }
3013 files
3014}
3015
3016#[cfg(unix)]
3017fn load_v2_baseline(
3018 cfg: &HubConfig,
3019 brain: &str,
3020 checkout: &Path,
3021) -> LinkResult<Option<V2SyncBaseline>> {
3022 use std::os::fd::{AsRawFd as _, FromRawFd as _};
3023 let directory = open_trust_dir(cfg)?;
3024 let name_string = v2_baseline_name(cfg, brain, checkout)?;
3025 let _lock = lock_trust_name(&directory, &name_string)?;
3026 let name = c_name(name_string.as_bytes(), &name_string)?;
3027 let fd = unsafe {
3028 libc::openat(
3029 directory.as_raw_fd(),
3030 name.as_ptr(),
3031 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
3032 )
3033 };
3034 if fd < 0 {
3035 let error = std::io::Error::last_os_error();
3036 return if error.kind() == std::io::ErrorKind::NotFound {
3037 Ok(None)
3038 } else {
3039 Err(LinkError::UnsafePath { path: name_string })
3040 };
3041 }
3042 let file = unsafe { std::fs::File::from_raw_fd(fd) };
3043 let mut bytes = Vec::new();
3044 file.take(MAX_FEED_RESPONSE_BYTES + 1)
3045 .read_to_end(&mut bytes)?;
3046 if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
3047 return Err(invalid_feed("v2 sync baseline is oversized"));
3048 }
3049 let baseline: V2SyncBaseline =
3050 serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
3051 if baseline.v != 2
3052 || baseline.origin != normalized_origin(&cfg.hub)?
3053 || baseline.brain != brain
3054 || baseline
3055 .commit_hash
3056 .as_deref()
3057 .is_some_and(|hash| !is_sha256(hash))
3058 || baseline
3059 .content_root
3060 .as_deref()
3061 .is_some_and(|hash| !is_sha256(hash))
3062 || baseline
3063 .local_policy_digest
3064 .as_deref()
3065 .is_some_and(|hash| !is_sha256(hash))
3066 || baseline
3067 .view_kind
3068 .as_deref()
3069 .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
3070 || baseline
3071 .view_revision
3072 .as_deref()
3073 .is_some_and(|hash| !is_sha256(hash))
3074 || baseline
3075 .projection_sha256
3076 .as_deref()
3077 .is_some_and(|hash| !is_sha256(hash))
3078 || (baseline.view_kind.as_deref() == Some("scoped")
3079 && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
3080 || baseline.files.len() > MAX_PUSH_FILES
3081 || baseline.local_eligibility.len() > MAX_PUSH_FILES
3082 || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
3083 || baseline.files.iter().any(|(path, file)| {
3084 crate::linkmd_v2::normalize_path(path).is_err()
3085 || !is_sha256(&file.sha256)
3086 || file.bytes > MAX_STORE_BYTES
3087 })
3088 || baseline
3089 .local_eligibility
3090 .keys()
3091 .chain(baseline.remote_copy_remains.keys())
3092 .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
3093 || baseline
3094 .remote_copy_remains
3095 .values()
3096 .any(|hash| !is_sha256(hash))
3097 {
3098 return Err(invalid_feed("v2 sync baseline failed validation"));
3099 }
3100 Ok(Some(baseline))
3101}
3102
3103#[cfg(not(unix))]
3104fn load_v2_baseline(
3105 _cfg: &HubConfig,
3106 _brain: &str,
3107 _checkout: &Path,
3108) -> LinkResult<Option<V2SyncBaseline>> {
3109 Err(LinkError::UnsupportedPlatform {
3110 operation: "verified link.md v2 baseline",
3111 })
3112}
3113
3114#[cfg(unix)]
3115fn save_v2_baseline(
3116 cfg: &HubConfig,
3117 brain: &str,
3118 checkout: &Path,
3119 baseline: &V2SyncBaseline,
3120) -> LinkResult<()> {
3121 use std::os::fd::{AsRawFd as _, FromRawFd as _};
3122 let directory = open_trust_dir(cfg)?;
3123 let name_string = v2_baseline_name(cfg, brain, checkout)?;
3124 let _lock = lock_trust_name(&directory, &name_string)?;
3125 let name = c_name(name_string.as_bytes(), &name_string)?;
3126 let mut bytes = serde_json::to_vec(baseline)
3127 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
3128 bytes.push(b'\n');
3129 let temp_string = format!(
3130 ".{name_string}.tmp.{}-{}",
3131 std::process::id(),
3132 std::time::SystemTime::now()
3133 .duration_since(std::time::UNIX_EPOCH)
3134 .unwrap_or_default()
3135 .as_nanos()
3136 );
3137 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
3138 let fd = unsafe {
3139 libc::openat(
3140 directory.as_raw_fd(),
3141 temp.as_ptr(),
3142 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
3143 0o600,
3144 )
3145 };
3146 if fd < 0 {
3147 return Err(std::io::Error::last_os_error().into());
3148 }
3149 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
3150 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
3151 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
3152 return Err(error.into());
3153 }
3154 drop(file);
3155 if unsafe {
3156 libc::renameat(
3157 directory.as_raw_fd(),
3158 temp.as_ptr(),
3159 directory.as_raw_fd(),
3160 name.as_ptr(),
3161 )
3162 } != 0
3163 {
3164 let error = std::io::Error::last_os_error();
3165 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
3166 return Err(error.into());
3167 }
3168 directory.sync_all()?;
3169 Ok(())
3170}
3171
3172#[cfg(not(unix))]
3173fn save_v2_baseline(
3174 _cfg: &HubConfig,
3175 _brain: &str,
3176 _checkout: &Path,
3177 _baseline: &V2SyncBaseline,
3178) -> LinkResult<()> {
3179 Err(LinkError::UnsupportedPlatform {
3180 operation: "verified link.md v2 baseline",
3181 })
3182}
3183
3184fn v2_baseline_from_head(
3185 cfg: &HubConfig,
3186 head: &V2VerifiedHead,
3187 files: std::collections::BTreeMap<String, V2BaselineFile>,
3188 local: Option<&V2LocalView>,
3189) -> LinkResult<V2SyncBaseline> {
3190 let mut local_eligibility = local
3191 .map(|view| view.eligibility.clone())
3192 .unwrap_or_default();
3193 if let Some(view) = local {
3194 for path in files.keys() {
3195 local_eligibility
3196 .entry(path.clone())
3197 .or_insert_with(|| !view.policy.keeps_home(path));
3198 }
3199 }
3200 let remote_copy_remains = local_eligibility
3201 .iter()
3202 .filter(|(_, riding)| !**riding)
3203 .filter_map(|(path, _)| {
3204 files
3205 .get(path)
3206 .map(|file| (path.clone(), file.sha256.clone()))
3207 })
3208 .collect();
3209 Ok(V2SyncBaseline {
3210 v: 2,
3211 origin: normalized_origin(&cfg.hub)?,
3212 brain: head.brain_id.clone(),
3213 commit_hash: head
3214 .pointer
3215 .as_ref()
3216 .map(|pointer| pointer.commit_hash.clone()),
3217 content_root: head
3218 .pointer
3219 .as_ref()
3220 .and_then(|pointer| pointer.content_root.clone()),
3221 view_kind: Some(head.view_kind.clone()),
3222 view_revision: Some(head.view_revision.clone()),
3223 projection_sha256: (head.view_kind == "scoped")
3224 .then(|| scoped_projection_sha256(&head.brain_id)),
3225 files,
3226 local_policy_digest: local.map(|view| view.policy.digest.clone()),
3227 local_eligibility,
3228 remote_copy_remains,
3229 })
3230}
3231
3232fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
3233 let policy = crate::linkmd_sync_policy::load(store)
3234 .map_err(|message| LinkError::InvalidPack { message })?;
3235 let mut result = std::collections::BTreeMap::new();
3236 let mut eligibility = std::collections::BTreeMap::new();
3237 let mut total = 0_u64;
3238 let mut paths = vec![PathBuf::from("DB.md")];
3239 paths.extend(store.walk()?);
3240 for relative in paths {
3241 let path = relative.to_string_lossy().replace('\\', "/");
3242 if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
3244 continue;
3245 }
3246 crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
3247 path: error.to_string(),
3248 })?;
3249 let riding = !policy.keeps_home(&path);
3250 eligibility.insert(path.clone(), riding);
3251 if !riding {
3252 continue;
3253 }
3254 let remaining = MAX_STORE_BYTES.saturating_sub(total);
3255 let bytes = store.read_bounded(&relative, remaining)?;
3256 total = total
3257 .checked_add(bytes.len() as u64)
3258 .ok_or_else(|| LinkError::PushTooLarge {
3259 detail: "v2 local byte count overflow".to_string(),
3260 })?;
3261 if total > MAX_STORE_BYTES {
3262 return Err(LinkError::PushTooLarge {
3263 detail: format!("{total} uncompressed bytes"),
3264 });
3265 }
3266 if std::str::from_utf8(&bytes).is_err() {
3267 return Err(LinkError::NotUtf8 { path });
3268 }
3269 result.insert(path, (content_sha256(&bytes), bytes));
3270 }
3271 Ok(V2LocalView {
3272 riding: result,
3273 eligibility,
3274 policy,
3275 })
3276}
3277
3278#[derive(Debug, Deserialize)]
3279struct V2DownloadItem {
3280 path: String,
3281 sha256: String,
3282 bytes: u64,
3283 url: String,
3284 method: String,
3285}
3286
3287#[derive(Debug, Deserialize)]
3288struct V2DownloadWindow {
3289 v: u8,
3290 commit: String,
3291 downloads: Vec<V2DownloadItem>,
3292}
3293
3294fn prepare_v2_downloads(
3295 cfg: &HubConfig,
3296 brain: &str,
3297 pointer: &V2PointerBody,
3298 pending: &[(&String, &V2BaselineFile)],
3299) -> LinkResult<Vec<V2DownloadItem>> {
3300 let mut result = Vec::with_capacity(pending.len());
3301 for chunk in pending.chunks(128) {
3302 let claims = chunk
3303 .iter()
3304 .map(|(path, file)| {
3305 Ok(json!({
3306 "path": path,
3307 "sha256": file.sha256,
3308 "bytes": file.bytes,
3309 "proof": file.proof.as_ref().ok_or_else(|| {
3310 invalid_feed("v2 manifest omitted a download proof")
3311 })?,
3312 }))
3313 })
3314 .collect::<LinkResult<Vec<_>>>()?;
3315 let value = ensure_ok(
3316 request_capped(
3317 cfg,
3318 "POST",
3319 &format!("/api/hub/brains/{brain}/v2/downloads"),
3320 Some(&json!({
3321 "commit": pointer.commit_hash,
3322 "files": claims,
3323 })),
3324 Auth::Required,
3325 MAX_FEED_RESPONSE_BYTES,
3326 )?,
3327 "prepare v2 blob downloads",
3328 )?;
3329 let window: V2DownloadWindow = serde_json::from_value(value)
3330 .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
3331 if window.v != 2
3332 || window.commit != pointer.commit_hash
3333 || window.downloads.len() != chunk.len()
3334 {
3335 return Err(invalid_feed(
3336 "v2 download window is not bound to the requested files",
3337 ));
3338 }
3339 let mut by_path = window
3340 .downloads
3341 .into_iter()
3342 .map(|item| (item.path.clone(), item))
3343 .collect::<std::collections::BTreeMap<_, _>>();
3344 if by_path.len() != chunk.len() {
3345 return Err(invalid_feed("v2 download window repeats a path"));
3346 }
3347 for (path, file) in chunk {
3348 let item = by_path
3349 .remove(*path)
3350 .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
3351 if item.method != "GET"
3352 || item.sha256 != file.sha256
3353 || item.bytes != file.bytes
3354 || item.url.is_empty()
3355 {
3356 return Err(invalid_feed(
3357 "v2 download capability differs from its proven file",
3358 ));
3359 }
3360 result.push(item);
3361 }
3362 }
3363 Ok(result)
3364}
3365
3366fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
3367 let bytes = get_presigned(cfg, &item.url)?;
3368 if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
3369 return Err(invalid_feed("v2 blob differs from its proven path entry"));
3370 }
3371 Ok(bytes)
3372}
3373
3374fn download_v2_blobs(
3375 cfg: &HubConfig,
3376 brain: &str,
3377 pointer: &V2PointerBody,
3378 pending: Vec<(&String, &V2BaselineFile)>,
3379) -> LinkResult<Vec<(String, Vec<u8>)>> {
3380 if pending.is_empty() {
3381 return Ok(Vec::new());
3382 }
3383 let downloads = prepare_v2_downloads(cfg, brain, pointer, &pending)?;
3384 let next = std::sync::atomic::AtomicUsize::new(0);
3385 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
3386 let mut results = std::iter::repeat_with(|| None)
3387 .take(downloads.len())
3388 .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
3389 std::thread::scope(|scope| {
3390 let (sender, receiver) = std::sync::mpsc::channel();
3391 for _ in 0..worker_count {
3392 let sender = sender.clone();
3393 let downloads = &downloads;
3394 let next = &next;
3395 scope.spawn(move || loop {
3396 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3397 let Some(item) = downloads.get(index) else {
3398 break;
3399 };
3400 let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
3401 if sender.send((index, result)).is_err() {
3402 break;
3403 }
3404 });
3405 }
3406 drop(sender);
3407 for (index, result) in receiver {
3408 results[index] = Some(result);
3409 }
3410 });
3411 results
3412 .into_iter()
3413 .map(|result| {
3414 result.ok_or_else(|| LinkError::Transport {
3415 hub: cfg.hub.clone(),
3416 message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
3417 })?
3418 })
3419 .collect()
3420}
3421
3422fn v2_sync_pull(
3423 cfg: &HubConfig,
3424 requested_brain: &str,
3425 head: V2VerifiedHead,
3426 out: Option<&Path>,
3427) -> LinkResult<PullReport> {
3428 let dest = out
3429 .map(Path::to_path_buf)
3430 .unwrap_or_else(|| PathBuf::from(requested_brain));
3431 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
3432 let head = v2_verified_head(cfg, requested_brain)?
3433 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
3434 let remote = files_for_v2_view(
3435 &head,
3436 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
3437 );
3438 let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
3439 ensure_v2_view_compatible(&head, baseline.as_ref())?;
3440 let mut local_view = match Store::open_strict(&dest) {
3441 Ok(store) => Some(v2_local_files(&store)?),
3442 Err(_) => None,
3443 };
3444 if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
3445 return Err(LinkError::ScopedViewChanged);
3446 }
3447 if let Some(view) = local_view.as_mut() {
3448 remove_scoped_projection(&head, baseline.as_ref(), view)?;
3449 }
3450 let local = local_view
3451 .as_ref()
3452 .map(|view| &view.riding)
3453 .cloned()
3454 .unwrap_or_default();
3455 let kept_home = |path: &str| {
3456 local_view
3457 .as_ref()
3458 .is_some_and(|view| view.policy.keeps_home(path))
3459 };
3460 let base = baseline
3461 .as_ref()
3462 .map(|state| &state.files)
3463 .cloned()
3464 .unwrap_or_default();
3465 let all_paths = base
3466 .keys()
3467 .chain(remote.keys())
3468 .chain(local.keys())
3469 .cloned()
3470 .collect::<std::collections::BTreeSet<_>>();
3471 let mut conflicts = Vec::new();
3472 for path in &all_paths {
3473 if kept_home(path) {
3474 continue;
3475 }
3476 let base_hash = base.get(path).map(|file| file.sha256.as_str());
3477 let remote_hash = remote.get(path).map(|file| file.sha256.as_str());
3478 let local_hash = local.get(path).map(|file| file.0.as_str());
3479 if local_hash != base_hash && remote_hash != base_hash && local_hash != remote_hash {
3480 conflicts.push(path.clone());
3481 }
3482 }
3483 if !conflicts.is_empty() {
3484 conflicts.truncate(100);
3485 return Err(LinkError::Conflict { paths: conflicts });
3486 }
3487 let pointer = head.pointer.as_ref();
3488 let mut changed = match pointer {
3489 Some(pointer) => download_v2_blobs(
3490 cfg,
3491 &head.brain_id,
3492 pointer,
3493 remote
3494 .iter()
3495 .filter(|(path, file)| {
3496 !kept_home(path)
3497 && local.get(*path).map(|value| value.0.as_str())
3498 != Some(file.sha256.as_str())
3499 })
3500 .collect(),
3501 )?,
3502 None => Vec::new(),
3503 };
3504 let deleted = base
3505 .iter()
3506 .filter(|(path, file)| {
3507 !remote.contains_key(*path)
3508 && !kept_home(path)
3509 && local.get(*path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
3510 })
3511 .map(|(path, _)| path.clone())
3512 .collect::<Vec<_>>();
3513 let extra_local = local
3514 .keys()
3515 .filter(|path| !remote.contains_key(*path) && !deleted.contains(path))
3516 .cloned()
3517 .collect::<Vec<_>>();
3518 if head.view_kind == "scoped" {
3519 changed.push(("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)));
3520 changed.push((
3521 ".dbmd/view.json".to_string(),
3522 scoped_view_metadata(&head, remote.len())?,
3523 ));
3524 }
3525 #[cfg(unix)]
3526 install_pulled_delta(&dest, &changed, &deleted, true)?;
3527 let installed_store = Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
3528 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
3529 })?;
3530 let mut installed_local = v2_local_files(&installed_store)?;
3531 remove_scoped_projection(&head, baseline.as_ref(), &mut installed_local)?;
3532 let mut expected_local = local.clone();
3533 for (path, file) in &remote {
3534 if !kept_home(path) {
3535 expected_local.insert(path.clone(), (file.sha256.clone(), Vec::new()));
3536 }
3537 }
3538 for path in &deleted {
3539 expected_local.remove(path);
3540 }
3541 let local_dirty = installed_local.riding.iter().any(|(path, (hash, _))| {
3542 expected_local.get(path).map(|expected| &expected.0) != Some(hash)
3543 }) || expected_local.iter().any(|(path, (hash, _))| {
3544 installed_local.riding.get(path).map(|actual| &actual.0) != Some(hash)
3545 });
3546 let final_head = v2_verified_head(cfg, requested_brain)?
3547 .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
3548 if !same_v2_head(&head, &final_head) {
3549 return Err(LinkError::RemoteAdvancedDuringSync);
3550 }
3551 accept_v2_head(cfg, &final_head)?;
3552 save_v2_baseline(
3553 cfg,
3554 &head.brain_id,
3555 &dest,
3556 &v2_baseline_from_head(cfg, &head, remote.clone(), Some(&installed_local))?,
3557 )?;
3558 Ok(PullReport {
3559 brain: head.brain_id,
3560 slug: requested_brain.to_string(),
3561 head_seq: pointer.map_or(0, |value| value.seq),
3562 files: remote.len(),
3563 dest: dest.to_string_lossy().into_owned(),
3564 extra_local,
3565 sync_status: if local_dirty {
3566 "local_dirty_after_install".to_string()
3567 } else {
3568 "synced".to_string()
3569 },
3570 })
3571}
3572
3573fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
3574 match remote {
3575 Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
3576 None => json!({ "kind": "absent" }),
3577 }
3578}
3579
3580fn v2_riding_matches_remote(
3581 local: &std::collections::BTreeMap<String, (String, Vec<u8>)>,
3582 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
3583 keeps_home: impl Fn(&str) -> bool,
3584) -> bool {
3585 remote.iter().all(|(path, file)| {
3586 keeps_home(path)
3587 || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
3588 }) && local.iter().all(|(path, (hash, _))| {
3589 remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
3590 })
3591}
3592
3593fn v2_sync_push(
3594 cfg: &HubConfig,
3595 requested_brain: &str,
3596 store: &Store,
3597 head: V2VerifiedHead,
3598 resume_local_policy: bool,
3599) -> LinkResult<Value> {
3600 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
3601 let head = v2_verified_head(cfg, requested_brain)?
3602 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
3603 let remote = files_for_v2_view(
3604 &head,
3605 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
3606 );
3607 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
3608 ensure_v2_view_compatible(&head, baseline.as_ref())?;
3609 if head.view_kind == "scoped" && baseline.is_none() {
3610 return Err(LinkError::ScopedViewChanged);
3611 }
3612 let mut local_view = v2_local_files(store)?;
3613 remove_scoped_projection(&head, baseline.as_ref(), &mut local_view)?;
3614 let local = &local_view.riding;
3615 if let Some(previous) = baseline.as_ref() {
3616 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
3617 && !resume_local_policy
3618 {
3619 let mut newly_eligible = previous
3620 .local_eligibility
3621 .iter()
3622 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
3623 .map(|(path, _)| path.clone())
3624 .collect::<Vec<_>>();
3625 if !newly_eligible.is_empty() {
3626 newly_eligible.truncate(100);
3627 return Err(LinkError::LocalPolicyTransition {
3628 paths: newly_eligible,
3629 });
3630 }
3631 }
3632 }
3633 let base = match baseline {
3634 Some(ref state) => state.files.clone(),
3635 None if remote.is_empty() => std::collections::BTreeMap::new(),
3636 None => {
3637 let mut conflicts = remote
3638 .iter()
3639 .filter(|(path, file)| {
3640 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
3641 })
3642 .map(|(path, _)| path.clone())
3643 .collect::<Vec<_>>();
3644 if !conflicts.is_empty() {
3645 conflicts.truncate(100);
3646 return Err(LinkError::Conflict { paths: conflicts });
3647 }
3648 remote.clone()
3649 }
3650 };
3651 let all_paths = base
3652 .keys()
3653 .chain(remote.keys())
3654 .chain(local.keys())
3655 .cloned()
3656 .collect::<std::collections::BTreeSet<_>>();
3657 let mut conflicts = Vec::new();
3658 let mut operations = Vec::new();
3659 let mut blobs = Vec::new();
3660 let mut upload_bytes = std::collections::BTreeMap::<String, Vec<u8>>::new();
3661 for path in all_paths {
3662 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
3663 let remote_file = remote.get(&path);
3664 let remote_hash = remote_file.map(|file| file.sha256.as_str());
3665 let local_file = local.get(&path);
3666 let local_hash = local_file.map(|file| file.0.as_str());
3667 if local_hash == base_hash {
3668 continue;
3669 }
3670 if local_view.policy.keeps_home(&path) {
3671 continue;
3674 }
3675 if remote_hash != base_hash && local_hash != remote_hash {
3676 conflicts.push(path);
3677 continue;
3678 }
3679 match local_file {
3680 Some((sha256, bytes)) => {
3681 operations.push(json!({
3682 "op": "put",
3683 "path": path,
3684 "expected": v2_expected(remote_file),
3685 "blob": sha256,
3686 "bytes": bytes.len(),
3687 }));
3688 blobs.push(json!({
3689 "sha256": sha256,
3690 "bytes": bytes.len(),
3691 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
3692 }));
3693 upload_bytes.insert(sha256.clone(), bytes.clone());
3694 }
3695 None => {
3696 let Some(current) = remote_file else {
3697 continue;
3698 };
3699 operations.push(json!({
3700 "op": "delete",
3701 "path": path,
3702 "expected": { "kind": "blob", "hash": current.sha256 },
3703 }));
3704 }
3705 }
3706 }
3707 if !conflicts.is_empty() {
3708 conflicts.truncate(100);
3709 return Err(LinkError::Conflict { paths: conflicts });
3710 }
3711 if operations.is_empty() {
3712 let final_head = v2_verified_head(cfg, requested_brain)?
3713 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
3714 if !same_v2_head(&head, &final_head) {
3715 return Err(LinkError::RemoteAdvancedDuringSync);
3716 }
3717 let mut final_local = v2_local_files(store)?;
3718 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
3719 let local_changed = final_local.riding != local_view.riding;
3720 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
3721 final_local.policy.keeps_home(path)
3722 });
3723 let next = v2_baseline_from_head(cfg, &head, remote, Some(&final_local))?;
3724 let split_count = next.remote_copy_remains.len();
3725 accept_v2_head(cfg, &final_head)?;
3726 if !local_changed && !remote_ahead {
3727 refresh_scoped_view_marker(store, &head, next.files.len())?;
3728 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
3729 }
3730 return Ok(json!({
3731 "v": 2,
3732 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
3733 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
3734 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
3735 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
3736 "local_policy": {
3737 "remote_copy_remains": split_count,
3738 },
3739 }));
3740 }
3741 let includes_contract = operations
3742 .iter()
3743 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
3744 let rebase = if head.pointer.is_none() || includes_contract {
3745 "strict"
3746 } else {
3747 "disjoint"
3748 };
3749 let base_value = head.pointer.as_ref().map(|pointer| {
3750 json!({
3751 "seq": pointer.seq,
3752 "commit_hash": pointer.commit_hash,
3753 "content_root": pointer.content_root,
3754 })
3755 });
3756 let entropy = format!(
3757 "{}\0{}\0{}\0{}\0{}",
3758 normalized_origin(&cfg.hub)?,
3759 head.brain_id,
3760 std::process::id(),
3761 std::time::SystemTime::now()
3762 .duration_since(std::time::UNIX_EPOCH)
3763 .unwrap_or_default()
3764 .as_nanos(),
3765 serde_json::to_string(&operations).unwrap_or_default()
3766 );
3767 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
3768 let mut body = json!({
3769 "mutation_id": mutation_id,
3770 "base": base_value,
3771 "rebase": rebase,
3772 "reason": "dbmd sync",
3773 "operations": operations,
3774 "blobs": blobs,
3775 });
3776 let changed_bytes = upload_bytes.values().try_fold(0_usize, |total, bytes| {
3777 total
3778 .checked_add(bytes.len())
3779 .ok_or_else(|| LinkError::PushTooLarge {
3780 detail: "v2 changed-byte total overflow".to_string(),
3781 })
3782 })?;
3783 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
3784 let declarations = upload_bytes
3785 .iter()
3786 .map(|(sha256, bytes)| json!({ "sha256": sha256, "bytes": bytes.len() }))
3787 .collect::<Vec<_>>();
3788 let reserved = ensure_ok(
3789 request(
3790 cfg,
3791 "POST",
3792 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
3793 Some(&json!({ "blobs": declarations })),
3794 Auth::Required,
3795 )?,
3796 "prepare v2 changed-byte uploads",
3797 )?;
3798 let items = reserved
3799 .get("uploads")
3800 .and_then(Value::as_array)
3801 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
3802 if items.len() != upload_bytes.len() {
3803 return Err(invalid_feed(
3804 "v2 upload reservation response changed the requested set",
3805 ));
3806 }
3807 let mut references = Vec::with_capacity(items.len());
3808 let mut seen = std::collections::BTreeSet::new();
3809 for item in items {
3810 let sha256 = item
3811 .get("sha256")
3812 .and_then(Value::as_str)
3813 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
3814 let bytes = upload_bytes
3815 .get(sha256)
3816 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
3817 let declared_bytes = item
3818 .get("bytes")
3819 .and_then(Value::as_u64)
3820 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
3821 let reservation_id = item
3822 .get("reservation_id")
3823 .and_then(Value::as_str)
3824 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
3825 if declared_bytes != bytes.len() as u64
3826 || !crate::ulid::is_ulid(reservation_id)
3827 || !seen.insert(sha256.to_string())
3828 {
3829 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
3830 }
3831 match item.get("status").and_then(Value::as_str) {
3832 Some("upload") => {
3833 let url = item
3834 .get("url")
3835 .and_then(Value::as_str)
3836 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
3837 put_presigned(cfg, url, item.get("headers").unwrap_or(&Value::Null), bytes)?;
3838 }
3839 Some("already_present") => {}
3840 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
3841 }
3842 references.push(json!({
3843 "sha256": sha256,
3844 "bytes": bytes.len(),
3845 "reservation_id": reservation_id,
3846 }));
3847 }
3848 body["blobs"] = Value::Array(references);
3849 }
3850 if body.to_string().len() > MAX_PUSH_BYTES {
3851 return Err(LinkError::PushTooLarge {
3852 detail: "v2 operation metadata exceeds the bounded commit request".to_string(),
3853 });
3854 }
3855 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
3856 let mut result = ensure_ok(
3857 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
3858 "v2 sync push",
3859 )?;
3860 let refreshed = v2_verified_head(cfg, requested_brain)?
3861 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
3862 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
3863 if refreshed
3864 .pointer
3865 .as_ref()
3866 .map(|pointer| pointer.commit_hash.as_str())
3867 != accepted_hash
3868 {
3869 return Err(LinkError::RemoteAdvancedDuringSync);
3870 }
3871 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
3872 let refreshed_files = files_for_v2_view(
3873 &refreshed,
3874 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
3875 );
3876 let mut final_local = v2_local_files(store)?;
3877 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
3878 let local_dirty = final_local.riding != local_view.riding
3879 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
3880 final_local.policy.keeps_home(path)
3881 });
3882 let next = v2_baseline_from_head(cfg, &refreshed, refreshed_files, Some(&final_local))?;
3883 let split_count = next.remote_copy_remains.len();
3884 accept_v2_head(cfg, &refreshed)?;
3885 if !local_dirty {
3886 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
3887 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
3888 }
3889 if let Some(object) = result.as_object_mut() {
3890 object.insert(
3891 "local_policy".to_string(),
3892 json!({ "remote_copy_remains": split_count }),
3893 );
3894 object.insert(
3895 "sync_status".to_string(),
3896 Value::String(if local_dirty {
3897 "remote_committed_local_dirty".to_string()
3898 } else {
3899 "synced".to_string()
3900 }),
3901 );
3902 }
3903 Ok(result)
3904}
3905
3906pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
3909 sync_push_incremental_with_policy(cfg, brain, store, false)
3910}
3911
3912pub fn sync_push_incremental_with_policy(
3915 cfg: &HubConfig,
3916 brain: &str,
3917 store: &Store,
3918 resume_local_policy: bool,
3919) -> LinkResult<Value> {
3920 require_safe_ref(brain)?;
3921 if let Some(head) = v2_verified_head(cfg, brain)? {
3922 return v2_sync_push(cfg, brain, store, head, resume_local_policy);
3923 }
3924 if resume_local_policy {
3925 return Err(LinkError::InvalidPack {
3926 message: "--resume-local-policy requires a link.md v2 brain".to_string(),
3927 });
3928 }
3929 let files = collect_push_files(store)?;
3930 sync_push(cfg, brain, &files)
3931}
3932
3933pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
3939 require_hardened_filesystem("sync pull")?;
3940 require_safe_ref(brain)?;
3941 if let Some(head) = v2_verified_head(cfg, brain)? {
3942 return v2_sync_pull(cfg, brain, head, out);
3943 }
3944 let remote = verified_remote_head(cfg, brain, false)?;
3945 if !remote.head.verified {
3946 return Err(invalid_feed(
3947 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
3948 ));
3949 }
3950 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
3951 let path = format!(
3952 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
3953 remote.head.seq
3954 );
3955 let body = ensure_ok(
3956 request(cfg, "GET", &path, None, Auth::Required)?,
3957 "sync pull",
3958 )?;
3959 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
3960 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
3961 {
3962 return Err(invalid_feed(
3963 "export response is not bound to the verified snapshot token",
3964 ));
3965 }
3966
3967 let remote_slug = body
3968 .get("slug")
3969 .and_then(Value::as_str)
3970 .filter(|slug| is_safe_slug(slug));
3971 let slug = remote_slug
3972 .or_else(|| is_safe_slug(brain).then_some(brain))
3973 .unwrap_or("brain")
3974 .to_string();
3975 let brain_id = body
3976 .get("brain")
3977 .and_then(Value::as_str)
3978 .unwrap_or(&remote.head.brain)
3979 .to_string();
3980 if brain_id != remote.head.brain {
3981 return Err(invalid_feed(
3982 "export response names a different brain than the verified head",
3983 ));
3984 }
3985 let head_seq = remote.head.seq;
3986 let dest: PathBuf = match out {
3987 Some(p) => p.to_path_buf(),
3988 None => PathBuf::from(&slug),
3989 };
3990 let entries = if head_seq == 0 {
3991 let files = body
3992 .get("files")
3993 .and_then(Value::as_array)
3994 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
3995 if !files.is_empty() || body.get("url").is_some() {
3996 return Err(invalid_feed(
3997 "empty signed feed cannot authorize non-empty exported content",
3998 ));
3999 }
4000 Vec::new()
4001 } else {
4002 let signed_head = remote
4003 .head_entry
4004 .as_ref()
4005 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
4006 let expected = &signed_head.entry.pack_sha256;
4007 if !is_sha256(expected) {
4008 return Err(invalid_feed(
4009 "signed head carries an invalid snapshot pack digest",
4010 ));
4011 }
4012 if let Some(url) = body.get("url").and_then(Value::as_str) {
4013 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
4014 return Err(invalid_feed(
4015 "export pack digest does not match the signed head entry",
4016 ));
4017 }
4018 let bytes = get_presigned(cfg, url)?;
4019 let actual = format!("{:x}", Sha256::digest(&bytes));
4020 if actual != *expected {
4021 return Err(LinkError::InvalidPack {
4022 message: "downloaded pack does not match the signed snapshot digest"
4023 .to_string(),
4024 });
4025 }
4026 let entries = parse_store_pack(bytes)?;
4027 if signed_head.entry.kind == "push" {
4028 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
4029 }
4030 entries
4031 } else {
4032 if signed_head.entry.kind != "push" {
4033 return Err(invalid_feed(
4034 "delta snapshots must export the exact signed pack",
4035 ));
4036 }
4037 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
4038 invalid_feed("verified snapshot export carried neither a pack nor files")
4039 })?;
4040 let mut entries = Vec::with_capacity(files.len());
4041 for file in files {
4042 let path = file
4043 .get("path")
4044 .and_then(Value::as_str)
4045 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
4046 let content = file
4047 .get("content")
4048 .and_then(Value::as_str)
4049 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
4050 entries.push((path.to_string(), content.as_bytes().to_vec()));
4051 }
4052 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
4053 entries
4054 }
4055 };
4056
4057 let mut seen = std::collections::HashSet::new();
4059 for (path, _) in &entries {
4060 if !safe_store_rel_path(path) {
4061 return Err(LinkError::UnsafePath { path: path.clone() });
4062 }
4063 if !seen.insert(path) {
4064 return Err(LinkError::InvalidPack {
4065 message: format!("duplicate path `{path}`"),
4066 });
4067 }
4068 }
4069 let pulled: std::collections::BTreeSet<&str> =
4072 entries.iter().map(|(p, _)| p.as_str()).collect();
4073 let mut extra_local = Vec::new();
4074 if let Ok(store) = Store::open(&dest) {
4075 if let Ok(walked) = store.walk() {
4076 for rel in walked {
4077 let rel_str = rel.to_string_lossy().replace('\\', "/");
4078 if !pulled.contains(rel_str.as_str()) {
4079 extra_local.push(rel_str);
4080 }
4081 }
4082 }
4083 }
4084 #[cfg(unix)]
4085 install_pulled_snapshot(&dest, &entries)?;
4086
4087 Ok(PullReport {
4088 brain: brain_id,
4089 slug,
4090 head_seq,
4091 files: entries.len(),
4092 dest: dest.to_string_lossy().into_owned(),
4093 extra_local,
4094 sync_status: "synced".to_string(),
4095 })
4096}
4097
4098#[cfg(unix)]
4099fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
4100 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
4101 path: display.to_string(),
4102 })
4103}
4104
4105#[cfg(unix)]
4106fn open_dir_at(
4107 parent: std::os::fd::RawFd,
4108 name: &std::ffi::CStr,
4109 display: &str,
4110) -> LinkResult<std::fs::File> {
4111 use std::os::fd::FromRawFd as _;
4112 let fd = unsafe {
4113 libc::openat(
4114 parent,
4115 name.as_ptr(),
4116 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4117 )
4118 };
4119 if fd < 0 {
4120 return Err(LinkError::UnsafePath {
4121 path: display.to_string(),
4122 });
4123 }
4124 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
4125}
4126
4127#[cfg(unix)]
4131fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
4132 use std::os::fd::AsRawFd as _;
4133
4134 #[cfg(target_os = "macos")]
4138 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
4139 .into_iter()
4140 .find_map(|(alias, real)| {
4141 path.strip_prefix(alias)
4142 .ok()
4143 .map(|rest| Path::new(real).join(rest))
4144 })
4145 .unwrap_or_else(|| path.to_path_buf());
4146 #[cfg(not(target_os = "macos"))]
4147 let normalized = path.to_path_buf();
4148
4149 let start = if normalized.is_absolute() {
4150 std::fs::File::open("/")?
4151 } else {
4152 std::fs::File::open(".")?
4153 };
4154 let mut directory = start;
4155 for component in normalized.components() {
4156 use std::path::Component;
4157 let name = match component {
4158 Component::RootDir | Component::CurDir => continue,
4159 Component::Normal(name) => name,
4160 Component::ParentDir | Component::Prefix(_) => {
4161 return Err(LinkError::UnsafePath {
4162 path: path.display().to_string(),
4163 });
4164 }
4165 };
4166 use std::os::unix::ffi::OsStrExt as _;
4167 let name = c_name(name.as_bytes(), &path.display().to_string())?;
4168 if create {
4169 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
4170 if made != 0 {
4171 let error = std::io::Error::last_os_error();
4172 if error.raw_os_error() != Some(libc::EEXIST) {
4173 return Err(error.into());
4174 }
4175 }
4176 }
4177 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
4178 }
4179 Ok(directory)
4180}
4181
4182#[cfg(unix)]
4183fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
4184 open_dir_path_nofollow(path, true)
4185}
4186
4187#[cfg(unix)]
4188fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
4189 open_dir_path_nofollow(path, false)
4190}
4191
4192#[cfg(unix)]
4193fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
4194 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
4195 let result =
4196 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
4197 if result == 0 {
4198 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
4199 }
4200 let error = std::io::Error::last_os_error();
4201 if error.kind() == std::io::ErrorKind::NotFound {
4202 Ok(None)
4203 } else {
4204 Err(error.into())
4205 }
4206}
4207
4208#[cfg(unix)]
4209fn create_dir_exclusive_at(
4210 parent: std::os::fd::RawFd,
4211 name: &std::ffi::CStr,
4212 display: &str,
4213) -> LinkResult<std::fs::File> {
4214 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
4215 if made != 0 {
4216 return Err(LinkError::UnsafePath {
4217 path: display.to_string(),
4218 });
4219 }
4220 open_dir_at(parent, name, display)
4221}
4222
4223#[cfg(unix)]
4224fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
4225 use std::os::fd::AsRawFd as _;
4226
4227 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
4228 if duplicate < 0 {
4229 return Err(std::io::Error::last_os_error().into());
4230 }
4231 let stream = unsafe { libc::fdopendir(duplicate) };
4232 if stream.is_null() {
4233 let error = std::io::Error::last_os_error();
4234 unsafe {
4235 libc::close(duplicate);
4236 }
4237 return Err(error.into());
4238 }
4239 let mut names = Vec::new();
4240 loop {
4241 let entry = unsafe { libc::readdir(stream) };
4242 if entry.is_null() {
4243 break;
4244 }
4245 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
4246 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
4247 names.push(raw.to_owned());
4248 }
4249 }
4250 if unsafe { libc::closedir(stream) } != 0 {
4251 return Err(std::io::Error::last_os_error().into());
4252 }
4253 Ok(names)
4254}
4255
4256#[cfg(unix)]
4259fn remove_tree_at(
4260 parent: std::os::fd::RawFd,
4261 name: &std::ffi::CStr,
4262 display: &str,
4263) -> LinkResult<()> {
4264 use std::os::fd::AsRawFd as _;
4265
4266 match entry_is_dir_at(parent, name)? {
4267 None => return Ok(()),
4268 Some(false) => {
4269 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
4270 return Err(std::io::Error::last_os_error().into());
4271 }
4272 }
4273 Some(true) => {
4274 let directory = open_dir_at(parent, name, display)?;
4275 for child in directory_entry_names(&directory)? {
4276 let child_display =
4277 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
4278 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
4279 }
4280 drop(directory);
4281 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
4282 return Err(std::io::Error::last_os_error().into());
4283 }
4284 }
4285 }
4286 Ok(())
4287}
4288
4289#[cfg(unix)]
4293fn clone_tree_contents(
4294 source: &std::fs::File,
4295 destination: &std::fs::File,
4296 display: &str,
4297) -> LinkResult<()> {
4298 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4299
4300 for name in directory_entry_names(source)? {
4301 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
4302 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
4303 if unsafe {
4304 libc::fstatat(
4305 source.as_raw_fd(),
4306 name.as_ptr(),
4307 &mut stat,
4308 libc::AT_SYMLINK_NOFOLLOW,
4309 )
4310 } != 0
4311 {
4312 return Err(std::io::Error::last_os_error().into());
4313 }
4314 match stat.st_mode & libc::S_IFMT {
4315 libc::S_IFDIR => {
4316 if unsafe {
4317 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
4318 } != 0
4319 {
4320 return Err(std::io::Error::last_os_error().into());
4321 }
4322 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
4323 let destination_child =
4324 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
4325 clone_tree_contents(&source_child, &destination_child, &child_display)?;
4326 destination_child.sync_all()?;
4327 }
4328 libc::S_IFREG => {
4329 let source_fd = unsafe {
4330 libc::openat(
4331 source.as_raw_fd(),
4332 name.as_ptr(),
4333 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4334 )
4335 };
4336 if source_fd < 0 {
4337 return Err(std::io::Error::last_os_error().into());
4338 }
4339 let destination_fd = unsafe {
4340 libc::openat(
4341 destination.as_raw_fd(),
4342 name.as_ptr(),
4343 libc::O_WRONLY
4344 | libc::O_CREAT
4345 | libc::O_EXCL
4346 | libc::O_CLOEXEC
4347 | libc::O_NOFOLLOW,
4348 (stat.st_mode & 0o777) as libc::c_uint,
4349 )
4350 };
4351 if destination_fd < 0 {
4352 unsafe {
4353 libc::close(source_fd);
4354 }
4355 return Err(std::io::Error::last_os_error().into());
4356 }
4357 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
4358 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
4359 std::io::copy(&mut input, &mut output)?;
4360 output.sync_all()?;
4361 }
4362 libc::S_IFLNK => {
4363 let mut target = vec![0_u8; 4097];
4364 let length = unsafe {
4365 libc::readlinkat(
4366 source.as_raw_fd(),
4367 name.as_ptr(),
4368 target.as_mut_ptr().cast(),
4369 target.len(),
4370 )
4371 };
4372 if length < 0 || length as usize >= target.len() {
4373 return Err(LinkError::UnsafePath {
4374 path: child_display,
4375 });
4376 }
4377 target.truncate(length as usize);
4378 let target = c_name(&target, &child_display)?;
4379 if unsafe {
4380 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
4381 } != 0
4382 {
4383 return Err(std::io::Error::last_os_error().into());
4384 }
4385 }
4386 _ => {
4387 return Err(LinkError::UnsafePath {
4388 path: child_display,
4389 });
4390 }
4391 }
4392 }
4393 destination.sync_all()?;
4394 Ok(())
4395}
4396
4397#[cfg(target_os = "linux")]
4398fn install_stage_at(
4399 parent: std::os::fd::RawFd,
4400 stage: &std::ffi::CStr,
4401 dest: &std::ffi::CStr,
4402 dest_exists: bool,
4403) -> LinkResult<()> {
4404 let flags = if dest_exists {
4405 libc::RENAME_EXCHANGE
4406 } else {
4407 libc::RENAME_NOREPLACE
4408 };
4409 let result = unsafe {
4413 libc::syscall(
4414 libc::SYS_renameat2,
4415 parent,
4416 stage.as_ptr(),
4417 parent,
4418 dest.as_ptr(),
4419 flags,
4420 )
4421 };
4422 if result == 0 {
4423 Ok(())
4424 } else {
4425 Err(std::io::Error::last_os_error().into())
4426 }
4427}
4428
4429#[cfg(target_os = "macos")]
4430fn install_stage_at(
4431 parent: std::os::fd::RawFd,
4432 stage: &std::ffi::CStr,
4433 dest: &std::ffi::CStr,
4434 dest_exists: bool,
4435) -> LinkResult<()> {
4436 let flags = if dest_exists {
4437 libc::RENAME_SWAP
4438 } else {
4439 libc::RENAME_EXCL
4440 };
4441 let result =
4442 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
4443 if result == 0 {
4444 Ok(())
4445 } else {
4446 Err(std::io::Error::last_os_error().into())
4447 }
4448}
4449
4450#[cfg(unix)]
4451fn write_pull_entries_beneath_dir(
4452 root: &std::fs::File,
4453 entries: &[(String, Vec<u8>)],
4454) -> LinkResult<()> {
4455 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4456
4457 for (path, content) in entries {
4458 let components: Vec<&str> = path.split('/').collect();
4459 let (leaf, parents) = components
4460 .split_last()
4461 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
4462 let mut directory = root.try_clone()?;
4463 for component in parents {
4464 let name = c_name(component.as_bytes(), path)?;
4465 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
4466 if made != 0 {
4467 let error = std::io::Error::last_os_error();
4468 if error.raw_os_error() != Some(libc::EEXIST) {
4469 return Err(error.into());
4470 }
4471 }
4472 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
4473 }
4474
4475 let leaf_name = c_name(leaf.as_bytes(), path)?;
4476 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
4477 let inspected = unsafe {
4478 libc::fstatat(
4479 directory.as_raw_fd(),
4480 leaf_name.as_ptr(),
4481 &mut existing,
4482 libc::AT_SYMLINK_NOFOLLOW,
4483 )
4484 };
4485 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
4486 return Err(LinkError::UnsafePath { path: path.clone() });
4487 }
4488
4489 let nonce = std::time::SystemTime::now()
4490 .duration_since(std::time::UNIX_EPOCH)
4491 .unwrap_or_default()
4492 .as_nanos();
4493 let temp_name = format!(
4494 ".dbmd-pull-{}-{nonce}-{}",
4495 std::process::id(),
4496 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
4497 );
4498 let temp = c_name(temp_name.as_bytes(), path)?;
4499 let fd = unsafe {
4500 libc::openat(
4501 directory.as_raw_fd(),
4502 temp.as_ptr(),
4503 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4504 0o600,
4505 )
4506 };
4507 if fd < 0 {
4508 return Err(std::io::Error::last_os_error().into());
4509 }
4510 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
4511 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
4512 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4513 return Err(error.into());
4514 }
4515 drop(file);
4516 let renamed = unsafe {
4517 libc::renameat(
4518 directory.as_raw_fd(),
4519 temp.as_ptr(),
4520 directory.as_raw_fd(),
4521 leaf_name.as_ptr(),
4522 )
4523 };
4524 if renamed != 0 {
4525 let error = std::io::Error::last_os_error();
4526 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4527 return Err(error.into());
4528 }
4529 directory.sync_all()?;
4530 }
4531 root.sync_all()?;
4532 Ok(())
4533}
4534
4535#[cfg(unix)]
4536fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
4537 use std::os::fd::AsRawFd as _;
4538 for path in paths {
4539 if !safe_store_rel_path(path) {
4540 return Err(LinkError::UnsafePath { path: path.clone() });
4541 }
4542 let components = path.split('/').collect::<Vec<_>>();
4543 let Some((leaf, parents)) = components.split_last() else {
4544 return Err(LinkError::UnsafePath { path: path.clone() });
4545 };
4546 let mut directory = root.try_clone()?;
4547 let mut missing = false;
4548 for component in parents {
4549 let name = c_name(component.as_bytes(), path)?;
4550 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
4551 None => {
4552 missing = true;
4553 break;
4554 }
4555 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
4556 Some(true) => {
4557 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
4558 }
4559 }
4560 }
4561 if missing {
4562 continue;
4563 }
4564 let leaf = c_name(leaf.as_bytes(), path)?;
4565 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
4566 None => {}
4567 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
4568 Some(false) => {
4569 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
4570 return Err(std::io::Error::last_os_error().into());
4571 }
4572 directory.sync_all()?;
4573 }
4574 }
4575 }
4576 Ok(())
4577}
4578
4579#[cfg(unix)]
4580fn install_pulled_delta(
4581 dest: &Path,
4582 entries: &[(String, Vec<u8>)],
4583 deleted: &[String],
4584 rebuild_indexes: bool,
4585) -> LinkResult<()> {
4586 use ring::rand::SecureRandom as _;
4587 use std::os::fd::AsRawFd as _;
4588 use std::os::unix::ffi::OsStrExt as _;
4589
4590 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
4591 let name = dest
4592 .file_name()
4593 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
4594 .ok_or_else(|| LinkError::UnsafePath {
4595 path: dest.display().to_string(),
4596 })?;
4597 let parent_dir = open_or_create_dir_nofollow(parent)?;
4598 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
4599 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
4600 None => false,
4601 Some(true) => true,
4602 Some(false) => {
4603 return Err(LinkError::UnsafePath {
4604 path: dest.display().to_string(),
4605 });
4606 }
4607 };
4608
4609 let mut nonce = [0_u8; 16];
4610 ring::rand::SystemRandom::new()
4611 .fill(&mut nonce)
4612 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
4613 let stage_label = format!(
4614 ".{}.dbmd-pull-stage-{}",
4615 name.to_string_lossy(),
4616 URL_SAFE_NO_PAD.encode(nonce)
4617 );
4618 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
4619 let stage_dir = create_dir_exclusive_at(
4620 parent_dir.as_raw_fd(),
4621 &stage_name,
4622 &dest.display().to_string(),
4623 )?;
4624
4625 let prepared = (|| -> LinkResult<()> {
4626 if dest_exists {
4627 let live = open_dir_at(
4628 parent_dir.as_raw_fd(),
4629 &dest_name,
4630 &dest.display().to_string(),
4631 )?;
4632 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
4633 }
4634 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
4635 write_pull_entries_beneath_dir(&stage_dir, entries)?;
4636 if rebuild_indexes {
4637 let stage_store =
4638 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
4639 .map_err(|error| LinkError::InvalidPack {
4640 message: format!("v2 staging tree is not a valid db.md store: {error}"),
4641 })?;
4642 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
4643 LinkError::InvalidPack {
4644 message: format!("could not materialize v2 local catalogs: {error}"),
4645 }
4646 })?;
4647 }
4648 stage_dir.sync_all()?;
4649 Ok(())
4650 })();
4651 if let Err(error) = prepared {
4652 let _ = remove_tree_at(
4653 parent_dir.as_raw_fd(),
4654 &stage_name,
4655 &dest.display().to_string(),
4656 );
4657 return Err(error);
4658 }
4659
4660 if let Err(error) =
4661 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
4662 {
4663 let _ = remove_tree_at(
4664 parent_dir.as_raw_fd(),
4665 &stage_name,
4666 &dest.display().to_string(),
4667 );
4668 return Err(error);
4669 }
4670 parent_dir.sync_all()?;
4671 if dest_exists {
4672 let _ = remove_tree_at(
4676 parent_dir.as_raw_fd(),
4677 &stage_name,
4678 &dest.display().to_string(),
4679 );
4680 let _ = parent_dir.sync_all();
4681 }
4682 Ok(())
4683}
4684
4685#[cfg(unix)]
4686fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
4687 install_pulled_delta(dest, entries, &[], false)
4688}
4689
4690fn is_safe_slug(slug: &str) -> bool {
4691 !slug.is_empty()
4692 && slug.len() <= 63
4693 && !slug.starts_with('-')
4694 && !slug.ends_with('-')
4695 && slug
4696 .bytes()
4697 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
4698}
4699
4700fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
4701 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
4702}
4703
4704fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
4705 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
4706}
4707
4708fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
4709 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
4710}
4711
4712fn preflight_zip_central_directory(
4713 bytes: &[u8],
4714 offset: usize,
4715 size: usize,
4716 count: u64,
4717) -> LinkResult<()> {
4718 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
4719 let end = offset
4720 .checked_add(size)
4721 .filter(|end| *end <= bytes.len())
4722 .ok_or_else(|| LinkError::InvalidPack {
4723 message: "ZIP central directory is out of bounds".to_string(),
4724 })?;
4725 let mut cursor = offset;
4726 for _ in 0..count {
4727 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
4728 return Err(LinkError::InvalidPack {
4729 message: "ZIP central directory entry count is inconsistent".to_string(),
4730 });
4731 }
4732 if le_u16(bytes, cursor + 34) != Some(0) {
4733 return Err(LinkError::InvalidPack {
4734 message: "multi-disk ZIP archives are not supported".to_string(),
4735 });
4736 }
4737 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
4738 total.checked_add(le_u16(bytes, cursor + at)? as usize)
4739 });
4740 cursor = cursor
4741 .checked_add(46)
4742 .and_then(|fixed| fixed.checked_add(variable?))
4743 .filter(|cursor| *cursor <= end)
4744 .ok_or_else(|| LinkError::InvalidPack {
4745 message: "ZIP central directory entry is truncated".to_string(),
4746 })?;
4747 }
4748 if cursor != end {
4749 return Err(LinkError::InvalidPack {
4750 message: "ZIP central directory size is inconsistent".to_string(),
4751 });
4752 }
4753 Ok(())
4754}
4755
4756fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
4760 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
4761 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
4762 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
4763 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
4764 let eocd = bytes[search_start..]
4765 .windows(4)
4766 .rposition(|window| window == EOCD_SIG)
4767 .map(|offset| search_start + offset)
4768 .ok_or_else(|| LinkError::InvalidPack {
4769 message: "ZIP has no end-of-central-directory record".to_string(),
4770 })?;
4771 let invalid_end = || LinkError::InvalidPack {
4772 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
4773 };
4774 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
4775 if eocd
4776 .checked_add(22)
4777 .and_then(|end| end.checked_add(comment_len))
4778 != Some(bytes.len())
4779 {
4780 return Err(invalid_end());
4784 }
4785 let disk = le_u16(bytes, eocd + 4);
4786 let central_disk = le_u16(bytes, eocd + 6);
4787 if disk != Some(0) || central_disk != Some(0) {
4788 return Err(LinkError::InvalidPack {
4789 message: "multi-disk ZIP archives are not supported".to_string(),
4790 });
4791 }
4792 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
4793 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
4794 if entries_on_disk != ordinary {
4795 return Err(LinkError::InvalidPack {
4796 message: "multi-disk ZIP archives are not supported".to_string(),
4797 });
4798 }
4799 let zip64_locator = eocd
4800 .checked_sub(20)
4801 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
4802 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
4803 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
4804 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
4805 if central_offset
4806 .checked_add(central_size)
4807 .filter(|end| *end == eocd)
4808 .is_none()
4809 {
4810 return Err(invalid_end());
4811 }
4812 (ordinary as u64, central_offset, central_size)
4813 } else {
4814 let Some(locator) = zip64_locator else {
4815 return Err(invalid_end());
4816 };
4817 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
4818 return Err(LinkError::InvalidPack {
4819 message: "multi-disk ZIP64 archives are not supported".to_string(),
4820 });
4821 }
4822 let record = le_u64(bytes, locator + 8)
4823 .and_then(|offset| usize::try_from(offset).ok())
4824 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
4825 .ok_or_else(|| LinkError::InvalidPack {
4826 message: "ZIP64 archive has an invalid end record".to_string(),
4827 })?;
4828 let record_size = le_u64(bytes, record + 4)
4829 .and_then(|size| usize::try_from(size).ok())
4830 .filter(|size| *size >= 44)
4831 .ok_or_else(invalid_end)?;
4832 if record
4833 .checked_add(12)
4834 .and_then(|end| end.checked_add(record_size))
4835 != Some(locator)
4836 || le_u32(bytes, record + 16) != Some(0)
4837 || le_u32(bytes, record + 20) != Some(0)
4838 {
4839 return Err(invalid_end());
4840 }
4841 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
4842 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
4843 let central_size = le_u64(bytes, record + 40)
4844 .and_then(|size| usize::try_from(size).ok())
4845 .ok_or_else(invalid_end)?;
4846 let central_offset = le_u64(bytes, record + 48)
4847 .and_then(|offset| usize::try_from(offset).ok())
4848 .ok_or_else(invalid_end)?;
4849 if zip64_on_disk != zip64_total
4850 || central_offset
4851 .checked_add(central_size)
4852 .filter(|end| *end == record)
4853 .is_none()
4854 {
4855 return Err(invalid_end());
4856 }
4857 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
4858 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
4859 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
4860 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
4861 {
4862 return Err(invalid_end());
4863 }
4864 (zip64_total, central_offset, central_size)
4865 };
4866 if count == 0 || count > max_entries as u64 {
4867 return Err(LinkError::InvalidPack {
4868 message: format!("invalid file count {count}"),
4869 });
4870 }
4871 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
4872 Ok(())
4873}
4874
4875fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
4876 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
4877 let mut archive =
4878 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
4879 message: format!("ZIP parse failed: {err}"),
4880 })?;
4881 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
4882 return Err(LinkError::InvalidPack {
4883 message: format!("invalid file count {}", archive.len()),
4884 });
4885 }
4886 let mut total = 0u64;
4887 let mut seen = std::collections::HashSet::new();
4888 let mut entries = Vec::with_capacity(archive.len());
4889 for index in 0..archive.len() {
4890 let mut file = archive
4891 .by_index(index)
4892 .map_err(|err| LinkError::InvalidPack {
4893 message: format!("ZIP entry failed: {err}"),
4894 })?;
4895 if file.is_dir() {
4896 continue;
4897 }
4898 let path = file.name().to_string();
4899 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
4900 return Err(LinkError::UnsafePath { path });
4901 }
4902 if file
4903 .unix_mode()
4904 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
4905 {
4906 return Err(LinkError::InvalidPack {
4907 message: format!("non-file entry `{path}`"),
4908 });
4909 }
4910 if !seen.insert(path.clone()) {
4911 return Err(LinkError::InvalidPack {
4912 message: format!("duplicate path `{path}`"),
4913 });
4914 }
4915 let remaining = MAX_STORE_BYTES.saturating_sub(total);
4916 if file.size() > remaining {
4917 return Err(LinkError::InvalidPack {
4918 message: "expanded content exceeds the 512 MB limit".to_string(),
4919 });
4920 }
4921 let mut content = Vec::new();
4922 (&mut file)
4923 .take(remaining + 1)
4924 .read_to_end(&mut content)
4925 .map_err(|err| LinkError::InvalidPack {
4926 message: format!("could not decompress `{path}`: {err}"),
4927 })?;
4928 if content.len() as u64 > remaining {
4929 return Err(LinkError::InvalidPack {
4930 message: "expanded content exceeds the 512 MB limit".to_string(),
4931 });
4932 }
4933 if content.len() as u64 != file.size() {
4934 return Err(LinkError::InvalidPack {
4935 message: format!("length mismatch for `{path}`"),
4936 });
4937 }
4938 total += content.len() as u64;
4939 entries.push((path, content));
4940 }
4941 if entries.is_empty() {
4942 return Err(LinkError::InvalidPack {
4943 message: "pack contains no files".to_string(),
4944 });
4945 }
4946 Ok(entries)
4947}
4948
4949fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
4950 let mut expected = std::collections::BTreeMap::new();
4951 for file in signed {
4952 if !safe_store_rel_path(&file.path) {
4953 return Err(LinkError::UnsafePath {
4954 path: file.path.clone(),
4955 });
4956 }
4957 if !is_sha256(&file.sha256)
4958 || expected
4959 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
4960 .is_some()
4961 {
4962 return Err(invalid_feed(
4963 "signed snapshot manifest contains an invalid or duplicate file",
4964 ));
4965 }
4966 }
4967 if expected.len() != entries.len() {
4968 return Err(invalid_feed(
4969 "downloaded pack file set differs from the signed snapshot manifest",
4970 ));
4971 }
4972 for (path, bytes) in entries {
4973 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
4974 return Err(invalid_feed(format!(
4975 "downloaded pack contains unsigned path `{path}`"
4976 )));
4977 };
4978 if *declared_bytes != bytes.len() as u64
4979 || *sha256 != format!("{:x}", Sha256::digest(bytes))
4980 {
4981 return Err(invalid_feed(format!(
4982 "downloaded file `{path}` differs from its signed manifest"
4983 )));
4984 }
4985 }
4986 Ok(())
4987}
4988
4989pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
4996 require_hardened_filesystem("sync push")?;
4997 preflight_push_ownership(store)?;
4998 let mut out: Vec<(String, String)> = Vec::new();
4999 let mut total = 0u64;
5000
5001 let mut read_text = |rel: &str| -> LinkResult<String> {
5002 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
5003 total = total
5004 .checked_add(bytes.len() as u64)
5005 .ok_or_else(|| LinkError::PushTooLarge {
5006 detail: "uncompressed byte count overflow".to_string(),
5007 })?;
5008 if total > MAX_STORE_BYTES {
5009 return Err(LinkError::PushTooLarge {
5010 detail: format!("{total} uncompressed bytes"),
5011 });
5012 }
5013 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
5014 path: rel.to_string(),
5015 })
5016 };
5017
5018 out.push(("DB.md".to_string(), read_text("DB.md")?));
5019 if store
5020 .regular_file_exists(Path::new("assets.jsonl"))
5021 .unwrap_or(false)
5022 {
5023 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
5024 }
5025
5026 for rel in store.walk()? {
5027 let rel_str = rel.to_string_lossy().replace('\\', "/");
5028 if !safe_store_rel_path(&rel_str) {
5029 return Err(LinkError::UnsafePath { path: rel_str });
5032 }
5033 let content = read_text(&rel_str)?;
5034 out.push((rel_str, content));
5035 }
5036
5037 out.sort_by(|a, b| a.0.cmp(&b.0));
5038 Ok(out)
5039}
5040
5041fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
5045 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
5046 return Err(LinkError::from(std::io::Error::new(
5047 std::io::ErrorKind::PermissionDenied,
5048 format!("cannot push: nested db.md store at {}", nested.display()),
5049 )));
5050 }
5051
5052 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
5053 return Err(LinkError::from(std::io::Error::new(
5054 std::io::ErrorKind::PermissionDenied,
5055 format!(
5056 "cannot push: {} is a symlink outside the store ownership model",
5057 symlink.display()
5058 ),
5059 )));
5060 }
5061 Ok(())
5062}
5063
5064pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
5070 require_safe_ref(brain)?;
5071 let remote = verified_remote_head(cfg, brain, false)?;
5072 if files.len() > MAX_PUSH_FILES {
5073 return Err(LinkError::PushTooLarge {
5074 detail: format!("{} files", files.len()),
5075 });
5076 }
5077 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
5078 if raw_total > MAX_STORE_BYTES {
5079 return Err(LinkError::PushTooLarge {
5080 detail: format!("{raw_total} uncompressed bytes"),
5081 });
5082 }
5083
5084 if cfg.brain_key.is_none() {
5088 let body = json!({
5089 "files": files
5090 .iter()
5091 .map(|(p, c)| json!({ "path": p, "content": c }))
5092 .collect::<Vec<_>>(),
5093 });
5094 if body.to_string().len() <= MAX_PUSH_BYTES {
5095 let path = format!("/api/hub/brains/{brain}/push");
5096 let pushed = ensure_ok(
5097 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
5098 "sync push",
5099 )?;
5100 return Ok(pushed);
5101 }
5102 }
5103
5104 let pack = build_store_pack(files)?;
5105 if pack.len() as u64 > MAX_PACK_BYTES {
5106 return Err(LinkError::PushTooLarge {
5107 detail: format!("{} pack bytes", pack.len()),
5108 });
5109 }
5110 let sha256 = format!("{:x}", Sha256::digest(&pack));
5111 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
5112 if let Some(key) = &cfg.brain_key {
5113 if !remote.head.verified {
5114 return Err(invalid_feed(
5115 "self-custody push requires a fully verified, unscoped feed head",
5116 ));
5117 }
5118 let identity = remote
5119 .identity
5120 .as_ref()
5121 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
5122 let current_multikey = format!("ed25519:{}", identity.fingerprint);
5123 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
5124 return Err(invalid_feed(
5125 "configured brain key is not the verified current brain identity",
5126 ));
5127 }
5128 let next_seq = remote
5131 .head
5132 .seq
5133 .checked_add(1)
5134 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
5135 let mut manifest: Vec<WireFeedFile> = files
5136 .iter()
5137 .map(|(path, content)| WireFeedFile {
5138 path: path.clone(),
5139 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
5140 bytes: content.len() as u64,
5141 })
5142 .collect();
5143 manifest.sort_by(|a, b| a.path.cmp(&b.path));
5144 let ts = crate::now()
5145 .with_timezone(&chrono::Utc)
5146 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
5147 .to_string();
5148 let entry = self_custody_entry(
5149 key,
5150 next_seq,
5151 ts,
5152 &sha256,
5153 &manifest,
5154 remote.head.feed_hash.as_deref(),
5155 )?;
5156 meta["entry"] = Value::String(entry);
5157 }
5158 let presigned = ensure_ok(
5159 request(
5160 cfg,
5161 "POST",
5162 &format!("/api/hub/brains/{brain}/packs/presign"),
5163 Some(&meta),
5164 Auth::Required,
5165 )?,
5166 "prepare pack upload",
5167 )?;
5168 let url = presigned
5169 .get("url")
5170 .and_then(Value::as_str)
5171 .ok_or_else(|| LinkError::InvalidPack {
5172 message: "the hub returned no upload URL".to_string(),
5173 })?;
5174 put_presigned(
5175 cfg,
5176 url,
5177 presigned.get("headers").unwrap_or(&Value::Null),
5178 &pack,
5179 )?;
5180 let committed = ensure_ok(
5181 request(
5182 cfg,
5183 "POST",
5184 &format!("/api/hub/brains/{brain}/packs/commit"),
5185 Some(&meta),
5186 Auth::Required,
5187 )?,
5188 "commit pack",
5189 )?;
5190 Ok(committed)
5191}
5192
5193fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
5194 const LOCAL_HEADER: u32 = 0x0403_4b50;
5195 const CENTRAL_HEADER: u32 = 0x0201_4b50;
5196 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
5197 const VERSION_20: u16 = 20;
5198 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
5199 const UTF8_FLAG: u16 = 1 << 11;
5200 const STORED: u16 = 0;
5201 const DOS_TIME_MIDNIGHT: u16 = 0;
5202 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
5203 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
5204
5205 struct CentralEntry<'a> {
5206 name: &'a [u8],
5207 crc32: u32,
5208 size: u32,
5209 local_offset: u32,
5210 }
5211
5212 fn push_u16(out: &mut Vec<u8>, value: u16) {
5213 out.extend_from_slice(&value.to_le_bytes());
5214 }
5215
5216 fn push_u32(out: &mut Vec<u8>, value: u32) {
5217 out.extend_from_slice(&value.to_le_bytes());
5218 }
5219
5220 if files.is_empty() {
5221 return Err(LinkError::InvalidPack {
5222 message: "cannot create an empty snapshot pack".to_string(),
5223 });
5224 }
5225 if files.len() > u16::MAX as usize {
5226 return Err(LinkError::PushTooLarge {
5227 detail: format!(
5228 "{} files (canonical ZIP32 packs cap at {})",
5229 files.len(),
5230 u16::MAX
5231 ),
5232 });
5233 }
5234
5235 let mut sorted: Vec<_> = files.iter().collect();
5236 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
5237 let mut previous: Option<&str> = None;
5238 for (path, content) in &sorted {
5239 if !safe_store_rel_path(path) {
5240 return Err(LinkError::UnsafePath {
5241 path: (*path).clone(),
5242 });
5243 }
5244 if previous == Some(path.as_str()) {
5245 return Err(LinkError::InvalidPack {
5246 message: format!("duplicate path `{path}`"),
5247 });
5248 }
5249 previous = Some(path.as_str());
5250 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
5251 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
5252 })?;
5253 }
5254
5255 let mut out = Vec::new();
5256 let mut central = Vec::with_capacity(sorted.len());
5257 for (path, content) in sorted {
5258 let name = path.as_bytes();
5259 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
5260 message: format!("ZIP entry name is too long: `{path}`"),
5261 })?;
5262 let bytes = content.as_bytes();
5263 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
5264 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
5265 })?;
5266 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
5267 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
5268 })?;
5269 let crc32 = crc32fast::hash(bytes);
5270
5271 push_u32(&mut out, LOCAL_HEADER);
5274 push_u16(&mut out, VERSION_20);
5275 push_u16(&mut out, UTF8_FLAG);
5276 push_u16(&mut out, STORED);
5277 push_u16(&mut out, DOS_TIME_MIDNIGHT);
5278 push_u16(&mut out, DOS_DATE_1980_01_01);
5279 push_u32(&mut out, crc32);
5280 push_u32(&mut out, size);
5281 push_u32(&mut out, size);
5282 push_u16(&mut out, name_len);
5283 push_u16(&mut out, 0); out.extend_from_slice(name);
5285 out.extend_from_slice(bytes);
5286
5287 central.push(CentralEntry {
5288 name,
5289 crc32,
5290 size,
5291 local_offset,
5292 });
5293 }
5294
5295 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
5296 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
5297 })?;
5298 for entry in ¢ral {
5299 push_u32(&mut out, CENTRAL_HEADER);
5300 push_u16(&mut out, MADE_BY_UNIX_20);
5301 push_u16(&mut out, VERSION_20);
5302 push_u16(&mut out, UTF8_FLAG);
5303 push_u16(&mut out, STORED);
5304 push_u16(&mut out, DOS_TIME_MIDNIGHT);
5305 push_u16(&mut out, DOS_DATE_1980_01_01);
5306 push_u32(&mut out, entry.crc32);
5307 push_u32(&mut out, entry.size);
5308 push_u32(&mut out, entry.size);
5309 push_u16(&mut out, entry.name.len() as u16);
5310 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);
5315 push_u32(&mut out, entry.local_offset);
5316 out.extend_from_slice(entry.name);
5317 }
5318 let central_size = u32::try_from(out.len())
5319 .ok()
5320 .and_then(|end| end.checked_sub(central_offset))
5321 .ok_or_else(|| LinkError::PushTooLarge {
5322 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
5323 })?;
5324 let entry_count = central.len() as u16;
5325
5326 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
5327 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
5330 push_u16(&mut out, entry_count);
5331 push_u32(&mut out, central_size);
5332 push_u32(&mut out, central_offset);
5333 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
5336 return Err(LinkError::PushTooLarge {
5337 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
5338 });
5339 }
5340 Ok(out)
5341}
5342
5343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5349pub enum Capability {
5350 Read,
5352 Write,
5354}
5355
5356impl Capability {
5357 pub fn as_str(self) -> &'static str {
5359 match self {
5360 Capability::Read => "read",
5361 Capability::Write => "write",
5362 }
5363 }
5364}
5365
5366pub fn grant_issue(
5372 cfg: &HubConfig,
5373 brain: &str,
5374 grantee: &str,
5375 can: Capability,
5376 scope: Option<&str>,
5377 until: Option<&str>,
5378) -> LinkResult<Value> {
5379 require_safe_ref(brain)?;
5380 let _ = verified_remote_head(cfg, brain, false)?;
5381 let is_key_grantee = URL_SAFE_NO_PAD
5386 .decode(grantee)
5387 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
5388 .unwrap_or(false);
5389 let mut body = if is_key_grantee {
5390 json!({ "keySpki": grantee, "capability": can.as_str() })
5391 } else {
5392 json!({ "email": grantee, "capability": can.as_str() })
5393 };
5394 if let Some(s) = scope {
5395 body["scopePrefix"] = json!(s);
5396 }
5397 if let Some(u) = until {
5398 body["expiresAt"] = json!(u);
5399 }
5400 let path = format!("/api/hub/brains/{brain}/grants");
5401 ensure_ok(
5402 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
5403 "grant issue",
5404 )
5405}
5406
5407pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
5409 require_safe_ref(brain)?;
5410 let _ = verified_remote_head(cfg, brain, false)?;
5411 let path = format!("/api/hub/brains/{brain}/grants");
5412 ensure_ok(
5413 request(cfg, "GET", &path, None, Auth::Required)?,
5414 "grant list",
5415 )
5416}
5417
5418pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
5421 require_safe_ref(brain)?;
5422 require_safe_grant_id(grant_id)?;
5423 let _ = verified_remote_head(cfg, brain, false)?;
5424 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
5425 ensure_ok(
5426 request(cfg, "DELETE", &path, None, Auth::Required)?,
5427 "grant revoke",
5428 )
5429}
5430
5431pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
5442 require_valid_handle(handle)?;
5443 if body.len() as u64 > MAX_PROPOSE_BYTES {
5444 return Err(LinkError::ProposeTooLarge {
5445 bytes: body.len() as u64,
5446 });
5447 }
5448 let payload = json!({ "app": app, "body": body });
5449 let (path, auth) = if crate::ulid::is_ulid(handle) {
5454 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
5455 } else {
5456 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
5457 };
5458 ensure_ok(
5459 request(cfg, "POST", &path, Some(&payload), auth)?,
5460 "propose",
5461 )
5462}
5463
5464#[derive(Debug, serde::Serialize)]
5470pub struct Head {
5471 pub brain: String,
5473 pub seq: u64,
5475 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
5477 pub updated_at: Option<String>,
5478 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
5480 pub feed_hash: Option<String>,
5481 pub verified: bool,
5484}
5485
5486struct BoundedVecVisitor<T, const MAX: usize> {
5487 label: &'static str,
5488 marker: std::marker::PhantomData<T>,
5489}
5490
5491impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
5492where
5493 T: Deserialize<'de>,
5494{
5495 type Value = Vec<T>;
5496
5497 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5498 write!(formatter, "at most {MAX} {}", self.label)
5499 }
5500
5501 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
5502 where
5503 A: serde::de::SeqAccess<'de>,
5504 {
5505 if sequence.size_hint().is_some_and(|size| size > MAX) {
5506 return Err(serde::de::Error::custom(format!(
5507 "{} exceeds the {MAX}-item limit",
5508 self.label
5509 )));
5510 }
5511 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
5512 while let Some(value) = sequence.next_element()? {
5513 if values.len() == MAX {
5514 return Err(serde::de::Error::custom(format!(
5515 "{} exceeds the {MAX}-item limit",
5516 self.label
5517 )));
5518 }
5519 values.push(value);
5520 }
5521 Ok(values)
5522 }
5523}
5524
5525fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
5526 deserializer: D,
5527 label: &'static str,
5528) -> Result<Vec<T>, D::Error>
5529where
5530 D: serde::Deserializer<'de>,
5531 T: Deserialize<'de>,
5532{
5533 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
5534 label,
5535 marker: std::marker::PhantomData,
5536 })
5537}
5538
5539fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
5540where
5541 D: serde::Deserializer<'de>,
5542{
5543 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
5544}
5545
5546fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
5547where
5548 D: serde::Deserializer<'de>,
5549{
5550 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
5551}
5552
5553fn deserialize_previous_identities<'de, D>(
5554 deserializer: D,
5555) -> Result<Vec<PreviousIdentity>, D::Error>
5556where
5557 D: serde::Deserializer<'de>,
5558{
5559 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
5560 deserializer,
5561 "previous identities",
5562 )
5563}
5564
5565fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
5566where
5567 D: serde::Deserializer<'de>,
5568{
5569 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
5570 deserializer,
5571 "rotation statements",
5572 )
5573}
5574
5575fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
5576where
5577 D: serde::Deserializer<'de>,
5578{
5579 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
5580}
5581
5582#[derive(Debug, Clone, Deserialize, Serialize)]
5583struct FeedFile {
5584 path: String,
5585 sha256: String,
5586 bytes: u64,
5587}
5588
5589#[derive(Debug, Clone, Deserialize, Serialize)]
5590struct FeedEntry {
5591 v: u8,
5592 seq: u64,
5593 ts: String,
5594 brain: String,
5595 public_key: String,
5596 kind: String,
5597 op: String,
5598 pack_sha256: String,
5599 #[serde(deserialize_with = "deserialize_feed_files")]
5600 files: Vec<FeedFile>,
5601 #[serde(deserialize_with = "deserialize_removed_paths")]
5602 removed: Vec<String>,
5603 prev_entry_hash: Option<String>,
5604 sig: String,
5605}
5606
5607#[derive(Serialize)]
5608struct UnsignedFeedEntry<'a> {
5609 v: u8,
5610 seq: u64,
5611 ts: &'a str,
5612 brain: &'a str,
5613 public_key: &'a str,
5614 kind: &'a str,
5615 op: &'a str,
5616 pack_sha256: &'a str,
5617 files: &'a [FeedFile],
5618 removed: &'a [String],
5619 prev_entry_hash: &'a Option<String>,
5620}
5621
5622#[derive(Debug, Clone, Deserialize, Serialize)]
5623struct FeedItem {
5624 hash: String,
5625 entry: FeedEntry,
5626}
5627
5628#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
5629struct FeedIdentity {
5630 fingerprint: String,
5631 #[serde(rename = "publicKeySpki")]
5632 public_key_spki: String,
5633 #[serde(default, deserialize_with = "deserialize_previous_identities")]
5637 previous: Vec<PreviousIdentity>,
5638 #[serde(default, deserialize_with = "deserialize_rotations")]
5641 rotations: Vec<String>,
5642}
5643
5644#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
5645struct PreviousIdentity {
5646 fingerprint: String,
5647 #[serde(rename = "publicKeySpki")]
5648 public_key_spki: String,
5649}
5650
5651#[derive(Debug, Deserialize)]
5652struct FeedResponse {
5653 #[serde(rename = "headSeq")]
5654 head_seq: u64,
5655 #[serde(rename = "feedHash")]
5656 feed_hash: Option<String>,
5657 identity: Option<FeedIdentity>,
5658 #[serde(deserialize_with = "deserialize_feed_items")]
5659 entries: Vec<FeedItem>,
5660 #[serde(rename = "scopeLimited")]
5661 scope_limited: bool,
5662}
5663
5664#[derive(Debug, Deserialize, Serialize)]
5665#[serde(deny_unknown_fields)]
5666struct RotationStatement {
5667 v: u8,
5668 op: String,
5669 brain: String,
5670 public_key: String,
5671 new_brain: String,
5672 new_public_key: String,
5673 prior_head_seq: u64,
5674 prior_feed_hash: Option<String>,
5675 ts: String,
5676 sig: String,
5677}
5678
5679#[derive(Debug, Clone, Deserialize, Serialize)]
5680struct TrustState {
5681 v: u8,
5682 origin: String,
5683 #[serde(default)]
5687 requested: String,
5688 brain: String,
5690 #[serde(default, skip_serializing_if = "Option::is_none")]
5693 home: Option<String>,
5694 anchor: String,
5695 current: String,
5696 #[serde(rename = "headSeq")]
5697 head_seq: u64,
5698 #[serde(rename = "feedHash")]
5699 feed_hash: Option<String>,
5700 #[serde(default)]
5704 rotations: Vec<String>,
5705 #[serde(default, skip_serializing_if = "Option::is_none")]
5708 hub_signer: Option<String>,
5709 #[serde(default, skip_serializing_if = "Option::is_none")]
5712 protocol_profile: Option<String>,
5713}
5714
5715fn accepted_as_v2(state: &TrustState) -> bool {
5716 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
5717}
5718
5719fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
5720 let directory = open_trust_dir(cfg)?;
5721 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
5722 return Ok(true);
5723 }
5724 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
5725 return Ok(false);
5726 };
5727 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
5728}
5729
5730#[derive(Debug, Clone, Deserialize, Serialize)]
5731struct AliasBinding {
5732 v: u8,
5733 origin: String,
5734 requested: String,
5735 brain: String,
5736 #[serde(default, skip_serializing_if = "Option::is_none")]
5737 home: Option<String>,
5738}
5739
5740struct VerifiedRemote {
5741 head: Head,
5742 identity: Option<FeedIdentity>,
5743 head_entry: Option<FeedItem>,
5744 entries: Vec<FeedItem>,
5746 anchor: Option<String>,
5747}
5748
5749fn invalid_feed(message: impl Into<String>) -> LinkError {
5750 LinkError::InvalidFeed {
5751 message: message.into(),
5752 }
5753}
5754
5755fn is_sha256(value: &str) -> bool {
5756 value.len() == 64
5757 && value
5758 .bytes()
5759 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
5760}
5761
5762fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
5763 let der = URL_SAFE_NO_PAD
5764 .decode(public_key_spki)
5765 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
5766 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
5767 return Err(invalid_feed(
5768 "identity public key is not a valid Ed25519 SPKI",
5769 ));
5770 }
5771 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
5772}
5773
5774fn verify_identity_chain(
5778 identity: &FeedIdentity,
5779 pinned: Option<&TrustState>,
5780) -> LinkResult<String> {
5781 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
5782 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
5783 {
5784 return Err(invalid_feed(
5785 "identity rotation history exceeds the client cap",
5786 ));
5787 }
5788 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
5789 return Err(invalid_feed(
5790 "current identity fingerprint does not match its public key",
5791 ));
5792 }
5793 for previous in &identity.previous {
5794 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
5795 return Err(invalid_feed(
5796 "previous identity fingerprint does not match its public key",
5797 ));
5798 }
5799 }
5800 if identity.rotations.len() != identity.previous.len() {
5801 return Err(invalid_feed(
5802 "identity history is missing an old-key-signed rotation statement",
5803 ));
5804 }
5805
5806 let mut chain: Vec<(&str, &str)> = identity
5810 .previous
5811 .iter()
5812 .rev()
5813 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
5814 .collect();
5815 chain.push((&identity.fingerprint, &identity.public_key_spki));
5816
5817 for (index, raw) in identity.rotations.iter().enumerate() {
5818 let statement: RotationStatement = serde_json::from_str(raw)
5819 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
5820 let (old_fingerprint, old_spki) = chain[index];
5821 let (new_fingerprint, new_spki) = chain[index + 1];
5822 if statement.v != 1
5823 || statement.op != "rotate"
5824 || statement.brain != format!("ed25519:{old_fingerprint}")
5825 || statement.public_key != old_spki
5826 || statement.new_brain != format!("ed25519:{new_fingerprint}")
5827 || statement.new_public_key != new_spki
5828 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
5829 || (statement.prior_head_seq > 0
5830 && statement
5831 .prior_feed_hash
5832 .as_deref()
5833 .is_none_or(|hash| !is_sha256(hash)))
5834 {
5835 return Err(invalid_feed(
5836 "rotation statement does not connect adjacent identities",
5837 ));
5838 }
5839 let unsigned = serde_json::to_string(&UnsignedRotation {
5840 v: statement.v,
5841 op: &statement.op,
5842 brain: &statement.brain,
5843 public_key: &statement.public_key,
5844 new_brain: &statement.new_brain,
5845 new_public_key: &statement.new_public_key,
5846 prior_head_seq: statement.prior_head_seq,
5847 prior_feed_hash: statement.prior_feed_hash.as_deref(),
5848 ts: statement.ts.clone(),
5849 })
5850 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
5851 let exact = format!(
5852 "{},\"sig\":\"{}\"}}",
5853 &unsigned[..unsigned.len() - 1],
5854 statement.sig
5855 );
5856 if exact != *raw {
5857 return Err(invalid_feed(
5858 "rotation statement is not in normative serialization",
5859 ));
5860 }
5861 let der = URL_SAFE_NO_PAD
5862 .decode(old_spki)
5863 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
5864 let signature = URL_SAFE_NO_PAD
5865 .decode(&statement.sig)
5866 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
5867 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
5868 .verify(unsigned.as_bytes(), &signature)
5869 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
5870 if index > 0 {
5871 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
5872 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
5873 if statement.prior_head_seq < prior.prior_head_seq {
5874 return Err(invalid_feed("rotation feed boundaries move backward"));
5875 }
5876 }
5877 }
5878
5879 let anchor = format!("ed25519:{}", chain[0].0);
5880 let current = format!("ed25519:{}", identity.fingerprint);
5881 if let Some(pin) = pinned {
5882 if pin.anchor != anchor {
5883 return Err(invalid_feed(
5884 "served identity chain does not descend from the pinned anchor",
5885 ));
5886 }
5887 if !chain
5888 .iter()
5889 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
5890 {
5891 return Err(invalid_feed(
5892 "served identity chain forked away from the last pinned identity",
5893 ));
5894 }
5895 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
5896 return Err(invalid_feed("served identity discarded its rotation chain"));
5897 }
5898 if pin.v >= 2
5899 && (identity.rotations.len() < pin.rotations.len()
5900 || identity.rotations[..pin.rotations.len()] != pin.rotations)
5901 {
5902 return Err(invalid_feed(
5903 "served identity rewrote the locally accepted rotation history",
5904 ));
5905 }
5906 }
5907 Ok(anchor)
5908}
5909
5910fn verify_rotation_feed_boundaries(
5911 identity: &FeedIdentity,
5912 pinned: Option<&TrustState>,
5913 observed: &[FeedItem],
5914 advertised_seq: u64,
5915) -> LinkResult<()> {
5916 let mut chain: Vec<String> = identity
5917 .previous
5918 .iter()
5919 .rev()
5920 .map(|previous| format!("ed25519:{}", previous.fingerprint))
5921 .collect();
5922 chain.push(format!("ed25519:{}", identity.fingerprint));
5923 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
5924
5925 for (index, raw) in identity.rotations.iter().enumerate() {
5926 let rotation: RotationStatement = serde_json::from_str(raw)
5927 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
5928 if rotation.prior_head_seq > advertised_seq {
5929 return Err(invalid_feed(
5930 "rotation claims a feed boundary beyond the advertised head",
5931 ));
5932 }
5933 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
5934 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
5935 return Err(invalid_feed(
5936 "newly disclosed rotation predates the local feed checkpoint",
5937 ));
5938 }
5939 }
5940 let actual = if rotation.prior_head_seq == 0 {
5941 None
5942 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
5943 pinned.and_then(|pin| pin.feed_hash.as_deref())
5944 } else {
5945 observed
5946 .iter()
5947 .find(|item| item.entry.seq == rotation.prior_head_seq)
5948 .map(|item| item.hash.as_str())
5949 };
5950 if let Some(actual) = actual {
5951 if rotation.prior_feed_hash.as_deref() != Some(actual) {
5952 return Err(invalid_feed(
5953 "rotation statement does not commit the verified feed boundary",
5954 ));
5955 }
5956 } else if rotation.prior_head_seq == 0 {
5957 } else if pinned.is_some_and(|pin| {
5960 pinned_index.is_some_and(|pin_index| index >= pin_index)
5961 || rotation.prior_head_seq >= pin.head_seq
5962 }) {
5963 return Err(invalid_feed(
5964 "rotation feed boundary was not present in the verified chain",
5965 ));
5966 }
5967 }
5968 Ok(())
5969}
5970
5971fn reject_retired_signer_after_checkpoint(
5976 identity: &FeedIdentity,
5977 pinned: Option<&TrustState>,
5978 item: &FeedItem,
5979) -> LinkResult<()> {
5980 let Some(pin) = pinned else {
5981 return Ok(());
5982 };
5983 if item.entry.seq <= pin.head_seq {
5984 return Ok(());
5985 }
5986 let mut chain: Vec<String> = identity
5987 .previous
5988 .iter()
5989 .rev()
5990 .map(|previous| format!("ed25519:{}", previous.fingerprint))
5991 .collect();
5992 chain.push(format!("ed25519:{}", identity.fingerprint));
5993 let pinned_index = chain
5994 .iter()
5995 .position(|key| key == &pin.current)
5996 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
5997 let signer_index = chain
5998 .iter()
5999 .position(|key| key == &item.entry.brain)
6000 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
6001 if signer_index < pinned_index {
6002 return Err(invalid_feed(
6003 "a retired identity attempted to sign after the local checkpoint",
6004 ));
6005 }
6006 Ok(())
6007}
6008
6009fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
6010 let origin = normalized_origin(&cfg.hub)?;
6011 let key = format!(
6012 "{:x}",
6013 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
6014 );
6015 Ok(format!("{key}.json"))
6016}
6017
6018fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
6019 let origin = normalized_origin(&cfg.hub)?;
6020 let key = format!(
6021 "{:x}",
6022 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
6023 );
6024 Ok(format!("alias-{key}.json"))
6025}
6026
6027#[cfg(unix)]
6028struct TrustLock {
6029 _file: std::fs::File,
6030}
6031
6032#[cfg(unix)]
6033fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
6034 use std::os::fd::{AsRawFd as _, FromRawFd as _};
6035
6036 let lock_string = format!(".{state_name}.lock");
6037 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
6038 let fd = unsafe {
6039 libc::openat(
6040 directory.as_raw_fd(),
6041 lock_name.as_ptr(),
6042 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6043 0o600,
6044 )
6045 };
6046 if fd < 0 {
6047 return Err(std::io::Error::last_os_error().into());
6048 }
6049 let file = unsafe { std::fs::File::from_raw_fd(fd) };
6050 if !file.metadata()?.is_file() {
6051 return Err(LinkError::UnsafePath { path: lock_string });
6052 }
6053 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
6054 return Err(std::io::Error::last_os_error().into());
6055 }
6056 Ok(TrustLock { _file: file })
6057}
6058
6059#[cfg(unix)]
6060fn lock_trust_many(
6061 cfg: &HubConfig,
6062 directory: &std::fs::File,
6063 refs: &[&str],
6064) -> LinkResult<Vec<TrustLock>> {
6065 let mut names = refs
6066 .iter()
6067 .map(|reference| trust_file_name(cfg, reference))
6068 .collect::<LinkResult<Vec<_>>>()?;
6069 names.sort();
6070 names.dedup();
6071 names
6072 .iter()
6073 .map(|name| lock_trust_name(directory, name))
6074 .collect()
6075}
6076
6077#[cfg(not(unix))]
6078fn lock_trust_many(
6079 _cfg: &HubConfig,
6080 _directory: &TrustDirectory,
6081 _refs: &[&str],
6082) -> LinkResult<Vec<()>> {
6083 Err(LinkError::UnsupportedPlatform {
6084 operation: "verified link.md state",
6085 })
6086}
6087
6088#[cfg(unix)]
6089type TrustDirectory = std::fs::File;
6090
6091#[cfg(not(unix))]
6092struct TrustDirectory;
6093
6094#[cfg(unix)]
6095fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
6096 use std::os::fd::AsRawFd as _;
6097
6098 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
6099 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
6100 return Err(std::io::Error::last_os_error().into());
6101 }
6102 directory.sync_all()?;
6103 Ok(directory)
6104}
6105
6106#[cfg(not(unix))]
6107fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
6108 Err(LinkError::UnsupportedPlatform {
6109 operation: "verified link.md state",
6110 })
6111}
6112
6113#[cfg(unix)]
6114fn load_trust_in(
6115 cfg: &HubConfig,
6116 directory: &TrustDirectory,
6117 requested: &str,
6118) -> LinkResult<Option<TrustState>> {
6119 use std::os::fd::{AsRawFd as _, FromRawFd as _};
6120
6121 let name_string = trust_file_name(cfg, requested)?;
6122 let name = c_name(name_string.as_bytes(), &name_string)?;
6123 let fd = unsafe {
6124 libc::openat(
6125 directory.as_raw_fd(),
6126 name.as_ptr(),
6127 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6128 )
6129 };
6130 if fd < 0 {
6131 let error = std::io::Error::last_os_error();
6132 if error.kind() == std::io::ErrorKind::NotFound {
6133 return Ok(None);
6134 }
6135 return Err(LinkError::UnsafePath { path: name_string });
6136 }
6137 let file = unsafe { std::fs::File::from_raw_fd(fd) };
6138 if !file.metadata()?.is_file() {
6139 return Err(LinkError::UnsafePath { path: name_string });
6140 }
6141 let mut bytes = Vec::new();
6142 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
6143 if bytes.len() > 1024 * 1024 {
6144 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
6145 }
6146 let mut state: TrustState = serde_json::from_slice(&bytes)
6147 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
6148 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
6149 return Err(invalid_feed(
6150 "local identity/feed checkpoint does not match this hub and brain",
6151 ));
6152 }
6153 if state.v == 1 {
6154 if state.brain != requested {
6158 return Err(invalid_feed(
6159 "legacy checkpoint is not bound to the requested brain id",
6160 ));
6161 }
6162 state.requested = requested.to_string();
6163 } else if state.requested != requested {
6164 return Err(invalid_feed(
6165 "local identity/feed checkpoint is bound to a different requested ref",
6166 ));
6167 }
6168 Ok(Some(state))
6169}
6170
6171#[cfg(not(unix))]
6172fn load_trust_in(
6173 _cfg: &HubConfig,
6174 _directory: &TrustDirectory,
6175 _brain: &str,
6176) -> LinkResult<Option<TrustState>> {
6177 Err(LinkError::UnsupportedPlatform {
6178 operation: "verified link.md state",
6179 })
6180}
6181
6182#[cfg(all(test, unix))]
6183fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
6184 let directory = open_trust_dir(cfg)?;
6185 load_trust_in(cfg, &directory, requested)
6186}
6187
6188#[cfg(unix)]
6189fn save_trust_in(
6190 cfg: &HubConfig,
6191 directory: &TrustDirectory,
6192 state: &TrustState,
6193) -> LinkResult<()> {
6194 use std::os::fd::{AsRawFd as _, FromRawFd as _};
6195
6196 let name_string = trust_file_name(cfg, &state.requested)?;
6197 let name = c_name(name_string.as_bytes(), &name_string)?;
6198 let mut bytes = serde_json::to_vec(state)
6199 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
6200 bytes.push(b'\n');
6201
6202 let nonce = std::time::SystemTime::now()
6203 .duration_since(std::time::UNIX_EPOCH)
6204 .unwrap_or_default()
6205 .as_nanos();
6206 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
6207 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
6208 let fd = unsafe {
6209 libc::openat(
6210 directory.as_raw_fd(),
6211 temp.as_ptr(),
6212 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6213 0o600,
6214 )
6215 };
6216 if fd < 0 {
6217 return Err(std::io::Error::last_os_error().into());
6218 }
6219 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
6220 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
6221 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6222 return Err(error.into());
6223 }
6224 drop(file);
6225 if unsafe {
6226 libc::renameat(
6227 directory.as_raw_fd(),
6228 temp.as_ptr(),
6229 directory.as_raw_fd(),
6230 name.as_ptr(),
6231 )
6232 } != 0
6233 {
6234 let error = std::io::Error::last_os_error();
6235 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6236 return Err(error.into());
6237 }
6238 directory.sync_all()?;
6239 Ok(())
6240}
6241
6242#[cfg(not(unix))]
6243fn save_trust_in(
6244 _cfg: &HubConfig,
6245 _directory: &TrustDirectory,
6246 _state: &TrustState,
6247) -> LinkResult<()> {
6248 Err(LinkError::UnsupportedPlatform {
6249 operation: "verified link.md state",
6250 })
6251}
6252
6253#[cfg(unix)]
6254fn load_alias_in(
6255 cfg: &HubConfig,
6256 directory: &TrustDirectory,
6257 requested: &str,
6258) -> LinkResult<Option<AliasBinding>> {
6259 use std::os::fd::{AsRawFd as _, FromRawFd as _};
6260
6261 let name_string = alias_file_name(cfg, requested)?;
6262 let name = c_name(name_string.as_bytes(), &name_string)?;
6263 let fd = unsafe {
6264 libc::openat(
6265 directory.as_raw_fd(),
6266 name.as_ptr(),
6267 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6268 )
6269 };
6270 if fd < 0 {
6271 let error = std::io::Error::last_os_error();
6272 if error.kind() == std::io::ErrorKind::NotFound {
6273 return Ok(None);
6274 }
6275 return Err(LinkError::UnsafePath { path: name_string });
6276 }
6277 let file = unsafe { std::fs::File::from_raw_fd(fd) };
6278 if !file.metadata()?.is_file() {
6279 return Err(LinkError::UnsafePath { path: name_string });
6280 }
6281 let mut bytes = Vec::new();
6282 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
6283 if bytes.len() > 64 * 1024 {
6284 return Err(invalid_feed("local alias binding is oversized"));
6285 }
6286 let alias: AliasBinding = serde_json::from_slice(&bytes)
6287 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
6288 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
6289 {
6290 return Err(invalid_feed(
6291 "local alias binding does not match this hub and requested ref",
6292 ));
6293 }
6294 Ok(Some(alias))
6295}
6296
6297#[cfg(not(unix))]
6298fn load_alias_in(
6299 _cfg: &HubConfig,
6300 _directory: &TrustDirectory,
6301 _requested: &str,
6302) -> LinkResult<Option<AliasBinding>> {
6303 Err(LinkError::UnsupportedPlatform {
6304 operation: "verified link.md state",
6305 })
6306}
6307
6308#[cfg(unix)]
6309fn save_alias_in(
6310 cfg: &HubConfig,
6311 directory: &TrustDirectory,
6312 alias: &AliasBinding,
6313) -> LinkResult<()> {
6314 use std::os::fd::{AsRawFd as _, FromRawFd as _};
6315
6316 let name_string = alias_file_name(cfg, &alias.requested)?;
6317 let name = c_name(name_string.as_bytes(), &name_string)?;
6318 let mut bytes = serde_json::to_vec(alias)
6319 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
6320 bytes.push(b'\n');
6321 let nonce = std::time::SystemTime::now()
6322 .duration_since(std::time::UNIX_EPOCH)
6323 .unwrap_or_default()
6324 .as_nanos();
6325 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
6326 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
6327 let fd = unsafe {
6328 libc::openat(
6329 directory.as_raw_fd(),
6330 temp.as_ptr(),
6331 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6332 0o600,
6333 )
6334 };
6335 if fd < 0 {
6336 return Err(std::io::Error::last_os_error().into());
6337 }
6338 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
6339 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
6340 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6341 return Err(error.into());
6342 }
6343 drop(file);
6344 if unsafe {
6345 libc::renameat(
6346 directory.as_raw_fd(),
6347 temp.as_ptr(),
6348 directory.as_raw_fd(),
6349 name.as_ptr(),
6350 )
6351 } != 0
6352 {
6353 let error = std::io::Error::last_os_error();
6354 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6355 return Err(error.into());
6356 }
6357 directory.sync_all()?;
6358 Ok(())
6359}
6360
6361#[cfg(not(unix))]
6362fn save_alias_in(
6363 _cfg: &HubConfig,
6364 _directory: &TrustDirectory,
6365 _alias: &AliasBinding,
6366) -> LinkResult<()> {
6367 Err(LinkError::UnsupportedPlatform {
6368 operation: "verified link.md state",
6369 })
6370}
6371
6372fn load_canonical_pin(
6377 cfg: &HubConfig,
6378 directory: &TrustDirectory,
6379 requested: &str,
6380 resolved_brain: &str,
6381) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
6382 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
6383 if requested == resolved_brain {
6384 return Ok((canonical, None));
6385 }
6386
6387 let mut alias = load_alias_in(cfg, directory, requested)?;
6388 if let Some(binding) = &alias {
6389 if binding.brain != resolved_brain {
6390 return Err(invalid_feed(
6391 "requested brain alias now resolves to a different canonical brain",
6392 ));
6393 }
6394 return Ok((canonical, alias));
6395 }
6396
6397 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
6401 if legacy.brain != resolved_brain {
6402 return Err(invalid_feed(
6403 "legacy alias checkpoint names a different canonical brain",
6404 ));
6405 }
6406 if let Some(existing) = &canonical {
6407 if existing.brain != legacy.brain
6408 || existing.anchor != legacy.anchor
6409 || existing.current != legacy.current
6410 || existing.head_seq != legacy.head_seq
6411 || existing.feed_hash != legacy.feed_hash
6412 || existing.rotations != legacy.rotations
6413 {
6414 return Err(invalid_feed(
6415 "legacy alias checkpoint conflicts with the canonical checkpoint",
6416 ));
6417 }
6418 } else {
6419 let mut promoted = legacy.clone();
6420 promoted.requested = resolved_brain.to_string();
6421 promoted.home = None;
6422 save_trust_in(cfg, directory, &promoted)?;
6423 canonical = Some(promoted);
6424 }
6425 alias = Some(AliasBinding {
6426 v: 1,
6427 origin: normalized_origin(&cfg.hub)?,
6428 requested: requested.to_string(),
6429 brain: resolved_brain.to_string(),
6430 home: legacy.home,
6431 });
6432 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
6433 }
6434 Ok((canonical, alias))
6435}
6436
6437fn save_canonical_pin_and_alias(
6438 cfg: &HubConfig,
6439 directory: &TrustDirectory,
6440 requested: &str,
6441 resolved_brain: &str,
6442 mut state: TrustState,
6443 existing_alias: Option<&AliasBinding>,
6444) -> LinkResult<()> {
6445 state.requested = resolved_brain.to_string();
6446 state.brain = resolved_brain.to_string();
6447 state.home = None;
6448 save_trust_in(cfg, directory, &state)?;
6449 if requested != resolved_brain {
6450 save_alias_in(
6451 cfg,
6452 directory,
6453 &AliasBinding {
6454 v: 1,
6455 origin: normalized_origin(&cfg.hub)?,
6456 requested: requested.to_string(),
6457 brain: resolved_brain.to_string(),
6458 home: existing_alias.and_then(|alias| alias.home.clone()),
6459 },
6460 )?;
6461 }
6462 Ok(())
6463}
6464
6465fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
6466 const ED25519_SPKI_PREFIX: &[u8] = &[
6467 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
6468 ];
6469 let entry = &item.entry;
6470 let public_der = URL_SAFE_NO_PAD
6471 .decode(&entry.public_key)
6472 .map_err(|_| invalid_feed("public key is not base64url"))?;
6473 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
6474 || !public_der.starts_with(ED25519_SPKI_PREFIX)
6475 {
6476 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
6477 }
6478 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
6479 if entry.brain != format!("ed25519:{fingerprint}") {
6480 return Err(invalid_feed(
6481 "brain fingerprint does not match its public key",
6482 ));
6483 }
6484 let _ = verify_identity_chain(identity, None)?;
6486 let mut chain: Vec<(&str, &str)> = identity
6487 .previous
6488 .iter()
6489 .rev()
6490 .map(|previous| {
6491 (
6492 previous.fingerprint.as_str(),
6493 previous.public_key_spki.as_str(),
6494 )
6495 })
6496 .collect();
6497 chain.push((&identity.fingerprint, &identity.public_key_spki));
6498 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
6499 *known_fingerprint == fingerprint && *spki == entry.public_key
6500 });
6501 let Some(signer_index) = signer_index else {
6502 return Err(invalid_feed(
6503 "entry signer is not this brain's identity (current or rotated-from)",
6504 ));
6505 };
6506 let lower_boundary = if signer_index == 0 {
6507 None
6508 } else {
6509 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
6510 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
6511 Some(prior.prior_head_seq)
6512 };
6513 let upper_boundary = if signer_index == identity.rotations.len() {
6514 None
6515 } else {
6516 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
6517 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
6518 Some(next.prior_head_seq)
6519 };
6520 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
6521 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
6522 {
6523 return Err(invalid_feed(
6524 "entry signer is outside its authenticated rotation epoch",
6525 ));
6526 }
6527 let unsigned = UnsignedFeedEntry {
6528 v: entry.v,
6529 seq: entry.seq,
6530 ts: &entry.ts,
6531 brain: &entry.brain,
6532 public_key: &entry.public_key,
6533 kind: &entry.kind,
6534 op: &entry.op,
6535 pack_sha256: &entry.pack_sha256,
6536 files: &entry.files,
6537 removed: &entry.removed,
6538 prev_entry_hash: &entry.prev_entry_hash,
6539 };
6540 let message =
6541 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
6542 let signature = URL_SAFE_NO_PAD
6543 .decode(&entry.sig)
6544 .map_err(|_| invalid_feed("signature is not base64url"))?;
6545 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
6546 .verify(&message, &signature)
6547 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
6548
6549 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
6550 exact.push(b'\n');
6551 let actual_hash = format!("{:x}", Sha256::digest(&exact));
6552 if actual_hash != item.hash {
6553 return Err(invalid_feed("entry SHA-256 does not match"));
6554 }
6555 Ok(())
6556}
6557
6558#[derive(Serialize)]
6564struct UnsignedRotation<'a> {
6565 v: u8,
6566 op: &'a str,
6567 brain: &'a str,
6568 public_key: &'a str,
6569 new_brain: &'a str,
6570 new_public_key: &'a str,
6571 prior_head_seq: u64,
6572 prior_feed_hash: Option<&'a str>,
6573 ts: String,
6574}
6575
6576#[derive(Debug, Deserialize, Serialize)]
6581#[serde(deny_unknown_fields)]
6582struct RotationJournal {
6583 v: u8,
6584 origin: String,
6585 brain: String,
6586 old_brain: String,
6587 new_brain: String,
6588 prior_head_seq: u64,
6589 prior_feed_hash: Option<String>,
6590 statement: String,
6591}
6592
6593fn rotation_journal_path(key_path: &Path) -> PathBuf {
6594 let mut path = key_path.as_os_str().to_os_string();
6595 path.push(".rotation.json");
6596 PathBuf::from(path)
6597}
6598
6599fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
6600 #[cfg(unix)]
6601 let file = {
6602 use std::os::fd::{AsRawFd as _, FromRawFd as _};
6603 use std::os::unix::ffi::OsStrExt as _;
6604 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
6605 .map_err(|error| {
6606 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
6607 })?;
6608 let leaf_name = path
6609 .file_name()
6610 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
6611 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
6612 let fd = unsafe {
6613 libc::openat(
6614 parent.as_raw_fd(),
6615 leaf.as_ptr(),
6616 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6617 )
6618 };
6619 if fd < 0 {
6620 return Err(bad_agent_key(
6621 "the rotation journal must be an existing regular file without symlink ancestors",
6622 ));
6623 }
6624 unsafe { std::fs::File::from_raw_fd(fd) }
6625 };
6626 #[cfg(not(unix))]
6627 let file = std::fs::File::open(path)
6628 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
6629 let metadata = file
6630 .metadata()
6631 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
6632 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
6633 return Err(bad_agent_key(
6634 "the rotation journal must be a bounded regular file",
6635 ));
6636 }
6637 #[cfg(unix)]
6638 {
6639 use std::os::unix::fs::PermissionsExt as _;
6640 if metadata.permissions().mode() & 0o077 != 0 {
6641 return Err(bad_agent_key(
6642 "the rotation journal is accessible to group/other; set mode 0600",
6643 ));
6644 }
6645 }
6646 serde_json::from_reader(file)
6647 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
6648}
6649
6650fn remove_rotation_journal(path: &Path) {
6651 #[cfg(unix)]
6652 {
6653 use std::os::fd::AsRawFd as _;
6654 use std::os::unix::ffi::OsStrExt as _;
6655 let Ok(parent) =
6656 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
6657 else {
6658 return;
6659 };
6660 let Some(leaf_name) = path.file_name() else {
6661 return;
6662 };
6663 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
6664 return;
6665 };
6666 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
6667 let _ = parent.sync_all();
6668 }
6669 }
6670 #[cfg(not(unix))]
6671 {
6672 let _ = std::fs::remove_file(path);
6673 }
6674}
6675
6676fn validate_rotation_journal(
6677 journal: &RotationJournal,
6678 cfg: &HubConfig,
6679 canonical_brain: &str,
6680 old_key: &AgentSigningKey,
6681 new_key: &AgentSigningKey,
6682 head: &Head,
6683) -> LinkResult<()> {
6684 if journal.v != 1
6685 || journal.origin != normalized_origin(&cfg.hub)?
6686 || journal.brain != canonical_brain
6687 || journal.old_brain != old_key.multikey
6688 || journal.new_brain != new_key.multikey
6689 || journal.prior_head_seq != head.seq
6690 || journal.prior_feed_hash != head.feed_hash
6691 {
6692 return Err(invalid_feed(
6693 "rotation journal does not match the verified key and feed boundary",
6694 ));
6695 }
6696 let statement: RotationStatement = serde_json::from_str(&journal.statement)
6697 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
6698 if statement.prior_head_seq != journal.prior_head_seq
6699 || statement.prior_feed_hash != journal.prior_feed_hash
6700 || statement.brain != old_key.multikey
6701 || statement.public_key != old_key.public_key_spki
6702 || statement.new_brain != new_key.multikey
6703 || statement.new_public_key != new_key.public_key_spki
6704 {
6705 return Err(invalid_feed(
6706 "rotation journal statement does not match its durable intent",
6707 ));
6708 }
6709 let identity = FeedIdentity {
6710 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
6711 public_key_spki: new_key.public_key_spki.clone(),
6712 previous: vec![PreviousIdentity {
6713 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
6714 public_key_spki: old_key.public_key_spki.clone(),
6715 }],
6716 rotations: vec![journal.statement.clone()],
6717 };
6718 verify_identity_chain(&identity, None)?;
6719 Ok(())
6720}
6721
6722#[derive(Debug, Serialize)]
6724pub struct RotationReport {
6725 pub brain: String,
6727 pub multikey: String,
6729 #[serde(rename = "keyFile")]
6731 pub key_file: String,
6732 pub previous: Vec<String>,
6734}
6735
6736pub fn rotate_brain_key(
6742 cfg: &HubConfig,
6743 brain: &str,
6744 old_key: &AgentSigningKey,
6745 out: &Path,
6746) -> LinkResult<RotationReport> {
6747 require_hardened_filesystem("key rotation")?;
6748 require_safe_ref(brain)?;
6749 let new_key = if out.exists() {
6753 load_signing_key(out)?
6754 } else {
6755 let rng = ring::rand::SystemRandom::new();
6756 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
6757 .map_err(|_| bad_agent_key("key generation failed"))?;
6758 let pair = agent_keypair(pkcs8.as_ref())?;
6759 let (public_key_spki, multikey) = public_identity_for(&pair);
6760 write_secret_new(
6761 out,
6762 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
6763 )?;
6764 AgentSigningKey {
6765 pkcs8: pkcs8.as_ref().to_vec(),
6766 multikey,
6767 public_key_spki,
6768 }
6769 };
6770 let new_spki = new_key.public_key_spki.clone();
6771 let new_multikey = new_key.multikey.clone();
6772 let journal_path = rotation_journal_path(out);
6773 let before = verified_remote_head(cfg, brain, false)?;
6774 let served_identity = before
6775 .identity
6776 .as_ref()
6777 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
6778 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
6779 if served_multikey == new_multikey {
6780 remove_rotation_journal(&journal_path);
6781 return Ok(RotationReport {
6782 brain: brain.to_string(),
6783 multikey: new_multikey,
6784 key_file: out.display().to_string(),
6785 previous: served_identity
6786 .previous
6787 .iter()
6788 .map(|identity| format!("ed25519:{}", identity.fingerprint))
6789 .collect(),
6790 });
6791 }
6792 if served_multikey != old_key.multikey {
6793 return Err(invalid_feed(
6794 "the supplied old key is not the brain's verified current identity",
6795 ));
6796 }
6797
6798 let journal = if journal_path.exists() {
6799 read_rotation_journal(&journal_path)?
6800 } else {
6801 let ts = crate::now()
6802 .with_timezone(&chrono::Utc)
6803 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
6804 .to_string();
6805 let unsigned = serde_json::to_string(&UnsignedRotation {
6806 v: 1,
6807 op: "rotate",
6808 brain: &old_key.multikey,
6809 public_key: &old_key.public_key_spki,
6810 new_brain: &new_multikey,
6811 new_public_key: &new_spki,
6812 prior_head_seq: before.head.seq,
6813 prior_feed_hash: before.head.feed_hash.as_deref(),
6814 ts,
6815 })
6816 .expect("serialize rotation");
6817 let old_pair = agent_keypair(&old_key.pkcs8)?;
6818 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
6819 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
6820 let journal = RotationJournal {
6821 v: 1,
6822 origin: normalized_origin(&cfg.hub)?,
6823 brain: before.head.brain.clone(),
6824 old_brain: old_key.multikey.clone(),
6825 new_brain: new_multikey.clone(),
6826 prior_head_seq: before.head.seq,
6827 prior_feed_hash: before.head.feed_hash.clone(),
6828 statement,
6829 };
6830 let mut exact = serde_json::to_vec(&journal)
6831 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
6832 exact.push(b'\n');
6833 if write_secret_new(&journal_path, &exact).is_err() {
6834 read_rotation_journal(&journal_path)?
6837 } else {
6838 journal
6839 }
6840 };
6841 validate_rotation_journal(
6842 &journal,
6843 cfg,
6844 &before.head.brain,
6845 old_key,
6846 &new_key,
6847 &before.head,
6848 )?;
6849
6850 let body = json!({ "statement": journal.statement });
6851 let path = format!("/api/hub/brains/{brain}/rotate");
6852 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
6853 let attempted_failure = match attempted {
6854 Ok(response) if (200..300).contains(&response.status) => None,
6855 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
6856 Err(error) => Some(error),
6857 };
6858
6859 let after = match verified_remote_head(cfg, brain, false) {
6863 Ok(after) => after,
6864 Err(error) => return Err(attempted_failure.unwrap_or(error)),
6865 };
6866 let identity = after
6867 .identity
6868 .as_ref()
6869 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?;
6870 if format!("ed25519:{}", identity.fingerprint) != new_multikey
6871 || identity.public_key_spki != new_spki
6872 {
6873 return Err(attempted_failure.unwrap_or_else(|| {
6874 invalid_feed("hub acknowledged rotation without committing the verified new identity")
6875 }));
6876 }
6877 let previous = identity
6878 .previous
6879 .iter()
6880 .map(|prior| format!("ed25519:{}", prior.fingerprint))
6881 .collect();
6882 remove_rotation_journal(&journal_path);
6883
6884 Ok(RotationReport {
6885 brain: brain.to_string(),
6886 multikey: new_multikey,
6887 key_file: out.display().to_string(),
6888 previous,
6889 })
6890}
6891
6892#[derive(Debug, Serialize)]
6898pub struct MirrorReport {
6899 pub brain: String,
6901 #[serde(rename = "headSeq")]
6903 pub head_seq: u64,
6904 #[serde(rename = "feedHash")]
6906 pub feed_hash: Option<String>,
6907 pub entries: u64,
6909 pub pinned: String,
6911 pub files: usize,
6913}
6914
6915pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
6917
6918#[derive(Debug)]
6920pub struct VerifiedMirrorMaterial {
6921 pub brain: String,
6922 pub head_seq: u64,
6923 pub feed_hash: Option<String>,
6924 pub identity: serde_json::Value,
6925 pub entries: Vec<(u64, String, String)>,
6927 pub pack_sha256: Option<String>,
6928}
6929
6930#[derive(Deserialize)]
6931#[serde(deny_unknown_fields)]
6932struct StoredMirrorHead {
6933 brain: String,
6934 #[serde(rename = "headSeq")]
6935 head_seq: u64,
6936 #[serde(rename = "feedHash")]
6937 feed_hash: Option<String>,
6938}
6939
6940pub fn verify_mirror_material(
6943 head_bytes: &[u8],
6944 identity_bytes: &[u8],
6945 feed_bytes: &[Vec<u8>],
6946 snapshot_pack: Option<&[u8]>,
6947 expected_anchor: &str,
6948) -> LinkResult<VerifiedMirrorMaterial> {
6949 let snapshot_hash = snapshot_pack
6950 .filter(|pack| !pack.is_empty())
6951 .map(content_sha256);
6952 verify_mirror_material_with_pack_hash(
6953 head_bytes,
6954 identity_bytes,
6955 feed_bytes,
6956 snapshot_hash.as_deref(),
6957 expected_anchor,
6958 )
6959}
6960
6961pub fn verify_mirror_material_with_pack_hash(
6965 head_bytes: &[u8],
6966 identity_bytes: &[u8],
6967 feed_bytes: &[Vec<u8>],
6968 snapshot_pack_sha256: Option<&str>,
6969 expected_anchor: &str,
6970) -> LinkResult<VerifiedMirrorMaterial> {
6971 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
6972 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
6973 require_safe_ref(&head.brain)?;
6974 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
6975 return Err(invalid_feed(
6976 "stored mirror feed count does not match its bounded head sequence",
6977 ));
6978 }
6979 let aggregate = feed_bytes
6980 .iter()
6981 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
6982 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
6983 if aggregate > MAX_FEED_REPLAY_BYTES {
6984 return Err(invalid_feed(
6985 "stored mirror feed metadata exceeds the aggregate limit",
6986 ));
6987 }
6988 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
6989 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
6990 let anchor = verify_identity_chain(&identity, None)?;
6991 if anchor != expected_anchor {
6992 return Err(invalid_feed(
6993 "stored mirror identity does not descend from the explicitly trusted anchor",
6994 ));
6995 }
6996
6997 let mut entries = Vec::with_capacity(feed_bytes.len());
6998 let mut items = Vec::with_capacity(feed_bytes.len());
6999 let mut previous_hash = None;
7000 let mut pack_sha256 = None;
7001 for (index, bytes) in feed_bytes.iter().enumerate() {
7002 let exact = bytes
7003 .strip_suffix(b"\n")
7004 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
7005 if exact.ends_with(b"\n") {
7006 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
7007 }
7008 let entry: FeedEntry = serde_json::from_slice(exact)
7009 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
7010 let expected_seq = index as u64 + 1;
7011 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
7012 return Err(invalid_feed(
7013 "stored mirror feed is not contiguous and hash-chained",
7014 ));
7015 }
7016 let canonical = serde_json::to_vec(&entry)
7017 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
7018 if canonical != exact {
7019 return Err(invalid_feed(
7020 "stored feed entry is not in normative serialization",
7021 ));
7022 }
7023 let hash = content_sha256(bytes);
7024 let item = FeedItem {
7025 hash: hash.clone(),
7026 entry,
7027 };
7028 verify_feed_item(&item, &identity)?;
7029 previous_hash = Some(hash.clone());
7030 if expected_seq == head.head_seq {
7031 pack_sha256 = Some(item.entry.pack_sha256.clone());
7032 }
7033 entries.push((
7034 expected_seq,
7035 std::str::from_utf8(exact)
7036 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
7037 .to_string(),
7038 hash,
7039 ));
7040 items.push(item);
7041 }
7042 if previous_hash != head.feed_hash {
7043 return Err(invalid_feed(
7044 "stored mirror feed does not converge on its advertised head",
7045 ));
7046 }
7047 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
7048 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
7049 (0, None, None) => {}
7050 (_, Some(actual), Some(expected)) if actual == expected => {}
7051 _ => {
7052 return Err(LinkError::InvalidPack {
7053 message: "stored snapshot pack does not match the signed head digest".to_string(),
7054 });
7055 }
7056 }
7057 let identity_value = serde_json::to_value(&identity)
7058 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
7059 Ok(VerifiedMirrorMaterial {
7060 brain: head.brain,
7061 head_seq: head.head_seq,
7062 feed_hash: head.feed_hash,
7063 identity: identity_value,
7064 entries,
7065 pack_sha256,
7066 })
7067}
7068
7069pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
7072 format!(
7073 "{:x}",
7074 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
7075 )
7076}
7077
7078pub fn content_sha256(bytes: &[u8]) -> String {
7081 format!("{:x}", Sha256::digest(bytes))
7082}
7083
7084pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
7086 let mut digest = Sha256::new();
7087 let mut buffer = [0u8; 64 * 1024];
7088 loop {
7089 let read = reader.read(&mut buffer)?;
7090 if read == 0 {
7091 break;
7092 }
7093 digest.update(&buffer[..read]);
7094 }
7095 Ok(format!("{:x}", digest.finalize()))
7096}
7097
7098pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
7106 require_hardened_filesystem("mirror")?;
7107 require_safe_ref(brain)?;
7108 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
7109 let name = dest
7110 .file_name()
7111 .and_then(|name| name.to_str())
7112 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
7113 .ok_or_else(|| LinkError::UnsafePath {
7114 path: dest.display().to_string(),
7115 })?;
7116 #[cfg(unix)]
7117 let parent_dir = open_or_create_dir_nofollow(parent)?;
7118 #[cfg(unix)]
7119 use std::os::fd::AsRawFd as _;
7120 #[cfg(unix)]
7121 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
7122 #[cfg(unix)]
7123 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
7124 None => false,
7125 Some(true) => true,
7126 Some(false) => {
7127 return Err(LinkError::UnsafePath {
7128 path: dest.display().to_string(),
7129 });
7130 }
7131 };
7132
7133 #[cfg(unix)]
7136 let legacy_backup_name = c_name(
7137 format!(".{name}.dbmd-backup").as_bytes(),
7138 &dest.display().to_string(),
7139 )?;
7140 #[cfg(unix)]
7141 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
7142 return Err(LinkError::UnsafePath {
7143 path: parent
7144 .join(format!(".{name}.dbmd-backup"))
7145 .display()
7146 .to_string(),
7147 });
7148 }
7149
7150 let nonce = std::time::SystemTime::now()
7151 .duration_since(std::time::UNIX_EPOCH)
7152 .unwrap_or_default()
7153 .as_nanos();
7154 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
7155 #[cfg(unix)]
7156 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
7157 #[cfg(unix)]
7158 let stage_dir = create_dir_exclusive_at(
7159 parent_dir.as_raw_fd(),
7160 &stage_name,
7161 &dest.display().to_string(),
7162 )?;
7163
7164 let assembled = (|| -> LinkResult<MirrorReport> {
7165 let remote = verified_remote_head(cfg, brain, true)?;
7166 let brain_id = remote.head.brain.clone();
7167 let identity = remote
7168 .identity
7169 .as_ref()
7170 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
7171 let anchor = remote
7172 .anchor
7173 .clone()
7174 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
7175 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
7176 let snapshot_entries = parse_store_pack(pack.clone())?;
7177 let snapshot_count = snapshot_entries.len();
7178 let mut staged_entries = snapshot_entries;
7179 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
7180 for item in &remote.entries {
7181 let mut exact = serde_json::to_vec(&item.entry)
7182 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
7183 exact.push(b'\n');
7184 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
7185 return Err(invalid_feed(
7186 "serialized mirror entry differs from its verified hash",
7187 ));
7188 }
7189 staged_entries.push((
7190 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
7191 exact,
7192 ));
7193 }
7194 let mut identity_bytes = serde_json::to_vec(identity)
7195 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
7196 identity_bytes.push(b'\n');
7197 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
7198 let mut head_bytes = serde_json::to_vec(&json!({
7199 "brain": brain_id,
7200 "headSeq": remote.head.seq,
7201 "feedHash": remote.head.feed_hash,
7202 }))
7203 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
7204 head_bytes.push(b'\n');
7205 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
7206 staged_entries.push((
7207 CONFIG_REL_PATH.to_string(),
7208 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
7209 ));
7210 #[cfg(unix)]
7211 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
7212
7213 Ok(MirrorReport {
7214 brain: brain_id,
7215 head_seq: remote.head.seq,
7216 feed_hash: remote.head.feed_hash,
7217 entries: remote.entries.len() as u64,
7218 pinned: anchor,
7219 files: snapshot_count,
7220 })
7221 })();
7222
7223 let report = match assembled {
7224 Ok(report) => report,
7225 Err(error) => {
7226 #[cfg(unix)]
7227 let _ = remove_tree_at(
7228 parent_dir.as_raw_fd(),
7229 &stage_name,
7230 &dest.display().to_string(),
7231 );
7232 return Err(error);
7233 }
7234 };
7235
7236 #[cfg(unix)]
7237 if let Err(error) =
7238 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
7239 {
7240 let _ = remove_tree_at(
7241 parent_dir.as_raw_fd(),
7242 &stage_name,
7243 &dest.display().to_string(),
7244 );
7245 return Err(error);
7246 }
7247 #[cfg(unix)]
7250 if dest_exists {
7251 remove_tree_at(
7252 parent_dir.as_raw_fd(),
7253 &stage_name,
7254 &dest.display().to_string(),
7255 )?;
7256 }
7257 #[cfg(unix)]
7258 parent_dir.sync_all()?;
7259 Ok(report)
7260}
7261
7262fn verified_remote_head(
7263 cfg: &HubConfig,
7264 brain: &str,
7265 require_full_chain: bool,
7266) -> LinkResult<VerifiedRemote> {
7267 require_hardened_filesystem("verified link.md state")?;
7268 require_safe_ref(brain)?;
7269 let trust_directory = open_trust_dir(cfg)?;
7273 let path = format!("/api/hub/brains/{brain}");
7274 let body = ensure_ok(
7275 request(cfg, "GET", &path, None, Auth::Required)?,
7276 "subscribe",
7277 )?;
7278 let resolved_brain = body
7279 .get("id")
7280 .and_then(Value::as_str)
7281 .filter(|id| crate::ulid::is_ulid(id))
7282 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
7283 .to_string();
7284 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
7285 return Err(invalid_feed(
7286 "brain card id differs from the explicitly requested brain id",
7287 ));
7288 }
7289 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
7294 let (pinned, alias_binding) =
7295 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
7296 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
7297 let advertised_hash = body
7298 .get("feedHash")
7299 .and_then(Value::as_str)
7300 .map(str::to_string);
7301 let updated_at = body
7302 .get("updatedAt")
7303 .and_then(Value::as_str)
7304 .map(str::to_string);
7305 if let Some(pin) = &pinned {
7306 if seq < pin.head_seq {
7307 return Err(invalid_feed(format!(
7308 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
7309 pin.head_seq
7310 )));
7311 }
7312 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
7313 return Err(invalid_feed(
7314 "feed equivocation: the checkpoint sequence now has a different hash",
7315 ));
7316 }
7317 }
7318 if seq == 0 {
7319 if advertised_hash.is_some() {
7320 return Err(invalid_feed("an empty feed advertised a head hash"));
7321 }
7322 let identity: FeedIdentity = serde_json::from_value(
7323 body.get("identity")
7324 .cloned()
7325 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
7326 )
7327 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
7328 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
7329 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
7334 save_canonical_pin_and_alias(
7335 cfg,
7336 &trust_directory,
7337 brain,
7338 &resolved_brain,
7339 TrustState {
7340 v: 2,
7341 origin: normalized_origin(&cfg.hub)?,
7342 requested: resolved_brain.clone(),
7343 brain: resolved_brain.clone(),
7344 home: None,
7345 anchor: anchor.clone(),
7346 current: format!("ed25519:{}", identity.fingerprint),
7347 head_seq: 0,
7348 feed_hash: None,
7349 rotations: identity.rotations.clone(),
7350 hub_signer: None,
7351 protocol_profile: None,
7352 },
7353 alias_binding.as_ref(),
7354 )?;
7355 return Ok(VerifiedRemote {
7356 head: Head {
7357 brain: resolved_brain,
7358 seq,
7359 updated_at,
7360 feed_hash: None,
7361 verified: true,
7362 },
7363 identity: Some(identity),
7364 head_entry: None,
7365 entries: Vec::new(),
7366 anchor: Some(anchor),
7367 });
7368 }
7369 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
7370 return Err(invalid_feed(
7371 "non-empty feed did not advertise a valid SHA-256 head",
7372 ));
7373 }
7374
7375 let replay_head_only = !require_full_chain
7379 && pinned
7380 .as_ref()
7381 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
7382 let mut after = if replay_head_only {
7383 seq - 1
7384 } else if require_full_chain || pinned.is_none() {
7385 0
7386 } else {
7387 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
7388 };
7389 let mut expected_seq = after + 1;
7390 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
7391 None
7392 } else {
7393 pinned
7394 .as_ref()
7395 .and_then(|checkpoint| checkpoint.feed_hash.clone())
7396 };
7397 let mut identity: Option<FeedIdentity> = None;
7398 let mut anchor: Option<String> = None;
7399 let mut head_entry: Option<FeedItem> = None;
7400 let mut all_entries = Vec::new();
7401 let mut observed_entries = Vec::new();
7402 let replay_count = seq
7403 .checked_sub(after)
7404 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
7405 if replay_count > MAX_FEED_REPLAY_ENTRIES {
7406 return Err(invalid_feed(format!(
7407 "feed replay requires {replay_count} entries, over the client cap"
7408 )));
7409 }
7410 let mut replay_bytes = 0u64;
7411
7412 loop {
7413 let feed_bytes = ensure_raw_ok(
7414 request_raw(
7415 cfg,
7416 "GET",
7417 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
7418 None,
7419 Auth::Required,
7420 MAX_FEED_RESPONSE_BYTES,
7421 )?,
7422 "subscribe feed",
7423 )?;
7424 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
7425 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
7426 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
7427 return Err(invalid_feed("brain card and feed head disagree"));
7428 }
7429 if feed.entries.len() > FEED_PAGE_LIMIT {
7430 return Err(invalid_feed("feed page exceeds the requested entry limit"));
7431 }
7432 if feed.scope_limited {
7433 if require_full_chain {
7434 return Err(invalid_feed(
7435 "path-scoped grants cannot verify a full snapshot chain",
7436 ));
7437 }
7438 return Ok(VerifiedRemote {
7439 head: Head {
7440 brain: resolved_brain,
7441 seq,
7442 updated_at,
7443 feed_hash: advertised_hash,
7444 verified: false,
7445 },
7446 identity: None,
7447 head_entry: None,
7448 entries: Vec::new(),
7449 anchor: None,
7450 });
7451 }
7452 let page_identity = feed
7453 .identity
7454 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
7455 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
7456 if identity
7457 .as_ref()
7458 .is_some_and(|existing| existing != &page_identity)
7459 {
7460 return Err(invalid_feed("identity changed while reading the feed"));
7461 }
7462 if anchor
7463 .as_ref()
7464 .is_some_and(|existing| existing != &page_anchor)
7465 {
7466 return Err(invalid_feed(
7467 "identity anchor changed while reading the feed",
7468 ));
7469 }
7470 identity = Some(page_identity.clone());
7471 if anchor.is_none() {
7472 anchor = Some(page_anchor);
7473 }
7474 if feed.entries.is_empty() {
7475 return Err(invalid_feed("feed page was empty before the signed head"));
7476 }
7477
7478 for item in feed.entries {
7479 if item.entry.seq != expected_seq {
7480 return Err(invalid_feed(format!(
7481 "expected entry {expected_seq}, feed served {}",
7482 item.entry.seq
7483 )));
7484 }
7485 if item.entry.seq > seq {
7486 return Err(invalid_feed("feed advanced past the card snapshot"));
7487 }
7488 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
7489 return Err(invalid_feed(format!(
7490 "entry {} does not chain to the local checkpoint",
7491 item.entry.seq
7492 )));
7493 }
7494 verify_feed_item(&item, &page_identity)?;
7495 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
7496 replay_bytes = replay_bytes.saturating_add(
7497 serde_json::to_vec(&item)
7498 .map_err(|_| invalid_feed("could not size feed entry"))?
7499 .len() as u64,
7500 );
7501 if replay_bytes > MAX_FEED_REPLAY_BYTES {
7502 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
7503 }
7504 previous_hash = Some(item.hash.clone());
7505 after = item.entry.seq;
7506 expected_seq = expected_seq
7507 .checked_add(1)
7508 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
7509 if require_full_chain {
7510 all_entries.push(item.clone());
7511 }
7512 observed_entries.push(item.clone());
7513 head_entry = Some(item);
7514 }
7515 if after == seq {
7516 break;
7517 }
7518 }
7519
7520 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
7521 return Err(invalid_feed(
7522 "verified chain does not converge on the advertised head",
7523 ));
7524 }
7525 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
7526 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
7527 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
7528 save_canonical_pin_and_alias(
7529 cfg,
7530 &trust_directory,
7531 brain,
7532 &resolved_brain,
7533 TrustState {
7534 v: 2,
7535 origin: normalized_origin(&cfg.hub)?,
7536 requested: resolved_brain.clone(),
7537 brain: resolved_brain.clone(),
7538 home: None,
7539 anchor: anchor.clone(),
7540 current: format!("ed25519:{}", identity.fingerprint),
7541 head_seq: seq,
7542 feed_hash: advertised_hash.clone(),
7543 rotations: identity.rotations.clone(),
7544 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
7545 protocol_profile: pinned
7546 .as_ref()
7547 .and_then(|state| state.protocol_profile.clone()),
7548 },
7549 alias_binding.as_ref(),
7550 )?;
7551 Ok(VerifiedRemote {
7552 head: Head {
7553 brain: resolved_brain,
7554 seq,
7555 updated_at,
7556 feed_hash: advertised_hash,
7557 verified: true,
7558 },
7559 identity: Some(identity),
7560 head_entry,
7561 entries: all_entries,
7562 anchor: Some(anchor),
7563 })
7564}
7565
7566pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
7571 Ok(verified_remote_head(cfg, brain, false)?.head)
7572}
7573
7574#[cfg(test)]
7575mod tests {
7576 use super::*;
7577
7578 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
7579
7580 #[cfg(target_os = "linux")]
7581 #[test]
7582 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
7583 use std::os::fd::AsRawFd as _;
7584
7585 let sandbox = tempfile::TempDir::new().unwrap();
7586 let parent = std::fs::File::open(sandbox.path()).unwrap();
7587 let stage = std::ffi::CString::new("stage").unwrap();
7588 let destination = std::ffi::CString::new("brain").unwrap();
7589
7590 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
7591 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
7592 install_stage_at(
7593 parent.as_raw_fd(),
7594 stage.as_c_str(),
7595 destination.as_c_str(),
7596 false,
7597 )
7598 .unwrap();
7599 assert!(!sandbox.path().join("stage").exists());
7600 assert_eq!(
7601 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
7602 b"created"
7603 );
7604
7605 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
7606 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
7607 install_stage_at(
7608 parent.as_raw_fd(),
7609 stage.as_c_str(),
7610 destination.as_c_str(),
7611 true,
7612 )
7613 .unwrap();
7614 assert_eq!(
7615 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
7616 b"replacement"
7617 );
7618 assert_eq!(
7619 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
7620 b"created",
7621 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
7622 );
7623 }
7624
7625 struct SignedRemoteFixture {
7626 card: String,
7627 feed: String,
7628 key: AgentSigningKey,
7629 identity: FeedIdentity,
7630 }
7631
7632 fn signed_remote_fixture() -> SignedRemoteFixture {
7633 let rng = ring::rand::SystemRandom::new();
7634 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
7635 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
7636 let (public_key, multikey) = public_identity_for(&pair);
7637 let identity = FeedIdentity {
7638 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
7639 public_key_spki: public_key.clone(),
7640 previous: Vec::new(),
7641 rotations: Vec::new(),
7642 };
7643 let mut entry = FeedEntry {
7644 v: 1,
7645 seq: 1,
7646 ts: "2026-07-30T12:00:00.000Z".to_string(),
7647 brain: multikey.clone(),
7648 public_key: public_key.clone(),
7649 kind: "push".to_string(),
7650 op: "snapshot".to_string(),
7651 pack_sha256: "a".repeat(64),
7652 files: Vec::new(),
7653 removed: Vec::new(),
7654 prev_entry_hash: None,
7655 sig: String::new(),
7656 };
7657 let unsigned = UnsignedFeedEntry {
7658 v: entry.v,
7659 seq: entry.seq,
7660 ts: &entry.ts,
7661 brain: &entry.brain,
7662 public_key: &entry.public_key,
7663 kind: &entry.kind,
7664 op: &entry.op,
7665 pack_sha256: &entry.pack_sha256,
7666 files: &entry.files,
7667 removed: &entry.removed,
7668 prev_entry_hash: &entry.prev_entry_hash,
7669 };
7670 entry.sig =
7671 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
7672 let mut exact = serde_json::to_vec(&entry).unwrap();
7673 exact.push(b'\n');
7674 let hash = content_sha256(&exact);
7675 let card = json!({
7676 "id": TEST_BRAIN_ID,
7677 "headSeq": 1,
7678 "feedHash": hash,
7679 "identity": identity.clone(),
7680 })
7681 .to_string();
7682 let feed = json!({
7683 "headSeq": 1,
7684 "feedHash": hash,
7685 "identity": identity.clone(),
7686 "entries": [{"hash": hash, "entry": entry}],
7687 "scopeLimited": false,
7688 })
7689 .to_string();
7690 SignedRemoteFixture {
7691 card,
7692 feed,
7693 key: AgentSigningKey {
7694 pkcs8: pkcs8.as_ref().to_vec(),
7695 multikey,
7696 public_key_spki: public_key,
7697 },
7698 identity,
7699 }
7700 }
7701
7702 #[test]
7703 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
7704 let fixture = signed_remote_fixture();
7705 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
7706 let item = feed["entries"][0].to_string();
7707 let oversized_page = format!(
7708 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
7709 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
7710 .collect::<Vec<_>>()
7711 .join(",")
7712 );
7713 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
7714
7715 let oversized_identity = format!(
7716 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
7717 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
7718 .collect::<Vec<_>>()
7719 .join(",")
7720 );
7721 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
7722
7723 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
7724 let oversized_entry = format!(
7725 "{{\"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\"}}",
7726 "a".repeat(64),
7727 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
7728 .collect::<Vec<_>>()
7729 .join(",")
7730 );
7731 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
7732 }
7733
7734 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
7735 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
7736 use std::net::TcpListener;
7737
7738 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
7739 let url = format!("http://{}", listener.local_addr().unwrap());
7740 let handle = std::thread::spawn(move || {
7741 for (status, body) in responses {
7742 let (stream, _) = listener.accept().unwrap();
7743 let mut reader = BufReader::new(stream);
7744 let mut line = String::new();
7745 reader.read_line(&mut line).unwrap();
7746 let mut content_length = 0usize;
7747 loop {
7748 line.clear();
7749 reader.read_line(&mut line).unwrap();
7750 if line == "\r\n" || line == "\n" || line.is_empty() {
7751 break;
7752 }
7753 if let Some((name, value)) = line.split_once(':') {
7754 if name.eq_ignore_ascii_case("content-length") {
7755 content_length = value.trim().parse().unwrap();
7756 }
7757 }
7758 }
7759 let mut request_body = vec![0_u8; content_length];
7760 reader.read_exact(&mut request_body).unwrap();
7761 let response = format!(
7762 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
7763 body.len()
7764 );
7765 reader.get_mut().write_all(response.as_bytes()).unwrap();
7766 }
7767 });
7768 (url, handle)
7769 }
7770
7771 fn routed_json_hub(
7772 requests: usize,
7773 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
7774 ) -> (String, std::thread::JoinHandle<()>) {
7775 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
7776 use std::net::TcpListener;
7777
7778 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
7779 let url = format!("http://{}", listener.local_addr().unwrap());
7780 let handle = std::thread::spawn(move || {
7781 for _ in 0..requests {
7782 let (stream, _) = listener.accept().unwrap();
7783 let mut reader = BufReader::new(stream);
7784 let mut line = String::new();
7785 reader.read_line(&mut line).unwrap();
7786 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
7787 let mut content_length = 0usize;
7788 loop {
7789 line.clear();
7790 reader.read_line(&mut line).unwrap();
7791 if line == "\r\n" || line == "\n" || line.is_empty() {
7792 break;
7793 }
7794 if let Some((name, value)) = line.split_once(':') {
7795 if name.eq_ignore_ascii_case("content-length") {
7796 content_length = value.trim().parse().unwrap();
7797 }
7798 }
7799 }
7800 let mut request_body = vec![0_u8; content_length];
7801 reader.read_exact(&mut request_body).unwrap();
7802 let (status, body) = respond(&path);
7803 let response = format!(
7804 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
7805 body.len()
7806 );
7807 reader.get_mut().write_all(response.as_bytes()).unwrap();
7808 }
7809 });
7810 (url, handle)
7811 }
7812
7813 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
7814 HubConfig {
7815 hub,
7816 key: Some("test-key".to_string()),
7817 agent_key: None,
7818 brain_key: None,
7819 state_dir,
7820 store_selected: false,
7821 }
7822 }
7823
7824 #[test]
7825 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
7826 use ring::signature::KeyPair as _;
7827
7828 let rng = ring::rand::SystemRandom::new();
7829 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
7830 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
7831 let (spki, multikey) = public_identity_for(&pair);
7832 let key = AgentSigningKey {
7833 pkcs8: pkcs8.as_ref().to_vec(),
7834 multikey,
7835 public_key_spki: spki,
7836 };
7837 let header = linkmd_sig_header(
7838 &key,
7839 "https://hub-a.example",
7840 "post",
7841 "/api/hub/brains/brain/push?mode=exact",
7842 Some("{\"ok\":true}"),
7843 )
7844 .unwrap();
7845 assert!(header.starts_with("LinkMD-Sig v2,"));
7846 let ts = header
7847 .split(",ts=")
7848 .nth(1)
7849 .unwrap()
7850 .split(',')
7851 .next()
7852 .unwrap();
7853 let signature = URL_SAFE_NO_PAD
7854 .decode(header.rsplit(",sig=").next().unwrap())
7855 .unwrap();
7856 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
7857 let accepted = format!(
7858 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
7859 );
7860 let replayed = format!(
7861 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
7862 );
7863 let public = pair.public_key().as_ref();
7864 assert!(UnparsedPublicKey::new(&ED25519, public)
7865 .verify(accepted.as_bytes(), &signature)
7866 .is_ok());
7867 assert!(
7868 UnparsedPublicKey::new(&ED25519, public)
7869 .verify(replayed.as_bytes(), &signature)
7870 .is_err(),
7871 "a proof captured at hub A must not authenticate at hub B"
7872 );
7873 }
7874
7875 #[test]
7876 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
7877 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
7878 let card = json!({
7879 "id": other,
7880 "headSeq": 0,
7881 "identity": signed_remote_fixture().identity,
7882 })
7883 .to_string();
7884 let (hub, server) = scripted_json_hub(vec![(200, card)]);
7885 let state = tempfile::tempdir().unwrap();
7886 let cfg = test_hub_config(hub, state.path().to_path_buf());
7887 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
7888 assert!(
7889 error.contains("differs from the explicitly requested"),
7890 "{error}"
7891 );
7892 server.join().unwrap();
7893 }
7894
7895 #[test]
7896 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
7897 let first = signed_remote_fixture().identity;
7898 let second = signed_remote_fixture().identity;
7899 let card = |identity: FeedIdentity| {
7900 json!({
7901 "id": TEST_BRAIN_ID,
7902 "headSeq": 0,
7903 "identity": identity,
7904 })
7905 .to_string()
7906 };
7907 let (hub, server) = scripted_json_hub(vec![(200, card(first)), (200, card(second))]);
7908 let state = tempfile::tempdir().unwrap();
7909 let cfg = test_hub_config(hub, state.path().to_path_buf());
7910 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
7911 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
7912 assert!(
7913 error.contains("pinned anchor") || error.contains("forked away"),
7914 "{error}"
7915 );
7916 server.join().unwrap();
7917 }
7918
7919 #[test]
7920 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
7921 let old = signed_remote_fixture();
7922 let new = signed_remote_fixture();
7923 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
7924 let unsigned = serde_json::to_string(&UnsignedRotation {
7925 v: 1,
7926 op: "rotate",
7927 brain: &old.key.multikey,
7928 public_key: &old.key.public_key_spki,
7929 new_brain: &new.key.multikey,
7930 new_public_key: &new.key.public_key_spki,
7931 prior_head_seq: 1,
7932 prior_feed_hash: Some(&"a".repeat(64)),
7933 ts: "2026-07-30T12:00:00.000Z".to_string(),
7934 })
7935 .unwrap();
7936 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
7937 let rotation = format!(
7938 "{},\"sig\":\"{}\"}}",
7939 &unsigned[..unsigned.len() - 1],
7940 signature
7941 );
7942 let identity = FeedIdentity {
7943 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
7944 public_key_spki: new.key.public_key_spki,
7945 previous: vec![PreviousIdentity {
7946 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
7947 public_key_spki: old.key.public_key_spki,
7948 }],
7949 rotations: vec![rotation],
7950 };
7951 let card = json!({
7952 "id": TEST_BRAIN_ID,
7953 "headSeq": 0,
7954 "feedHash": null,
7955 "identity": identity,
7956 })
7957 .to_string();
7958 let (hub, server) = scripted_json_hub(vec![(200, card)]);
7959 let state = tempfile::tempdir().unwrap();
7960 let cfg = test_hub_config(hub, state.path().to_path_buf());
7961 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
7962 assert!(
7963 error.contains("rotation claims a feed boundary beyond the advertised head"),
7964 "{error}"
7965 );
7966 assert!(
7967 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
7968 "an inconsistent empty-head identity must not become the TOFU checkpoint"
7969 );
7970 server.join().unwrap();
7971 }
7972
7973 #[test]
7974 fn trust_checkpoint_rejects_a_later_fork() {
7975 let fixture = signed_remote_fixture();
7976 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
7977 fork["feedHash"] = Value::String("b".repeat(64));
7978 let (hub, server) = scripted_json_hub(vec![
7979 (200, fixture.card),
7980 (200, fixture.feed),
7981 (200, fork.to_string()),
7982 ]);
7983 let state = tempfile::tempdir().unwrap();
7984 let cfg = test_hub_config(hub, state.path().to_path_buf());
7985 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
7986 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
7987 server.join().unwrap();
7988 }
7989
7990 #[test]
7991 fn alias_and_canonical_id_share_one_identity_checkpoint() {
7992 let trusted = signed_remote_fixture();
7993 let attacker = signed_remote_fixture();
7994 let (hub, server) = scripted_json_hub(vec![
7995 (200, trusted.card),
7996 (200, trusted.feed),
7997 (200, attacker.card),
7998 ]);
7999 let state = tempfile::tempdir().unwrap();
8000 let cfg = test_hub_config(hub, state.path().to_path_buf());
8001 assert!(head(&cfg, "trusted-slug").unwrap().verified);
8002 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
8003 assert!(
8004 error.contains("equivocation")
8005 || error.contains("pinned")
8006 || error.contains("identity"),
8007 "{error}"
8008 );
8009 server.join().unwrap();
8010 }
8011
8012 #[test]
8013 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
8014 let alpha = signed_remote_fixture();
8015 let beta = signed_remote_fixture();
8016 let alpha_card = alpha.card.clone();
8017 let alpha_feed = alpha.feed.clone();
8018 let beta_card = beta.card.clone();
8019 let beta_feed = beta.feed.clone();
8020 let (hub, server) = routed_json_hub(3, move |path| {
8021 if path.contains("/alpha/feed?") {
8022 (200, alpha_feed.clone())
8023 } else if path.contains("/beta/feed?") {
8024 (200, beta_feed.clone())
8025 } else if path.ends_with("/alpha") {
8026 (200, alpha_card.clone())
8027 } else if path.ends_with("/beta") {
8028 (200, beta_card.clone())
8029 } else {
8030 (500, r#"{"error":"unexpected path"}"#.to_string())
8031 }
8032 });
8033 let state = tempfile::tempdir().unwrap();
8034 let cfg = test_hub_config(hub, state.path().to_path_buf());
8035 let alpha_cfg = cfg.clone();
8036 let beta_cfg = cfg;
8037 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
8038 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
8039 let results = [first.join().unwrap(), second.join().unwrap()];
8040 assert_eq!(
8041 results.iter().filter(|result| result.is_ok()).count(),
8042 1,
8043 "only one alias identity may establish canonical TOFU: {results:?}"
8044 );
8045 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
8046 server.join().unwrap();
8047 }
8048
8049 #[cfg(unix)]
8050 #[test]
8051 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
8052 use std::os::unix::fs::symlink;
8053
8054 let fixture = signed_remote_fixture();
8055 let card = json!({
8056 "id": TEST_BRAIN_ID,
8057 "headSeq": 0,
8058 "feedHash": Value::Null,
8059 "identity": fixture.identity,
8060 })
8061 .to_string();
8062 let work = tempfile::tempdir().unwrap();
8063 let outside = tempfile::tempdir().unwrap();
8064 let state = work.path().join("state");
8065 let moved = work.path().join("state-held");
8066 let swap_state = state.clone();
8067 let swap_moved = moved.clone();
8068 let outside_path = outside.path().to_path_buf();
8069 let (hub, server) = routed_json_hub(1, move |_| {
8070 std::fs::rename(&swap_state, &swap_moved).unwrap();
8072 symlink(&outside_path, &swap_state).unwrap();
8073 (200, card.clone())
8074 });
8075 let cfg = test_hub_config(hub, state);
8076
8077 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
8078 assert_eq!(verified.head.seq, 0);
8079 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
8080 assert!(std::fs::read_dir(moved.join("trust"))
8081 .unwrap()
8082 .flatten()
8083 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
8084 server.join().unwrap();
8085 }
8086
8087 #[test]
8088 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
8089 let remote = signed_remote_fixture();
8090 let unrelated = signed_remote_fixture().key;
8091 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
8092 let state = tempfile::tempdir().unwrap();
8093 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
8094 cfg.brain_key = Some(unrelated);
8095 let error = sync_push(
8096 &cfg,
8097 TEST_BRAIN_ID,
8098 &[("DB.md".to_string(), "signed local content".to_string())],
8099 )
8100 .unwrap_err()
8101 .to_string();
8102 assert!(
8103 error.contains("not the verified current brain identity"),
8104 "{error}"
8105 );
8106 server.join().unwrap();
8107 }
8108
8109 #[test]
8110 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
8111 let remote = signed_remote_fixture();
8112 let new = signed_remote_fixture().key;
8113 let state = tempfile::tempdir().unwrap();
8114 let new_file = state.path().join("new.key");
8115 std::fs::write(
8116 &new_file,
8117 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
8118 )
8119 .unwrap();
8120 #[cfg(unix)]
8121 {
8122 use std::os::unix::fs::PermissionsExt as _;
8123 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
8124 }
8125 let forged = json!({
8126 "brain": TEST_BRAIN_ID,
8127 "identity": {
8128 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
8129 "publicKeySpki": new.public_key_spki,
8130 }
8131 })
8132 .to_string();
8133 let (hub, server) = scripted_json_hub(vec![
8134 (200, remote.card.clone()),
8135 (200, remote.feed.clone()),
8136 (200, forged),
8137 (200, remote.card),
8138 (200, remote.feed),
8139 ]);
8140 let cfg = test_hub_config(hub, state.path().to_path_buf());
8141 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
8142 .unwrap_err()
8143 .to_string();
8144 assert!(
8145 error.contains("without committing the verified new identity"),
8146 "{error}"
8147 );
8148 server.join().unwrap();
8149 }
8150
8151 #[test]
8152 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
8153 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
8154 let raw = format!(
8155 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
8156 );
8157 let pack = build_store_pack(&[
8158 (
8159 "DB.md".to_string(),
8160 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
8161 ),
8162 ("records/clients/truth.md".to_string(), raw.clone()),
8163 ])
8164 .unwrap();
8165 let by_id = resolve_from_verified_pack(
8166 "01j5qc3v9k4ym8rwbn2tqe6f7d",
8167 &AddressTarget::Id(record_id.to_string()),
8168 pack.clone(),
8169 )
8170 .unwrap();
8171 assert_eq!(by_id["document"]["summary"], "Signed truth");
8172 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
8173 assert_eq!(
8174 by_id["document"]["contentSha"],
8175 content_sha256(raw.as_bytes())
8176 );
8177
8178 let by_path = resolve_from_verified_pack(
8179 "01j5qc3v9k4ym8rwbn2tqe6f7d",
8180 &AddressTarget::Path("records/clients/truth.md".to_string()),
8181 pack,
8182 )
8183 .unwrap();
8184 assert_eq!(by_path["document"]["id"], record_id);
8185 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
8186 }
8187
8188 #[test]
8189 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
8190 let unsorted = vec![
8191 ("records/a.md".to_string(), "alpha\n".to_string()),
8192 ("DB.md".to_string(), "# db\n".to_string()),
8193 ];
8194 let sorted = vec![
8195 ("DB.md".to_string(), "# db\n".to_string()),
8196 ("records/a.md".to_string(), "alpha\n".to_string()),
8197 ];
8198 let pack = build_store_pack(&unsorted).unwrap();
8199
8200 assert_eq!(pack.len(), 219);
8205 assert_eq!(
8206 content_sha256(&pack),
8207 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
8208 );
8209 assert_eq!(pack, build_store_pack(&sorted).unwrap());
8210 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
8211 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
8212 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
8213
8214 assert_eq!(
8215 parse_store_pack(pack).unwrap(),
8216 vec![
8217 ("DB.md".to_string(), b"# db\n".to_vec()),
8218 ("records/a.md".to_string(), b"alpha\n".to_vec()),
8219 ]
8220 );
8221 }
8222
8223 #[test]
8224 fn canonical_store_pack_validates_every_path_before_writing() {
8225 let duplicate = vec![
8226 ("DB.md".to_string(), "first".to_string()),
8227 ("DB.md".to_string(), "second".to_string()),
8228 ];
8229 assert!(build_store_pack(&duplicate)
8230 .unwrap_err()
8231 .to_string()
8232 .contains("duplicate path"));
8233 assert!(matches!(
8234 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
8235 Err(LinkError::UnsafePath { .. })
8236 ));
8237 }
8238
8239 #[test]
8240 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
8241 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
8242 let mut bytes = vec![0_u8];
8245 let zip64_offset = bytes.len() as u64;
8246 bytes.extend_from_slice(b"PK\x06\x06");
8247 bytes.extend_from_slice(&44_u64.to_le_bytes());
8248 bytes.extend_from_slice(&[0_u8; 12]);
8249 bytes.extend_from_slice(&COUNT.to_le_bytes());
8250 bytes.extend_from_slice(&COUNT.to_le_bytes());
8251 bytes.extend_from_slice(&1_u64.to_le_bytes());
8252 bytes.extend_from_slice(&0_u64.to_le_bytes());
8253 bytes.extend_from_slice(b"PK\x06\x07");
8254 bytes.extend_from_slice(&0_u32.to_le_bytes());
8255 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
8256 bytes.extend_from_slice(&1_u32.to_le_bytes());
8257 bytes.extend_from_slice(b"PK\x05\x06");
8258 bytes.extend_from_slice(&0_u16.to_le_bytes());
8259 bytes.extend_from_slice(&0_u16.to_le_bytes());
8260 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
8261 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
8262 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
8263 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
8264 bytes.extend_from_slice(&0_u16.to_le_bytes());
8265
8266 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
8267 .unwrap_err()
8268 .to_string();
8269 assert!(error.contains("invalid file count"), "{error}");
8270 }
8271
8272 #[test]
8273 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
8274 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
8275 let mut bytes = vec![0_u8];
8276 let zip64_offset = bytes.len() as u64;
8277 bytes.extend_from_slice(b"PK\x06\x06");
8278 bytes.extend_from_slice(&44_u64.to_le_bytes());
8279 bytes.extend_from_slice(&[0_u8; 12]);
8280 bytes.extend_from_slice(&COUNT.to_le_bytes());
8281 bytes.extend_from_slice(&COUNT.to_le_bytes());
8282 bytes.extend_from_slice(&1_u64.to_le_bytes());
8283 bytes.extend_from_slice(&0_u64.to_le_bytes());
8284 bytes.extend_from_slice(b"PK\x06\x07");
8285 bytes.extend_from_slice(&0_u32.to_le_bytes());
8286 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
8287 bytes.extend_from_slice(&1_u32.to_le_bytes());
8288 bytes.extend_from_slice(b"PK\x05\x06");
8289 bytes.extend_from_slice(&0_u16.to_le_bytes());
8290 bytes.extend_from_slice(&0_u16.to_le_bytes());
8291 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
8292 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
8293 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
8294 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
8295 bytes.extend_from_slice(&0_u16.to_le_bytes());
8296 let fake_eocd = bytes.len() as u32;
8300 bytes.extend_from_slice(b"PK\x05\x06");
8301 bytes.extend_from_slice(&0_u16.to_le_bytes());
8302 bytes.extend_from_slice(&0_u16.to_le_bytes());
8303 bytes.extend_from_slice(&1_u16.to_le_bytes());
8304 bytes.extend_from_slice(&1_u16.to_le_bytes());
8305 bytes.extend_from_slice(&0_u32.to_le_bytes());
8306 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
8307 bytes.extend_from_slice(&0_u16.to_le_bytes());
8308
8309 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
8310 .unwrap_err()
8311 .to_string();
8312 assert!(error.contains("central directory"), "{error}");
8313 }
8314
8315 #[test]
8316 fn strict_http_status_handling_rejects_redirects_without_panicking() {
8317 let error = ensure_ok(
8318 HubResponse {
8319 status: 302,
8320 body: Some(json!({"redirect": "/elsewhere"})),
8321 },
8322 "mutation",
8323 )
8324 .unwrap_err();
8325 assert!(matches!(error, LinkError::Http { status: 302, .. }));
8326
8327 let error = ensure_raw_ok(
8328 RawHubResponse {
8329 status: 302,
8330 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
8331 },
8332 "feed",
8333 )
8334 .unwrap_err();
8335 assert!(matches!(error, LinkError::Http { status: 302, .. }));
8336 }
8337
8338 #[cfg(unix)]
8339 #[test]
8340 fn collect_push_files_refuses_external_symlink_and_nested_store() {
8341 use std::os::unix::fs::symlink;
8342
8343 let root = tempfile::tempdir().unwrap();
8344 std::fs::write(
8345 root.path().join("DB.md"),
8346 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
8347 )
8348 .unwrap();
8349 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
8350
8351 let external = tempfile::tempdir().unwrap();
8352 let secret = external.path().join("secret.md");
8353 std::fs::write(&secret, "TOP SECRET").unwrap();
8354 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
8355
8356 let store = Store::open_strict(root.path()).unwrap();
8357 let err = collect_push_files(&store).unwrap_err().to_string();
8358 assert!(err.contains("cannot push"), "{err}");
8359 assert!(
8360 !err.contains("TOP SECRET"),
8361 "external bytes must never leak"
8362 );
8363
8364 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
8365 let nested = root.path().join("records/nested");
8366 std::fs::create_dir_all(&nested).unwrap();
8367 std::fs::write(
8368 nested.join("DB.md"),
8369 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
8370 )
8371 .unwrap();
8372 let err = collect_push_files(&store).unwrap_err().to_string();
8373 assert!(err.contains("nested db.md store"), "{err}");
8374 }
8375
8376 #[cfg(unix)]
8377 #[test]
8378 fn remote_push_uses_opened_root_after_path_replacement() {
8379 use std::os::unix::fs::symlink;
8380
8381 let sandbox = tempfile::tempdir().unwrap();
8382 let root = sandbox.path().join("store");
8383 std::fs::create_dir_all(root.join("records/notes")).unwrap();
8384 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
8385 std::fs::write(
8386 root.join("records/notes/owned.md"),
8387 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
8388 )
8389 .unwrap();
8390 let store = Store::open_strict(&root).unwrap();
8391 let detached = sandbox.path().join("detached");
8392 std::fs::rename(&root, &detached).unwrap();
8393
8394 let replacement = sandbox.path().join("replacement");
8395 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
8396 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
8397 std::fs::write(
8398 replacement.join("records/notes/secret.md"),
8399 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
8400 )
8401 .unwrap();
8402 symlink(&replacement, &root).unwrap();
8403
8404 let files = collect_push_files(&store).unwrap();
8405 let wire_text = files
8406 .iter()
8407 .map(|(path, content)| format!("{path}\n{content}"))
8408 .collect::<Vec<_>>()
8409 .join("\n");
8410 assert!(wire_text.contains("owned upload"));
8411 assert!(!wire_text.contains("replacement sentinel"));
8412 assert!(!wire_text.contains("records/notes/secret.md"));
8413
8414 let remote = signed_remote_fixture();
8415 let (hub, server) = scripted_json_hub(vec![
8416 (200, remote.card),
8417 (200, remote.feed),
8418 (200, json!({"ok": true}).to_string()),
8419 ]);
8420 let state = tempfile::tempdir().unwrap();
8421 let cfg = test_hub_config(hub, state.path().to_path_buf());
8422 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
8423 assert_eq!(pushed, json!({"ok": true}));
8424 server.join().unwrap();
8425 }
8426
8427 #[test]
8428 fn signed_feed_item_verifies_identity_hash_and_signature() {
8429 use ring::rand::SystemRandom;
8430 use ring::signature::{Ed25519KeyPair, KeyPair};
8431
8432 const PREFIX: &[u8] = &[
8433 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
8434 ];
8435 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
8436 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
8437 let mut spki = PREFIX.to_vec();
8438 spki.extend_from_slice(pair.public_key().as_ref());
8439 let public_key = URL_SAFE_NO_PAD.encode(&spki);
8440 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
8441 let mut entry = FeedEntry {
8442 v: 1,
8443 seq: 1,
8444 ts: "2026-07-14T00:00:00.000Z".to_string(),
8445 brain: format!("ed25519:{fingerprint}"),
8446 public_key: public_key.clone(),
8447 kind: "push".to_string(),
8448 op: "snapshot".to_string(),
8449 pack_sha256: "a".repeat(64),
8450 files: vec![FeedFile {
8451 path: "DB.md".to_string(),
8452 sha256: "b".repeat(64),
8453 bytes: 3,
8454 }],
8455 removed: vec![],
8456 prev_entry_hash: None,
8457 sig: String::new(),
8458 };
8459 let unsigned = UnsignedFeedEntry {
8460 v: entry.v,
8461 seq: entry.seq,
8462 ts: &entry.ts,
8463 brain: &entry.brain,
8464 public_key: &entry.public_key,
8465 kind: &entry.kind,
8466 op: &entry.op,
8467 pack_sha256: &entry.pack_sha256,
8468 files: &entry.files,
8469 removed: &entry.removed,
8470 prev_entry_hash: &entry.prev_entry_hash,
8471 };
8472 entry.sig =
8473 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
8474 let mut exact = serde_json::to_vec(&entry).unwrap();
8475 exact.push(b'\n');
8476 let item = FeedItem {
8477 hash: format!("{:x}", Sha256::digest(&exact)),
8478 entry,
8479 };
8480 let identity = FeedIdentity {
8481 fingerprint,
8482 public_key_spki: public_key,
8483 previous: Vec::new(),
8484 rotations: Vec::new(),
8485 };
8486 assert!(verify_feed_item(&item, &identity).is_ok());
8487 let mut tampered = item;
8488 tampered.entry.pack_sha256 = "c".repeat(64);
8489 assert!(verify_feed_item(&tampered, &identity).is_err());
8490 }
8491
8492 #[test]
8493 fn a_self_custody_entry_verifies_like_any_hub_entry() {
8494 let rng = ring::rand::SystemRandom::new();
8495 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
8496 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
8497 let (spki, multikey) = public_identity_for(&pair);
8498 let key = AgentSigningKey {
8499 pkcs8: pkcs8.as_ref().to_vec(),
8500 multikey: multikey.clone(),
8501 public_key_spki: spki.clone(),
8502 };
8503 let files = vec![WireFeedFile {
8504 path: "DB.md".to_string(),
8505 sha256: "a".repeat(64),
8506 bytes: 3,
8507 }];
8508 let raw = self_custody_entry(
8509 &key,
8510 1,
8511 "2026-07-23T12:00:00.000Z".to_string(),
8512 &"c".repeat(64),
8513 &files,
8514 None,
8515 )
8516 .unwrap();
8517 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
8521 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
8522 let item = FeedItem { hash, entry };
8523 let identity = FeedIdentity {
8524 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
8525 public_key_spki: spki,
8526 previous: Vec::new(),
8527 rotations: Vec::new(),
8528 };
8529 assert!(verify_feed_item(&item, &identity).is_ok());
8530 }
8531
8532 #[test]
8533 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
8534 let rng = ring::rand::SystemRandom::new();
8535 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
8536 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
8537 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
8538 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
8539 let (old_spki, old_multikey) = public_identity_for(&old);
8540 let (new_spki, new_multikey) = public_identity_for(&new);
8541 let unsigned = serde_json::to_string(&UnsignedRotation {
8542 v: 1,
8543 op: "rotate",
8544 brain: &old_multikey,
8545 public_key: &old_spki,
8546 new_brain: &new_multikey,
8547 new_public_key: &new_spki,
8548 prior_head_seq: 1,
8549 prior_feed_hash: Some(&"a".repeat(64)),
8550 ts: "2026-07-30T12:00:00.000Z".to_string(),
8551 })
8552 .unwrap();
8553 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
8554 let rotation = format!(
8555 "{},\"sig\":\"{}\"}}",
8556 &unsigned[..unsigned.len() - 1],
8557 signature
8558 );
8559 let identity = FeedIdentity {
8560 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
8561 public_key_spki: new_spki,
8562 previous: vec![PreviousIdentity {
8563 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
8564 public_key_spki: old_spki,
8565 }],
8566 rotations: vec![rotation],
8567 };
8568 let pin = TrustState {
8569 v: 2,
8570 origin: "https://hub.example".to_string(),
8571 requested: "brain".to_string(),
8572 brain: "brain".to_string(),
8573 home: None,
8574 anchor: old_multikey.clone(),
8575 current: old_multikey.clone(),
8576 head_seq: 1,
8577 feed_hash: Some("a".repeat(64)),
8578 rotations: Vec::new(),
8579 hub_signer: None,
8580 protocol_profile: None,
8581 };
8582 assert_eq!(
8583 verify_identity_chain(&identity, Some(&pin)).unwrap(),
8584 old_multikey
8585 );
8586 let mut accepted = pin.clone();
8587 accepted.current = new_multikey.clone();
8588 accepted.rotations = identity.rotations.clone();
8589 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
8590 v: 1,
8591 op: "rotate",
8592 brain: &old_multikey,
8593 public_key: &identity.previous[0].public_key_spki,
8594 new_brain: &new_multikey,
8595 new_public_key: &identity.public_key_spki,
8596 prior_head_seq: 1,
8597 prior_feed_hash: Some(&"a".repeat(64)),
8598 ts: "2026-07-30T12:00:01.000Z".to_string(),
8599 })
8600 .unwrap();
8601 let alternate_signature =
8602 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
8603 let mut rewritten = identity.clone();
8604 rewritten.rotations[0] = format!(
8605 "{},\"sig\":\"{}\"}}",
8606 &alternate_unsigned[..alternate_unsigned.len() - 1],
8607 alternate_signature
8608 );
8609 assert!(
8610 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
8611 "an alternate valid statement must not rewrite accepted history"
8612 );
8613
8614 let mut stale_entry = FeedEntry {
8615 v: 1,
8616 seq: 2,
8617 ts: "2026-07-30T12:01:00.000Z".to_string(),
8618 brain: pin.current.clone(),
8619 public_key: identity.previous[0].public_key_spki.clone(),
8620 kind: "push".to_string(),
8621 op: "snapshot".to_string(),
8622 pack_sha256: "b".repeat(64),
8623 files: Vec::new(),
8624 removed: Vec::new(),
8625 prev_entry_hash: pin.feed_hash.clone(),
8626 sig: String::new(),
8627 };
8628 let stale_unsigned = UnsignedFeedEntry {
8629 v: stale_entry.v,
8630 seq: stale_entry.seq,
8631 ts: &stale_entry.ts,
8632 brain: &stale_entry.brain,
8633 public_key: &stale_entry.public_key,
8634 kind: &stale_entry.kind,
8635 op: &stale_entry.op,
8636 pack_sha256: &stale_entry.pack_sha256,
8637 files: &stale_entry.files,
8638 removed: &stale_entry.removed,
8639 prev_entry_hash: &stale_entry.prev_entry_hash,
8640 };
8641 stale_entry.sig = URL_SAFE_NO_PAD.encode(
8642 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
8643 .as_ref(),
8644 );
8645 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
8646 stale_exact.push(b'\n');
8647 let stale_item = FeedItem {
8648 hash: content_sha256(&stale_exact),
8649 entry: stale_entry,
8650 };
8651 assert!(
8652 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
8653 .is_err(),
8654 "a key retired before the checkpoint must never append after it"
8655 );
8656 assert!(
8657 verify_feed_item(&stale_item, &identity).is_err(),
8658 "an old key must never append after its signed rotation boundary"
8659 );
8660
8661 let mut missing = identity.clone();
8662 missing.rotations.clear();
8663 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
8664
8665 let mut tampered = identity;
8666 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
8667 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
8668 }
8669
8670 #[cfg(unix)]
8671 #[test]
8672 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
8673 use std::os::unix::fs::symlink;
8674
8675 let dir = tempfile::tempdir().unwrap();
8676 let target = dir.path().join("valuable.txt");
8677 let planted = dir.path().join("agent.key");
8678 std::fs::write(&target, "do not overwrite").unwrap();
8679 symlink(&target, &planted).unwrap();
8680
8681 assert!(matches!(
8682 generate_agent_key(&planted),
8683 Err(LinkError::BadAgentKey { .. })
8684 ));
8685 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
8686 }
8687
8688 #[cfg(unix)]
8689 #[test]
8690 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
8691 use std::os::unix::fs::symlink;
8692
8693 let root = tempfile::tempdir().unwrap();
8694 let outside = tempfile::tempdir().unwrap();
8695 symlink(outside.path(), root.path().join("redirect")).unwrap();
8696
8697 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
8698 assert!(!outside.path().join("agent.key").exists());
8699 }
8700
8701 #[test]
8704 fn address_bare_brain_with_and_without_sigil() {
8705 for raw in ["@acme-ops", "acme-ops"] {
8706 let a = Address::parse(raw).expect(raw);
8707 assert_eq!(a.brain, "acme-ops");
8708 assert_eq!(a.target, None);
8709 }
8710 }
8711
8712 #[test]
8713 fn address_ulid_target_parses_as_id() {
8714 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
8715 assert_eq!(a.brain, "acme");
8716 assert_eq!(
8717 a.target,
8718 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
8719 );
8720 }
8721
8722 #[test]
8723 fn address_md_path_target_parses_as_path() {
8724 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
8725 assert_eq!(
8726 a.target,
8727 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
8728 );
8729 }
8730
8731 #[test]
8732 fn address_rejects_malformed_forms() {
8733 for raw in [
8734 "",
8735 "@",
8736 "@/x",
8737 "@acme/",
8738 "@acme/../etc/passwd",
8739 "@acme/records/.hidden.md",
8740 "@ACME", "@acme/notes/x.txt", "@a b", ] {
8744 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
8745 }
8746 }
8747
8748 #[test]
8751 fn safe_paths_accept_store_shapes_and_reject_escapes() {
8752 for ok in [
8753 "DB.md",
8754 "assets.jsonl",
8755 "records/clients/lumio.md",
8756 "sources/emails/2026/07/x.md",
8757 ] {
8758 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
8759 }
8760 for bad in [
8761 "",
8762 "/etc/passwd",
8763 "../up.md",
8764 "records/../../up.md",
8765 "records//x.md",
8766 ".dbmd/config",
8767 "records/.hidden/x.md",
8768 "records/a b.md",
8769 "records\\win.md",
8770 ] {
8771 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
8772 }
8773 }
8774
8775 #[cfg(unix)]
8776 #[test]
8777 fn opened_destination_capability_survives_an_ancestor_path_swap() {
8778 use std::os::unix::fs::symlink;
8779
8780 let work = tempfile::tempdir().unwrap();
8781 let outside = tempfile::tempdir().unwrap();
8782 let original = work.path().join("destination");
8783 let moved = work.path().join("destination-moved");
8784 let directory = open_or_create_dir_nofollow(&original).unwrap();
8785
8786 std::fs::rename(&original, &moved).unwrap();
8787 symlink(outside.path(), &original).unwrap();
8788 write_pull_entries_beneath_dir(
8789 &directory,
8790 &[("records/note.md".to_string(), b"held inode".to_vec())],
8791 )
8792 .unwrap();
8793
8794 assert_eq!(
8795 std::fs::read(moved.join("records/note.md")).unwrap(),
8796 b"held inode"
8797 );
8798 assert!(!outside.path().join("records/note.md").exists());
8799 }
8800
8801 #[test]
8805 fn hub_config_flag_beats_file_and_requires_some_source() {
8806 let dir = tempfile::tempdir().unwrap();
8807 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
8808 std::fs::write(
8809 dir.path().join(CONFIG_REL_PATH),
8810 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
8811 )
8812 .unwrap();
8813
8814 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
8815 assert_eq!(from_flag.hub, "https://flag.example.com");
8816
8817 let from_file = hub_config(None, dir.path()).unwrap();
8818 assert_eq!(from_file.hub, "https://file.example.com");
8819
8820 let none = hub_config(None, tempfile::tempdir().unwrap().path());
8821 assert!(matches!(none, Err(LinkError::NoHub)));
8822 }
8823
8824 #[test]
8825 fn https_guard_allows_loopback_only_for_plain_http() {
8826 assert!(assert_safe_hub("https://hub.example.com").is_ok());
8827 assert!(assert_safe_hub("http://localhost:3000").is_ok());
8828 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
8829 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
8830 assert!(matches!(
8831 assert_safe_hub("http://hub.example.com"),
8832 Err(LinkError::UnsafeHub { .. })
8833 ));
8834 assert!(matches!(
8835 assert_safe_hub("hub.example.com"),
8836 Err(LinkError::UnsafeHub { .. })
8837 ));
8838 assert!(matches!(
8839 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
8840 Err(LinkError::UnsafeHub { .. })
8841 ));
8842 assert!(matches!(
8843 assert_safe_hub("https://hub.example.com@attacker.example"),
8844 Err(LinkError::UnsafeHub { .. })
8845 ));
8846 assert!(matches!(
8847 assert_safe_hub("https://hub.example.com/base"),
8848 Err(LinkError::UnsafeHub { .. })
8849 ));
8850 }
8851
8852 #[test]
8853 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
8854 for blocked in [
8855 "127.0.0.1",
8856 "10.0.0.1",
8857 "100.64.0.1",
8858 "169.254.169.254",
8859 "172.16.0.1",
8860 "192.168.0.1",
8861 "192.88.99.1",
8862 "198.18.0.1",
8863 "203.0.113.1",
8864 "::1",
8865 "fe80::1",
8866 "fd00::1",
8867 "2001:db8::1",
8868 "2001:1::1",
8869 "2002:7f00:1::",
8870 "3fff::1",
8871 ] {
8872 assert!(
8873 !is_public_registry_ip(blocked.parse().unwrap()),
8874 "must block {blocked}"
8875 );
8876 }
8877 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
8878 assert!(is_public_registry_ip(
8879 "2606:4700:4700::1111".parse().unwrap()
8880 ));
8881 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
8882 }
8883
8884 #[test]
8885 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
8886 use ureq::Resolver as _;
8887
8888 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
8889 let resolver = PinnedRegistryResolver {
8890 netloc: "home.example:443".to_string(),
8891 addresses: vec![pinned],
8892 };
8893 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
8894 assert!(resolver.resolve("127.0.0.1:443").is_err());
8895 assert_eq!(
8896 resolver.resolve("home.example:443").unwrap(),
8897 vec![pinned],
8898 "subsequent connects reuse the validated answer instead of DNS"
8899 );
8900 }
8901
8902 #[test]
8903 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
8904 let cfg = HubConfig {
8905 hub: "https://hub.example".to_string(),
8906 key: None,
8907 agent_key: None,
8908 brain_key: None,
8909 state_dir: tempfile::tempdir().unwrap().keep(),
8910 store_selected: false,
8911 };
8912 assert!(
8913 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
8914 "a production hub must not turn its presigned URL into an SSRF primitive"
8915 );
8916
8917 let store_selected = HubConfig {
8918 hub: "https://127.0.0.1".to_string(),
8919 store_selected: true,
8920 ..cfg
8921 };
8922 assert!(
8923 hub_agent(&store_selected).is_err(),
8924 "bytes in a cloned store must not select a private-network hub"
8925 );
8926 }
8927
8928 #[test]
8929 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
8930 assert_eq!(
8931 one_past_bounded_limit(MAX_PACK_BYTES),
8932 Some(MAX_PACK_BYTES + 1),
8933 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
8934 );
8935 assert_eq!(
8936 presigned_download_read_limit(),
8937 MAX_PACK_BYTES + 1,
8938 "the presigned reader is capped by the client constant, not a hub response"
8939 );
8940 assert_eq!(
8941 one_past_bounded_limit(u64::MAX),
8942 None,
8943 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
8944 );
8945 }
8946
8947 #[test]
8948 fn https_guard_matches_the_scheme_case_insensitively() {
8949 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
8952 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
8953 assert!(matches!(
8955 assert_safe_hub("HTTP://hub.example.com"),
8956 Err(LinkError::UnsafeHub { .. })
8957 ));
8958 }
8959
8960 #[test]
8961 fn clean_key_refuses_paste_artifacts_without_echoing() {
8962 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
8963 for bad in ["vc account", "vc\naccount", "ключ", ""] {
8964 let err = clean_key(bad).unwrap_err();
8965 assert!(matches!(err, LinkError::BadKey));
8966 assert!(
8967 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
8968 "error must not echo the key"
8969 );
8970 }
8971 }
8972
8973 fn dead_hub() -> HubConfig {
8979 HubConfig {
8980 hub: "http://127.0.0.1:9".to_string(),
8981 key: Some("k".to_string()),
8982 agent_key: None,
8983 brain_key: None,
8984 state_dir: PathBuf::from("."),
8985 store_selected: false,
8986 }
8987 }
8988
8989 #[test]
8990 fn request_retries_a_connection_failure_before_sending() {
8991 use std::io::{Read as _, Write as _};
8992 use std::net::TcpListener;
8993 use std::thread;
8994 use std::time::Duration;
8995
8996 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
8997 let address = probe.local_addr().unwrap();
8998 drop(probe);
8999 let server = thread::spawn(move || {
9000 thread::sleep(Duration::from_millis(40));
9001 let listener = TcpListener::bind(address).unwrap();
9002 let (mut stream, _) = listener.accept().unwrap();
9003 let mut request_bytes = [0_u8; 1024];
9004 let _ = stream.read(&mut request_bytes).unwrap();
9005 stream
9006 .write_all(
9007 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
9008 )
9009 .unwrap();
9010 });
9011 let cfg = HubConfig {
9012 hub: format!("http://{address}"),
9013 key: None,
9014 agent_key: None,
9015 brain_key: None,
9016 state_dir: tempfile::tempdir().unwrap().keep(),
9017 store_selected: false,
9018 };
9019
9020 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
9021 assert_eq!(response.status, 200);
9022 assert_eq!(response.body, Some(json!({ "ok": true })));
9023 server.join().unwrap();
9024 }
9025
9026 #[test]
9027 fn endpoint_cap_refuses_a_body_before_json_parsing() {
9028 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
9029 let cfg = HubConfig {
9030 hub,
9031 key: None,
9032 agent_key: None,
9033 brain_key: None,
9034 state_dir: tempfile::tempdir().unwrap().keep(),
9035 store_selected: false,
9036 };
9037
9038 assert!(matches!(
9039 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
9040 Err(LinkError::ResponseTooLarge { .. })
9041 ));
9042 server.join().unwrap();
9043 }
9044
9045 #[test]
9046 fn overall_deadline_stops_a_dribbled_response_body() {
9047 use std::io::{Read as _, Write as _};
9048 use std::net::TcpListener;
9049 use std::time::{Duration, Instant};
9050
9051 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
9052 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
9053 let server = std::thread::spawn(move || {
9054 let (mut stream, _) = listener.accept().unwrap();
9055 let mut request = [0_u8; 1024];
9056 let _ = stream.read(&mut request);
9057 stream
9058 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
9059 .unwrap();
9060 for byte in [b'x'; 32] {
9061 if stream.write_all(&[byte]).is_err() {
9062 break;
9063 }
9064 std::thread::sleep(Duration::from_millis(40));
9065 }
9066 });
9067 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
9068 let started = Instant::now();
9069 let response = http.get(&url).call().unwrap();
9070 let mut body = Vec::new();
9071 let error = response
9072 .into_reader()
9073 .read_to_end(&mut body)
9074 .expect_err("per-read progress must not reset the overall deadline");
9075 assert!(
9076 started.elapsed() < Duration::from_millis(700),
9077 "dribbled body exceeded the wall-clock budget: {error}"
9078 );
9079 server.join().unwrap();
9080 }
9081
9082 #[test]
9083 fn overall_deadline_stops_a_stalled_upload() {
9084 use std::net::TcpListener;
9085 use std::time::{Duration, Instant};
9086
9087 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
9088 let url = format!("http://{}/upload", listener.local_addr().unwrap());
9089 let server = std::thread::spawn(move || {
9090 let (_stream, _) = listener.accept().unwrap();
9091 std::thread::sleep(Duration::from_millis(600));
9094 });
9095 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
9096 let body = vec![0x5a; 32 * 1024 * 1024];
9097 let started = Instant::now();
9098 let error = http
9099 .put(&url)
9100 .send_bytes(&body)
9101 .expect_err("stalled request-body writes must time out");
9102 assert!(
9103 started.elapsed() < Duration::from_millis(700),
9104 "stalled upload exceeded the wall-clock budget: {error}"
9105 );
9106 server.join().unwrap();
9107 }
9108
9109 #[test]
9110 fn verb_entry_gates_accept_the_hub_ref_shapes() {
9111 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
9112 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
9113 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
9114 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
9115 }
9116 }
9117
9118 #[test]
9119 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
9120 let cfg = dead_hub();
9121 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
9122 assert!(
9123 matches!(
9124 sync_pull(&cfg, bad, None),
9125 Err(LinkError::BadAddress { .. })
9126 ),
9127 "sync_pull must refuse {bad:?}"
9128 );
9129 assert!(
9130 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
9131 "sync_push must refuse {bad:?}"
9132 );
9133 assert!(
9134 matches!(
9135 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
9136 Err(LinkError::BadAddress { .. })
9137 ),
9138 "grant_issue must refuse {bad:?}"
9139 );
9140 assert!(
9141 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
9142 "grant_list must refuse {bad:?}"
9143 );
9144 assert!(
9145 matches!(
9146 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
9147 Err(LinkError::BadAddress { .. })
9148 ),
9149 "grant_revoke must refuse brain {bad:?}"
9150 );
9151 assert!(
9152 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
9153 "head must refuse {bad:?}"
9154 );
9155 }
9156 }
9157
9158 #[test]
9159 fn grant_revoke_refuses_url_reshaping_grant_ids() {
9160 let cfg = dead_hub();
9161 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
9162 assert!(
9163 matches!(
9164 grant_revoke(&cfg, "acme", bad),
9165 Err(LinkError::BadGrantId { .. })
9166 ),
9167 "grant_revoke must refuse grant id {bad:?}"
9168 );
9169 }
9170 }
9171
9172 #[test]
9173 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
9174 let cfg = dead_hub();
9175 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
9176 assert!(
9177 matches!(
9178 propose(&cfg, bad, "intake", "hi"),
9179 Err(LinkError::BadAddress { .. })
9180 ),
9181 "propose must refuse handle {bad:?}"
9182 );
9183 }
9184 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
9185 assert!(matches!(
9186 propose(&cfg, "acme-site", "intake", &oversize),
9187 Err(LinkError::ProposeTooLarge { .. })
9188 ));
9189 assert!(matches!(
9192 propose(&cfg, "acme-site", "intake", "hi"),
9193 Err(LinkError::Transport { .. })
9194 ));
9195 }
9196
9197 #[test]
9198 fn resolve_refuses_a_hand_built_unsafe_address() {
9199 let cfg = dead_hub();
9200 for brain in ["../up", "a/b", "a?x", "a#f"] {
9201 let addr = Address {
9202 brain: brain.to_string(),
9203 target: None,
9204 };
9205 assert!(
9206 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
9207 "resolve must refuse brain {brain:?}"
9208 );
9209 }
9210 for target in [
9211 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
9212 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
9214 AddressTarget::Path("records/x.md#frag".to_string()),
9215 ] {
9216 let addr = Address {
9217 brain: "acme".to_string(),
9218 target: Some(target.clone()),
9219 };
9220 assert!(
9221 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
9222 "resolve must refuse target {target:?}"
9223 );
9224 }
9225 }
9226
9227 #[test]
9228 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
9229 let mut local = std::collections::BTreeMap::new();
9230 local.insert("records/a.md".to_string(), ("a".repeat(64), Vec::new()));
9231 local.insert("records/b.md".to_string(), ("b".repeat(64), Vec::new()));
9232 let mut remote = std::collections::BTreeMap::new();
9233 remote.insert(
9234 "records/a.md".to_string(),
9235 V2BaselineFile {
9236 sha256: "c".repeat(64),
9237 bytes: 1,
9238 proof: None,
9239 },
9240 );
9241 remote.insert(
9242 "records/b.md".to_string(),
9243 V2BaselineFile {
9244 sha256: "b".repeat(64),
9245 bytes: 1,
9246 proof: None,
9247 },
9248 );
9249 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
9250 }
9251
9252 #[test]
9253 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
9254 let local = std::collections::BTreeMap::new();
9255 let mut remote = std::collections::BTreeMap::new();
9256 remote.insert(
9257 "private/local.md".to_string(),
9258 V2BaselineFile {
9259 sha256: "d".repeat(64),
9260 bytes: 1,
9261 proof: None,
9262 },
9263 );
9264 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
9265 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
9266 }
9267
9268 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
9269 V2VerifiedHead {
9270 requested: TEST_BRAIN_ID.to_string(),
9271 brain_id: TEST_BRAIN_ID.to_string(),
9272 view_kind: "scoped".to_string(),
9273 view_revision: revision.to_string(),
9274 pointer: None,
9275 trust: TrustState {
9276 v: 2,
9277 origin: "https://hub.example".to_string(),
9278 requested: TEST_BRAIN_ID.to_string(),
9279 brain: TEST_BRAIN_ID.to_string(),
9280 home: None,
9281 anchor: "ed25519:test".to_string(),
9282 current: "ed25519:test".to_string(),
9283 head_seq: 0,
9284 feed_hash: None,
9285 rotations: Vec::new(),
9286 hub_signer: None,
9287 protocol_profile: Some("link-v2".to_string()),
9288 },
9289 alias: None,
9290 }
9291 }
9292
9293 #[test]
9294 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
9295 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
9296 assert!(accepted_as_v2(&trust));
9297
9298 trust.protocol_profile = None;
9299 trust.hub_signer = Some("ed25519:hub".to_string());
9300 assert!(accepted_as_v2(&trust));
9301
9302 trust.hub_signer = None;
9303 assert!(!accepted_as_v2(&trust));
9304 }
9305
9306 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
9307 V2SyncBaseline {
9308 v: 2,
9309 origin: "https://hub.example".to_string(),
9310 brain: TEST_BRAIN_ID.to_string(),
9311 commit_hash: None,
9312 content_root: None,
9313 view_kind: Some("scoped".to_string()),
9314 view_revision: Some(revision.to_string()),
9315 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
9316 files: std::collections::BTreeMap::new(),
9317 local_policy_digest: None,
9318 local_eligibility: std::collections::BTreeMap::new(),
9319 remote_copy_remains: std::collections::BTreeMap::new(),
9320 }
9321 }
9322
9323 #[test]
9324 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
9325 let directory = tempfile::tempdir().unwrap();
9326 std::fs::write(
9327 directory.path().join("DB.md"),
9328 scoped_projection_bytes(TEST_BRAIN_ID),
9329 )
9330 .unwrap();
9331 let store = Store::open_strict(directory.path()).unwrap();
9332 let head = scoped_test_head(&"a".repeat(64));
9333 let baseline = scoped_test_baseline(&"a".repeat(64));
9334 let mut view = v2_local_files(&store).unwrap();
9335 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
9336 assert!(!view.riding.contains_key("DB.md"));
9337 assert!(!view.eligibility.contains_key("DB.md"));
9338 }
9339
9340 #[test]
9341 fn scoped_projection_edit_and_scope_transition_fail_closed() {
9342 let directory = tempfile::tempdir().unwrap();
9343 std::fs::write(
9344 directory.path().join("DB.md"),
9345 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
9346 )
9347 .unwrap();
9348 let store = Store::open_strict(directory.path()).unwrap();
9349 let head = scoped_test_head(&"a".repeat(64));
9350 let baseline = scoped_test_baseline(&"a".repeat(64));
9351 let mut view = v2_local_files(&store).unwrap();
9352 assert!(matches!(
9353 remove_scoped_projection(&head, Some(&baseline), &mut view),
9354 Err(LinkError::ScopedProjectionModified)
9355 ));
9356
9357 let changed = scoped_test_head(&"b".repeat(64));
9358 assert!(matches!(
9359 ensure_v2_view_compatible(&changed, Some(&baseline)),
9360 Err(LinkError::ScopedViewChanged)
9361 ));
9362 }
9363
9364 #[test]
9365 fn scoped_view_metadata_is_explicitly_non_authoritative() {
9366 let head = scoped_test_head(&"a".repeat(64));
9367 let value: Value =
9368 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
9369 assert_eq!(value["kind"], "link.md-scoped-view");
9370 assert_eq!(value["authoritative"], false);
9371 assert_eq!(value["visible_files"], 7);
9372 assert_eq!(value["brain"], TEST_BRAIN_ID);
9373 }
9374
9375 #[test]
9376 fn local_scoped_marker_requires_the_exact_generated_projection() {
9377 let directory = tempfile::tempdir().unwrap();
9378 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
9379 std::fs::write(
9380 directory.path().join("DB.md"),
9381 scoped_projection_bytes(TEST_BRAIN_ID),
9382 )
9383 .unwrap();
9384 let head = scoped_test_head(&"a".repeat(64));
9385 std::fs::write(
9386 directory.path().join(".dbmd/view.json"),
9387 scoped_view_metadata(&head, 0).unwrap(),
9388 )
9389 .unwrap();
9390 let store = Store::open_strict(directory.path()).unwrap();
9391 assert!(has_verified_local_scoped_view(&store));
9392
9393 std::fs::write(
9394 directory.path().join("DB.md"),
9395 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
9396 )
9397 .unwrap();
9398 let altered = Store::open_strict(directory.path()).unwrap();
9399 assert!(!has_verified_local_scoped_view(&altered));
9400 }
9401
9402 #[cfg(unix)]
9403 #[test]
9404 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
9405 let sandbox = tempfile::tempdir().unwrap();
9406 let destination = sandbox.path().join("brain");
9407 let entries = vec![
9408 (
9409 "DB.md".to_string(),
9410 scoped_projection_bytes(TEST_BRAIN_ID),
9411 ),
9412 (
9413 "records/contacts/a.md".to_string(),
9414 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
9415 .to_vec(),
9416 ),
9417 ];
9418 install_pulled_delta(&destination, &entries, &[], true).unwrap();
9419 assert!(destination.join("index.md").is_file());
9420 assert!(destination.join("records/index.md").is_file());
9421 assert!(destination.join("records/contacts/index.md").is_file());
9422 assert!(destination.join("records/contacts/index.jsonl").is_file());
9423 }
9424}