1use std::collections::{BTreeMap, BTreeSet};
61use std::io::{Cursor, Read, Write};
62use std::path::{Path, PathBuf};
63use std::time::{SystemTime, UNIX_EPOCH};
64
65use base64::{
66 engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
67 Engine as _,
68};
69use ring::signature::{UnparsedPublicKey, ED25519};
70use serde::{Deserialize, Serialize};
71use serde_json::{json, Value};
72use sha2::{Digest, Sha256};
73
74use crate::store::Store;
75
76pub const HUB_URL_ENV: &str = "DBMD_HUB_URL";
78
79pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
82
83pub const HUB_CREDENTIAL_ORIGIN_ENV: &str = "DBMD_HUB_CREDENTIAL_ORIGIN";
87
88pub const STATE_DIR_ENV: &str = "DBMD_STATE_DIR";
92
93pub const ALLOW_PRIVATE_REGISTRY_HOME_ENV: &str = "DBMD_ALLOW_PRIVATE_REGISTRY_HOME";
97
98pub const ALLOW_PRIVATE_OBJECT_URL_ENV: &str = "DBMD_ALLOW_PRIVATE_OBJECT_URL";
102
103pub const BRAIN_KEY_FILE_ENV: &str = "DBMD_BRAIN_KEY_FILE";
109
110pub const AGENT_KEY_FILE_ENV: &str = "DBMD_AGENT_KEY_FILE";
118
119pub const CONFIG_REL_PATH: &str = ".dbmd/config";
122
123const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024;
126const MAX_FEED_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
129const MAX_REGISTRY_CARD_BYTES: u64 = 1024 * 1024;
131
132const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
135
136const MAX_PUSH_FILES: usize = u16::MAX as usize;
138const MAX_STORE_PATH_BYTES: usize = 1_024;
139const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
140const MAX_PACK_BYTES: u64 =
143 MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
144const MAX_IDENTITY_ROTATIONS: usize = 1_024;
147const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
151const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
152const FEED_PAGE_LIMIT: usize = 100;
153
154pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
159
160const CONNECT_TIMEOUT_SECS: u64 = 10;
163const READ_TIMEOUT_SECS: u64 = 120;
164const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
168const CONNECT_ATTEMPTS: usize = 3;
169const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
170const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
174#[cfg(unix)]
178const V2_PULL_INSTALL_WORKERS: usize = 16;
179const V2_BULK_STREAM_FILES: usize = 256;
183const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
184const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
185const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
186
187#[derive(Debug, thiserror::Error)]
191pub enum LinkError {
192 #[error(
194 "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
195 )]
196 NoHub,
197
198 #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
200 NoCredential,
201
202 #[error(
205 "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
206 )]
207 BadKey,
208
209 #[error(
215 "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}"
216 )]
217 UnboundCredential,
218
219 #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
223 BadAgentKey {
224 message: String,
226 },
227
228 #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
230 UnsafeHub {
231 hub: String,
233 },
234
235 #[error("hub unreachable at {hub}: {message}")]
237 Transport {
238 hub: String,
240 message: String,
242 },
243
244 #[error("{what} failed (HTTP {status}): {message}")]
246 Http {
247 what: &'static str,
249 status: u16,
251 message: String,
253 code: Option<String>,
255 details: Option<Value>,
257 },
258
259 #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
262 NotJson {
263 what: &'static str,
265 status: u16,
267 },
268
269 #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
271 ResponseTooLarge {
272 limit_bytes: u64,
274 },
275
276 #[error("invalid address `{given}`: {reason}")]
278 BadAddress {
279 given: String,
281 reason: String,
283 },
284
285 #[error(
287 "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
288 )]
289 BadGrantId {
290 given: String,
292 },
293
294 #[error("refusing unsafe path from the hub: `{path}`")]
298 UnsafePath {
299 path: String,
301 },
302
303 #[error(
305 "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
306 MAX_STORE_BYTES / (1024 * 1024),
307 MAX_PACK_BYTES / (1024 * 1024)
308 )]
309 PushTooLarge {
310 detail: String,
312 },
313
314 #[error(
316 "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
317 MAX_PROPOSE_BYTES / 1024
318 )]
319 ProposeTooLarge {
320 bytes: u64,
322 },
323
324 #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
326 NotUtf8 {
327 path: String,
329 },
330
331 #[error("invalid store pack: {message}")]
333 InvalidPack {
334 message: String,
336 },
337
338 #[error("invalid signed feed: {message}")]
340 InvalidFeed {
341 message: String,
343 },
344
345 #[error(
349 "brain alias `{alias}` was pinned to `{from}` but now resolves to `{to}` — review both ids, then run `dbmd sync {alias} rebind --from {from} --to {to}`"
350 )]
351 AliasRebindRequired {
352 alias: String,
353 from: String,
354 to: String,
355 },
356
357 #[error("sync conflict on {paths:?} — resolve the named files and retry")]
360 Conflict {
361 paths: Vec<String>,
363 },
364
365 #[error(
369 "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
370 )]
371 ConflictBundle {
372 bundle: String,
374 paths: Vec<String>,
376 },
377
378 #[error(
382 "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
383 )]
384 LocalPolicyTransition {
385 paths: Vec<String>,
387 },
388
389 #[error(
394 "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
395 )]
396 BulkPreviewRequired {
397 preview: Value,
399 },
400
401 #[error(
404 "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
405 )]
406 ScopedProjectionModified,
407
408 #[error(
412 "the checkout's permission scope changed — clone into a new directory to accept the new view"
413 )]
414 ScopedViewChanged,
415
416 #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
419 BrainUnavailable,
420
421 #[error(
424 "the remote brain advanced during sync — retry to converge from the new verified head"
425 )]
426 RemoteAdvancedDuringSync,
427
428 #[error(
431 "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
432 )]
433 UnsupportedPlatform {
434 operation: &'static str,
436 },
437
438 #[error(transparent)]
440 Io(#[from] std::io::Error),
441
442 #[error(transparent)]
444 Store(#[from] crate::StoreError),
445}
446
447pub type LinkResult<T> = std::result::Result<T, LinkError>;
449
450#[derive(Debug, Clone, PartialEq, Eq)]
452pub struct V2BulkConfirmation {
453 pub id: String,
455 pub digest: String,
458}
459
460impl V2BulkConfirmation {
461 pub fn parse(value: &str) -> LinkResult<Self> {
464 let (id, digest) = value
465 .split_once(':')
466 .ok_or_else(|| LinkError::InvalidPack {
467 message: "bulk confirmation must be <id>:<digest>".to_string(),
468 })?;
469 if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
470 return Err(LinkError::InvalidPack {
471 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
472 .to_string(),
473 });
474 }
475 Ok(Self {
476 id: id.to_string(),
477 digest: digest.to_string(),
478 })
479 }
480}
481
482fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
487 #[cfg(any(target_os = "linux", target_os = "macos", windows))]
488 {
489 let _ = operation;
490 Ok(())
491 }
492 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
493 {
494 Err(LinkError::UnsupportedPlatform { operation })
495 }
496}
497
498#[derive(Debug, Clone, PartialEq, Eq)]
504pub enum AddressTarget {
505 Id(String),
507 Path(String),
511}
512
513const BAD_BRAIN_REASON: &str =
516 "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
517
518const BAD_TARGET_REASON: &str =
521 "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
522
523#[derive(Debug, Clone, PartialEq, Eq)]
528pub struct Address {
529 pub brain: String,
531 pub target: Option<AddressTarget>,
533}
534
535impl Address {
536 pub fn parse(raw: &str) -> LinkResult<Address> {
540 let bad = |reason: &str| LinkError::BadAddress {
541 given: raw.to_string(),
542 reason: reason.to_string(),
543 };
544
545 let trimmed = raw.trim();
546 let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
547 if body.is_empty() {
548 return Err(bad("empty address"));
549 }
550
551 let (brain, rest) = match body.split_once('/') {
552 Some((b, r)) => (b, Some(r)),
553 None => (body, None),
554 };
555
556 if brain.is_empty() {
557 return Err(bad("missing brain reference before `/`"));
558 }
559 if !is_safe_ref(brain) {
560 return Err(bad(BAD_BRAIN_REASON));
561 }
562
563 let target = match rest {
564 None => None,
565 Some("") => return Err(bad("trailing `/` with no record id or path")),
566 Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
567 Some(r) => {
568 if !safe_store_rel_path(r) || !r.ends_with(".md") {
569 return Err(bad(BAD_TARGET_REASON));
570 }
571 Some(AddressTarget::Path(r.to_string()))
572 }
573 };
574
575 Ok(Address {
576 brain: brain.to_string(),
577 target,
578 })
579 }
580}
581
582fn is_safe_ref(s: &str) -> bool {
585 !s.is_empty()
586 && s.len() <= 64
587 && s.bytes()
588 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
589}
590
591pub fn is_valid_handle(s: &str) -> bool {
594 is_safe_ref(s)
595}
596
597pub fn safe_store_rel_path(p: &str) -> bool {
603 if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
604 return false;
605 }
606 if !p
607 .bytes()
608 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
609 {
610 return false;
611 }
612 p.split('/')
613 .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
614}
615
616fn require_safe_ref(brain: &str) -> LinkResult<()> {
624 if is_safe_ref(brain) {
625 Ok(())
626 } else {
627 Err(LinkError::BadAddress {
628 given: brain.to_string(),
629 reason: BAD_BRAIN_REASON.to_string(),
630 })
631 }
632}
633
634fn require_valid_handle(handle: &str) -> LinkResult<()> {
636 if is_valid_handle(handle) {
637 Ok(())
638 } else {
639 Err(LinkError::BadAddress {
640 given: handle.to_string(),
641 reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
642 })
643 }
644}
645
646fn require_safe_grant_id(id: &str) -> LinkResult<()> {
650 if is_safe_ref(id) {
651 Ok(())
652 } else {
653 Err(LinkError::BadGrantId {
654 given: id.to_string(),
655 })
656 }
657}
658
659#[derive(Debug, Clone)]
665pub struct HubConfig {
666 pub hub: String,
668 pub key: Option<String>,
670 pub agent_key: Option<AgentSigningKey>,
673 pub brain_key: Option<AgentSigningKey>,
676 pub state_dir: PathBuf,
679 store_selected: bool,
682}
683
684#[derive(Clone)]
687pub struct AgentSigningKey {
688 pkcs8: Vec<u8>,
689 pub multikey: String,
691 pub public_key_spki: String,
693}
694
695impl std::fmt::Debug for AgentSigningKey {
696 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697 f.debug_struct("AgentSigningKey")
698 .field("multikey", &self.multikey)
699 .field("pkcs8", &"<redacted>")
700 .finish()
701 }
702}
703
704impl HubConfig {
705 pub fn require_key(&self) -> LinkResult<&str> {
708 self.key.as_deref().ok_or(LinkError::NoCredential)
709 }
710}
711
712pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
717 let explicit_hub = flag_hub
718 .map(str::to_string)
719 .or_else(|| env_nonempty(HUB_URL_ENV));
720 let selected_by_store = explicit_hub.is_none();
721 let hub = explicit_hub
722 .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
723 .ok_or(LinkError::NoHub)?;
724 let hub = hub.trim().trim_end_matches('/').to_string();
725 assert_safe_hub(&hub)?;
726 if selected_by_store {
727 let parsed =
728 url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
729 if !parsed.scheme().eq_ignore_ascii_case("https")
733 || (parsed.path() != "/" && !parsed.path().is_empty())
734 {
735 return Err(LinkError::UnsafeHub { hub });
736 }
737 }
738
739 let key = match env_nonempty(HUB_KEY_ENV) {
740 Some(raw) => Some(clean_key(&raw)?),
741 None => None,
742 };
743
744 let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
745 Some(path) => Some(load_agent_key(Path::new(&path))?),
746 None => None,
747 };
748
749 let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
750 Some(path) => Some(load_agent_key(Path::new(&path))?),
751 None => None,
752 };
753
754 if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
761 let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
762 .and_then(|value| normalized_origin(&value).ok());
763 let selected_origin = normalized_origin(&hub)?;
764 if bound.as_deref() != Some(selected_origin.as_str()) {
765 return Err(LinkError::UnboundCredential);
766 }
767 }
768
769 Ok(HubConfig {
770 hub,
771 key,
772 agent_key,
773 brain_key,
774 state_dir: toolkit_state_dir()?,
775 store_selected: selected_by_store,
776 })
777}
778
779fn toolkit_state_dir() -> LinkResult<PathBuf> {
780 if let Some(path) = env_nonempty(STATE_DIR_ENV) {
781 let path = PathBuf::from(path);
782 if !path.is_absolute() {
783 return Err(LinkError::UnsafePath {
784 path: path.display().to_string(),
785 });
786 }
787 return Ok(path);
788 }
789 #[cfg(windows)]
790 if let Some(base) = env_nonempty("LOCALAPPDATA") {
791 let base = PathBuf::from(base);
792 if base.is_absolute() {
793 return Ok(base.join("dbmd").join("state"));
794 }
795 }
796 #[cfg(windows)]
797 {
798 Err(LinkError::Io(std::io::Error::new(
799 std::io::ErrorKind::NotFound,
800 format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
801 )))
802 }
803 #[cfg(not(windows))]
804 if let Some(base) = env_nonempty("XDG_STATE_HOME") {
805 let base = PathBuf::from(base);
806 if base.is_absolute() {
807 return Ok(base.join("dbmd"));
808 }
809 }
810 #[cfg(not(windows))]
811 let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
812 LinkError::Io(std::io::Error::new(
813 std::io::ErrorKind::NotFound,
814 format!("cannot locate user state; set {STATE_DIR_ENV}"),
815 ))
816 })?);
817 #[cfg(not(windows))]
818 if !home.is_absolute() {
819 return Err(LinkError::UnsafePath {
820 path: home.display().to_string(),
821 });
822 }
823 #[cfg(target_os = "macos")]
824 {
825 Ok(home
826 .join("Library")
827 .join("Application Support")
828 .join("dbmd")
829 .join("state"))
830 }
831 #[cfg(all(not(target_os = "macos"), not(windows)))]
832 {
833 Ok(home.join(".local").join("state").join("dbmd"))
834 }
835}
836
837fn normalized_origin(value: &str) -> LinkResult<String> {
838 let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
839 hub: value.to_string(),
840 })?;
841 if !(parsed.scheme().eq_ignore_ascii_case("https")
842 || parsed.scheme().eq_ignore_ascii_case("http"))
843 || !parsed.username().is_empty()
844 || parsed.password().is_some()
845 || (parsed.path() != "/" && !parsed.path().is_empty())
846 || parsed.query().is_some()
847 || parsed.fragment().is_some()
848 {
849 return Err(LinkError::UnsafeHub {
850 hub: value.to_string(),
851 });
852 }
853 let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
854 hub: value.to_string(),
855 })?;
856 let host = if host.contains(':') {
857 format!("[{host}]")
858 } else {
859 host.to_ascii_lowercase()
860 };
861 let port = parsed
862 .port_or_known_default()
863 .ok_or_else(|| LinkError::UnsafeHub {
864 hub: value.to_string(),
865 })?;
866 let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
867 || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
868 Ok(format!(
869 "{}://{}{}",
870 parsed.scheme().to_ascii_lowercase(),
871 host,
872 if default {
873 String::new()
874 } else {
875 format!(":{port}")
876 }
877 ))
878}
879
880const ED25519_SPKI_PREFIX: [u8; 12] = [
887 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
888];
889
890fn bad_agent_key(message: &str) -> LinkError {
891 LinkError::BadAgentKey {
892 message: message.to_string(),
893 }
894}
895
896fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
897 ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
901 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
902 .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
903}
904
905fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
907 use ring::signature::KeyPair as _;
908 let mut spki = Vec::with_capacity(44);
909 spki.extend_from_slice(&ED25519_SPKI_PREFIX);
910 spki.extend_from_slice(pair.public_key().as_ref());
911 (
912 URL_SAFE_NO_PAD.encode(&spki),
913 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
914 )
915}
916
917pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
921 load_agent_key(path)
922}
923
924fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
926 #[cfg(unix)]
927 let file = {
928 use std::os::fd::{AsRawFd as _, FromRawFd as _};
929 use std::os::unix::ffi::OsStrExt as _;
930 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
931 .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
932 let leaf = path
933 .file_name()
934 .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
935 let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
936 let fd = unsafe {
937 libc::openat(
938 parent.as_raw_fd(),
939 leaf.as_ptr(),
940 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
941 )
942 };
943 if fd < 0 {
944 return Err(bad_agent_key(
945 "the key path must be an existing regular file without symlink ancestors",
946 ));
947 }
948 unsafe { std::fs::File::from_raw_fd(fd) }
949 };
950 #[cfg(not(unix))]
951 let file = std::fs::File::open(path)
952 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
953 let metadata = file
954 .metadata()
955 .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
956 if !metadata.is_file() {
957 return Err(bad_agent_key("the key path must be a regular file"));
958 }
959 #[cfg(unix)]
960 {
961 use std::os::unix::fs::PermissionsExt as _;
962 if metadata.permissions().mode() & 0o077 != 0 {
963 return Err(bad_agent_key(
964 "the key file is accessible to group/other; set mode 0600",
965 ));
966 }
967 }
968 let mut text = String::new();
969 file.take(1024 * 1024 + 1)
970 .read_to_string(&mut text)
971 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
972 if text.len() > 1024 * 1024 {
973 return Err(bad_agent_key("the key file exceeds the size limit"));
974 }
975 let pkcs8 = URL_SAFE_NO_PAD
976 .decode(text.trim())
977 .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
978 let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
979 Ok(AgentSigningKey {
980 pkcs8,
981 multikey,
982 public_key_spki,
983 })
984}
985
986fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
992 #[cfg(unix)]
993 let (mut file, parent, leaf) = {
994 use std::os::fd::{AsRawFd as _, FromRawFd as _};
995 use std::os::unix::ffi::OsStrExt as _;
996 let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
997 let leaf_name = path
998 .file_name()
999 .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1000 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1001 let fd = unsafe {
1002 libc::openat(
1003 parent.as_raw_fd(),
1004 leaf.as_ptr(),
1005 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1006 0o600,
1007 )
1008 };
1009 if fd < 0 {
1010 let error = std::io::Error::last_os_error();
1011 if error.kind() == std::io::ErrorKind::AlreadyExists {
1012 return Err(bad_agent_key(
1013 "the output file already exists — refusing to overwrite a key",
1014 ));
1015 }
1016 return Err(error.into());
1017 }
1018 (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1019 };
1020 #[cfg(not(unix))]
1021 let mut file = std::fs::OpenOptions::new()
1022 .write(true)
1023 .create_new(true)
1024 .open(path)
1025 .map_err(|error| {
1026 if error.kind() == std::io::ErrorKind::AlreadyExists {
1027 bad_agent_key("the output file already exists — refusing to overwrite a key")
1028 } else {
1029 LinkError::Io(error)
1030 }
1031 })?;
1032 if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1033 drop(file);
1034 #[cfg(unix)]
1035 let _ =
1036 unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1037 #[cfg(not(unix))]
1038 let _ = std::fs::remove_file(path);
1039 return Err(LinkError::Io(error));
1040 }
1041 drop(file);
1042 #[cfg(unix)]
1043 parent.sync_all()?;
1044 Ok(())
1045}
1046
1047#[derive(Debug, Serialize)]
1050pub struct GeneratedAgentKey {
1051 pub multikey: String,
1053 #[serde(rename = "publicKeySpki")]
1055 pub public_key_spki: String,
1056 #[serde(rename = "keyFile")]
1058 pub key_file: String,
1059}
1060
1061pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1066 require_hardened_filesystem("key generation")?;
1067 let rng = ring::rand::SystemRandom::new();
1068 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1069 .map_err(|_| bad_agent_key("key generation failed"))?;
1070 let pair = agent_keypair(pkcs8.as_ref())?;
1071 let (spki_b64u, multikey) = public_identity_for(&pair);
1072
1073 write_secret_new(
1074 out,
1075 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1076 )?;
1077
1078 Ok(GeneratedAgentKey {
1079 multikey,
1080 public_key_spki: spki_b64u,
1081 key_file: out.display().to_string(),
1082 })
1083}
1084
1085fn linkmd_sig_header(
1094 key: &AgentSigningKey,
1095 origin: &str,
1096 method: &str,
1097 path: &str,
1098 body: Option<&str>,
1099) -> LinkResult<String> {
1100 let ts = std::time::SystemTime::now()
1101 .duration_since(std::time::UNIX_EPOCH)
1102 .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1103 .as_secs();
1104 let body_hash = match body {
1105 Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1106 None => "-".to_string(),
1107 };
1108 let canonical = format!(
1109 "v2\n{}\n{}\n{}\n{}\n{}",
1110 origin,
1111 method.to_uppercase(),
1112 path,
1113 ts,
1114 body_hash
1115 );
1116 let pair = agent_keypair(&key.pkcs8)?;
1117 let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1118 let fingerprint = key.multikey.trim_start_matches("ed25519:");
1119 Ok(format!(
1120 "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1121 ))
1122}
1123
1124#[derive(Serialize)]
1131struct WireFeedFile {
1132 path: String,
1133 sha256: String,
1134 bytes: u64,
1135}
1136
1137#[derive(Serialize)]
1140struct UnsignedWireEntry<'a> {
1141 v: u8,
1142 seq: u64,
1143 ts: String,
1144 brain: &'a str,
1145 public_key: &'a str,
1146 kind: &'a str,
1147 op: &'a str,
1148 pack_sha256: &'a str,
1149 files: &'a [WireFeedFile],
1150 removed: &'a [String],
1151 prev_entry_hash: Option<&'a str>,
1152}
1153
1154fn self_custody_entry(
1160 key: &AgentSigningKey,
1161 seq: u64,
1162 ts: String,
1163 pack_sha256: &str,
1164 files: &[WireFeedFile],
1165 prev_entry_hash: Option<&str>,
1166) -> LinkResult<String> {
1167 let removed: [String; 0] = [];
1168 let unsigned = serde_json::to_string(&UnsignedWireEntry {
1169 v: 1,
1170 seq,
1171 ts,
1172 brain: &key.multikey,
1173 public_key: &key.public_key_spki,
1174 kind: "push",
1175 op: "snapshot",
1176 pack_sha256,
1177 files,
1178 removed: &removed,
1179 prev_entry_hash,
1180 })
1181 .expect("serialize feed entry");
1182 let pair = agent_keypair(&key.pkcs8)?;
1183 let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1184 Ok(format!(
1185 "{},\"sig\":\"{}\"}}",
1186 &unsigned[..unsigned.len() - 1],
1187 sig
1188 ))
1189}
1190
1191fn env_nonempty(name: &str) -> Option<String> {
1194 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1195}
1196
1197fn config_file_hub(path: &Path) -> Option<String> {
1202 const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1203 #[cfg(unix)]
1204 let file = {
1205 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1206 use std::os::unix::ffi::OsStrExt as _;
1207 let parent =
1208 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1209 let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1210 let fd = unsafe {
1211 libc::openat(
1212 parent.as_raw_fd(),
1213 leaf.as_ptr(),
1214 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1215 )
1216 };
1217 if fd < 0 {
1218 return None;
1219 }
1220 unsafe { std::fs::File::from_raw_fd(fd) }
1221 };
1222 #[cfg(not(unix))]
1223 let file = std::fs::File::open(path).ok()?;
1224 let metadata = file.metadata().ok()?;
1225 if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1226 return None;
1227 }
1228 let mut bytes = Vec::with_capacity(metadata.len() as usize);
1229 file.take(MAX_CONFIG_BYTES + 1)
1230 .read_to_end(&mut bytes)
1231 .ok()?;
1232 if bytes.len() as u64 > MAX_CONFIG_BYTES {
1233 return None;
1234 }
1235 let text = String::from_utf8(bytes).ok()?;
1236 for line in text.lines() {
1237 let line = line.trim();
1238 if line.is_empty() || line.starts_with('#') {
1239 continue;
1240 }
1241 if let Some((k, v)) = line.split_once('=') {
1242 if k.trim() == "hub" {
1243 let v = v.trim();
1244 if !v.is_empty() {
1245 return Some(v.to_string());
1246 }
1247 }
1248 }
1249 }
1250 None
1251}
1252
1253fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1256 let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1257 hub: hub.to_string(),
1258 })?;
1259 if !(parsed.scheme().eq_ignore_ascii_case("https")
1260 || parsed.scheme().eq_ignore_ascii_case("http"))
1261 || !parsed.username().is_empty()
1262 || parsed.password().is_some()
1263 || (parsed.path() != "/" && !parsed.path().is_empty())
1264 || parsed.query().is_some()
1265 || parsed.fragment().is_some()
1266 {
1267 return Err(LinkError::UnsafeHub {
1268 hub: hub.to_string(),
1269 });
1270 }
1271 let loopback = match parsed.host() {
1272 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1273 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1274 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1275 None => false,
1276 };
1277 if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1278 Ok(())
1279 } else {
1280 Err(LinkError::UnsafeHub {
1281 hub: hub.to_string(),
1282 })
1283 }
1284}
1285
1286fn clean_key(raw: &str) -> LinkResult<String> {
1291 let k = raw.trim();
1292 if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1293 return Err(LinkError::BadKey);
1294 }
1295 Ok(k.to_string())
1296}
1297
1298#[derive(Debug)]
1304pub struct HubResponse {
1305 pub status: u16,
1307 pub body: Option<Value>,
1309}
1310
1311struct RawHubResponse {
1312 status: u16,
1313 body: Vec<u8>,
1314}
1315
1316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1318enum Auth {
1319 Required,
1321 None,
1323 Optional,
1327}
1328
1329fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1330 ureq::AgentBuilder::new()
1331 .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1332 .redirects(0)
1336 .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1337 .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1338 .timeout_write(overall)
1339 .timeout(overall)
1340}
1341
1342fn agent_builder() -> ureq::AgentBuilder {
1343 agent_builder_with_timeout(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS))
1344}
1345
1346fn agent() -> ureq::Agent {
1347 agent_builder().build()
1348}
1349
1350fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1351 if !cfg.store_selected {
1352 return Ok(agent());
1353 }
1354 let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1355 hub: cfg.hub.clone(),
1356 })?;
1357 pinned_public_agent(&parsed, false, "store-selected hub")
1358}
1359
1360fn request_raw(
1365 cfg: &HubConfig,
1366 method: &str,
1367 path: &str,
1368 body: Option<&Value>,
1369 auth: Auth,
1370 max_response_bytes: u64,
1371) -> LinkResult<RawHubResponse> {
1372 let http = hub_agent(cfg)?;
1373 request_raw_with_agent(cfg, &http, method, path, body, auth, max_response_bytes)
1374}
1375
1376fn request_raw_with_agent(
1377 cfg: &HubConfig,
1378 http: &ureq::Agent,
1379 method: &str,
1380 path: &str,
1381 body: Option<&Value>,
1382 auth: Auth,
1383 max_response_bytes: u64,
1384) -> LinkResult<RawHubResponse> {
1385 let url = format!("{}{}", cfg.hub, path);
1386 let encoded_body = body.map(Value::to_string);
1387 let origin = normalized_origin(&cfg.hub)?;
1388 let credential = match auth {
1391 Auth::Required => Some(match &cfg.agent_key {
1392 Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1393 None => format!("Bearer {}", cfg.require_key()?),
1394 }),
1395 Auth::Optional => match &cfg.agent_key {
1396 Some(key) => Some(linkmd_sig_header(
1397 key,
1398 &origin,
1399 method,
1400 path,
1401 encoded_body.as_deref(),
1402 )?),
1403 None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1404 },
1405 Auth::None => None,
1406 };
1407 let result = with_connect_retries(|| {
1408 let mut req = http.request(method, &url);
1409 if let Some(value) = &credential {
1410 req = req.set("authorization", value);
1411 }
1412 match &encoded_body {
1413 Some(value) => req
1414 .set("content-type", "application/json")
1415 .send_string(value)
1416 .map_err(Box::new),
1417 None => req.call().map_err(Box::new),
1418 }
1419 });
1420 let resp = match result {
1421 Ok(resp) => resp,
1422 Err(error) => match *error {
1423 ureq::Error::Status(_, resp) => resp,
1424 ureq::Error::Transport(error) => {
1425 return Err(LinkError::Transport {
1426 hub: cfg.hub.clone(),
1427 message: error.to_string(),
1428 });
1429 }
1430 },
1431 };
1432
1433 let status = resp.status();
1434 let mut buf = Vec::new();
1435 resp.into_reader()
1436 .take(max_response_bytes + 1)
1437 .read_to_end(&mut buf)?;
1438 if buf.len() as u64 > max_response_bytes {
1439 return Err(LinkError::ResponseTooLarge {
1440 limit_bytes: max_response_bytes,
1441 });
1442 }
1443 Ok(RawHubResponse { status, body: buf })
1444}
1445
1446fn request_capped(
1447 cfg: &HubConfig,
1448 method: &str,
1449 path: &str,
1450 body: Option<&Value>,
1451 auth: Auth,
1452 max_response_bytes: u64,
1453) -> LinkResult<HubResponse> {
1454 let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1455 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1456 Ok(HubResponse {
1457 status: raw.status,
1458 body: parsed,
1459 })
1460}
1461
1462fn request(
1463 cfg: &HubConfig,
1464 method: &str,
1465 path: &str,
1466 body: Option<&Value>,
1467 auth: Auth,
1468) -> LinkResult<HubResponse> {
1469 request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1470}
1471
1472fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1473 if (200..300).contains(&r.status) {
1474 return Ok(r.body);
1475 }
1476 ensure_ok(
1477 HubResponse {
1478 status: r.status,
1479 body: serde_json::from_slice(&r.body).ok(),
1480 },
1481 what,
1482 )
1483 .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1484}
1485
1486fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1491 matches!(
1492 kind,
1493 ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1494 )
1495}
1496
1497fn with_connect_retries(
1498 mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1499) -> Result<ureq::Response, Box<ureq::Error>> {
1500 let mut attempt = 0;
1501 loop {
1502 match send() {
1503 Err(error)
1504 if matches!(
1505 error.as_ref(),
1506 ureq::Error::Transport(transport)
1507 if is_pre_request_transport(transport.kind())
1508 ) && attempt + 1 < CONNECT_ATTEMPTS =>
1509 {
1510 std::thread::sleep(std::time::Duration::from_millis(
1511 CONNECT_RETRY_BACKOFF_MS[attempt],
1512 ));
1513 attempt += 1;
1514 }
1515 result => return result,
1516 }
1517 }
1518}
1519
1520fn hub_is_loopback(hub: &str) -> bool {
1521 url::Url::parse(hub).ok().is_some_and(|parsed| {
1522 parsed.host().is_some_and(|host| match host {
1523 url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1524 url::Host::Ipv4(ip) => ip.is_loopback(),
1525 url::Host::Ipv6(ip) => ip.is_loopback(),
1526 })
1527 })
1528}
1529
1530fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1531 let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1532 message: "the hub returned an invalid object-store URL".to_string(),
1533 })?;
1534 let allow_private = hub_is_loopback(&cfg.hub)
1535 || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1536 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1537 || !parsed.username().is_empty()
1538 || parsed.password().is_some()
1539 || parsed.fragment().is_some()
1540 {
1541 return Err(LinkError::InvalidPack {
1542 message: "the hub returned an unsafe object-store URL".to_string(),
1543 });
1544 }
1545 pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1546 LinkError::InvalidPack {
1547 message: "the hub returned an object-store URL with an unsafe network target"
1548 .to_string(),
1549 }
1550 })
1551}
1552
1553fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1554 let http = presigned_agent(cfg, raw)?;
1555 let result = with_connect_retries(|| {
1556 let mut req = http.put(raw);
1557 if let Some(map) = headers.as_object() {
1558 for (name, value) in map {
1559 if let Some(value) = value.as_str() {
1560 req = req.set(name, value);
1561 }
1562 }
1563 }
1564 req.send_bytes(bytes).map_err(Box::new)
1565 });
1566 match result {
1567 Ok(resp) if (200..300).contains(&resp.status()) => Ok(()),
1568 Ok(resp) => Err(LinkError::Http {
1569 what: "pack upload",
1570 status: resp.status(),
1571 message: "object store rejected the upload".to_string(),
1572 code: None,
1573 details: None,
1574 }),
1575 Err(error) => match *error {
1576 ureq::Error::Status(412, _) => Ok(()),
1581 ureq::Error::Status(_, resp) => Err(LinkError::Http {
1582 what: "pack upload",
1583 status: resp.status(),
1584 message: "object store rejected the upload".to_string(),
1585 code: None,
1586 details: None,
1587 }),
1588 ureq::Error::Transport(err) => Err(LinkError::Transport {
1589 hub: "the object store".to_string(),
1590 message: err.to_string(),
1591 }),
1592 },
1593 }
1594}
1595
1596fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1597 max_bytes.checked_add(1)
1598}
1599
1600fn presigned_download_read_limit() -> u64 {
1601 one_past_bounded_limit(MAX_PACK_BYTES)
1602 .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1603}
1604
1605fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1606 let http = presigned_agent(cfg, raw)?;
1607 let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1608 Ok(resp) => resp,
1609 Err(error) => match *error {
1610 ureq::Error::Status(_, resp) => {
1611 return Err(LinkError::Http {
1612 what: "pack download",
1613 status: resp.status(),
1614 message: "object store rejected the download".to_string(),
1615 code: None,
1616 details: None,
1617 });
1618 }
1619 ureq::Error::Transport(err) => {
1620 return Err(LinkError::Transport {
1621 hub: "the object store".to_string(),
1622 message: err.to_string(),
1623 });
1624 }
1625 },
1626 };
1627 if !(200..300).contains(&resp.status()) {
1628 return Err(LinkError::Http {
1629 what: "pack download",
1630 status: resp.status(),
1631 message: "object store rejected the download".to_string(),
1632 code: None,
1633 details: None,
1634 });
1635 }
1636 let mut bytes = Vec::new();
1637 resp.into_reader()
1638 .take(presigned_download_read_limit())
1639 .read_to_end(&mut bytes)?;
1640 if bytes.len() as u64 > MAX_PACK_BYTES {
1641 return Err(LinkError::InvalidPack {
1642 message: "download exceeds the compressed-size limit".to_string(),
1643 });
1644 }
1645 Ok(bytes)
1646}
1647
1648fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
1652 if !(200..300).contains(&r.status) {
1653 let message = r
1654 .body
1655 .as_ref()
1656 .and_then(|b| b.get("error"))
1657 .and_then(Value::as_str)
1658 .unwrap_or("unknown error")
1659 .to_string();
1660 let code = r
1661 .body
1662 .as_ref()
1663 .and_then(|b| b.get("code"))
1664 .and_then(Value::as_str)
1665 .map(str::to_string);
1666 let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
1667 return Err(LinkError::Http {
1668 what,
1669 status: r.status,
1670 message,
1671 code,
1672 details,
1673 });
1674 }
1675 r.body.ok_or(LinkError::NotJson {
1676 what,
1677 status: r.status,
1678 })
1679}
1680
1681fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
1690 match ip {
1691 std::net::IpAddr::V4(ip) => {
1692 let [a, b, c, _] = ip.octets();
1693 !(a == 0
1694 || a == 10
1695 || a == 127
1696 || (a == 100 && (64..=127).contains(&b))
1697 || (a == 169 && b == 254)
1698 || (a == 172 && (16..=31).contains(&b))
1699 || (a == 192 && b == 0 && c == 0)
1700 || (a == 192 && b == 0 && c == 2)
1701 || (a == 192 && b == 88 && c == 99)
1702 || (a == 192 && b == 168)
1703 || (a == 198 && (b == 18 || b == 19))
1704 || (a == 198 && b == 51 && c == 100)
1705 || (a == 203 && b == 0 && c == 113)
1706 || a >= 224)
1707 }
1708 std::net::IpAddr::V6(ip) => {
1709 let segments = ip.segments();
1710 (segments[0] & 0xe000) == 0x2000
1715 && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
1716 && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
1717 && segments[0] != 0x2002
1718 && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
1719 }
1720 }
1721}
1722
1723#[derive(Clone)]
1724struct PinnedRegistryResolver {
1725 netloc: String,
1726 addresses: Vec<std::net::SocketAddr>,
1727}
1728
1729impl ureq::Resolver for PinnedRegistryResolver {
1730 fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
1731 if requested == self.netloc {
1732 Ok(self.addresses.clone())
1733 } else {
1734 Err(std::io::Error::new(
1735 std::io::ErrorKind::PermissionDenied,
1736 "registry request attempted to resolve an unvalidated authority",
1737 ))
1738 }
1739 }
1740}
1741
1742fn pinned_public_agent(
1743 url: &url::Url,
1744 allow_private: bool,
1745 label: &str,
1746) -> LinkResult<ureq::Agent> {
1747 let host = url
1748 .host_str()
1749 .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
1750 let port = url
1751 .port_or_known_default()
1752 .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
1753 let addresses = resolve_addresses_with_deadline(
1754 host,
1755 port,
1756 std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
1757 )
1758 .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
1759 if addresses.is_empty() {
1760 return Err(invalid_feed(format!("{label} DNS returned no addresses")));
1761 }
1762 if !allow_private
1763 && addresses
1764 .iter()
1765 .any(|address| !is_public_registry_ip(address.ip()))
1766 {
1767 return Err(invalid_feed(format!(
1768 "{label} resolves to a non-public address"
1769 )));
1770 }
1771 let netloc = if host.contains(':') {
1772 format!("[{host}]:{port}")
1773 } else {
1774 format!("{host}:{port}")
1775 };
1776 Ok(agent_builder()
1777 .resolver(PinnedRegistryResolver { netloc, addresses })
1778 .build())
1779}
1780
1781fn resolve_addresses_with_deadline(
1786 host: &str,
1787 port: u16,
1788 timeout: std::time::Duration,
1789) -> std::io::Result<Vec<std::net::SocketAddr>> {
1790 use std::net::ToSocketAddrs as _;
1791
1792 let host = host.to_string();
1793 let (send, receive) = std::sync::mpsc::sync_channel(1);
1794 std::thread::Builder::new()
1795 .name("dbmd-dns".to_string())
1796 .spawn(move || {
1797 let result = (host.as_str(), port)
1798 .to_socket_addrs()
1799 .map(|addresses| addresses.collect());
1800 let _ = send.send(result);
1801 })
1802 .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
1803 match receive.recv_timeout(timeout) {
1804 Ok(result) => result,
1805 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
1806 std::io::ErrorKind::TimedOut,
1807 "resolution exceeded its deadline",
1808 )),
1809 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
1810 "resolver stopped without returning a result",
1811 )),
1812 }
1813}
1814
1815fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
1816 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1817 pinned_public_agent(url, allow_private, "registry home")
1818}
1819
1820fn get_json_absolute(url: &str) -> LinkResult<Value> {
1825 let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
1826 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1827 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1828 || !parsed.username().is_empty()
1829 || parsed.password().is_some()
1830 || parsed.query().is_some()
1831 || parsed.fragment().is_some()
1832 {
1833 return Err(invalid_feed("unsafe registry home URL"));
1834 }
1835 let http = registry_agent(&parsed)?;
1836 let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
1837 Ok(resp) => resp,
1838 Err(error) => match *error {
1839 ureq::Error::Status(status, resp) => {
1840 let _ = resp;
1841 return Err(LinkError::Http {
1842 what: "registry home fetch",
1843 status,
1844 message: "the home node rejected the card request".to_string(),
1845 code: None,
1846 details: None,
1847 });
1848 }
1849 ureq::Error::Transport(err) => {
1850 return Err(LinkError::Transport {
1851 hub: url.to_string(),
1852 message: err.to_string(),
1853 });
1854 }
1855 },
1856 };
1857 if !(200..300).contains(&resp.status()) {
1858 return Err(LinkError::Http {
1859 what: "registry home fetch",
1860 status: resp.status(),
1861 message: "the home node returned a redirect or error".to_string(),
1862 code: None,
1863 details: None,
1864 });
1865 }
1866 let mut buf = Vec::new();
1867 resp.into_reader()
1868 .take(MAX_REGISTRY_CARD_BYTES + 1)
1869 .read_to_end(&mut buf)?;
1870 if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
1871 return Err(LinkError::ResponseTooLarge {
1872 limit_bytes: MAX_REGISTRY_CARD_BYTES,
1873 });
1874 }
1875 serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
1876 message: "the home node returned invalid JSON".to_string(),
1877 })
1878}
1879
1880pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
1887 require_safe_ref(handle)?;
1888 let trust_directory = open_trust_dir(cfg)?;
1892 let reg = request_capped(
1893 cfg,
1894 "GET",
1895 &format!("/api/hub/registry/{handle}"),
1896 None,
1897 Auth::None,
1898 MAX_REGISTRY_CARD_BYTES,
1899 )?;
1900 if reg.status == 404 {
1901 return Ok(None);
1902 }
1903 let body = ensure_ok(reg, "registry resolve")?;
1904 let home = body
1905 .get("home")
1906 .and_then(Value::as_str)
1907 .ok_or_else(|| invalid_feed("registry entry has no home"))?;
1908 let brain = body
1909 .get("brain")
1910 .and_then(Value::as_str)
1911 .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
1912 if !crate::ulid::is_ulid(brain) {
1913 return Err(invalid_feed(
1914 "registry entry brain is not a canonical lowercase ULID",
1915 ));
1916 }
1917 let want_fp = body
1918 .get("identity")
1919 .and_then(|i| i.get("fingerprint"))
1920 .and_then(Value::as_str)
1921 .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
1922
1923 let home = home.trim_end_matches('/');
1924 let origin = normalized_origin(home)?;
1925 if origin != home {
1926 return Err(invalid_feed(
1927 "registry home must be an origin without a path, query, or fragment",
1928 ));
1929 }
1930 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
1931 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
1932 if let Some(binding) = &alias_binding {
1933 if binding
1934 .home
1935 .as_deref()
1936 .is_some_and(|pinned_home| pinned_home != home)
1937 {
1938 return Err(invalid_feed(
1939 "registry relocated a pinned handle to a different home",
1940 ));
1941 }
1942 }
1943 let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
1944 if card.get("id").and_then(Value::as_str) != Some(brain) {
1945 return Err(invalid_feed(
1946 "the home node served a card for a different brain",
1947 ));
1948 }
1949 let identity: FeedIdentity = serde_json::from_value(
1950 card.get("identity")
1951 .cloned()
1952 .ok_or_else(|| invalid_feed("the home node served no identity"))?,
1953 )
1954 .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
1955 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
1956 let got_fp = card
1957 .get("identity")
1958 .and_then(|i| i.get("fingerprint"))
1959 .and_then(Value::as_str)
1960 .unwrap_or_default();
1961 if got_fp != want_fp {
1962 return Err(invalid_feed(
1963 "the home node served an identity that does not match the registry — refusing",
1964 ));
1965 }
1966 let current = format!("ed25519:{}", identity.fingerprint);
1967 let advertised_seq = card
1968 .get("headSeq")
1969 .and_then(Value::as_u64)
1970 .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
1971 let advertised_hash = card.get("feedHash").and_then(Value::as_str);
1972 if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
1973 || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
1974 {
1975 return Err(invalid_feed(
1976 "the home node served an invalid feed head boundary",
1977 ));
1978 }
1979 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
1983 let registry_alias = AliasBinding {
1984 v: 1,
1985 origin: normalized_origin(&cfg.hub)?,
1986 requested: handle.to_string(),
1987 brain: brain.to_string(),
1988 home: Some(home.to_string()),
1989 };
1990 save_canonical_pin_and_alias(
1991 cfg,
1992 &trust_directory,
1993 handle,
1994 brain,
1995 TrustState {
1996 v: 2,
1997 origin: normalized_origin(&cfg.hub)?,
1998 requested: brain.to_string(),
1999 brain: brain.to_string(),
2000 home: None,
2001 anchor,
2002 current,
2003 head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2004 feed_hash: pinned
2005 .as_ref()
2006 .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2007 rotations: identity.rotations.clone(),
2008 hub_signer: None,
2009 protocol_profile: None,
2010 },
2011 Some(®istry_alias),
2012 )?;
2013 let mut out = card;
2014 if let Value::Object(map) = &mut out {
2015 map.insert("home".to_string(), Value::String(home.to_string()));
2016 map.insert(
2017 "resolvedVia".to_string(),
2018 Value::String("registry".to_string()),
2019 );
2020 }
2021 Ok(Some(out))
2022}
2023
2024pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2025 require_safe_ref(&addr.brain)?;
2029 if let Some(target) = &addr.target {
2030 let (given, ok) = match target {
2031 AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2032 AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2033 };
2034 if !ok {
2035 return Err(LinkError::BadAddress {
2036 given: given.clone(),
2037 reason: BAD_TARGET_REASON.to_string(),
2038 });
2039 }
2040 }
2041
2042 if let Some(target) = &addr.target {
2048 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2049 if !remote.head.verified {
2050 return Err(invalid_feed(
2051 "a path-scoped feed cannot prove a record against the full signed snapshot",
2052 ));
2053 }
2054 if remote.head.seq == 0 {
2055 return Err(LinkError::Http {
2056 what: "resolve",
2057 status: 404,
2058 message: "record not found".to_string(),
2059 code: Some("NOT_FOUND".to_string()),
2060 details: None,
2061 });
2062 }
2063 let brain = remote.head.brain.clone();
2064 let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2065 return resolve_from_verified_pack(&brain, target, pack);
2066 }
2067
2068 let path = format!("/api/hub/brains/{}", addr.brain);
2069 let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2074 if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2075 if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2076 return Ok(card);
2077 }
2078 }
2079 let resolved = ensure_ok(direct, "resolve")?;
2080 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2084 if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2085 || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2086 || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2087 {
2088 return Err(invalid_feed(
2089 "resolve card is not bound to the exact verified feed checkpoint",
2090 ));
2091 }
2092 let card_identity: FeedIdentity = serde_json::from_value(
2093 resolved
2094 .get("identity")
2095 .cloned()
2096 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2097 )
2098 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2099 if remote.identity.as_ref() != Some(&card_identity) {
2100 return Err(invalid_feed(
2101 "resolve card identity differs from the verified feed identity",
2102 ));
2103 }
2104 Ok(resolved)
2105}
2106
2107fn resolve_from_verified_pack(
2112 brain: &str,
2113 target: &AddressTarget,
2114 pack: Vec<u8>,
2115) -> LinkResult<Value> {
2116 let entries = parse_store_pack(pack)?;
2117 let mut matched: Option<(String, Vec<u8>)> = None;
2118
2119 for (path, bytes) in entries {
2120 let is_candidate = match target {
2121 AddressTarget::Path(want) => &path == want,
2122 AddressTarget::Id(_) => {
2123 path.ends_with(".md")
2124 && (path.starts_with("records/") || path.starts_with("sources/"))
2125 }
2126 };
2127 if !is_candidate {
2128 continue;
2129 }
2130 let text = std::str::from_utf8(&bytes)
2131 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2132 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2133 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2134 if let AddressTarget::Id(want) = target {
2135 let frontmatter =
2136 crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2137 .map_err(|_| {
2138 invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2139 })?;
2140 if frontmatter.id.as_deref() != Some(want) {
2141 continue;
2142 }
2143 }
2144 if matched.is_some() {
2145 return Err(invalid_feed(
2146 "signed snapshot contains more than one record for the requested target",
2147 ));
2148 }
2149 matched = Some((path, bytes));
2150 }
2151
2152 let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2153 what: "resolve",
2154 status: 404,
2155 message: "record not found".to_string(),
2156 code: Some("NOT_FOUND".to_string()),
2157 details: None,
2158 })?;
2159 let text = std::str::from_utf8(&bytes)
2160 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2161 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2162 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2163 let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2164 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2165 let Value::Object(fields) = frontmatter else {
2166 return Err(invalid_feed(format!(
2167 "signed snapshot record `{path}` frontmatter is not a mapping"
2168 )));
2169 };
2170 let mut document = serde_json::Map::new();
2171 document.insert("path".to_string(), Value::String(path));
2172 for (key, value) in fields {
2173 document.insert(key, value);
2174 }
2175 document.insert("body".to_string(), Value::String(parsed.body));
2176 document.insert(
2177 "contentSha".to_string(),
2178 Value::String(content_sha256(&bytes)),
2179 );
2180 Ok(json!({
2181 "brain": brain,
2182 "document": Value::Object(document),
2183 }))
2184}
2185
2186#[derive(Debug, Clone, serde::Serialize)]
2192pub struct PullReport {
2193 pub brain: String,
2195 pub slug: String,
2197 #[serde(rename = "headSeq")]
2199 pub head_seq: u64,
2200 pub files: usize,
2202 pub dest: String,
2204 #[serde(rename = "extraLocal")]
2207 pub extra_local: Vec<String>,
2208 #[serde(rename = "syncStatus")]
2210 pub sync_status: String,
2211}
2212
2213struct V2PulledSnapshot {
2214 report: PullReport,
2215 head: V2VerifiedHead,
2216 files: std::collections::BTreeMap<String, V2BaselineFile>,
2217 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2218 local: V2LocalView,
2219 local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2220}
2221
2222fn download_verified_snapshot_pack(
2223 cfg: &HubConfig,
2224 brain: &str,
2225 remote: &VerifiedRemote,
2226) -> LinkResult<Vec<u8>> {
2227 let feed_hash = remote
2228 .head
2229 .feed_hash
2230 .as_deref()
2231 .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2232 let signed_head = remote
2233 .head_entry
2234 .as_ref()
2235 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2236 let expected = &signed_head.entry.pack_sha256;
2237 if !is_sha256(expected) {
2238 return Err(invalid_feed(
2239 "signed head carries an invalid snapshot pack digest",
2240 ));
2241 }
2242 let path = format!(
2243 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2244 remote.head.seq
2245 );
2246 let body = ensure_ok(
2247 request(cfg, "GET", &path, None, Auth::Required)?,
2248 "sync pull",
2249 )?;
2250 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2251 || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2252 || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2253 || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2254 {
2255 return Err(invalid_feed(
2256 "export response is not bound to the exact verified snapshot",
2257 ));
2258 }
2259 let url = body
2260 .get("url")
2261 .and_then(Value::as_str)
2262 .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2263 let bytes = get_presigned(cfg, url)?;
2264 if content_sha256(&bytes) != *expected {
2265 return Err(LinkError::InvalidPack {
2266 message: "downloaded pack does not match the signed snapshot digest".to_string(),
2267 });
2268 }
2269 let entries = parse_store_pack(bytes.clone())?;
2270 if signed_head.entry.kind == "push" {
2271 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2272 }
2273 Ok(bytes)
2274}
2275
2276#[derive(Debug, Clone, Deserialize, Serialize)]
2277struct V2PointerBody {
2278 v: u8,
2279 brain: String,
2280 seq: u64,
2281 commit_hash: String,
2282 feed_hash: String,
2283 content_root: Option<String>,
2284 asset_root: Option<String>,
2285 materializer: String,
2286 signer_epoch: u64,
2287 control_revision: String,
2288 backup_preparation: String,
2289 prior_pointer_hash: Option<String>,
2290 signed_at: String,
2291}
2292
2293#[derive(Debug, Clone, Deserialize)]
2294struct V2SignedPointer {
2295 pointer: V2PointerBody,
2296 hub_public_key: String,
2297 hub_fingerprint: String,
2298 sig: String,
2299}
2300
2301#[derive(Debug, Clone, Deserialize)]
2302struct V2HeadIdentity {
2303 #[serde(default)]
2304 custody: String,
2305 fingerprint: String,
2306 public_key_spki: String,
2307 #[serde(default)]
2308 previous: Vec<V2PreviousIdentity>,
2309 #[serde(default)]
2310 rotations: Vec<String>,
2311}
2312
2313#[derive(Debug, Clone, Deserialize)]
2314struct V2PreviousIdentity {
2315 fingerprint: String,
2316 public_key_spki: String,
2317}
2318
2319#[derive(Debug, Deserialize)]
2320struct V2HeadResponse {
2321 v: u8,
2322 brain_id: String,
2323 profile: String,
2324 view: Option<V2HeadView>,
2325 pointer: Option<V2SignedPointer>,
2326 identity: Option<V2HeadIdentity>,
2327}
2328
2329#[derive(Debug, Clone, Deserialize)]
2330struct V2HeadView {
2331 kind: String,
2332 #[serde(default)]
2333 id: Option<String>,
2334 control_revision: String,
2335}
2336
2337#[derive(Debug, Clone)]
2338struct V2VerifiedHead {
2339 requested: String,
2340 brain_id: String,
2341 view_kind: String,
2342 view_revision: String,
2344 control_revision: String,
2346 identity: V2HeadIdentity,
2347 pointer: Option<V2PointerBody>,
2348 trust: TrustState,
2349 alias: Option<AliasBinding>,
2350}
2351
2352fn verify_v2_spki_signature(
2353 public_key: &str,
2354 message: &[u8],
2355 signature: &str,
2356) -> LinkResult<Vec<u8>> {
2357 let der = URL_SAFE_NO_PAD
2358 .decode(public_key)
2359 .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2360 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2361 return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2362 }
2363 let sig = URL_SAFE_NO_PAD
2364 .decode(signature)
2365 .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2366 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2367 .verify(message, &sig)
2368 .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2369 Ok(der)
2370}
2371
2372fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2373 if pointer.pointer.v != 2
2374 || pointer.pointer.brain != expected_brain
2375 || pointer.pointer.seq == 0
2376 || !is_sha256(&pointer.pointer.commit_hash)
2377 || !is_sha256(&pointer.pointer.feed_hash)
2378 || pointer
2379 .pointer
2380 .content_root
2381 .as_deref()
2382 .is_some_and(|hash| !is_sha256(hash))
2383 || !is_sha256(&pointer.pointer.backup_preparation)
2384 {
2385 return Err(invalid_feed("v2 pointer fields are invalid"));
2386 }
2387 let value = serde_json::to_value(&pointer.pointer)
2388 .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2389 let message = crate::linkmd_v2::canonical_bytes(&value)
2390 .map_err(|error| invalid_feed(error.to_string()))?;
2391 let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2392 let fingerprint = format!("{:x}", Sha256::digest(&der));
2393 if fingerprint != pointer.hub_fingerprint {
2394 return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2395 }
2396 Ok(format!(
2397 "{}:{}",
2398 pointer.hub_fingerprint, pointer.hub_public_key
2399 ))
2400}
2401
2402fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2403 FeedIdentity {
2404 fingerprint: identity.fingerprint.clone(),
2405 public_key_spki: identity.public_key_spki.clone(),
2406 previous: identity
2407 .previous
2408 .iter()
2409 .map(|previous| PreviousIdentity {
2410 fingerprint: previous.fingerprint.clone(),
2411 public_key_spki: previous.public_key_spki.clone(),
2412 })
2413 .collect(),
2414 rotations: identity.rotations.clone(),
2415 }
2416}
2417
2418fn verified_v2_commit_object(
2419 raw: &[u8],
2420 identity: &V2HeadIdentity,
2421) -> LinkResult<serde_json::Map<String, Value>> {
2422 let mut value: Value =
2423 serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2424 let canonical = crate::linkmd_v2::canonical_bytes(&value)
2425 .map_err(|error| invalid_feed(error.to_string()))?;
2426 if canonical != raw {
2427 return Err(invalid_feed("v2 commit is not canonical JSON"));
2428 }
2429 let object = value
2430 .as_object_mut()
2431 .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
2432 let sig = object
2433 .remove("sig")
2434 .and_then(|value| value.as_str().map(str::to_string))
2435 .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
2436 const FIELDS: [&str; 18] = [
2437 "actor_ref",
2438 "asset_root",
2439 "brain",
2440 "changes_sha256",
2441 "control_revision",
2442 "materializer",
2443 "op",
2444 "parent_asset_root",
2445 "parent_commit",
2446 "parent_root",
2447 "prev_entry_hash",
2448 "public_key",
2449 "seq",
2450 "signer_epoch",
2451 "state_root",
2452 "ts",
2453 "v",
2454 "v1_bridge",
2455 ];
2456 if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
2457 return Err(invalid_feed("v2 commit has a non-normative field set"));
2458 }
2459 let seq = object
2460 .get("seq")
2461 .and_then(Value::as_u64)
2462 .filter(|seq| *seq > 0)
2463 .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
2464 let signer_epoch = object
2465 .get("signer_epoch")
2466 .and_then(Value::as_u64)
2467 .filter(|epoch| *epoch > 0)
2468 .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
2469 let hash_or_null = |field: &str| {
2470 object
2471 .get(field)
2472 .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
2473 };
2474 if object.get("v").and_then(Value::as_u64) != Some(2)
2475 || object.get("op").and_then(Value::as_str) != Some("changeset")
2476 || !object
2477 .get("changes_sha256")
2478 .and_then(Value::as_str)
2479 .is_some_and(is_sha256)
2480 || !object
2481 .get("actor_ref")
2482 .and_then(Value::as_str)
2483 .is_some_and(is_sha256)
2484 || !object
2485 .get("control_revision")
2486 .and_then(Value::as_str)
2487 .is_some_and(is_sha256)
2488 || !object
2489 .get("state_root")
2490 .and_then(Value::as_str)
2491 .is_some_and(is_sha256)
2492 || !hash_or_null("parent_commit")
2493 || !hash_or_null("parent_root")
2494 || !hash_or_null("parent_asset_root")
2495 || !hash_or_null("asset_root")
2496 || !hash_or_null("prev_entry_hash")
2497 || !object
2498 .get("materializer")
2499 .and_then(Value::as_str)
2500 .is_some_and(|value| !value.is_empty() && value.len() <= 128)
2501 || !object
2502 .get("ts")
2503 .and_then(Value::as_str)
2504 .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
2505 {
2506 return Err(invalid_feed("v2 commit fields are invalid"));
2507 }
2508 if (seq == 1
2509 && [
2510 "parent_commit",
2511 "parent_root",
2512 "parent_asset_root",
2513 "prev_entry_hash",
2514 ]
2515 .iter()
2516 .any(|field| !object.get(*field).is_some_and(Value::is_null)))
2517 || (seq > 1
2518 && ["parent_commit", "parent_root", "prev_entry_hash"]
2519 .iter()
2520 .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
2521 {
2522 return Err(invalid_feed("v2 commit parent shape is invalid"));
2523 }
2524 match object.get("v1_bridge") {
2525 Some(Value::Null) => {}
2526 Some(Value::Object(bridge))
2527 if seq == 1
2528 && bridge.len() == 3
2529 && bridge
2530 .get("head_seq")
2531 .and_then(Value::as_u64)
2532 .is_some_and(|v| v > 0)
2533 && bridge
2534 .get("feed_hash")
2535 .and_then(Value::as_str)
2536 .is_some_and(is_sha256)
2537 && bridge
2538 .get("pack_sha256")
2539 .and_then(Value::as_str)
2540 .is_some_and(is_sha256) => {}
2541 _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
2542 }
2543 let public_key = object
2544 .get("public_key")
2545 .and_then(Value::as_str)
2546 .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
2547 let der = URL_SAFE_NO_PAD
2548 .decode(public_key)
2549 .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
2550 let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
2551 if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
2552 return Err(invalid_feed("v2 commit brain identity mismatch"));
2553 }
2554 verify_identity_chain(&v2_identity(identity), None)?;
2556 let mut chain: Vec<(&str, &str)> = identity
2559 .previous
2560 .iter()
2561 .rev()
2562 .map(|previous| {
2563 (
2564 previous.fingerprint.as_str(),
2565 previous.public_key_spki.as_str(),
2566 )
2567 })
2568 .collect();
2569 chain.push((&identity.fingerprint, &identity.public_key_spki));
2570 let signer_index = chain.iter().position(|(fingerprint, spki)| {
2571 *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
2572 });
2573 let Some(signer_index) = signer_index else {
2574 return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
2575 };
2576 if signer_epoch != signer_index as u64 + 1 {
2577 return Err(invalid_feed("v2 commit signer epoch differs from its key"));
2578 }
2579 let lower_boundary = if signer_index == 0 {
2580 None
2581 } else {
2582 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
2583 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
2584 Some(prior.prior_head_seq)
2585 };
2586 let upper_boundary = if signer_index == identity.rotations.len() {
2587 None
2588 } else {
2589 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
2590 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
2591 Some(next.prior_head_seq)
2592 };
2593 if lower_boundary.is_some_and(|boundary| seq <= boundary)
2594 || upper_boundary.is_some_and(|boundary| seq > boundary)
2595 {
2596 return Err(invalid_feed(
2597 "v2 commit signer is outside its authenticated rotation epoch",
2598 ));
2599 }
2600 let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
2601 .map_err(|error| invalid_feed(error.to_string()))?;
2602 verify_v2_spki_signature(public_key, &unsigned, &sig)?;
2603 Ok(object.clone())
2604}
2605
2606#[derive(Debug, Deserialize)]
2607struct V2FeedWireEntry {
2608 seq: u64,
2609 commit_hash: String,
2610 feed_hash: String,
2611 bytes_base64: String,
2612}
2613
2614#[derive(Debug, Deserialize)]
2615struct V2FeedPage {
2616 v: u8,
2617 head_seq: u64,
2618 head_commit_hash: String,
2619 head_feed_hash: String,
2620 entries: Vec<V2FeedWireEntry>,
2621 next_after: u64,
2622 complete: bool,
2623}
2624
2625fn replay_v2_feed(
2626 cfg: &HubConfig,
2627 brain: &str,
2628 pointer: &V2PointerBody,
2629 identity: &V2HeadIdentity,
2630 start_after: u64,
2631 start_feed: Option<String>,
2632) -> LinkResult<()> {
2633 let mut after = start_after;
2634 let mut prior_feed = start_feed;
2635 let mut final_object = None;
2636 let mut replayed_entries = 0_u64;
2637 let mut replayed_bytes = 0_u64;
2638 while after < pointer.seq {
2639 let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
2640 let value = ensure_ok(
2641 request_capped(
2642 cfg,
2643 "GET",
2644 &path,
2645 None,
2646 Auth::Required,
2647 MAX_FEED_REPLAY_BYTES,
2648 )?,
2649 "v2 feed replay",
2650 )?;
2651 let page: V2FeedPage = serde_json::from_value(value)
2652 .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
2653 if page.v != 2
2654 || page.head_seq != pointer.seq
2655 || page.head_commit_hash != pointer.commit_hash
2656 || page.head_feed_hash != pointer.feed_hash
2657 || page.entries.is_empty()
2658 || page.entries.len() > FEED_PAGE_LIMIT
2659 {
2660 return Err(invalid_feed("v2 feed page differs from the signed head"));
2661 }
2662 for entry in page.entries {
2663 if entry.seq != after + 1
2664 || !is_sha256(&entry.commit_hash)
2665 || !is_sha256(&entry.feed_hash)
2666 {
2667 return Err(invalid_feed("v2 feed sequence is not contiguous"));
2668 }
2669 let raw = base64::engine::general_purpose::STANDARD
2670 .decode(&entry.bytes_base64)
2671 .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
2672 replayed_entries = replayed_entries
2673 .checked_add(1)
2674 .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
2675 replayed_bytes = replayed_bytes
2676 .checked_add(raw.len() as u64)
2677 .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
2678 if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
2679 {
2680 return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
2681 }
2682 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2683 .map_err(|error| invalid_feed(error.to_string()))?
2684 != entry.commit_hash
2685 || content_sha256(&raw) != entry.feed_hash
2686 {
2687 return Err(invalid_feed("v2 feed entry address mismatch"));
2688 }
2689 let object = verified_v2_commit_object(&raw, identity)?;
2690 if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
2691 || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
2692 {
2693 return Err(invalid_feed(
2694 "v2 feed entry does not extend its predecessor",
2695 ));
2696 }
2697 after = entry.seq;
2698 prior_feed = Some(entry.feed_hash);
2699 final_object = Some((entry.commit_hash, object));
2700 }
2701 if page.next_after != after || (page.complete != (after == pointer.seq)) {
2702 return Err(invalid_feed("v2 feed page cursor is inconsistent"));
2703 }
2704 }
2705 let (final_hash, object) =
2706 final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
2707 if final_hash != pointer.commit_hash
2708 || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
2709 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2710 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2711 || object.get("control_revision").and_then(Value::as_str)
2712 != Some(pointer.control_revision.as_str())
2713 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2714 {
2715 return Err(invalid_feed(
2716 "v2 replay did not converge on the signed pointer",
2717 ));
2718 }
2719 Ok(())
2720}
2721
2722fn verify_v1_to_v2_bridge(
2723 cfg: &HubConfig,
2724 brain: &str,
2725 pointer: &V2PointerBody,
2726 identity: &V2HeadIdentity,
2727 checkpoint: &TrustState,
2728) -> LinkResult<()> {
2729 let value = ensure_ok(
2730 request_capped(
2731 cfg,
2732 "GET",
2733 &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
2734 None,
2735 Auth::Required,
2736 MAX_FEED_RESPONSE_BYTES,
2737 )?,
2738 "v2 genesis bridge",
2739 )?;
2740 let page: V2FeedPage = serde_json::from_value(value)
2741 .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
2742 if page.v != 2
2743 || page.head_seq != pointer.seq
2744 || page.head_commit_hash != pointer.commit_hash
2745 || page.head_feed_hash != pointer.feed_hash
2746 || page.entries.len() != 1
2747 || page.entries[0].seq != 1
2748 || !is_sha256(&page.entries[0].commit_hash)
2749 || !is_sha256(&page.entries[0].feed_hash)
2750 {
2751 return Err(invalid_feed(
2752 "v2 genesis bridge page differs from the signed head",
2753 ));
2754 }
2755 let first = &page.entries[0];
2756 let raw = STANDARD
2757 .decode(&first.bytes_base64)
2758 .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
2759 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2760 .map_err(|error| invalid_feed(error.to_string()))?
2761 != first.commit_hash
2762 || content_sha256(&raw) != first.feed_hash
2763 {
2764 return Err(invalid_feed("v2 genesis bridge address mismatch"));
2765 }
2766 let object = verified_v2_commit_object(&raw, identity)?;
2767 if checkpoint.head_seq == 0 {
2768 if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
2769 return Err(invalid_feed(
2770 "empty v1 checkpoint did not transition through an empty v2 genesis",
2771 ));
2772 }
2773 return Ok(());
2774 }
2775 let bridge = object
2776 .get("v1_bridge")
2777 .and_then(Value::as_object)
2778 .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
2779 let checkpoint_feed = checkpoint
2780 .feed_hash
2781 .as_deref()
2782 .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
2783 if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
2784 || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
2785 {
2786 return Err(invalid_feed(
2787 "v2 genesis bridge differs from the pinned v1 checkpoint",
2788 ));
2789 }
2790 let legacy_raw = ensure_raw_ok(
2791 request_raw(
2792 cfg,
2793 "GET",
2794 &format!(
2795 "/api/hub/brains/{brain}/feed?after={}&limit=1",
2796 checkpoint.head_seq - 1
2797 ),
2798 None,
2799 Auth::Required,
2800 MAX_FEED_RESPONSE_BYTES,
2801 )?,
2802 "v1 bridge boundary",
2803 )?;
2804 let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
2805 .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
2806 let legacy_identity = legacy
2807 .identity
2808 .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
2809 let item = legacy
2810 .entries
2811 .first()
2812 .filter(|_| legacy.entries.len() == 1)
2813 .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
2814 if legacy.scope_limited
2815 || legacy.head_seq != checkpoint.head_seq
2816 || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
2817 || item.entry.seq != checkpoint.head_seq
2818 || item.hash != checkpoint_feed
2819 || legacy_identity != v2_identity(identity)
2820 || bridge.get("pack_sha256").and_then(Value::as_str)
2821 != Some(item.entry.pack_sha256.as_str())
2822 {
2823 return Err(invalid_feed(
2824 "v1 bridge boundary differs from its signed legacy head",
2825 ));
2826 }
2827 let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
2828 if anchor != checkpoint.anchor {
2829 return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
2830 }
2831 verify_feed_item(item, &legacy_identity)?;
2832 verify_rotation_feed_boundaries(
2833 &legacy_identity,
2834 Some(checkpoint),
2835 std::slice::from_ref(item),
2836 checkpoint.head_seq,
2837 )?;
2838 Ok(())
2839}
2840
2841fn verify_v2_commit(
2842 cfg: &HubConfig,
2843 brain: &str,
2844 pointer: &V2PointerBody,
2845 identity: &V2HeadIdentity,
2846 pinned: Option<&TrustState>,
2847) -> LinkResult<()> {
2848 let path = format!(
2849 "/api/hub/brains/{brain}/v2/commit?commit={}",
2850 pointer.commit_hash
2851 );
2852 let raw = ensure_raw_ok(
2853 request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
2854 "v2 commit",
2855 )?;
2856 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2857 .map_err(|error| invalid_feed(error.to_string()))?
2858 != pointer.commit_hash
2859 || content_sha256(&raw) != pointer.feed_hash
2860 {
2861 return Err(invalid_feed("v2 commit address differs from the pointer"));
2862 }
2863 let object = verified_v2_commit_object(&raw, identity)?;
2864 if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
2865 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2866 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2867 || object.get("control_revision").and_then(Value::as_str)
2868 != Some(pointer.control_revision.as_str())
2869 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2870 {
2871 return Err(invalid_feed("v2 commit fields differ from the pointer"));
2872 }
2873 if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
2874 if pointer.seq == checkpoint.head_seq + 1
2875 && object.get("prev_entry_hash").and_then(Value::as_str)
2876 != checkpoint.feed_hash.as_deref()
2877 {
2878 return Err(invalid_feed(
2879 "v2 commit does not extend the pinned feed hash",
2880 ));
2881 }
2882 if pointer.seq > checkpoint.head_seq + 1 {
2883 return replay_v2_feed(
2884 cfg,
2885 brain,
2886 pointer,
2887 identity,
2888 checkpoint.head_seq,
2889 checkpoint.feed_hash.clone(),
2890 );
2891 }
2892 } else {
2893 if let Some(checkpoint) = pinned {
2894 verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
2895 }
2896 if pointer.seq > 1 {
2897 return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
2898 }
2899 }
2900 Ok(())
2901}
2902
2903fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
2904 require_hardened_filesystem("verified link.md v2 state")?;
2905 require_safe_ref(brain)?;
2906 let path = format!("/api/hub/brains/{brain}/v2/head");
2907 let response = request(cfg, "GET", &path, None, Auth::Required)?;
2908 if response.status == 404 {
2909 if has_accepted_v2_ref(cfg, brain)? {
2910 return Err(LinkError::BrainUnavailable);
2911 }
2912 return Ok(None);
2913 }
2914 let body = ensure_ok(response, "v2 head")?;
2915 let head: V2HeadResponse = serde_json::from_value(body)
2916 .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
2917 if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
2918 return Err(invalid_feed("v2 head has no canonical brain id"));
2919 }
2920 if crate::ulid::is_ulid(brain) && head.brain_id != brain {
2921 return Err(invalid_feed("v2 head resolved a different brain id"));
2922 }
2923 if head.profile == "v1" {
2924 return Ok(None);
2925 }
2926 if head.profile != "v2" && head.profile != "v2-empty" {
2927 return Err(invalid_feed("v2 head advertised an unknown profile"));
2928 }
2929 let view = head
2930 .view
2931 .as_ref()
2932 .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
2933 if !matches!(view.kind.as_str(), "full" | "scoped")
2934 || !is_sha256(&view.control_revision)
2935 || view.id.as_deref().is_some_and(|id| !is_sha256(id))
2936 {
2937 return Err(invalid_feed("v2 head has an invalid permission view"));
2938 }
2939 let view_kind = view.kind.clone();
2940 let view_revision = view
2943 .id
2944 .clone()
2945 .unwrap_or_else(|| view.control_revision.clone());
2946 let control_revision = view.control_revision.clone();
2947 let identity = head
2948 .identity
2949 .as_ref()
2950 .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
2951 let trust_directory = open_trust_dir(cfg)?;
2952 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
2953 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
2954 let feed_identity = v2_identity(identity);
2955 let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
2956 let (seq, feed_hash, hub_signer) = match &head.pointer {
2957 None => {
2958 if head.profile != "v2-empty" {
2959 return Err(invalid_feed("initialized v2 head has no pointer"));
2960 }
2961 (
2962 0,
2963 None,
2964 pinned.as_ref().and_then(|state| state.hub_signer.clone()),
2965 )
2966 }
2967 Some(signed) => {
2968 let signer = verify_v2_pointer(signed, &head.brain_id)?;
2969 if pinned
2970 .as_ref()
2971 .and_then(|state| state.hub_signer.as_ref())
2972 .is_some_and(|known| known != &signer)
2973 {
2974 return Err(invalid_feed(
2975 "v2 hub pointer signer changed without a trust transition",
2976 ));
2977 }
2978 if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
2979 if signed.pointer.seq < checkpoint.head_seq
2980 || (signed.pointer.seq == checkpoint.head_seq
2981 && checkpoint.feed_hash.as_deref()
2982 != Some(signed.pointer.feed_hash.as_str()))
2983 {
2984 return Err(invalid_feed("v2 pointer rolled back or equivocated"));
2985 }
2986 }
2987 verify_v2_commit(
2988 cfg,
2989 &head.brain_id,
2990 &signed.pointer,
2991 identity,
2992 pinned.as_ref(),
2993 )?;
2994 (
2995 signed.pointer.seq,
2996 Some(signed.pointer.feed_hash.clone()),
2997 Some(signer),
2998 )
2999 }
3000 };
3001 let trust = TrustState {
3002 v: 2,
3003 origin: normalized_origin(&cfg.hub)?,
3004 requested: head.brain_id.clone(),
3005 brain: head.brain_id.clone(),
3006 home: None,
3007 anchor,
3008 current: format!("ed25519:{}", identity.fingerprint),
3009 head_seq: seq,
3010 feed_hash,
3011 rotations: identity.rotations.clone(),
3012 hub_signer,
3013 protocol_profile: Some("link-v2".to_string()),
3014 };
3015 Ok(Some(V2VerifiedHead {
3016 requested: brain.to_string(),
3017 brain_id: head.brain_id,
3018 view_kind,
3019 view_revision,
3020 control_revision,
3021 identity: identity.clone(),
3022 pointer: head.pointer.map(|signed| signed.pointer),
3023 trust,
3024 alias: alias_binding,
3025 }))
3026}
3027
3028fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3029 let directory = open_trust_dir(cfg)?;
3030 let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3031 let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3032 if let Some(current) = current {
3033 let common_invalid = head.trust.anchor != current.anchor
3034 || !head.trust.rotations.starts_with(¤t.rotations);
3035 let profile_invalid = if accepted_as_v2(¤t) {
3036 head.trust.head_seq < current.head_seq
3037 || (head.trust.head_seq == current.head_seq
3038 && head.trust.feed_hash != current.feed_hash)
3039 || current
3040 .hub_signer
3041 .as_ref()
3042 .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3043 } else {
3044 head.trust.protocol_profile.as_deref() != Some("link-v2")
3045 || head.trust.hub_signer.is_none()
3046 };
3047 if common_invalid || profile_invalid {
3048 return Err(invalid_feed(
3049 "v2 head cannot advance the currently accepted trust checkpoint",
3050 ));
3051 }
3052 }
3053 save_canonical_pin_and_alias(
3054 cfg,
3055 &directory,
3056 &head.requested,
3057 &head.brain_id,
3058 head.trust.clone(),
3059 alias.as_ref().or(head.alias.as_ref()),
3060 )
3061}
3062
3063#[derive(Debug, Clone, Deserialize, Serialize)]
3064struct V2BaselineFile {
3065 sha256: String,
3066 bytes: u64,
3067 #[serde(skip)]
3068 proof: Option<Vec<V2ProofStep>>,
3069}
3070
3071#[derive(Debug, Clone, Deserialize, Serialize)]
3072struct V2SyncBaseline {
3073 v: u8,
3074 origin: String,
3075 brain: String,
3076 #[serde(default)]
3077 checkout_id: Option<String>,
3078 #[serde(default)]
3079 head_seq: Option<u64>,
3080 commit_hash: Option<String>,
3081 content_root: Option<String>,
3082 #[serde(default)]
3083 asset_root: Option<String>,
3084 #[serde(default)]
3085 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3086 #[serde(default)]
3087 view_kind: Option<String>,
3088 #[serde(default)]
3089 view_revision: Option<String>,
3090 #[serde(default)]
3091 projection_sha256: Option<String>,
3092 files: std::collections::BTreeMap<String, V2BaselineFile>,
3093 #[serde(default)]
3094 local_policy_digest: Option<String>,
3095 #[serde(default)]
3096 local_eligibility: std::collections::BTreeMap<String, bool>,
3097 #[serde(default)]
3098 remote_copy_remains: std::collections::BTreeMap<String, String>,
3099}
3100
3101struct V2LocalView {
3102 riding: std::collections::BTreeMap<String, (String, u64)>,
3103 eligibility: std::collections::BTreeMap<String, bool>,
3104 policy: crate::linkmd_sync_policy::SyncPolicy,
3105 withheld_links: Vec<V2WithheldLink>,
3106}
3107
3108#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3109struct V2WithheldLink {
3110 source: String,
3111 target: String,
3112}
3113
3114#[derive(Debug, Clone, Deserialize, Serialize)]
3115struct V2ProofStep {
3116 directory_root: String,
3117 component: String,
3118 proof: crate::linkmd_v2::HamtProof,
3119}
3120
3121#[derive(Debug, Deserialize)]
3122struct V2ManifestFile {
3123 path: String,
3124 sha256: String,
3125 bytes: u64,
3126 proof: Vec<V2ProofStep>,
3127}
3128
3129#[derive(Debug, Deserialize)]
3130struct V2ManifestPage {
3131 v: u8,
3132 commit: String,
3133 content_root: Option<String>,
3134 files: Vec<V2ManifestFile>,
3135 next_cursor: Option<String>,
3136}
3137
3138#[derive(Debug, Clone, Deserialize, Serialize)]
3139struct V2BaselineAsset {
3140 blob_sha256: String,
3141 bytes: u64,
3142 media_type: String,
3143 wrappers: Vec<String>,
3144 required: bool,
3145 disposition: String,
3146 leaf_hash: String,
3147}
3148
3149#[derive(Debug, Deserialize)]
3150struct V2AssetManifestItem {
3151 path: String,
3152 blob_sha256: String,
3153 bytes: u64,
3154 media_type: String,
3155 wrappers: Vec<String>,
3156 required: bool,
3157 disposition: String,
3158 leaf_hash: String,
3159 proof: crate::linkmd_v2::HamtProof,
3160}
3161
3162#[derive(Debug, Deserialize)]
3163struct V2AssetManifestPage {
3164 v: u8,
3165 commit: String,
3166 asset_root: Option<String>,
3167 assets: Vec<V2AssetManifestItem>,
3168 next_cursor: Option<String>,
3169}
3170
3171#[derive(Debug, Deserialize)]
3172struct V2SigningCandidate {
3173 seq: u64,
3174 content_root: Option<String>,
3175 asset_root: Option<String>,
3176 signing_bytes_base64: String,
3177 changes_base64: String,
3178 actor_claim_base64: String,
3179}
3180
3181#[derive(Debug, Deserialize)]
3182struct V2SigningCandidatePage {
3183 v: u8,
3184 challenge_id: String,
3185 mutation_id: String,
3186 request_hash: String,
3187 parent: V2SigningParent,
3188 candidate: V2SigningCandidate,
3189 files: Vec<V2ManifestFile>,
3190 #[serde(default)]
3191 assets: Vec<V2AssetManifestItem>,
3192 next_cursor: Option<String>,
3193 expires_at: String,
3194}
3195
3196#[derive(Debug, Deserialize)]
3197struct V2SigningParent {
3198 seq: u64,
3199 commit_hash: Option<String>,
3200}
3201
3202fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3203 let normalized = crate::linkmd_v2::normalize_path(&file.path)
3204 .map_err(|error| invalid_feed(error.to_string()))?;
3205 let components = normalized.split('/').collect::<Vec<_>>();
3206 if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3207 return Err(invalid_feed("v2 file proof has the wrong shape"));
3208 }
3209 let mut directory_root = root.to_string();
3210 for (index, step) in file.proof.iter().enumerate() {
3211 if step.directory_root != directory_root || step.component != components[index] {
3212 return Err(invalid_feed(
3213 "v2 file proof path chain differs from its manifest",
3214 ));
3215 }
3216 if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3217 .map_err(|error| invalid_feed(error.to_string()))?
3218 {
3219 return Err(invalid_feed("v2 file proof failed verification"));
3220 }
3221 let entry = match &step.proof {
3222 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3223 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3224 return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3225 }
3226 };
3227 if index + 1 == components.len() {
3228 if entry.kind != crate::linkmd_v2::EntryKind::Blob
3229 || entry.child_hash != file.sha256
3230 || entry.bytes != Some(file.bytes)
3231 {
3232 return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3233 }
3234 } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3235 return Err(invalid_feed("v2 file proof traversed a non-directory"));
3236 } else {
3237 directory_root = entry.child_hash.clone();
3238 }
3239 }
3240 Ok(())
3241}
3242
3243fn v2_manifest(
3244 cfg: &HubConfig,
3245 brain: &str,
3246 pointer: Option<&V2PointerBody>,
3247) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3248 let Some(pointer) = pointer else {
3249 return Ok(std::collections::BTreeMap::new());
3250 };
3251 let Some(root) = pointer.content_root.as_deref() else {
3252 return Ok(std::collections::BTreeMap::new());
3253 };
3254 let mut files = std::collections::BTreeMap::new();
3255 let mut after = String::new();
3256 loop {
3257 let encoded_after: String =
3258 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3259 let path = format!(
3260 "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3261 pointer.commit_hash
3262 );
3263 let value = ensure_ok(
3264 request_capped(
3265 cfg,
3266 "GET",
3267 &path,
3268 None,
3269 Auth::Required,
3270 MAX_FEED_RESPONSE_BYTES,
3271 )?,
3272 "v2 file manifest",
3273 )?;
3274 let page: V2ManifestPage = serde_json::from_value(value)
3275 .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3276 if page.v != 2
3277 || page.commit != pointer.commit_hash
3278 || page.content_root.as_deref() != Some(root)
3279 || page.files.len() > 500
3280 {
3281 return Err(invalid_feed(
3282 "v2 file manifest is not bound to the verified head",
3283 ));
3284 }
3285 for file in page.files {
3286 verify_v2_file_proof(root, &file)?;
3287 if files
3288 .insert(
3289 file.path.clone(),
3290 V2BaselineFile {
3291 sha256: file.sha256,
3292 bytes: file.bytes,
3293 proof: Some(file.proof),
3294 },
3295 )
3296 .is_some()
3297 {
3298 return Err(invalid_feed("v2 file manifest repeats a path"));
3299 }
3300 if files.len() > MAX_PUSH_FILES {
3301 return Err(invalid_feed(
3302 "v2 file manifest exceeds the file-count bound",
3303 ));
3304 }
3305 }
3306 match page.next_cursor {
3307 None => break,
3308 Some(next) if next > after => after = next,
3309 Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3310 }
3311 }
3312 Ok(files)
3313}
3314
3315fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
3316 crate::linkmd_v2::normalize_path(&item.path)
3317 .map_err(|error| invalid_feed(error.to_string()))?;
3318 if !is_sha256(&item.blob_sha256)
3319 || !is_sha256(&item.leaf_hash)
3320 || item.wrappers.is_empty()
3321 || !matches!(item.disposition.as_str(), "hosted" | "withheld")
3322 || item
3323 .wrappers
3324 .iter()
3325 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
3326 {
3327 return Err(invalid_feed("v2 asset manifest item is invalid"));
3328 }
3329 let leaf = json!({
3330 "blob_sha256": item.blob_sha256,
3331 "bytes": item.bytes,
3332 "disposition": item.disposition,
3333 "media_type": item.media_type,
3334 "path": item.path,
3335 "required": item.required,
3336 "v": 2,
3337 "wrappers": item.wrappers,
3338 });
3339 if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
3340 .map_err(|error| invalid_feed(error.to_string()))?
3341 != item.leaf_hash
3342 || !crate::linkmd_v2::verify_proof_with_domain(
3343 root,
3344 &item.path,
3345 &item.proof,
3346 crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
3347 )
3348 .map_err(|error| invalid_feed(error.to_string()))?
3349 {
3350 return Err(invalid_feed("v2 asset inclusion proof failed"));
3351 }
3352 match &item.proof {
3353 crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
3354 if entry.name == item.path
3355 && entry.kind == crate::linkmd_v2::EntryKind::Blob
3356 && entry.child_hash == item.leaf_hash
3357 && entry.bytes == Some(item.bytes) =>
3358 {
3359 Ok(())
3360 }
3361 _ => Err(invalid_feed(
3362 "v2 asset proof leaf differs from its manifest",
3363 )),
3364 }
3365}
3366
3367fn v2_asset_manifest(
3368 cfg: &HubConfig,
3369 brain: &str,
3370 pointer: Option<&V2PointerBody>,
3371) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
3372 let Some(pointer) = pointer else {
3373 return Ok(std::collections::BTreeMap::new());
3374 };
3375 let Some(root) = pointer.asset_root.as_deref() else {
3376 return Ok(std::collections::BTreeMap::new());
3377 };
3378 let mut assets = std::collections::BTreeMap::new();
3379 let mut after = String::new();
3380 loop {
3381 let encoded_after: String =
3382 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3383 let path = format!(
3384 "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
3385 pointer.commit_hash
3386 );
3387 let value = ensure_ok(
3388 request_capped(
3389 cfg,
3390 "GET",
3391 &path,
3392 None,
3393 Auth::Required,
3394 MAX_FEED_RESPONSE_BYTES,
3395 )?,
3396 "v2 asset manifest",
3397 )?;
3398 let page: V2AssetManifestPage = serde_json::from_value(value)
3399 .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
3400 if page.v != 2
3401 || page.commit != pointer.commit_hash
3402 || page.asset_root.as_deref() != Some(root)
3403 || page.assets.len() > 500
3404 {
3405 return Err(invalid_feed(
3406 "v2 asset manifest is not bound to the verified head",
3407 ));
3408 }
3409 for item in page.assets {
3410 verify_v2_asset_proof(root, &item)?;
3411 let path = item.path.clone();
3412 if assets
3413 .insert(
3414 path,
3415 V2BaselineAsset {
3416 blob_sha256: item.blob_sha256,
3417 bytes: item.bytes,
3418 media_type: item.media_type,
3419 wrappers: item.wrappers,
3420 required: item.required,
3421 disposition: item.disposition,
3422 leaf_hash: item.leaf_hash,
3423 },
3424 )
3425 .is_some()
3426 {
3427 return Err(invalid_feed("v2 asset manifest repeats a path"));
3428 }
3429 if assets.len() > MAX_PUSH_FILES {
3430 return Err(invalid_feed(
3431 "v2 asset manifest exceeds the item-count bound",
3432 ));
3433 }
3434 }
3435 match page.next_cursor {
3436 None => break,
3437 Some(next) if next > after => after = next,
3438 Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
3439 }
3440 }
3441 Ok(assets)
3442}
3443
3444fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
3445 crate::AssetRecord {
3446 path: path.to_string(),
3447 sha256: asset.blob_sha256.clone(),
3448 bytes: asset.bytes,
3449 media_type: asset.media_type.clone(),
3450 wrappers: asset.wrappers.clone(),
3451 required: asset.required,
3452 }
3453}
3454
3455fn v2_asset_resumes_hosting(
3456 remote: Option<&V2BaselineAsset>,
3457 path: &str,
3458 record: &crate::AssetRecord,
3459 disposition: &str,
3460) -> bool {
3461 remote.is_some_and(|asset| {
3462 asset.disposition == "withheld"
3463 && disposition == "hosted"
3464 && v2_asset_record(asset, path) == *record
3465 })
3466}
3467
3468fn v2_asset_record_manifest_bytes(
3469 assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
3470) -> LinkResult<Vec<u8>> {
3471 let mut bytes = Vec::new();
3472 for (path, asset) in assets {
3473 if asset.path != *path {
3474 return Err(invalid_feed(
3475 "local asset manifest key differs from its record path",
3476 ));
3477 }
3478 serde_json::to_writer(&mut bytes, asset)
3479 .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
3480 bytes.push(b'\n');
3481 }
3482 Ok(bytes)
3483}
3484
3485fn v2_local_asset_records(
3486 store: &Store,
3487) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
3488 Ok(crate::assets::read_manifest(store)
3489 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
3490 .into_iter()
3491 .map(|asset| (asset.path.clone(), asset))
3492 .collect())
3493}
3494
3495fn v2_asset_records_match_remote(
3496 local: &std::collections::BTreeMap<String, crate::AssetRecord>,
3497 remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
3498) -> bool {
3499 local.len() == remote.len()
3500 && remote
3501 .iter()
3502 .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
3503}
3504
3505#[derive(Debug, Clone, PartialEq, Eq)]
3506struct V2PulledMerge<T> {
3507 records: std::collections::BTreeMap<String, T>,
3508 accept_remote: std::collections::BTreeSet<String>,
3509 conflicts: Vec<String>,
3510}
3511
3512fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
3518 base: &std::collections::BTreeMap<String, Base>,
3519 remote: &std::collections::BTreeMap<String, Remote>,
3520 local: &std::collections::BTreeMap<String, Record>,
3521 base_record: BaseRecord,
3522 remote_record: RemoteRecord,
3523 keep_local: KeepLocal,
3524) -> V2PulledMerge<Record>
3525where
3526 Record: Clone + Eq,
3527 BaseRecord: Fn(&Base, &str) -> Record,
3528 RemoteRecord: Fn(&Remote, &str) -> Record,
3529 KeepLocal: Fn(&str) -> bool,
3530{
3531 let paths = base
3532 .keys()
3533 .chain(remote.keys())
3534 .chain(local.keys())
3535 .cloned()
3536 .collect::<std::collections::BTreeSet<_>>();
3537 let mut records = local.clone();
3538 let mut accept_remote = std::collections::BTreeSet::new();
3539 let mut conflicts = Vec::new();
3540 for path in paths {
3541 if keep_local(&path) {
3542 continue;
3543 }
3544 let base_value = base.get(&path).map(|value| base_record(value, &path));
3545 let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
3546 let local_value = local.get(&path).cloned();
3547 if local_value != base_value && remote_value != base_value && local_value != remote_value {
3548 conflicts.push(path);
3549 continue;
3550 }
3551 if local_value == base_value || local_value == remote_value {
3552 accept_remote.insert(path.clone());
3553 match remote_value {
3554 Some(value) => {
3555 records.insert(path, value);
3556 }
3557 None => {
3558 records.remove(&path);
3559 }
3560 }
3561 }
3562 }
3563 V2PulledMerge {
3564 records,
3565 accept_remote,
3566 conflicts,
3567 }
3568}
3569
3570fn sign_verified_v2_candidate(
3571 cfg: &HubConfig,
3572 head: &V2VerifiedHead,
3573 expected: &std::collections::BTreeMap<String, V2BaselineFile>,
3574 expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
3575 mutation_id: &str,
3576 request_body: &Value,
3577 challenge_value: &Value,
3578) -> LinkResult<(String, String, String)> {
3579 if head.view_kind != "full" {
3580 return Err(invalid_feed(
3581 "a scoped self-custody writer must use the proposal workflow",
3582 ));
3583 }
3584 if head.identity.custody != "self" {
3585 return Err(invalid_feed(
3586 "a hub-custodied brain unexpectedly requested an external signature",
3587 ));
3588 }
3589 let key = cfg
3590 .brain_key
3591 .as_ref()
3592 .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
3593 if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
3594 || key.public_key_spki != head.identity.public_key_spki
3595 {
3596 return Err(bad_agent_key(
3597 "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
3598 ));
3599 }
3600 let challenge_id = challenge_value
3601 .get("id")
3602 .and_then(Value::as_str)
3603 .filter(|id| crate::ulid::is_ulid(id))
3604 .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
3605 let expected_endpoint = format!(
3606 "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
3607 head.brain_id
3608 );
3609 if challenge_value
3610 .get("candidate_endpoint")
3611 .and_then(Value::as_str)
3612 != Some(expected_endpoint.as_str())
3613 {
3614 return Err(invalid_feed(
3615 "self-custody challenge candidate endpoint is not origin-bound",
3616 ));
3617 }
3618
3619 let mut files = std::collections::BTreeMap::new();
3620 let mut after = String::new();
3621 type CandidateCoordinate = (
3622 String,
3623 String,
3624 String,
3625 String,
3626 Option<String>,
3627 Option<String>,
3628 u64,
3629 Option<String>,
3630 );
3631 let mut pinned: Option<CandidateCoordinate> = None;
3632 loop {
3633 let encoded_after: String =
3634 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3635 let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
3636 let value = ensure_ok(
3637 request_capped(
3638 cfg,
3639 "GET",
3640 &path,
3641 None,
3642 Auth::Required,
3643 MAX_FEED_RESPONSE_BYTES,
3644 )?,
3645 "v2 self-custody candidate",
3646 )?;
3647 let page: V2SigningCandidatePage = serde_json::from_value(value)
3648 .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
3649 if page.v != 2
3650 || page.challenge_id != challenge_id
3651 || page.mutation_id != mutation_id
3652 || page.candidate.seq != page.parent.seq + 1
3653 || page.files.len() > 500
3654 || page.expires_at.is_empty()
3655 {
3656 return Err(invalid_feed(
3657 "self-custody candidate is not bound to this mutation",
3658 ));
3659 }
3660 let coordinate = (
3661 page.request_hash.clone(),
3662 page.candidate.signing_bytes_base64.clone(),
3663 page.candidate.changes_base64.clone(),
3664 page.candidate.actor_claim_base64.clone(),
3665 page.candidate.content_root.clone(),
3666 page.candidate.asset_root.clone(),
3667 page.parent.seq,
3668 page.parent.commit_hash.clone(),
3669 );
3670 if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
3671 return Err(invalid_feed(
3672 "self-custody candidate changed between manifest pages",
3673 ));
3674 }
3675 pinned = Some(coordinate);
3676 let root = page
3677 .candidate
3678 .content_root
3679 .as_deref()
3680 .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
3681 for file in page.files {
3682 verify_v2_file_proof(root, &file)?;
3683 if files
3684 .insert(
3685 file.path.clone(),
3686 V2BaselineFile {
3687 sha256: file.sha256,
3688 bytes: file.bytes,
3689 proof: Some(file.proof),
3690 },
3691 )
3692 .is_some()
3693 {
3694 return Err(invalid_feed(
3695 "self-custody candidate repeats a manifest path",
3696 ));
3697 }
3698 if files.len() > MAX_PUSH_FILES {
3699 return Err(invalid_feed(
3700 "self-custody candidate exceeds the file-count bound",
3701 ));
3702 }
3703 }
3704 match page.next_cursor {
3705 None => break,
3706 Some(next) if next > after => after = next,
3707 Some(_) => {
3708 return Err(invalid_feed(
3709 "self-custody candidate cursor did not advance",
3710 ))
3711 }
3712 }
3713 }
3714 if files.len() != expected.len()
3715 || files.iter().any(|(path, file)| {
3716 expected.get(path).is_none_or(|expected| {
3717 expected.sha256 != file.sha256 || expected.bytes != file.bytes
3718 })
3719 })
3720 {
3721 return Err(invalid_feed(
3722 "self-custody candidate contains an unexpected file mutation",
3723 ));
3724 }
3725 let mut assets = std::collections::BTreeMap::new();
3726 after.clear();
3727 loop {
3728 let encoded_after: String =
3729 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3730 let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
3731 let value = ensure_ok(
3732 request_capped(
3733 cfg,
3734 "GET",
3735 &path,
3736 None,
3737 Auth::Required,
3738 MAX_FEED_RESPONSE_BYTES,
3739 )?,
3740 "v2 self-custody asset candidate",
3741 )?;
3742 let page: V2SigningCandidatePage = serde_json::from_value(value)
3743 .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
3744 let coordinate = (
3745 page.request_hash.clone(),
3746 page.candidate.signing_bytes_base64.clone(),
3747 page.candidate.changes_base64.clone(),
3748 page.candidate.actor_claim_base64.clone(),
3749 page.candidate.content_root.clone(),
3750 page.candidate.asset_root.clone(),
3751 page.parent.seq,
3752 page.parent.commit_hash.clone(),
3753 );
3754 if page.v != 2
3755 || page.challenge_id != challenge_id
3756 || page.mutation_id != mutation_id
3757 || page.assets.len() > 500
3758 || pinned.as_ref() != Some(&coordinate)
3759 {
3760 return Err(invalid_feed(
3761 "self-custody asset candidate changed or is not bound",
3762 ));
3763 }
3764 let root = page.candidate.asset_root.as_deref();
3765 if !page.assets.is_empty() && root.is_none() {
3766 return Err(invalid_feed("asset candidate has no asset root"));
3767 }
3768 for item in page.assets {
3769 verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
3770 if assets
3771 .insert(
3772 item.path.clone(),
3773 V2BaselineAsset {
3774 blob_sha256: item.blob_sha256,
3775 bytes: item.bytes,
3776 media_type: item.media_type,
3777 wrappers: item.wrappers,
3778 required: item.required,
3779 disposition: item.disposition,
3780 leaf_hash: item.leaf_hash,
3781 },
3782 )
3783 .is_some()
3784 {
3785 return Err(invalid_feed("self-custody candidate repeats an asset"));
3786 }
3787 }
3788 match page.next_cursor {
3789 None => break,
3790 Some(next) if next > after => after = next,
3791 Some(_) => {
3792 return Err(invalid_feed(
3793 "self-custody asset candidate cursor did not advance",
3794 ))
3795 }
3796 }
3797 }
3798 if assets.len() != expected_assets.len()
3799 || assets.iter().any(|(path, asset)| {
3800 expected_assets.get(path).is_none_or(|expected| {
3801 asset.blob_sha256 != expected.blob_sha256
3802 || asset.bytes != expected.bytes
3803 || asset.media_type != expected.media_type
3804 || asset.wrappers != expected.wrappers
3805 || asset.required != expected.required
3806 || asset.disposition != expected.disposition
3807 })
3808 })
3809 {
3810 return Err(invalid_feed(
3811 "self-custody candidate contains an unexpected asset mutation",
3812 ));
3813 }
3814 let Some((
3815 request_hash,
3816 signing_b64,
3817 changes_b64,
3818 actor_b64,
3819 root,
3820 asset_root,
3821 parent_seq,
3822 parent,
3823 )) = pinned
3824 else {
3825 return Err(invalid_feed("self-custody candidate has no manifest"));
3826 };
3827 let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
3828 let current_commit = head
3829 .pointer
3830 .as_ref()
3831 .map(|pointer| pointer.commit_hash.clone());
3832 if parent_seq != current_seq || parent != current_commit {
3833 return Err(LinkError::RemoteAdvancedDuringSync);
3834 }
3835 let changes = STANDARD
3836 .decode(changes_b64)
3837 .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
3838 let mut expected_changes = json!({
3839 "mutation_id": mutation_id,
3840 "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
3841 "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
3842 "v": 2,
3843 });
3844 if let Some(withheld_links) = request_body.get("withheld_links") {
3845 expected_changes["withheld_links"] = withheld_links.clone();
3846 }
3847 if let Some(checkout_id) = request_body.get("checkout_id") {
3848 expected_changes["checkout_id"] = checkout_id.clone();
3849 }
3850 let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
3851 .map_err(|error| invalid_feed(error.to_string()))?;
3852 if changes != expected_changes_bytes {
3853 return Err(invalid_feed(
3854 "self-custody changeset differs from the requested mutation",
3855 ));
3856 }
3857 let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
3858 .map_err(|error| invalid_feed(error.to_string()))?;
3859 let request_value = json!({
3860 "base": request_body.get("base").cloned().unwrap_or(Value::Null),
3861 "brain": head.brain_id,
3862 "changes_sha256": changes_hash,
3863 "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
3864 "v": 2,
3865 "v1_bridge": Value::Null,
3866 });
3867 let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
3868 .map_err(|error| invalid_feed(error.to_string()))?;
3869 if request_hash != expected_request_hash {
3870 return Err(invalid_feed(
3871 "self-custody request hash differs from the requested mutation",
3872 ));
3873 }
3874 let actor = STANDARD
3875 .decode(actor_b64)
3876 .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
3877 let actor_value: Value = serde_json::from_slice(&actor)
3878 .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
3879 if crate::linkmd_v2::canonical_bytes(&actor_value)
3880 .map_err(|error| invalid_feed(error.to_string()))?
3881 != actor
3882 {
3883 return Err(invalid_feed("self-custody actor claim is not canonical"));
3884 }
3885 let actor_object = actor_value
3886 .as_object()
3887 .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
3888 let actor_claim = actor_object
3889 .get("claim")
3890 .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
3891 let actor_public_key = actor_object
3892 .get("public_key")
3893 .and_then(Value::as_str)
3894 .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
3895 let actor_fingerprint = actor_object
3896 .get("fingerprint")
3897 .and_then(Value::as_str)
3898 .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
3899 let actor_signature = actor_object
3900 .get("sig")
3901 .and_then(Value::as_str)
3902 .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
3903 let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
3904 .map_err(|error| invalid_feed(error.to_string()))?;
3905 let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
3906 let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
3907 let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
3908 let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
3909 let impact = actor_claim
3910 .get("result")
3911 .and_then(|result| result.get("impact"))
3912 .and_then(Value::as_object);
3913 let impact_fields = [
3914 "creates",
3915 "updates",
3916 "deletes",
3917 "withdrawals",
3918 "renames",
3919 "restores",
3920 "asset_changes",
3921 "public_expansions",
3922 "executable_activations",
3923 ];
3924 let impact_is_valid = impact.is_some_and(|impact| {
3925 impact.len() == impact_fields.len() + 1
3926 && impact.get("v").and_then(Value::as_u64) == Some(1)
3927 && impact_fields
3928 .iter()
3929 .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
3930 });
3931 if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
3932 || head
3933 .trust
3934 .hub_signer
3935 .as_ref()
3936 .is_some_and(|known| known != &expected_actor_signer)
3937 || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
3938 || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
3939 || actor_claim
3940 .get("candidate")
3941 .and_then(|candidate| candidate.get("changes_sha256"))
3942 .and_then(Value::as_str)
3943 != Some(changes_hash.as_str())
3944 || actor_claim
3945 .get("candidate")
3946 .and_then(|candidate| candidate.get("state_root"))
3947 != Some(&expected_actor_root)
3948 || actor_claim
3949 .get("candidate")
3950 .and_then(|candidate| candidate.get("asset_root"))
3951 != Some(&expected_actor_asset_root)
3952 || actor_claim
3953 .get("candidate")
3954 .and_then(|candidate| candidate.get("control_revision"))
3955 .and_then(Value::as_str)
3956 != Some(head.control_revision.as_str())
3957 || !impact_is_valid
3958 {
3959 return Err(invalid_feed(
3960 "self-custody actor claim does not bind the verified authority",
3961 ));
3962 }
3963 let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
3964 .map_err(|error| invalid_feed(error.to_string()))?;
3965 let signing = STANDARD
3966 .decode(signing_b64)
3967 .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
3968 let signing_value: Value = serde_json::from_slice(&signing)
3969 .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
3970 if crate::linkmd_v2::canonical_bytes(&signing_value)
3971 .map_err(|error| invalid_feed(error.to_string()))?
3972 != signing
3973 {
3974 return Err(invalid_feed("self-custody signing bytes are not canonical"));
3975 }
3976 let pointer = head.pointer.as_ref();
3977 let expected_materializer = pointer
3978 .map(|value| value.materializer.as_str())
3979 .unwrap_or("dbmd-projection-v1");
3980 let expected_parent_commit = request_body
3981 .get("base")
3982 .and_then(|base| base.get("commit_hash"))
3983 .cloned()
3984 .unwrap_or(Value::Null);
3985 let expected_parent_root = request_body
3986 .get("base")
3987 .and_then(|base| base.get("content_root"))
3988 .cloned()
3989 .unwrap_or(Value::Null);
3990 let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
3991 let expected_parent_asset_root = request_body
3992 .get("base")
3993 .and_then(|base| base.get("asset_root"))
3994 .cloned()
3995 .unwrap_or(Value::Null);
3996 let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
3997 let expected_prev_entry = pointer
3998 .map(|value| Value::String(value.feed_hash.clone()))
3999 .unwrap_or(Value::Null);
4000 let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4001 .map_err(|_| invalid_feed("brain identity history is too large"))?
4002 + 1;
4003 if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4004 || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4005 || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4006 || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4007 || signing_value.get("public_key").and_then(Value::as_str)
4008 != Some(key.public_key_spki.as_str())
4009 || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4010 || signing_value.get("parent_root") != Some(&expected_parent_root)
4011 || signing_value.get("state_root") != Some(&expected_state_root)
4012 || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4013 || signing_value.get("asset_root") != Some(&expected_asset_root)
4014 || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4015 || signing_value.get("changes_sha256").and_then(Value::as_str)
4016 != Some(changes_hash.as_str())
4017 || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4018 || signing_value
4019 .get("control_revision")
4020 .and_then(Value::as_str)
4021 != Some(head.control_revision.as_str())
4022 || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4023 || signing_value.get("v1_bridge") != Some(&Value::Null)
4024 || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4025 {
4026 return Err(invalid_feed(
4027 "self-custody signing bytes do not bind the verified candidate",
4028 ));
4029 }
4030 let pair = agent_keypair(&key.pkcs8)?;
4031 let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4032 Ok((challenge_id.to_string(), signature, expected_actor_signer))
4033}
4034
4035fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4036 let origin = normalized_origin(&cfg.hub)?;
4037 let absolute = if checkout.is_absolute() {
4038 checkout.to_path_buf()
4039 } else {
4040 std::env::current_dir()?.join(checkout)
4041 };
4042 Ok(format!(
4043 "sync-{}.json",
4044 content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4045 ))
4046}
4047
4048fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4049 if let Some(value) = existing {
4050 if !is_sha256(value) {
4051 return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4052 }
4053 return Ok(value.to_string());
4054 }
4055 use ring::rand::SecureRandom as _;
4056 let mut random = [0_u8; 32];
4057 ring::rand::SystemRandom::new()
4058 .fill(&mut random)
4059 .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4060 Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4061}
4062
4063#[cfg(any(unix, windows))]
4064fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4065 let directory = open_trust_dir(cfg)?;
4066 let origin = normalized_origin(&cfg.hub)?;
4067 let name = format!(
4068 "operation-{}.lock",
4069 content_sha256(format!("{origin}\0{brain}").as_bytes())
4070 );
4071 lock_trust_name(&directory, &name)
4072}
4073
4074#[cfg(not(any(unix, windows)))]
4075fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4076 Err(LinkError::UnsupportedPlatform {
4077 operation: "serialized link.md v2 sync",
4078 })
4079}
4080
4081fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4082 left.brain_id == right.brain_id
4083 && left.view_kind == right.view_kind
4084 && left.view_revision == right.view_revision
4085 && left.control_revision == right.control_revision
4086 && match (&left.pointer, &right.pointer) {
4087 (None, None) => true,
4088 (Some(left), Some(right)) => {
4089 left.seq == right.seq
4090 && left.commit_hash == right.commit_hash
4091 && left.content_root == right.content_root
4092 && left.asset_root == right.asset_root
4093 && left.feed_hash == right.feed_hash
4094 }
4095 _ => false,
4096 }
4097}
4098
4099fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4100 format!(
4101 "---\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"
4102 )
4103 .into_bytes()
4104}
4105
4106fn scoped_projection_sha256(brain: &str) -> String {
4107 content_sha256(&scoped_projection_bytes(brain))
4108}
4109
4110#[derive(Deserialize)]
4111struct LocalScopedViewMarker {
4112 v: u8,
4113 kind: String,
4114 authoritative: bool,
4115 brain: String,
4116 projection_sha256: String,
4117}
4118
4119pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4123 let marker = store
4124 .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4125 .ok()
4126 .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4127 let Some(marker) = marker else {
4128 return false;
4129 };
4130 if marker.v != 1
4131 || marker.kind != "link.md-scoped-view"
4132 || marker.authoritative
4133 || !crate::ulid::is_ulid(&marker.brain)
4134 || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4135 {
4136 return false;
4137 }
4138 store
4139 .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4140 .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4141}
4142
4143fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4144 let mut bytes = serde_json::to_vec_pretty(&json!({
4145 "v": 1,
4146 "kind": "link.md-scoped-view",
4147 "authoritative": false,
4148 "brain": head.brain_id,
4149 "view_revision": head.view_revision,
4150 "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4151 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4152 "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4153 "visible_files": files,
4154 "projection_sha256": scoped_projection_sha256(&head.brain_id),
4155 }))
4156 .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4157 bytes.push(b'\n');
4158 Ok(bytes)
4159}
4160
4161fn refresh_scoped_view_marker(
4162 store: &Store,
4163 head: &V2VerifiedHead,
4164 files: usize,
4165) -> LinkResult<()> {
4166 if head.view_kind == "scoped" {
4167 store.write_atomic(
4168 Path::new(".dbmd/view.json"),
4169 &scoped_view_metadata(head, files)?,
4170 )?;
4171 }
4172 Ok(())
4173}
4174
4175fn ensure_v2_view_compatible(
4176 head: &V2VerifiedHead,
4177 baseline: Option<&V2SyncBaseline>,
4178) -> LinkResult<()> {
4179 let Some(baseline) = baseline else {
4180 return Ok(());
4181 };
4182 match (
4183 baseline.view_kind.as_deref(),
4184 baseline.view_revision.as_deref(),
4185 ) {
4186 (None, None) if head.view_kind == "full" => Ok(()),
4187 (Some(kind), Some(revision))
4188 if kind == head.view_kind && revision == head.view_revision =>
4189 {
4190 Ok(())
4191 }
4192 _ => Err(LinkError::ScopedViewChanged),
4193 }
4194}
4195
4196fn ensure_established_v2_checkout_opened(
4197 head: &V2VerifiedHead,
4198 baseline: Option<&V2SyncBaseline>,
4199 opened: bool,
4200) -> LinkResult<()> {
4201 if baseline.is_none() || opened {
4202 return Ok(());
4203 }
4204 if head.view_kind == "scoped" {
4205 return Err(LinkError::ScopedProjectionModified);
4206 }
4207 Err(LinkError::InvalidPack {
4208 message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4209 })
4210}
4211
4212fn remove_scoped_projection(
4213 head: &V2VerifiedHead,
4214 baseline: Option<&V2SyncBaseline>,
4215 view: &mut V2LocalView,
4216) -> LinkResult<()> {
4217 if head.view_kind != "scoped" {
4218 return Ok(());
4219 }
4220 let expected = scoped_projection_sha256(&head.brain_id);
4221 if baseline
4222 .and_then(|state| state.projection_sha256.as_deref())
4223 .is_some_and(|pinned| pinned != expected)
4224 {
4225 return Err(LinkError::ScopedViewChanged);
4226 }
4227 if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4228 return Err(LinkError::ScopedProjectionModified);
4229 }
4230 view.riding.remove("DB.md");
4231 view.eligibility.remove("DB.md");
4232 Ok(())
4233}
4234
4235fn local_view_for_v2_push(
4236 store: &Store,
4237 head: &V2VerifiedHead,
4238 baseline: Option<&V2SyncBaseline>,
4239 carried: Option<V2LocalView>,
4240) -> LinkResult<V2LocalView> {
4241 match carried {
4242 Some(view) => Ok(view),
4247 None => {
4248 let mut view = v2_local_files(store)?;
4249 remove_scoped_projection(head, baseline, &mut view)?;
4250 Ok(view)
4251 }
4252 }
4253}
4254
4255fn files_for_v2_view(
4256 head: &V2VerifiedHead,
4257 mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4258) -> std::collections::BTreeMap<String, V2BaselineFile> {
4259 if head.view_kind == "scoped" {
4260 files.remove("DB.md");
4264 }
4265 files
4266}
4267
4268fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4269 let baseline: V2SyncBaseline =
4270 serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
4271 if baseline.v != 2
4272 || baseline.origin != normalized_origin(&cfg.hub)?
4273 || baseline.brain != brain
4274 || baseline
4275 .commit_hash
4276 .as_deref()
4277 .is_some_and(|hash| !is_sha256(hash))
4278 || baseline
4279 .content_root
4280 .as_deref()
4281 .is_some_and(|hash| !is_sha256(hash))
4282 || baseline
4283 .asset_root
4284 .as_deref()
4285 .is_some_and(|hash| !is_sha256(hash))
4286 || baseline
4287 .local_policy_digest
4288 .as_deref()
4289 .is_some_and(|hash| !is_sha256(hash))
4290 || baseline
4291 .view_kind
4292 .as_deref()
4293 .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
4294 || baseline
4295 .view_revision
4296 .as_deref()
4297 .is_some_and(|hash| !is_sha256(hash))
4298 || baseline
4299 .projection_sha256
4300 .as_deref()
4301 .is_some_and(|hash| !is_sha256(hash))
4302 || (baseline.view_kind.as_deref() == Some("scoped")
4303 && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
4304 || baseline.files.len() > MAX_PUSH_FILES
4305 || baseline.assets.len() > MAX_PUSH_FILES
4306 || baseline.local_eligibility.len() > MAX_PUSH_FILES
4307 || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
4308 || baseline.files.iter().any(|(path, file)| {
4309 crate::linkmd_v2::normalize_path(path).is_err()
4310 || !is_sha256(&file.sha256)
4311 || file.bytes > MAX_STORE_BYTES
4312 })
4313 || baseline.assets.iter().any(|(path, asset)| {
4314 crate::linkmd_v2::normalize_path(path).is_err()
4315 || !is_sha256(&asset.blob_sha256)
4316 || !is_sha256(&asset.leaf_hash)
4317 || asset.bytes > MAX_STORE_BYTES
4318 || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
4319 || asset.wrappers.is_empty()
4320 || asset
4321 .wrappers
4322 .iter()
4323 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4324 })
4325 || baseline
4326 .local_eligibility
4327 .keys()
4328 .chain(baseline.remote_copy_remains.keys())
4329 .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
4330 || baseline
4331 .remote_copy_remains
4332 .values()
4333 .any(|hash| !is_sha256(hash))
4334 || baseline
4335 .checkout_id
4336 .as_deref()
4337 .is_some_and(|checkout_id| !is_sha256(checkout_id))
4338 {
4339 return Err(invalid_feed("v2 sync baseline failed validation"));
4340 }
4341 Ok(baseline)
4342}
4343
4344#[cfg(unix)]
4345fn load_v2_baseline(
4346 cfg: &HubConfig,
4347 brain: &str,
4348 checkout: &Path,
4349) -> LinkResult<Option<V2SyncBaseline>> {
4350 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4351 let directory = open_trust_dir(cfg)?;
4352 let name_string = v2_baseline_name(cfg, brain, checkout)?;
4353 let _lock = lock_trust_name(&directory, &name_string)?;
4354 let name = c_name(name_string.as_bytes(), &name_string)?;
4355 let fd = unsafe {
4356 libc::openat(
4357 directory.as_raw_fd(),
4358 name.as_ptr(),
4359 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4360 )
4361 };
4362 if fd < 0 {
4363 let error = std::io::Error::last_os_error();
4364 return if error.kind() == std::io::ErrorKind::NotFound {
4365 Ok(None)
4366 } else {
4367 Err(LinkError::UnsafePath { path: name_string })
4368 };
4369 }
4370 let file = unsafe { std::fs::File::from_raw_fd(fd) };
4371 let mut bytes = Vec::new();
4372 file.take(MAX_FEED_RESPONSE_BYTES + 1)
4373 .read_to_end(&mut bytes)?;
4374 if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
4375 return Err(invalid_feed("v2 sync baseline is oversized"));
4376 }
4377 Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
4378}
4379
4380#[cfg(windows)]
4381fn load_v2_baseline(
4382 cfg: &HubConfig,
4383 brain: &str,
4384 checkout: &Path,
4385) -> LinkResult<Option<V2SyncBaseline>> {
4386 let directory = open_trust_dir(cfg)?;
4387 let name = v2_baseline_name(cfg, brain, checkout)?;
4388 let _lock = lock_trust_name(&directory, &name)?;
4389 let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
4390 match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
4391 Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
4392 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
4393 Err(_) => Err(LinkError::UnsafePath { path: name }),
4394 }
4395}
4396
4397#[cfg(not(any(unix, windows)))]
4398fn load_v2_baseline(
4399 _cfg: &HubConfig,
4400 _brain: &str,
4401 _checkout: &Path,
4402) -> LinkResult<Option<V2SyncBaseline>> {
4403 Err(LinkError::UnsupportedPlatform {
4404 operation: "verified link.md v2 baseline",
4405 })
4406}
4407
4408#[cfg(unix)]
4409fn save_v2_baseline(
4410 cfg: &HubConfig,
4411 brain: &str,
4412 checkout: &Path,
4413 baseline: &V2SyncBaseline,
4414) -> LinkResult<()> {
4415 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4416 let directory = open_trust_dir(cfg)?;
4417 let name_string = v2_baseline_name(cfg, brain, checkout)?;
4418 let _lock = lock_trust_name(&directory, &name_string)?;
4419 let name = c_name(name_string.as_bytes(), &name_string)?;
4420 let mut bytes = serde_json::to_vec(baseline)
4421 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
4422 bytes.push(b'\n');
4423 let temp_string = format!(
4424 ".{name_string}.tmp.{}-{}",
4425 std::process::id(),
4426 std::time::SystemTime::now()
4427 .duration_since(std::time::UNIX_EPOCH)
4428 .unwrap_or_default()
4429 .as_nanos()
4430 );
4431 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
4432 let fd = unsafe {
4433 libc::openat(
4434 directory.as_raw_fd(),
4435 temp.as_ptr(),
4436 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4437 0o600,
4438 )
4439 };
4440 if fd < 0 {
4441 return Err(std::io::Error::last_os_error().into());
4442 }
4443 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
4444 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
4445 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4446 return Err(error.into());
4447 }
4448 drop(file);
4449 if unsafe {
4450 libc::renameat(
4451 directory.as_raw_fd(),
4452 temp.as_ptr(),
4453 directory.as_raw_fd(),
4454 name.as_ptr(),
4455 )
4456 } != 0
4457 {
4458 let error = std::io::Error::last_os_error();
4459 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4460 return Err(error.into());
4461 }
4462 directory.sync_all()?;
4463 Ok(())
4464}
4465
4466#[cfg(windows)]
4467fn save_v2_baseline(
4468 cfg: &HubConfig,
4469 brain: &str,
4470 checkout: &Path,
4471 baseline: &V2SyncBaseline,
4472) -> LinkResult<()> {
4473 let directory = open_trust_dir(cfg)?;
4474 let name = v2_baseline_name(cfg, brain, checkout)?;
4475 let _lock = lock_trust_name(&directory, &name)?;
4476 let mut bytes = serde_json::to_vec(baseline)
4477 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
4478 bytes.push(b'\n');
4479 crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
4480 Ok(())
4481}
4482
4483#[cfg(not(any(unix, windows)))]
4484fn save_v2_baseline(
4485 _cfg: &HubConfig,
4486 _brain: &str,
4487 _checkout: &Path,
4488 _baseline: &V2SyncBaseline,
4489) -> LinkResult<()> {
4490 Err(LinkError::UnsupportedPlatform {
4491 operation: "verified link.md v2 baseline",
4492 })
4493}
4494
4495fn v2_baseline_from_head(
4496 cfg: &HubConfig,
4497 head: &V2VerifiedHead,
4498 files: std::collections::BTreeMap<String, V2BaselineFile>,
4499 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
4500 local: Option<&V2LocalView>,
4501 checkout_id: Option<&str>,
4502) -> LinkResult<V2SyncBaseline> {
4503 let mut local_eligibility = local
4504 .map(|view| view.eligibility.clone())
4505 .unwrap_or_default();
4506 if let Some(view) = local {
4507 for path in files.keys() {
4508 local_eligibility
4509 .entry(path.clone())
4510 .or_insert_with(|| !view.policy.keeps_home(path));
4511 }
4512 }
4513 let remote_copy_remains = local_eligibility
4514 .iter()
4515 .filter(|(_, riding)| !**riding)
4516 .filter_map(|(path, _)| {
4517 files
4518 .get(path)
4519 .map(|file| (path.clone(), file.sha256.clone()))
4520 })
4521 .collect();
4522 Ok(V2SyncBaseline {
4523 v: 2,
4524 origin: normalized_origin(&cfg.hub)?,
4525 brain: head.brain_id.clone(),
4526 checkout_id: Some(v2_checkout_id(checkout_id)?),
4527 head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
4528 commit_hash: head
4529 .pointer
4530 .as_ref()
4531 .map(|pointer| pointer.commit_hash.clone()),
4532 content_root: head
4533 .pointer
4534 .as_ref()
4535 .and_then(|pointer| pointer.content_root.clone()),
4536 asset_root: head
4537 .pointer
4538 .as_ref()
4539 .and_then(|pointer| pointer.asset_root.clone()),
4540 assets,
4541 view_kind: Some(head.view_kind.clone()),
4542 view_revision: Some(head.view_revision.clone()),
4543 projection_sha256: (head.view_kind == "scoped")
4544 .then(|| scoped_projection_sha256(&head.brain_id)),
4545 files,
4546 local_policy_digest: local.map(|view| view.policy.digest.clone()),
4547 local_eligibility,
4548 remote_copy_remains,
4549 })
4550}
4551
4552fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
4553 let policy = crate::linkmd_sync_policy::load(store)
4554 .map_err(|message| LinkError::InvalidPack { message })?;
4555 let asset_paths = crate::assets::read_manifest(store)
4556 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
4557 .into_iter()
4558 .map(|asset| asset.path)
4559 .collect::<std::collections::BTreeSet<_>>();
4560 let mut result = std::collections::BTreeMap::new();
4561 let mut eligibility = std::collections::BTreeMap::new();
4562 let mut riding_links = Vec::<(String, Vec<String>)>::new();
4563 let mut total = 0_u64;
4564 let mut paths = vec![PathBuf::from("DB.md")];
4565 paths.extend(store.walk()?);
4566 for relative in paths {
4567 let path = relative.to_string_lossy().replace('\\', "/");
4568 if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
4570 continue;
4571 }
4572 if asset_paths.contains(&path) {
4573 continue;
4574 }
4575 crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
4576 path: error.to_string(),
4577 })?;
4578 let riding = !policy.keeps_home(&path);
4579 eligibility.insert(path.clone(), riding);
4580 if !riding {
4581 continue;
4582 }
4583 let remaining = MAX_STORE_BYTES.saturating_sub(total);
4584 let bytes = store.read_bounded(&relative, remaining)?;
4585 total = total
4586 .checked_add(bytes.len() as u64)
4587 .ok_or_else(|| LinkError::PushTooLarge {
4588 detail: "v2 local byte count overflow".to_string(),
4589 })?;
4590 if total > MAX_STORE_BYTES {
4591 return Err(LinkError::PushTooLarge {
4592 detail: format!("{total} uncompressed bytes"),
4593 });
4594 }
4595 if std::str::from_utf8(&bytes).is_err() {
4596 return Err(LinkError::NotUtf8 { path });
4597 }
4598 let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
4599 riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
4600 result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
4601 }
4602 let kept_home = eligibility
4603 .iter()
4604 .filter(|(_, riding)| !**riding)
4605 .map(|(path, _)| path.clone())
4606 .collect::<std::collections::BTreeSet<_>>();
4607 let mut withheld_links = riding_links
4608 .into_iter()
4609 .flat_map(|(source, targets)| {
4610 let kept_home = &kept_home;
4611 targets.into_iter().filter_map(move |target| {
4612 let target = format!("{target}.md");
4613 kept_home.contains(&target).then_some(V2WithheldLink {
4614 source: source.clone(),
4615 target,
4616 })
4617 })
4618 })
4619 .collect::<Vec<_>>();
4620 withheld_links.sort();
4621 withheld_links.dedup();
4622 Ok(V2LocalView {
4623 riding: result,
4624 eligibility,
4625 policy,
4626 withheld_links,
4627 })
4628}
4629
4630#[derive(Debug, Deserialize)]
4631struct V2DownloadItem {
4632 path: String,
4633 sha256: String,
4634 bytes: u64,
4635 url: String,
4636 method: String,
4637}
4638
4639#[derive(Debug, Deserialize)]
4640struct V2DownloadWindow {
4641 v: u8,
4642 commit: String,
4643 downloads: Vec<V2DownloadItem>,
4644}
4645
4646#[derive(Debug, Deserialize)]
4647struct V2BulkStreamHeader {
4648 v: u8,
4649 path: String,
4650 sha256: String,
4651 bytes: u64,
4652}
4653
4654fn parse_v2_bulk_stream(
4655 bytes: &[u8],
4656 expected: &[(&String, &V2BaselineFile)],
4657) -> LinkResult<Vec<(String, Vec<u8>)>> {
4658 if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
4659 return Err(invalid_feed("v2 bulk stream has an invalid magic"));
4660 }
4661 let mut cursor = V2_BULK_STREAM_MAGIC.len();
4662 let mut result = Vec::with_capacity(expected.len());
4663 for (expected_path, expected_file) in expected {
4664 let length_bytes = bytes
4665 .get(cursor..cursor + 4)
4666 .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
4667 cursor += 4;
4668 let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
4669 if header_len == 0 || header_len > 4 * 1024 {
4670 return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
4671 }
4672 let header_bytes = bytes
4673 .get(cursor..cursor + header_len)
4674 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
4675 cursor += header_len;
4676 let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
4677 .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
4678 if header.v != 2
4679 || &header.path != *expected_path
4680 || header.sha256 != expected_file.sha256
4681 || header.bytes != expected_file.bytes
4682 || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
4683 {
4684 return Err(invalid_feed(
4685 "v2 bulk stream frame differs from its proven manifest entry",
4686 ));
4687 }
4688 let body_len = usize::try_from(header.bytes)
4689 .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
4690 let body = bytes
4691 .get(cursor..cursor + body_len)
4692 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
4693 cursor += body_len;
4694 if content_sha256(body) != header.sha256 {
4695 return Err(invalid_feed(
4696 "v2 bulk stream file differs from its proven manifest entry",
4697 ));
4698 }
4699 result.push((header.path, body.to_vec()));
4700 }
4701 if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
4702 return Err(invalid_feed("v2 bulk stream has no exact end marker"));
4703 }
4704 cursor += 4;
4705 if cursor != bytes.len() {
4706 return Err(invalid_feed("v2 bulk stream carries trailing data"));
4707 }
4708 Ok(result)
4709}
4710
4711fn download_v2_bulk_stream(
4712 cfg: &HubConfig,
4713 brain: &str,
4714 pointer: &V2PointerBody,
4715 pending: &[(&String, &V2BaselineFile)],
4716) -> LinkResult<Vec<(String, Vec<u8>)>> {
4717 let claims = pending
4718 .iter()
4719 .map(|(path, file)| {
4720 Ok(json!({
4721 "path": path,
4722 "sha256": file.sha256,
4723 "bytes": file.bytes,
4724 "proof": file.proof.as_ref().ok_or_else(|| {
4725 invalid_feed("v2 manifest omitted a bulk-stream proof")
4726 })?,
4727 }))
4728 })
4729 .collect::<LinkResult<Vec<_>>>()?;
4730 let raw = request_raw(
4731 cfg,
4732 "POST",
4733 &format!("/api/hub/brains/{brain}/v2/stream"),
4734 Some(&json!({
4735 "commit": pointer.commit_hash,
4736 "files": claims,
4737 })),
4738 Auth::Required,
4739 V2_BULK_STREAM_RESPONSE_BYTES,
4740 )?;
4741 let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
4742 parse_v2_bulk_stream(&body, pending)
4743}
4744
4745fn prepare_v2_downloads(
4746 cfg: &HubConfig,
4747 brain: &str,
4748 pointer: &V2PointerBody,
4749 pending: &[(&String, &V2BaselineFile)],
4750) -> LinkResult<Vec<V2DownloadItem>> {
4751 let mut result = Vec::with_capacity(pending.len());
4752 for chunk in pending.chunks(128) {
4753 let claims = chunk
4754 .iter()
4755 .map(|(path, file)| {
4756 Ok(json!({
4757 "path": path,
4758 "sha256": file.sha256,
4759 "bytes": file.bytes,
4760 "proof": file.proof.as_ref().ok_or_else(|| {
4761 invalid_feed("v2 manifest omitted a download proof")
4762 })?,
4763 }))
4764 })
4765 .collect::<LinkResult<Vec<_>>>()?;
4766 let value = ensure_ok(
4767 request_capped(
4768 cfg,
4769 "POST",
4770 &format!("/api/hub/brains/{brain}/v2/downloads"),
4771 Some(&json!({
4772 "commit": pointer.commit_hash,
4773 "files": claims,
4774 })),
4775 Auth::Required,
4776 MAX_FEED_RESPONSE_BYTES,
4777 )?,
4778 "prepare v2 blob downloads",
4779 )?;
4780 let window: V2DownloadWindow = serde_json::from_value(value)
4781 .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
4782 if window.v != 2
4783 || window.commit != pointer.commit_hash
4784 || window.downloads.len() != chunk.len()
4785 {
4786 return Err(invalid_feed(
4787 "v2 download window is not bound to the requested files",
4788 ));
4789 }
4790 let mut by_path = window
4791 .downloads
4792 .into_iter()
4793 .map(|item| (item.path.clone(), item))
4794 .collect::<std::collections::BTreeMap<_, _>>();
4795 if by_path.len() != chunk.len() {
4796 return Err(invalid_feed("v2 download window repeats a path"));
4797 }
4798 for (path, file) in chunk {
4799 let item = by_path
4800 .remove(*path)
4801 .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
4802 if item.method != "GET"
4803 || item.sha256 != file.sha256
4804 || item.bytes != file.bytes
4805 || item.url.is_empty()
4806 {
4807 return Err(invalid_feed(
4808 "v2 download capability differs from its proven file",
4809 ));
4810 }
4811 result.push(item);
4812 }
4813 }
4814 Ok(result)
4815}
4816
4817fn prepare_v2_asset_downloads(
4818 cfg: &HubConfig,
4819 brain: &str,
4820 pointer: &V2PointerBody,
4821 pending: &[(&String, &V2BaselineAsset)],
4822) -> LinkResult<Vec<V2DownloadItem>> {
4823 let mut result = Vec::with_capacity(pending.len());
4824 for chunk in pending.chunks(128) {
4825 let claims = chunk
4826 .iter()
4827 .map(|(path, asset)| {
4828 json!({
4829 "path": path,
4830 "sha256": asset.blob_sha256,
4831 "bytes": asset.bytes,
4832 "leaf_hash": asset.leaf_hash,
4833 })
4834 })
4835 .collect::<Vec<_>>();
4836 let value = ensure_ok(
4837 request_capped(
4838 cfg,
4839 "POST",
4840 &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
4841 Some(&json!({
4842 "commit": pointer.commit_hash,
4843 "assets": claims,
4844 })),
4845 Auth::Required,
4846 MAX_FEED_RESPONSE_BYTES,
4847 )?,
4848 "prepare v2 asset downloads",
4849 )?;
4850 let window: V2DownloadWindow = serde_json::from_value(value)
4851 .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
4852 if window.v != 2
4853 || window.commit != pointer.commit_hash
4854 || window.downloads.len() != chunk.len()
4855 {
4856 return Err(invalid_feed(
4857 "v2 asset download window is not bound to the requested assets",
4858 ));
4859 }
4860 let mut by_path = window
4861 .downloads
4862 .into_iter()
4863 .map(|item| (item.path.clone(), item))
4864 .collect::<std::collections::BTreeMap<_, _>>();
4865 if by_path.len() != chunk.len() {
4866 return Err(invalid_feed("v2 asset download window repeats a path"));
4867 }
4868 for (path, asset) in chunk {
4869 let item = by_path
4870 .remove(*path)
4871 .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
4872 if item.method != "GET"
4873 || item.sha256 != asset.blob_sha256
4874 || item.bytes != asset.bytes
4875 || item.url.is_empty()
4876 {
4877 return Err(invalid_feed(
4878 "v2 asset download capability differs from its signed leaf",
4879 ));
4880 }
4881 result.push(item);
4882 }
4883 }
4884 Ok(result)
4885}
4886
4887fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
4888 let bytes = get_presigned(cfg, &item.url)?;
4889 if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
4890 return Err(invalid_feed("v2 blob differs from its proven path entry"));
4891 }
4892 Ok(bytes)
4893}
4894
4895#[derive(Debug, Clone)]
4896struct V2StagedFile {
4897 path: String,
4898 source: PathBuf,
4899 sha256: String,
4900 bytes: u64,
4901}
4902
4903#[cfg(unix)]
4904fn v2_download_cache_dir(
4905 cfg: &HubConfig,
4906 brain: &str,
4907 pointer: &V2PointerBody,
4908) -> LinkResult<PathBuf> {
4909 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
4910}
4911
4912#[cfg(unix)]
4913fn v2_download_cache_dir_for(
4914 cfg: &HubConfig,
4915 brain: &str,
4916 transaction: &str,
4917) -> LinkResult<PathBuf> {
4918 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4919 return Err(invalid_feed("v2 download cache address is invalid"));
4920 }
4921 let path = cfg
4922 .state_dir
4923 .join("downloads")
4924 .join(brain)
4925 .join(transaction);
4926 let directory = open_or_create_dir_nofollow(&path)?;
4927 use std::os::fd::AsRawFd as _;
4928 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
4929 return Err(std::io::Error::last_os_error().into());
4930 }
4931 directory.sync_all()?;
4932 Ok(path)
4933}
4934
4935#[cfg(windows)]
4936fn v2_download_cache_dir(
4937 cfg: &HubConfig,
4938 brain: &str,
4939 pointer: &V2PointerBody,
4940) -> LinkResult<PathBuf> {
4941 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
4942}
4943
4944#[cfg(windows)]
4945fn v2_download_cache_dir_for(
4946 cfg: &HubConfig,
4947 brain: &str,
4948 transaction: &str,
4949) -> LinkResult<PathBuf> {
4950 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4951 return Err(invalid_feed("v2 download cache address is invalid"));
4952 }
4953 let path = cfg
4954 .state_dir
4955 .join("downloads")
4956 .join(brain)
4957 .join(transaction);
4958 crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
4959 crate::fsx::open_directory_nofollow(&path)?;
4960 Ok(path)
4961}
4962
4963#[cfg(unix)]
4964fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
4965 use std::os::fd::AsRawFd as _;
4966 let parent = cfg.state_dir.join("downloads").join(brain);
4967 let Ok(directory) = open_existing_dir_nofollow(&parent) else {
4968 return;
4969 };
4970 let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
4971 return;
4972 };
4973 let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
4974 let _ = directory.sync_all();
4975}
4976
4977#[cfg(windows)]
4978fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
4979 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4980 return;
4981 }
4982 let parent = cfg.state_dir.join("downloads").join(brain);
4983 let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
4984 return;
4985 };
4986 let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
4987}
4988
4989#[cfg(not(any(unix, windows)))]
4990fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
4991
4992#[cfg(not(any(unix, windows)))]
4993fn v2_download_cache_dir_for(
4994 _cfg: &HubConfig,
4995 _brain: &str,
4996 _transaction: &str,
4997) -> LinkResult<PathBuf> {
4998 Err(LinkError::UnsupportedPlatform {
4999 operation: "resumable v2 download staging",
5000 })
5001}
5002
5003#[cfg(any(unix, windows))]
5004fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5005 let file = match crate::fsx::open_regular_nofollow(path) {
5006 Ok(file) => file,
5007 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5008 Err(error) => return Err(error.into()),
5009 };
5010 if file.metadata()?.len() != bytes {
5011 return Ok(false);
5012 }
5013 Ok(content_sha256_reader(file)? == sha256)
5014}
5015
5016#[cfg(any(unix, windows))]
5017fn cache_v2_blob_bytes(
5018 cache_dir: &Path,
5019 sha256: &str,
5020 expected_bytes: u64,
5021 bytes: &[u8],
5022) -> LinkResult<PathBuf> {
5023 if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5024 return Err(invalid_feed("v2 cached blob differs from its declaration"));
5025 }
5026 let path = cache_dir.join(sha256);
5027 if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5028 crate::fsx::write_atomic(&path, bytes)?;
5029 }
5030 Ok(path)
5031}
5032
5033#[cfg(not(any(unix, windows)))]
5034fn cache_v2_blob_bytes(
5035 _cache_dir: &Path,
5036 _sha256: &str,
5037 _expected_bytes: u64,
5038 _bytes: &[u8],
5039) -> LinkResult<PathBuf> {
5040 Err(LinkError::UnsupportedPlatform {
5041 operation: "resumable v2 download staging",
5042 })
5043}
5044
5045#[cfg(unix)]
5046fn download_presigned_to_cache(
5047 cfg: &HubConfig,
5048 url: &str,
5049 cache_dir: &Path,
5050 sha256: &str,
5051 expected_bytes: u64,
5052) -> LinkResult<PathBuf> {
5053 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5054
5055 let target = cache_dir.join(sha256);
5056 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5057 return Ok(target);
5058 }
5059 let directory = open_existing_dir_nofollow(cache_dir)?;
5060 let mut nonce = [0_u8; 16];
5061 ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5062 .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5063 let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5064 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5065 let fd = unsafe {
5066 libc::openat(
5067 directory.as_raw_fd(),
5068 temp.as_ptr(),
5069 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5070 0o600,
5071 )
5072 };
5073 if fd < 0 {
5074 return Err(std::io::Error::last_os_error().into());
5075 }
5076 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5077 let response = match presigned_agent(cfg, url)?.get(url).call() {
5078 Ok(response) => response,
5079 Err(ureq::Error::Status(_, response)) => {
5080 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5081 return Err(LinkError::Http {
5082 what: "v2 direct download",
5083 status: response.status(),
5084 message: "object store rejected the download".to_string(),
5085 code: None,
5086 details: None,
5087 });
5088 }
5089 Err(ureq::Error::Transport(error)) => {
5090 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5091 return Err(LinkError::Transport {
5092 hub: cfg.hub.clone(),
5093 message: error.to_string(),
5094 });
5095 }
5096 };
5097 let mut reader = response
5098 .into_reader()
5099 .take(expected_bytes.saturating_add(1));
5100 let mut digest = Sha256::new();
5101 let mut total = 0_u64;
5102 let mut buffer = [0_u8; 64 * 1024];
5103 let write_result = (|| -> std::io::Result<()> {
5104 loop {
5105 let read = reader.read(&mut buffer)?;
5106 if read == 0 {
5107 break;
5108 }
5109 total = total.saturating_add(read as u64);
5110 digest.update(&buffer[..read]);
5111 output.write_all(&buffer[..read])?;
5112 }
5113 output.sync_all()
5114 })();
5115 if let Err(error) = write_result {
5116 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5117 return Err(error.into());
5118 }
5119 drop(output);
5120 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5121 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5122 return Err(invalid_feed(
5123 "v2 direct download failed integrity verification",
5124 ));
5125 }
5126 let target_name = c_name(sha256.as_bytes(), sha256)?;
5127 if unsafe {
5130 libc::renameat(
5131 directory.as_raw_fd(),
5132 temp.as_ptr(),
5133 directory.as_raw_fd(),
5134 target_name.as_ptr(),
5135 )
5136 } != 0
5137 {
5138 let error = std::io::Error::last_os_error();
5139 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5140 return Err(error.into());
5141 }
5142 directory.sync_all()?;
5143 Ok(target)
5144}
5145
5146#[cfg(windows)]
5147fn download_presigned_to_cache(
5148 cfg: &HubConfig,
5149 url: &str,
5150 cache_dir: &Path,
5151 sha256: &str,
5152 expected_bytes: u64,
5153) -> LinkResult<PathBuf> {
5154 use std::fs::OpenOptions;
5155
5156 let target = cache_dir.join(sha256);
5157 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5158 return Ok(target);
5159 }
5160 let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
5164 let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
5165 let mut output = OpenOptions::new()
5166 .write(true)
5167 .create_new(true)
5168 .open(&temp)?;
5169 let response = match presigned_agent(cfg, url)?.get(url).call() {
5170 Ok(response) => response,
5171 Err(ureq::Error::Status(_, response)) => {
5172 let _ = std::fs::remove_file(&temp);
5173 return Err(LinkError::Http {
5174 what: "v2 direct download",
5175 status: response.status(),
5176 message: "object store rejected the download".to_string(),
5177 code: None,
5178 details: None,
5179 });
5180 }
5181 Err(ureq::Error::Transport(error)) => {
5182 let _ = std::fs::remove_file(&temp);
5183 return Err(LinkError::Transport {
5184 hub: cfg.hub.clone(),
5185 message: error.to_string(),
5186 });
5187 }
5188 };
5189 let mut reader = response
5190 .into_reader()
5191 .take(expected_bytes.saturating_add(1));
5192 let mut digest = Sha256::new();
5193 let mut total = 0_u64;
5194 let mut buffer = [0_u8; 64 * 1024];
5195 let copied = (|| -> std::io::Result<()> {
5196 loop {
5197 let read = reader.read(&mut buffer)?;
5198 if read == 0 {
5199 break;
5200 }
5201 total = total.saturating_add(read as u64);
5202 digest.update(&buffer[..read]);
5203 output.write_all(&buffer[..read])?;
5204 }
5205 output.sync_all()
5206 })();
5207 if let Err(error) = copied {
5208 let _ = std::fs::remove_file(&temp);
5209 return Err(error.into());
5210 }
5211 drop(output);
5212 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5213 let _ = std::fs::remove_file(&temp);
5214 return Err(invalid_feed(
5215 "v2 direct download failed integrity verification",
5216 ));
5217 }
5218 if target.exists() {
5219 std::fs::remove_file(&target)?;
5220 }
5221 if let Err(error) = std::fs::rename(&temp, &target) {
5222 let _ = std::fs::remove_file(&temp);
5223 return Err(error.into());
5224 }
5225 Ok(target)
5226}
5227
5228#[cfg(not(any(unix, windows)))]
5229fn download_presigned_to_cache(
5230 _cfg: &HubConfig,
5231 _url: &str,
5232 _cache_dir: &Path,
5233 _sha256: &str,
5234 _expected_bytes: u64,
5235) -> LinkResult<PathBuf> {
5236 Err(LinkError::UnsupportedPlatform {
5237 operation: "resumable v2 download staging",
5238 })
5239}
5240
5241fn download_v2_blobs(
5242 cfg: &HubConfig,
5243 brain: &str,
5244 pointer: &V2PointerBody,
5245 pending: Vec<(&String, &V2BaselineFile)>,
5246) -> LinkResult<Vec<(String, Vec<u8>)>> {
5247 if pending.is_empty() {
5248 return Ok(Vec::new());
5249 }
5250 let expected_order = pending
5251 .iter()
5252 .map(|(path, _)| (*path).clone())
5253 .collect::<Vec<_>>();
5254 let mut streamed = std::collections::BTreeMap::new();
5255 let mut direct = Vec::new();
5256 let mut window = Vec::new();
5257 let mut window_bytes = 0_u64;
5258 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5259 window_bytes: &mut u64,
5260 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
5261 -> LinkResult<()> {
5262 if window.is_empty() {
5263 return Ok(());
5264 }
5265 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
5266 if streamed.insert(path, bytes).is_some() {
5267 return Err(invalid_feed("v2 bulk streams repeated a path"));
5268 }
5269 }
5270 window.clear();
5271 *window_bytes = 0;
5272 Ok(())
5273 };
5274 for &(path, file) in &pending {
5275 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5276 flush(&mut window, &mut window_bytes, &mut streamed)?;
5277 direct.push((path, file));
5278 continue;
5279 }
5280 if window.len() == V2_BULK_STREAM_FILES
5281 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5282 {
5283 flush(&mut window, &mut window_bytes, &mut streamed)?;
5284 }
5285 window.push((path, file));
5286 window_bytes += file.bytes;
5287 }
5288 flush(&mut window, &mut window_bytes, &mut streamed)?;
5289
5290 let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
5291 let next = std::sync::atomic::AtomicUsize::new(0);
5292 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5293 let mut results = std::iter::repeat_with(|| None)
5294 .take(downloads.len())
5295 .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
5296 std::thread::scope(|scope| {
5297 let (sender, receiver) = std::sync::mpsc::channel();
5298 for _ in 0..worker_count {
5299 let sender = sender.clone();
5300 let downloads = &downloads;
5301 let next = &next;
5302 scope.spawn(move || loop {
5303 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5304 let Some(item) = downloads.get(index) else {
5305 break;
5306 };
5307 let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
5308 if sender.send((index, result)).is_err() {
5309 break;
5310 }
5311 });
5312 }
5313 drop(sender);
5314 for (index, result) in receiver {
5315 results[index] = Some(result);
5316 }
5317 });
5318 for result in results.into_iter().map(|result| {
5319 result.ok_or_else(|| LinkError::Transport {
5320 hub: cfg.hub.clone(),
5321 message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
5322 })?
5323 }) {
5324 let (path, bytes) = result?;
5325 if streamed.insert(path, bytes).is_some() {
5326 return Err(invalid_feed("v2 download lanes repeated a path"));
5327 }
5328 }
5329 expected_order
5330 .into_iter()
5331 .map(|path| {
5332 streamed
5333 .remove(&path)
5334 .map(|bytes| (path, bytes))
5335 .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
5336 })
5337 .collect()
5338}
5339
5340#[cfg(any(unix, windows))]
5344fn stage_v2_blobs(
5345 cfg: &HubConfig,
5346 brain: &str,
5347 pointer: &V2PointerBody,
5348 pending: Vec<(&String, &V2BaselineFile)>,
5349) -> LinkResult<Vec<V2StagedFile>> {
5350 let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
5351 let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
5352 let mut direct = Vec::new();
5353 let mut window = Vec::new();
5354 let mut window_bytes = 0_u64;
5355 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5356 window_bytes: &mut u64,
5357 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
5358 -> LinkResult<()> {
5359 if window.is_empty() {
5360 return Ok(());
5361 }
5362 let missing = window
5363 .iter()
5364 .filter_map(|(path, file)| {
5365 let target = cache_dir.join(&file.sha256);
5366 match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
5367 Ok(true) => {
5368 staged.insert(
5369 (*path).clone(),
5370 V2StagedFile {
5371 path: (*path).clone(),
5372 source: target,
5373 sha256: file.sha256.clone(),
5374 bytes: file.bytes,
5375 },
5376 );
5377 None
5378 }
5379 Ok(false) => Some(Ok((*path, *file))),
5380 Err(error) => Some(Err(error)),
5381 }
5382 })
5383 .collect::<LinkResult<Vec<_>>>()?;
5384 if !missing.is_empty() {
5385 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
5386 let file = missing
5387 .iter()
5388 .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
5389 .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
5390 let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
5391 staged.insert(
5392 path.clone(),
5393 V2StagedFile {
5394 path,
5395 source,
5396 sha256: file.sha256.clone(),
5397 bytes: file.bytes,
5398 },
5399 );
5400 }
5401 }
5402 window.clear();
5403 *window_bytes = 0;
5404 Ok(())
5405 };
5406 for &(path, file) in &pending {
5407 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5408 flush(&mut window, &mut window_bytes, &mut staged)?;
5409 direct.push((path, file));
5410 continue;
5411 }
5412 if window.len() == V2_BULK_STREAM_FILES
5413 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5414 {
5415 flush(&mut window, &mut window_bytes, &mut staged)?;
5416 }
5417 window.push((path, file));
5418 window_bytes += file.bytes;
5419 }
5420 flush(&mut window, &mut window_bytes, &mut staged)?;
5421 for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
5422 let source =
5423 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
5424 staged.insert(
5425 item.path.clone(),
5426 V2StagedFile {
5427 path: item.path,
5428 source,
5429 sha256: item.sha256,
5430 bytes: item.bytes,
5431 },
5432 );
5433 }
5434 pending
5435 .into_iter()
5436 .map(|(path, _)| {
5437 staged
5438 .remove(path)
5439 .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
5440 })
5441 .collect()
5442}
5443
5444#[cfg(not(any(unix, windows)))]
5445fn stage_v2_blobs(
5446 _cfg: &HubConfig,
5447 _brain: &str,
5448 _pointer: &V2PointerBody,
5449 _pending: Vec<(&String, &V2BaselineFile)>,
5450) -> LinkResult<Vec<V2StagedFile>> {
5451 Err(LinkError::UnsupportedPlatform {
5452 operation: "resumable v2 download staging",
5453 })
5454}
5455
5456const V2_CONFLICT_BUNDLE_MAX: usize = 32;
5457const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
5458const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
5459
5460#[derive(Debug, Clone, Deserialize, Serialize)]
5461struct V2ConflictCoordinate {
5462 sha256: Option<String>,
5463 bytes: Option<u64>,
5464 file: Option<String>,
5465}
5466
5467#[derive(Debug, Clone, Deserialize, Serialize)]
5468struct V2ConflictFile {
5469 path: String,
5470 base: V2ConflictCoordinate,
5471 local: V2ConflictCoordinate,
5472 remote: V2ConflictCoordinate,
5473}
5474
5475#[derive(Debug, Clone, Deserialize, Serialize)]
5476struct V2ConflictPlan {
5477 v: u8,
5478 class: String,
5479 bundle: String,
5480 brain: String,
5481 origin: String,
5482 created_unix: u64,
5483 expires_unix: u64,
5484 base_seq: Option<u64>,
5485 base_commit: Option<String>,
5486 remote_seq: u64,
5487 remote_commit: Option<String>,
5488 remote_content_root: Option<String>,
5489 view_kind: String,
5490 view_revision: String,
5491 files: Vec<V2ConflictFile>,
5492}
5493
5494fn v2_take_remote_selection(
5495 files: &[V2ConflictFile],
5496 current: &std::collections::BTreeMap<String, V2BaselineFile>,
5497) -> LinkResult<(
5498 std::collections::BTreeMap<String, V2BaselineFile>,
5499 Vec<String>,
5500)> {
5501 let mut selected = std::collections::BTreeMap::new();
5502 let mut deleted = Vec::new();
5503 for file in files {
5504 match (&file.remote.sha256, file.remote.bytes) {
5505 (Some(sha256), Some(bytes)) => {
5506 let proven = current.get(&file.path).ok_or_else(|| {
5507 invalid_feed("conflict remote coordinate disappeared from the exact head")
5508 })?;
5509 if proven.sha256 != *sha256 || proven.bytes != bytes {
5510 return Err(invalid_feed(
5511 "conflict remote coordinate differs from the exact head",
5512 ));
5513 }
5514 if selected.insert(file.path.clone(), proven.clone()).is_some() {
5515 return Err(invalid_feed("conflict plan repeats a remote coordinate"));
5516 }
5517 }
5518 (None, None) => {
5519 if current.contains_key(&file.path) {
5520 return Err(invalid_feed(
5521 "conflict remote deletion differs from the exact head",
5522 ));
5523 }
5524 deleted.push(file.path.clone());
5525 }
5526 _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
5527 }
5528 }
5529 Ok((selected, deleted))
5530}
5531
5532fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
5533 PathBuf::from(".dbmd")
5534 .join("conflicts")
5535 .join(bundle)
5536 .join(suffix)
5537}
5538
5539fn read_historical_conflict_blob(
5540 cfg: &HubConfig,
5541 brain: &str,
5542 baseline: &V2SyncBaseline,
5543 path: &str,
5544 file: &V2BaselineFile,
5545) -> LinkResult<Option<Vec<u8>>> {
5546 let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
5547 return Ok(None);
5548 };
5549 if seq == 0 {
5550 return Ok(None);
5551 }
5552 let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
5553 let endpoint = format!(
5554 "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
5555 file.sha256
5556 );
5557 let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
5558 if raw.status == 404 || raw.status == 403 {
5559 return Ok(None);
5560 }
5561 let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
5562 if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
5563 return Err(invalid_feed(
5564 "v2 conflict base failed integrity verification",
5565 ));
5566 }
5567 Ok(Some(bytes))
5568}
5569
5570fn create_v2_conflict_bundle(
5573 cfg: &HubConfig,
5574 store: &Store,
5575 head: &V2VerifiedHead,
5576 baseline: Option<&V2SyncBaseline>,
5577 local: &std::collections::BTreeMap<String, (String, u64)>,
5578 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
5579 paths: &[String],
5580) -> LinkResult<(String, Vec<String>)> {
5581 let conflicts_root = Path::new(".dbmd/conflicts");
5582 store.create_dir_all(conflicts_root)?;
5583 let completed = store
5584 .directory_names(conflicts_root)?
5585 .into_iter()
5586 .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
5587 .count();
5588 if completed >= V2_CONFLICT_BUNDLE_MAX {
5589 return Err(LinkError::InvalidPack {
5590 message: format!(
5591 "private conflict cache has {completed} bundles; resolve or prune one before syncing"
5592 ),
5593 });
5594 }
5595
5596 let mut selected_paths = Vec::new();
5600 let mut selected_remote_bytes = 0_u64;
5601 for path in paths {
5602 let bytes = remote.get(path).map_or(0, |file| file.bytes);
5603 if !selected_paths.is_empty()
5604 && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
5605 {
5606 break;
5607 }
5608 selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
5609 selected_paths.push(path.clone());
5610 if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
5611 break;
5612 }
5613 }
5614 if selected_paths.is_empty() {
5615 return Err(invalid_feed("content conflict set is empty"));
5616 }
5617 let bundle = crate::ulid::mint();
5618 let bundle_root = v2_conflict_relative(&bundle, "");
5619 store.create_dir_all(&bundle_root.join("files"))?;
5620 let pointer = head.pointer.as_ref();
5621 let remote_bytes = match pointer {
5622 Some(pointer) => download_v2_blobs(
5623 cfg,
5624 &head.brain_id,
5625 pointer,
5626 selected_paths
5627 .iter()
5628 .filter_map(|path| {
5629 remote
5630 .get(path)
5631 .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
5632 .map(|file| (path, file))
5633 })
5634 .collect(),
5635 )?
5636 .into_iter()
5637 .collect::<std::collections::BTreeMap<_, _>>(),
5638 None => std::collections::BTreeMap::new(),
5639 };
5640
5641 let mut files = Vec::with_capacity(selected_paths.len());
5642 for (index, path) in selected_paths.iter().enumerate() {
5643 let base_file = baseline.and_then(|state| state.files.get(path));
5644 let base_bytes = match (baseline, base_file) {
5645 (Some(state), Some(file)) => {
5646 read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
5647 }
5648 _ => None,
5649 };
5650 let local_file = local.get(path);
5651 let remote_file = remote.get(path);
5652 let remote_content = remote_bytes.get(path);
5653 let prefix = format!("files/{index:04}");
5654 let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
5655 let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
5656 let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
5657 if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
5658 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
5659 }
5660 if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
5661 let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
5662 if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
5663 return Err(LinkError::InvalidPack {
5664 message: format!("local conflict path `{path}` changed while bundling"),
5665 });
5666 }
5667 store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
5668 }
5669 if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
5670 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
5671 }
5672 files.push(V2ConflictFile {
5673 path: path.clone(),
5674 base: V2ConflictCoordinate {
5675 sha256: base_file.map(|file| file.sha256.clone()),
5676 bytes: base_file.map(|file| file.bytes),
5677 file: base_name,
5678 },
5679 local: V2ConflictCoordinate {
5680 sha256: local_file.map(|(sha256, _)| sha256.clone()),
5681 bytes: local_file.map(|(_, bytes)| *bytes),
5682 file: local_name,
5683 },
5684 remote: V2ConflictCoordinate {
5685 sha256: remote_file.map(|file| file.sha256.clone()),
5686 bytes: remote_file.map(|file| file.bytes),
5687 file: remote_name,
5688 },
5689 });
5690 }
5691 let now = SystemTime::now()
5692 .duration_since(UNIX_EPOCH)
5693 .unwrap_or_default()
5694 .as_secs();
5695 let plan = V2ConflictPlan {
5696 v: 2,
5697 class: "content_resolution_required".to_string(),
5698 bundle: bundle.clone(),
5699 brain: head.brain_id.clone(),
5700 origin: normalized_origin(&cfg.hub)?,
5701 created_unix: now,
5702 expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
5703 base_seq: baseline.and_then(|state| state.head_seq),
5704 base_commit: baseline.and_then(|state| state.commit_hash.clone()),
5705 remote_seq: pointer.map_or(0, |value| value.seq),
5706 remote_commit: pointer.map(|value| value.commit_hash.clone()),
5707 remote_content_root: pointer.and_then(|value| value.content_root.clone()),
5708 view_kind: head.view_kind.clone(),
5709 view_revision: head.view_revision.clone(),
5710 files,
5711 };
5712 let mut bytes = serde_json::to_vec_pretty(&plan)
5713 .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
5714 bytes.push(b'\n');
5715 store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
5716 Ok((bundle, selected_paths))
5717}
5718
5719fn v2_sync_pull_with_resolution(
5720 cfg: &HubConfig,
5721 requested_brain: &str,
5722 expected_head: V2VerifiedHead,
5723 out: Option<&Path>,
5724 take_remote: Option<&std::collections::BTreeSet<String>>,
5725) -> LinkResult<V2PulledSnapshot> {
5726 let dest = out
5727 .map(Path::to_path_buf)
5728 .unwrap_or_else(|| PathBuf::from(requested_brain));
5729 let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
5730 recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
5731 let head = v2_verified_head(cfg, requested_brain)?
5732 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
5733 if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
5734 return Err(LinkError::RemoteAdvancedDuringSync);
5735 }
5736 let remote = files_for_v2_view(
5737 &head,
5738 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
5739 );
5740 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
5741 let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
5742 ensure_v2_view_compatible(&head, baseline.as_ref())?;
5743 let local_store = Store::open_strict(&dest).ok();
5744 ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
5749 let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
5750 if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
5751 return Err(LinkError::ScopedViewChanged);
5752 }
5753 if let Some(view) = local_view.as_mut() {
5754 remove_scoped_projection(&head, baseline.as_ref(), view)?;
5755 }
5756 let empty_local = std::collections::BTreeMap::new();
5757 let local = local_view
5758 .as_ref()
5759 .map_or(&empty_local, |view| &view.riding);
5760 let kept_home = |path: &str| {
5761 local_view
5762 .as_ref()
5763 .is_some_and(|view| view.policy.keeps_home(path))
5764 };
5765 let empty_base = std::collections::BTreeMap::new();
5766 let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
5767 let empty_base_assets = std::collections::BTreeMap::new();
5768 let base_assets = baseline
5769 .as_ref()
5770 .map_or(&empty_base_assets, |state| &state.assets);
5771 let mut local_assets = local_store
5772 .as_ref()
5773 .map(v2_local_asset_records)
5774 .transpose()?
5775 .unwrap_or_default();
5776 let mut content_merge = merge_v2_pulled_records(
5777 base,
5778 &remote,
5779 local,
5780 |file, _| (file.sha256.clone(), file.bytes),
5781 |file, _| (file.sha256.clone(), file.bytes),
5782 kept_home,
5783 );
5784 if let Some(selected) = take_remote {
5785 for path in selected {
5786 if let Some(position) = content_merge
5787 .conflicts
5788 .iter()
5789 .position(|conflict| conflict == path)
5790 {
5791 content_merge.conflicts.remove(position);
5792 content_merge.accept_remote.insert(path.clone());
5793 match remote.get(path) {
5794 Some(file) => {
5795 content_merge
5796 .records
5797 .insert(path.clone(), (file.sha256.clone(), file.bytes));
5798 }
5799 None => {
5800 content_merge.records.remove(path);
5801 }
5802 }
5803 } else if !content_merge.accept_remote.contains(path) {
5804 return Err(LinkError::InvalidPack {
5805 message: format!(
5806 "take-remote path `{path}` is no longer at its conflict coordinate"
5807 ),
5808 });
5809 }
5810 }
5811 }
5812 if !content_merge.conflicts.is_empty() {
5813 let mut conflicts = content_merge.conflicts.clone();
5814 conflicts.truncate(100);
5815 if let Some(store) = local_store.as_ref() {
5816 let (bundle, paths) = create_v2_conflict_bundle(
5817 cfg,
5818 store,
5819 &head,
5820 baseline.as_ref(),
5821 local,
5822 &remote,
5823 &conflicts,
5824 )?;
5825 return Err(LinkError::ConflictBundle { bundle, paths });
5826 }
5827 return Err(LinkError::Conflict { paths: conflicts });
5828 }
5829 let asset_merge = merge_v2_pulled_records(
5830 base_assets,
5831 &remote_assets,
5832 &local_assets,
5833 v2_asset_record,
5834 v2_asset_record,
5835 |_| false,
5836 );
5837 if !asset_merge.conflicts.is_empty() {
5838 let mut conflicts = asset_merge.conflicts.clone();
5839 conflicts.truncate(100);
5840 return Err(LinkError::Conflict { paths: conflicts });
5841 }
5842 let pointer = head.pointer.as_ref();
5843 let cache_transaction = pointer.map_or_else(
5844 || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
5845 |value| value.commit_hash.clone(),
5846 );
5847 let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
5848 let mut changed = match pointer {
5849 Some(pointer) => stage_v2_blobs(
5850 cfg,
5851 &head.brain_id,
5852 pointer,
5853 remote
5854 .iter()
5855 .filter(|(path, file)| {
5856 content_merge.accept_remote.contains(*path)
5857 && local.get(*path).map(|value| value.0.as_str())
5858 != Some(file.sha256.as_str())
5859 })
5860 .collect(),
5861 )?,
5862 None => Vec::new(),
5863 };
5864 let mut deleted = content_merge
5865 .accept_remote
5866 .iter()
5867 .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
5868 .cloned()
5869 .collect::<Vec<_>>();
5870 if local_assets != asset_merge.records {
5871 if asset_merge.records.is_empty() {
5872 deleted.push("assets.jsonl".to_string());
5873 } else {
5874 let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
5875 let sha256 = content_sha256(&bytes);
5876 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
5877 changed.push(V2StagedFile {
5878 path: "assets.jsonl".to_string(),
5879 source,
5880 sha256,
5881 bytes: bytes.len() as u64,
5882 });
5883 }
5884 }
5885 if let Some(pointer) = pointer {
5886 let mut pending_assets = Vec::new();
5887 for (path, asset) in &remote_assets {
5888 if asset.disposition != "hosted"
5889 || kept_home(path)
5890 || !asset_merge.accept_remote.contains(path)
5891 {
5892 continue;
5893 }
5894 let already_current = local_store.as_ref().is_some_and(|store| {
5895 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
5896 && store
5897 .read_bounded(Path::new(path), asset.bytes)
5898 .ok()
5899 .is_some_and(|bytes| {
5900 bytes.len() as u64 == asset.bytes
5901 && content_sha256(&bytes) == asset.blob_sha256
5902 })
5903 });
5904 if !already_current {
5905 pending_assets.push((path, asset));
5906 }
5907 }
5908 for item in prepare_v2_asset_downloads(cfg, &head.brain_id, pointer, &pending_assets)? {
5909 let source =
5910 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
5911 changed.push(V2StagedFile {
5912 path: item.path,
5913 source,
5914 sha256: item.sha256,
5915 bytes: item.bytes,
5916 });
5917 }
5918 }
5919 for (path, prior) in base_assets {
5920 if remote_assets.contains_key(path)
5921 || kept_home(path)
5922 || !asset_merge.accept_remote.contains(path)
5923 {
5924 continue;
5925 }
5926 let unchanged = local_store.as_ref().is_some_and(|store| {
5927 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
5928 && store
5929 .read_bounded(Path::new(path), prior.bytes)
5930 .ok()
5931 .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
5932 });
5933 if unchanged {
5934 deleted.push(path.clone());
5935 }
5936 }
5937 let extra_local = content_merge
5938 .records
5939 .keys()
5940 .filter(|path| !remote.contains_key(*path))
5941 .cloned()
5942 .collect::<Vec<_>>();
5943 if head.view_kind == "scoped" {
5944 for (path, bytes) in [
5945 ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
5946 (
5947 ".dbmd/view.json".to_string(),
5948 scoped_view_metadata(&head, remote.len())?,
5949 ),
5950 ] {
5951 let sha256 = content_sha256(&bytes);
5952 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
5953 changed.push(V2StagedFile {
5954 path,
5955 source,
5956 sha256,
5957 bytes: bytes.len() as u64,
5958 });
5959 }
5960 }
5961 let install_changed = !changed.is_empty() || !deleted.is_empty();
5962 install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
5963 let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
5964 let installed_store =
5965 Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
5966 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
5967 })?;
5968 let installed_local = if install_changed {
5969 let mut scanned = v2_local_files(&installed_store)?;
5970 remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
5971 scanned
5972 } else {
5973 local_view
5974 .take()
5975 .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
5976 };
5977 if installed_local.riding != content_merge.records {
5978 return Err(LinkError::InvalidPack {
5979 message: "local content changed while installing the v2 pull".to_string(),
5980 });
5981 }
5982 let installed_assets = if install_changed {
5983 v2_local_asset_records(&installed_store)?
5984 } else {
5985 std::mem::take(&mut local_assets)
5986 };
5987 if installed_assets != asset_merge.records {
5988 return Err(LinkError::InvalidPack {
5989 message: "local assets changed while installing the v2 pull".to_string(),
5990 });
5991 }
5992 let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
5993 installed_local.policy.keeps_home(path)
5994 })
5995 || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
5996 let final_head = v2_verified_head(cfg, requested_brain)?
5997 .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
5998 if !same_v2_head(&head, &final_head) {
5999 return Err(LinkError::RemoteAdvancedDuringSync);
6000 }
6001 accept_v2_head(cfg, &final_head)?;
6002 save_v2_baseline(
6003 cfg,
6004 &head.brain_id,
6005 &dest,
6006 &v2_baseline_from_head(
6007 cfg,
6008 &head,
6009 remote.clone(),
6010 remote_assets.clone(),
6011 Some(&installed_local),
6012 baseline
6013 .as_ref()
6014 .and_then(|current| current.checkout_id.as_deref()),
6015 )?,
6016 )?;
6017 complete_v2_pull(&dest)?;
6018 Ok((local_dirty, installed_local, installed_assets))
6019 })();
6020 let (local_dirty, installed_local, installed_assets) = match finalized {
6021 Ok(value) => value,
6022 Err(error) => {
6023 if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6024 return Err(LinkError::InvalidPack {
6025 message: format!("{error}; durable pull recovery also failed: {recovery}"),
6026 });
6027 }
6028 return Err(error);
6029 }
6030 };
6031 cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6032 let report = PullReport {
6033 brain: head.brain_id.clone(),
6034 slug: requested_brain.to_string(),
6035 head_seq: pointer.map_or(0, |value| value.seq),
6036 files: remote.len() + remote_assets.len(),
6037 dest: dest.to_string_lossy().into_owned(),
6038 extra_local,
6039 sync_status: if local_dirty {
6040 "local_dirty_after_install".to_string()
6041 } else {
6042 "synced".to_string()
6043 },
6044 };
6045 Ok(V2PulledSnapshot {
6046 report,
6047 head,
6048 files: remote,
6049 assets: remote_assets,
6050 local: installed_local,
6051 local_assets: installed_assets,
6052 })
6053}
6054
6055fn v2_sync_pull(
6056 cfg: &HubConfig,
6057 requested_brain: &str,
6058 head: V2VerifiedHead,
6059 out: Option<&Path>,
6060) -> LinkResult<PullReport> {
6061 Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
6062}
6063
6064fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
6065 match remote {
6066 Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
6067 None => json!({ "kind": "absent" }),
6068 }
6069}
6070
6071fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
6072 match remote {
6073 Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
6074 None => json!({ "kind": "absent" }),
6075 }
6076}
6077
6078fn v2_content_withdrawal_operation(
6079 store: &Store,
6080 local_view: &V2LocalView,
6081 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6082 path: &str,
6083 reason: &str,
6084) -> LinkResult<Value> {
6085 if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
6086 || path == "DB.md"
6087 {
6088 return Err(LinkError::InvalidPack {
6089 message: format!("content withdrawal path `{path}` is not a record or source"),
6090 });
6091 }
6092 if !local_view.policy.keeps_home(path)
6093 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6094 {
6095 return Err(LinkError::InvalidPack {
6096 message: format!(
6097 "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6098 ),
6099 });
6100 }
6101 let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
6102 message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
6103 })?;
6104 verify_v2_upload_source(store, path, ¤t.sha256, current.bytes)?;
6105 Ok(json!({
6106 "op": "withdraw_from_hosting",
6107 "path": path,
6108 "expected": { "kind": "blob", "hash": current.sha256 },
6109 "reason": reason,
6110 }))
6111}
6112
6113fn v2_asset_withdrawal_operation(
6114 store: &Store,
6115 local_view: &V2LocalView,
6116 path: &str,
6117 local: &crate::AssetRecord,
6118 current: &V2BaselineAsset,
6119 reason: &str,
6120) -> LinkResult<Value> {
6121 if !local_view.policy.keeps_home(path)
6122 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6123 {
6124 return Err(LinkError::InvalidPack {
6125 message: format!(
6126 "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6127 ),
6128 });
6129 }
6130 verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
6131 if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
6132 return Err(LinkError::InvalidPack {
6133 message: format!(
6134 "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
6135 ),
6136 });
6137 }
6138 Ok(json!({
6139 "op": "asset_withdraw",
6140 "path": path,
6141 "expected": v2_asset_expected(Some(current)),
6142 "reason": reason,
6143 }))
6144}
6145
6146fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
6147 json!({
6148 "blob_sha256": record.sha256,
6149 "bytes": record.bytes,
6150 "media_type": record.media_type,
6151 "wrappers": record.wrappers,
6152 "required": record.required,
6153 "disposition": disposition,
6154 })
6155}
6156
6157fn apply_generated_v2_operations(
6161 operations: &[Value],
6162 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
6163 candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
6164 candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
6165) -> LinkResult<bool> {
6166 let mut asset_changed = false;
6167 for operation in operations {
6168 match operation.get("op").and_then(Value::as_str) {
6169 Some("put") => {
6170 let path = operation
6171 .get("path")
6172 .and_then(Value::as_str)
6173 .ok_or_else(|| invalid_feed("v2 put has no path"))?;
6174 let sha256 = operation
6175 .get("blob")
6176 .and_then(Value::as_str)
6177 .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
6178 let bytes = operation
6179 .get("bytes")
6180 .and_then(Value::as_u64)
6181 .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
6182 candidate.insert(
6183 path.to_string(),
6184 V2BaselineFile {
6185 sha256: sha256.to_string(),
6186 bytes,
6187 proof: None,
6188 },
6189 );
6190 }
6191 Some("delete" | "withdraw_from_hosting") => {
6192 let path = operation
6193 .get("path")
6194 .and_then(Value::as_str)
6195 .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
6196 candidate.remove(path);
6197 }
6198 Some("asset_delete") => {
6199 let path = operation
6200 .get("path")
6201 .and_then(Value::as_str)
6202 .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
6203 candidate_assets.remove(path);
6204 asset_changed = true;
6205 }
6206 Some("asset_put" | "asset_resume" | "asset_withdraw") => {
6207 let path = operation
6208 .get("path")
6209 .and_then(Value::as_str)
6210 .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
6211 let record = local_assets
6212 .get(path)
6213 .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
6214 let disposition =
6215 if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
6216 "withheld"
6217 } else {
6218 operation
6219 .get("asset")
6220 .and_then(|asset| asset.get("disposition"))
6221 .and_then(Value::as_str)
6222 .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
6223 };
6224 candidate_assets.insert(
6225 path.to_string(),
6226 V2BaselineAsset {
6227 blob_sha256: record.sha256.clone(),
6228 bytes: record.bytes,
6229 media_type: record.media_type.clone(),
6230 wrappers: record.wrappers.clone(),
6231 required: record.required,
6232 disposition: disposition.to_string(),
6233 leaf_hash: String::new(),
6236 },
6237 );
6238 asset_changed = true;
6239 }
6240 _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
6241 }
6242 }
6243 Ok(asset_changed)
6244}
6245
6246fn v2_riding_matches_remote(
6247 local: &std::collections::BTreeMap<String, (String, u64)>,
6248 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6249 keeps_home: impl Fn(&str) -> bool,
6250) -> bool {
6251 remote.iter().all(|(path, file)| {
6252 keeps_home(path)
6253 || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
6254 }) && local.iter().all(|(path, (hash, _))| {
6255 remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
6256 })
6257}
6258
6259#[derive(Debug, Clone)]
6260struct V2ResolutionOverride {
6261 expected_remote: Option<String>,
6262 selected_local: Option<String>,
6263}
6264
6265#[derive(Debug, Clone)]
6266struct V2UploadSource {
6267 path: String,
6268 bytes: u64,
6269}
6270
6271struct V2SyncPushOptions<'a> {
6272 resume_local_policy: bool,
6273 bulk_confirmation: Option<&'a V2BulkConfirmation>,
6274 resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
6275 pulled: Option<V2PulledSnapshot>,
6276 withdrawal_paths: &'a [String],
6277 withdrawal_reason: Option<&'a str>,
6278}
6279
6280fn verify_v2_upload_source(
6281 store: &Store,
6282 path: &str,
6283 sha256: &str,
6284 expected_bytes: u64,
6285) -> LinkResult<()> {
6286 let file = store.open_regular(Path::new(path))?;
6287 if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
6288 return Err(LinkError::InvalidPack {
6289 message: format!("local path `{path}` changed during sync planning"),
6290 });
6291 }
6292 Ok(())
6293}
6294
6295fn put_presigned_source(
6296 cfg: &HubConfig,
6297 raw: &str,
6298 headers: &Value,
6299 store: &Store,
6300 source: &V2UploadSource,
6301) -> LinkResult<()> {
6302 let http = presigned_agent(cfg, raw)?;
6303 let mut attempt = 0;
6304 let result = loop {
6305 let file = store.open_regular(Path::new(&source.path))?;
6306 if file.metadata()?.len() != source.bytes {
6307 return Err(LinkError::InvalidPack {
6308 message: format!("local path `{}` changed before upload", source.path),
6309 });
6310 }
6311 let mut req = http
6312 .put(raw)
6313 .set("Content-Length", &source.bytes.to_string());
6314 if let Some(map) = headers.as_object() {
6315 for (name, value) in map {
6316 if let Some(value) = value.as_str() {
6317 req = req.set(name, value);
6318 }
6319 }
6320 }
6321 match req.send(file) {
6322 Err(ureq::Error::Transport(error))
6323 if is_pre_request_transport(error.kind()) && attempt + 1 < CONNECT_ATTEMPTS =>
6324 {
6325 std::thread::sleep(std::time::Duration::from_millis(
6326 CONNECT_RETRY_BACKOFF_MS[attempt],
6327 ));
6328 attempt += 1;
6329 }
6330 result => break result,
6331 }
6332 };
6333 match result {
6334 Ok(response) if (200..300).contains(&response.status()) => Ok(()),
6335 Ok(response) => Err(LinkError::Http {
6336 what: "v2 changed-byte upload",
6337 status: response.status(),
6338 message: "object store rejected the upload".to_string(),
6339 code: None,
6340 details: None,
6341 }),
6342 Err(error) => match error {
6343 ureq::Error::Status(412, _) => Ok(()),
6344 ureq::Error::Status(_, response) => Err(LinkError::Http {
6345 what: "v2 changed-byte upload",
6346 status: response.status(),
6347 message: "object store rejected the upload".to_string(),
6348 code: None,
6349 details: None,
6350 }),
6351 ureq::Error::Transport(error) => Err(LinkError::Transport {
6352 hub: "the object store".to_string(),
6353 message: error.to_string(),
6354 }),
6355 },
6356 }
6357}
6358
6359fn v2_sync_push(
6360 cfg: &HubConfig,
6361 requested_brain: &str,
6362 store: &Store,
6363 head: V2VerifiedHead,
6364 options: V2SyncPushOptions<'_>,
6365) -> LinkResult<Value> {
6366 let V2SyncPushOptions {
6367 resume_local_policy,
6368 bulk_confirmation,
6369 resolution,
6370 pulled,
6371 withdrawal_paths,
6372 withdrawal_reason,
6373 } = options;
6374 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
6375 let head = v2_verified_head(cfg, requested_brain)?
6376 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6377 let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
6378 match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
6379 Some(snapshot) => (
6380 snapshot.files,
6381 snapshot.assets,
6382 Some(snapshot.local),
6383 Some(snapshot.local_assets),
6384 ),
6385 None => (
6386 files_for_v2_view(
6387 &head,
6388 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6389 ),
6390 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6391 None,
6392 None,
6393 ),
6394 };
6395 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
6396 ensure_v2_view_compatible(&head, baseline.as_ref())?;
6397 if head.view_kind == "scoped" && baseline.is_none() {
6398 return Err(LinkError::ScopedViewChanged);
6399 }
6400 let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
6401 let local = &local_view.riding;
6402 let local_assets = match carried_local_assets {
6403 Some(assets) => assets,
6404 None => v2_local_asset_records(store)?,
6405 };
6406 if withdrawal_paths.len() > MAX_PUSH_FILES {
6407 return Err(LinkError::PushTooLarge {
6408 detail: "too many explicit withdrawal paths".to_string(),
6409 });
6410 }
6411 let withdrawal_reason = if withdrawal_paths.is_empty() {
6412 None
6413 } else {
6414 let reason = withdrawal_reason
6415 .map(str::trim)
6416 .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
6417 .ok_or_else(|| LinkError::InvalidPack {
6418 message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
6419 })?;
6420 Some(reason)
6421 };
6422 let mut withdrawals = withdrawal_paths
6423 .iter()
6424 .map(|path| {
6425 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
6426 path: error.to_string(),
6427 })
6428 })
6429 .collect::<LinkResult<Vec<_>>>()?;
6430 withdrawals.sort();
6431 withdrawals.dedup();
6432 if withdrawals.len() != withdrawal_paths.len() {
6433 return Err(LinkError::InvalidPack {
6434 message: "explicit withdrawal paths must be unique".to_string(),
6435 });
6436 }
6437 let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
6438 let mut consumed_withdrawals = BTreeSet::new();
6439 if let Some(previous) = baseline.as_ref() {
6440 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
6441 && !resume_local_policy
6442 {
6443 let mut newly_eligible = previous
6444 .local_eligibility
6445 .iter()
6446 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
6447 .map(|(path, _)| path.clone())
6448 .collect::<Vec<_>>();
6449 if !newly_eligible.is_empty() {
6450 newly_eligible.truncate(100);
6451 return Err(LinkError::LocalPolicyTransition {
6452 paths: newly_eligible,
6453 });
6454 }
6455 }
6456 }
6457 let base = match baseline.as_ref() {
6458 Some(state) => &state.files,
6459 None if remote.is_empty() => &remote,
6460 None => {
6461 let mut conflicts = remote
6462 .iter()
6463 .filter(|(path, file)| {
6464 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
6465 })
6466 .map(|(path, _)| path.clone())
6467 .collect::<Vec<_>>();
6468 if !conflicts.is_empty() {
6469 conflicts.truncate(100);
6470 let (bundle, paths) =
6471 create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
6472 return Err(LinkError::ConflictBundle { bundle, paths });
6473 }
6474 &remote
6475 }
6476 };
6477 let all_paths = base
6478 .keys()
6479 .chain(remote.keys())
6480 .chain(local.keys())
6481 .cloned()
6482 .collect::<std::collections::BTreeSet<_>>();
6483 let mut conflicts = Vec::new();
6484 let mut operations = Vec::new();
6485 let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
6486 for path in all_paths {
6487 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
6488 let remote_file = remote.get(&path);
6489 let remote_hash = remote_file.map(|file| file.sha256.as_str());
6490 let local_file = local.get(&path);
6491 let local_hash = local_file.map(|file| file.0.as_str());
6492 if local_hash == base_hash {
6493 continue;
6494 }
6495 if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
6496 continue;
6497 }
6498 if local_view.policy.keeps_home(&path) {
6499 continue;
6502 }
6503 if remote_hash != base_hash && local_hash != remote_hash {
6504 let explicitly_resolved = resolution
6505 .and_then(|allowed| allowed.get(&path))
6506 .is_some_and(|selected| {
6507 selected.expected_remote.as_deref() == remote_hash
6508 && selected.selected_local.as_deref() == local_hash
6509 });
6510 if !explicitly_resolved {
6511 conflicts.push(path);
6512 continue;
6513 }
6514 }
6515 match local_file {
6516 Some((sha256, byte_count)) => {
6517 verify_v2_upload_source(store, &path, sha256, *byte_count)?;
6518 operations.push(json!({
6519 "op": "put",
6520 "path": path,
6521 "expected": v2_expected(remote_file),
6522 "blob": sha256,
6523 "bytes": byte_count,
6524 }));
6525 upload_sources
6526 .entry(sha256.clone())
6527 .or_insert_with(|| V2UploadSource {
6528 path: path.clone(),
6529 bytes: *byte_count,
6530 });
6531 }
6532 None => {
6533 let Some(current) = remote_file else {
6534 continue;
6535 };
6536 operations.push(json!({
6537 "op": "delete",
6538 "path": path,
6539 "expected": { "kind": "blob", "hash": current.sha256 },
6540 }));
6541 }
6542 }
6543 }
6544 for path in &withdrawals {
6545 if local_assets.contains_key(path) {
6546 continue;
6547 }
6548 operations.push(v2_content_withdrawal_operation(
6549 store,
6550 &local_view,
6551 &remote,
6552 path,
6553 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
6554 )?);
6555 consumed_withdrawals.insert(path.clone());
6556 }
6557 if !conflicts.is_empty() {
6558 conflicts.truncate(100);
6559 let (bundle, paths) = create_v2_conflict_bundle(
6560 cfg,
6561 store,
6562 &head,
6563 baseline.as_ref(),
6564 local,
6565 &remote,
6566 &conflicts,
6567 )?;
6568 return Err(LinkError::ConflictBundle { bundle, paths });
6569 }
6570 let base_assets = match baseline.as_ref() {
6571 Some(state) => &state.assets,
6572 None if remote_assets.is_empty() => &remote_assets,
6573 None => {
6574 let mismatched = remote_assets.iter().any(|(path, remote)| {
6575 local_assets.get(path) != Some(&v2_asset_record(remote, path))
6576 }) || local_assets.len() != remote_assets.len();
6577 if mismatched {
6578 return Err(LinkError::Conflict {
6579 paths: vec!["assets.jsonl".to_string()],
6580 });
6581 }
6582 &remote_assets
6583 }
6584 };
6585 let asset_paths = base_assets
6586 .keys()
6587 .chain(remote_assets.keys())
6588 .chain(local_assets.keys())
6589 .cloned()
6590 .collect::<std::collections::BTreeSet<_>>();
6591 let mut asset_policy_transitions = Vec::new();
6592 for path in asset_paths {
6593 let base_record = base_assets
6594 .get(&path)
6595 .map(|asset| v2_asset_record(asset, &path));
6596 let remote = remote_assets.get(&path);
6597 let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
6598 let local_record = local_assets.get(&path);
6599 if withdrawal_set.contains(&path) {
6600 let record = local_record.ok_or_else(|| LinkError::InvalidPack {
6601 message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
6602 })?;
6603 let current = remote.ok_or_else(|| LinkError::InvalidPack {
6604 message: format!(
6605 "asset withdrawal path `{path}` has no readable hosted coordinate"
6606 ),
6607 })?;
6608 operations.push(v2_asset_withdrawal_operation(
6609 store,
6610 &local_view,
6611 &path,
6612 record,
6613 current,
6614 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
6615 )?);
6616 consumed_withdrawals.insert(path.clone());
6617 continue;
6618 }
6619 let mut raw_present = false;
6620 let mut disposition = "withheld";
6621 let mut resumes_hosting = false;
6622 if let Some(record) = local_record {
6623 crate::linkmd_v2::normalize_path(&record.path)
6624 .map_err(|error| invalid_feed(error.to_string()))?;
6625 let kept_home = local_view.policy.keeps_home(&path);
6626 raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
6627 disposition = if kept_home || !raw_present {
6628 "withheld"
6629 } else {
6630 "hosted"
6631 };
6632 if !raw_present && record.required && !kept_home {
6633 return Err(LinkError::InvalidPack {
6634 message: format!("required asset {path} is missing"),
6635 });
6636 }
6637 resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
6638 }
6639 if local_record == base_record.as_ref() && !resumes_hosting {
6640 continue;
6641 }
6642 if remote_record != base_record && local_record != remote_record.as_ref() {
6643 conflicts.push(path);
6644 continue;
6645 }
6646 let Some(record) = local_record else {
6647 if let Some(remote) = remote {
6648 operations.push(json!({
6649 "op": "asset_delete",
6650 "path": path,
6651 "expected": v2_asset_expected(Some(remote)),
6652 }));
6653 }
6654 continue;
6655 };
6656 let raw = if raw_present {
6657 verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
6658 Some(())
6659 } else {
6660 None
6661 };
6662 let op = if resumes_hosting {
6663 if !resume_local_policy {
6664 asset_policy_transitions.push(path);
6665 continue;
6666 }
6667 "asset_resume"
6668 } else {
6669 "asset_put"
6670 };
6671 operations.push(json!({
6672 "op": op,
6673 "path": path,
6674 "expected": v2_asset_expected(remote),
6675 "asset": v2_asset_value(record, disposition),
6676 }));
6677 if disposition == "hosted" {
6678 raw.expect("hosted asset was checked present");
6679 upload_sources
6680 .entry(record.sha256.clone())
6681 .or_insert_with(|| V2UploadSource {
6682 path: path.clone(),
6683 bytes: record.bytes,
6684 });
6685 }
6686 }
6687 if consumed_withdrawals != withdrawal_set {
6688 let missing = withdrawal_set
6689 .difference(&consumed_withdrawals)
6690 .next()
6691 .expect("different withdrawal sets have one member");
6692 return Err(LinkError::InvalidPack {
6693 message: format!(
6694 "withdrawal path `{missing}` is not a readable content or asset coordinate"
6695 ),
6696 });
6697 }
6698 if !conflicts.is_empty() {
6699 conflicts.truncate(100);
6700 return Err(LinkError::Conflict { paths: conflicts });
6701 }
6702 if !asset_policy_transitions.is_empty() {
6703 asset_policy_transitions.truncate(100);
6704 return Err(LinkError::LocalPolicyTransition {
6705 paths: asset_policy_transitions,
6706 });
6707 }
6708 let touched_sources = operations
6709 .iter()
6710 .filter_map(
6711 |operation| match operation.get("op").and_then(Value::as_str) {
6712 Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
6713 Some("rename") => operation.get("to").and_then(Value::as_str),
6714 _ => None,
6715 },
6716 )
6717 .collect::<std::collections::BTreeSet<_>>();
6718 let withheld_links = local_view
6719 .withheld_links
6720 .iter()
6721 .filter(|link| touched_sources.contains(link.source.as_str()))
6722 .collect::<Vec<_>>();
6723 let checkout_pseudonym = v2_checkout_id(
6724 baseline
6725 .as_ref()
6726 .and_then(|current| current.checkout_id.as_deref()),
6727 )?;
6728 let checkout_id = if withheld_links.is_empty() {
6729 None
6730 } else {
6731 Some(checkout_pseudonym.clone())
6732 };
6733 if operations.is_empty() {
6734 let final_head = v2_verified_head(cfg, requested_brain)?
6735 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
6736 if !same_v2_head(&head, &final_head) {
6737 return Err(LinkError::RemoteAdvancedDuringSync);
6738 }
6739 let mut final_local = v2_local_files(store)?;
6740 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
6741 let final_assets = v2_local_asset_records(store)?;
6742 let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
6743 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
6744 final_local.policy.keeps_home(path)
6745 }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
6746 let next = v2_baseline_from_head(
6747 cfg,
6748 &head,
6749 remote,
6750 remote_assets,
6751 Some(&final_local),
6752 Some(&checkout_pseudonym),
6753 )?;
6754 let split_count = next.remote_copy_remains.len();
6755 accept_v2_head(cfg, &final_head)?;
6756 if !local_changed && !remote_ahead {
6757 refresh_scoped_view_marker(store, &head, next.files.len())?;
6758 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
6759 }
6760 return Ok(json!({
6761 "v": 2,
6762 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
6763 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
6764 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
6765 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
6766 "local_policy": {
6767 "remote_copy_remains": split_count,
6768 },
6769 }));
6770 }
6771 let includes_contract = operations
6772 .iter()
6773 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
6774 let rebase = if head.pointer.is_none() || includes_contract {
6775 "strict"
6776 } else {
6777 "disjoint"
6778 };
6779 let base_value = head.pointer.as_ref().map(|pointer| {
6780 json!({
6781 "seq": pointer.seq,
6782 "commit_hash": pointer.commit_hash,
6783 "content_root": pointer.content_root,
6784 "asset_root": pointer.asset_root,
6785 })
6786 });
6787 let entropy = format!(
6791 "{}\0{}\0{}\0{}\0{}\0{}",
6792 normalized_origin(&cfg.hub)?,
6793 head.brain_id,
6794 serde_json::to_string(&base_value).unwrap_or_default(),
6795 serde_json::to_string(&operations).unwrap_or_default(),
6796 serde_json::to_string(&withheld_links).unwrap_or_default(),
6797 checkout_id.as_deref().unwrap_or("")
6798 );
6799 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
6800 let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
6801 total
6802 .checked_add(source.bytes)
6803 .ok_or_else(|| LinkError::PushTooLarge {
6804 detail: "v2 changed-byte total overflow".to_string(),
6805 })
6806 })?;
6807 let inline = changed_bytes <= 3 * 1024 * 1024;
6808 let inline_blobs = if inline {
6809 upload_sources
6810 .iter()
6811 .map(|(sha256, source)| {
6812 let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
6813 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
6814 return Err(LinkError::InvalidPack {
6815 message: format!("local path `{}` changed before upload", source.path),
6816 });
6817 }
6818 Ok(json!({
6819 "sha256": sha256,
6820 "bytes": source.bytes,
6821 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
6822 }))
6823 })
6824 .collect::<LinkResult<Vec<_>>>()?
6825 } else {
6826 Vec::new()
6827 };
6828 let mut body = json!({
6829 "mutation_id": mutation_id,
6830 "base": base_value,
6831 "rebase": rebase,
6832 "reason": "dbmd sync",
6833 "operations": operations,
6834 "blobs": inline_blobs,
6835 });
6836 if !withheld_links.is_empty() {
6837 body["withheld_links"] = serde_json::to_value(&withheld_links)
6838 .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
6839 body["checkout_id"] =
6840 Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
6841 }
6842 if let Some(confirmation) = bulk_confirmation {
6843 if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
6844 return Err(LinkError::InvalidPack {
6845 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
6846 .to_string(),
6847 });
6848 }
6849 body["rebase"] = Value::String("strict".to_string());
6853 body["bulk_preview_id"] = Value::String(confirmation.id.clone());
6854 body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
6855 }
6856 if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
6857 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6858 for operation in &operations {
6859 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
6860 return Err(invalid_feed("v2 upload operation has no kind"));
6861 };
6862 let hash = match kind {
6863 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
6864 "asset_put" | "asset_resume" => operation
6865 .get("asset")
6866 .and_then(|asset| asset.get("blob_sha256"))
6867 .and_then(Value::as_str),
6868 _ => None,
6869 };
6870 let Some(hash) = hash else { continue };
6871 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
6872 if kind == "rename" {
6873 for field in ["from", "to"] {
6874 coordinates.insert(
6875 operation
6876 .get(field)
6877 .and_then(Value::as_str)
6878 .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
6879 .to_string(),
6880 );
6881 }
6882 } else {
6883 let path = operation
6884 .get("path")
6885 .and_then(Value::as_str)
6886 .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
6887 coordinates.insert(if kind.starts_with("asset_") {
6888 format!("assets/{path}")
6889 } else {
6890 path.to_string()
6891 });
6892 }
6893 }
6894 let declarations = upload_sources
6895 .iter()
6896 .map(|(sha256, source)| {
6897 json!({
6898 "sha256": sha256,
6899 "bytes": source.bytes,
6900 "coordinates": coordinates_by_hash
6901 .get(sha256)
6902 .into_iter()
6903 .flatten()
6904 .collect::<Vec<_>>(),
6905 })
6906 })
6907 .collect::<Vec<_>>();
6908 let reserved = ensure_ok(
6909 request(
6910 cfg,
6911 "POST",
6912 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
6913 Some(&json!({ "blobs": declarations })),
6914 Auth::Required,
6915 )?,
6916 "prepare v2 changed-byte uploads",
6917 )?;
6918 let items = reserved
6919 .get("uploads")
6920 .and_then(Value::as_array)
6921 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
6922 if items.len() != upload_sources.len() {
6923 return Err(invalid_feed(
6924 "v2 upload reservation response changed the requested set",
6925 ));
6926 }
6927 let mut references = Vec::with_capacity(items.len());
6928 let mut seen = std::collections::BTreeSet::new();
6929 for item in items {
6930 let sha256 = item
6931 .get("sha256")
6932 .and_then(Value::as_str)
6933 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
6934 let source = upload_sources
6935 .get(sha256)
6936 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
6937 let declared_bytes = item
6938 .get("bytes")
6939 .and_then(Value::as_u64)
6940 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
6941 let reservation_id = item
6942 .get("reservation_id")
6943 .and_then(Value::as_str)
6944 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
6945 let expected_coordinates = coordinates_by_hash
6946 .get(sha256)
6947 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinate binding"))?;
6948 let returned_coordinates = item
6949 .get("coordinates")
6950 .and_then(Value::as_array)
6951 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
6952 if declared_bytes != source.bytes
6953 || !crate::ulid::is_ulid(reservation_id)
6954 || !seen.insert(sha256.to_string())
6955 || returned_coordinates.len() != expected_coordinates.len()
6956 || returned_coordinates
6957 .iter()
6958 .zip(expected_coordinates)
6959 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
6960 {
6961 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
6962 }
6963 match item.get("status").and_then(Value::as_str) {
6964 Some("upload") => {
6965 let url = item
6966 .get("url")
6967 .and_then(Value::as_str)
6968 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
6969 put_presigned_source(
6970 cfg,
6971 url,
6972 item.get("headers").unwrap_or(&Value::Null),
6973 store,
6974 source,
6975 )?;
6976 verify_v2_upload_source(store, &source.path, sha256, source.bytes)?;
6977 }
6978 Some("already_present") => {}
6979 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
6980 }
6981 references.push(json!({
6982 "sha256": sha256,
6983 "bytes": source.bytes,
6984 "reservation_id": reservation_id,
6985 }));
6986 }
6987 body["blobs"] = Value::Array(references);
6988 }
6989 if body.to_string().len() > MAX_PUSH_BYTES {
6990 return Err(LinkError::PushTooLarge {
6991 detail: "v2 operation metadata exceeds the bounded commit request".to_string(),
6992 });
6993 }
6994 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
6995 let mut candidate_hub_signer: Option<String> = None;
6996 let mut response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
6997 let bulk_preview_required = !(200..300).contains(&response.status)
6998 && response.body.as_ref().is_some_and(|value| {
6999 value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
7000 || value
7001 .get("details")
7002 .and_then(|details| details.get("code"))
7003 .and_then(Value::as_str)
7004 == Some("bulk_preview_required")
7005 });
7006 if bulk_preview_required && bulk_confirmation.is_none() {
7007 body["rebase"] = Value::String("strict".to_string());
7008 body["preview_only"] = Value::Bool(true);
7009 let preview = ensure_ok(
7010 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
7011 "v2 bulk preview",
7012 )?;
7013 let preview_code = preview.get("code").and_then(Value::as_str);
7014 let required = preview.get("required").and_then(Value::as_bool);
7015 if preview.get("v").and_then(Value::as_u64) != Some(2)
7016 || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
7017 || !matches!(
7018 preview_code,
7019 Some("bulk_preview_created" | "bulk_preview_not_required")
7020 )
7021 || required.is_none()
7022 {
7023 return Err(invalid_feed(
7024 "bulk preview response is not bound to the requested mutation",
7025 ));
7026 }
7027 if required == Some(true) {
7028 let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
7029 let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
7030 if preview_code != Some("bulk_preview_created")
7031 || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
7032 || preview_digest.is_none_or(|value| !is_sha256(value))
7033 || preview.get("expires_at").and_then(Value::as_str).is_none()
7034 || !preview.get("impact").is_some_and(Value::is_object)
7035 {
7036 return Err(invalid_feed("bulk preview receipt is malformed"));
7037 }
7038 return Err(LinkError::BulkPreviewRequired { preview });
7039 }
7040 if preview_code != Some("bulk_preview_not_required") {
7041 return Err(invalid_feed("bulk preview requirement is inconsistent"));
7042 }
7043 body.as_object_mut()
7046 .expect("v2 commit request is an object")
7047 .remove("preview_only");
7048 response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
7049 }
7050 let mut result = ensure_ok(response, "v2 sync push")?;
7051 if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
7052 if let Some(object) = result.as_object_mut() {
7053 object.insert(
7054 "sync_status".to_string(),
7055 Value::String("proposal_pending".to_string()),
7056 );
7057 }
7058 return Ok(result);
7059 }
7060 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
7061 let challenge = result
7062 .get("signing_challenge")
7063 .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
7064 let mut expected_candidate = remote.clone();
7065 let mut expected_candidate_assets = remote_assets.clone();
7066 apply_generated_v2_operations(
7067 &operations,
7068 &local_assets,
7069 &mut expected_candidate,
7070 &mut expected_candidate_assets,
7071 )?;
7072 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
7073 cfg,
7074 &head,
7075 &expected_candidate,
7076 &expected_candidate_assets,
7077 &mutation_id,
7078 &body,
7079 challenge,
7080 )?;
7081 body["signing_challenge_id"] = Value::String(challenge_id);
7082 body["signature_base64url"] = Value::String(signature);
7083 candidate_hub_signer = Some(actor_signer);
7084 result = ensure_ok(
7085 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
7086 "v2 self-custody commit",
7087 )?;
7088 }
7089 let refreshed = v2_verified_head(cfg, requested_brain)?
7090 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
7091 if candidate_hub_signer
7092 .as_ref()
7093 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
7094 {
7095 return Err(invalid_feed(
7096 "self-custody actor signer differs from the committed hub pointer signer",
7097 ));
7098 }
7099 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
7100 if refreshed
7101 .pointer
7102 .as_ref()
7103 .map(|pointer| pointer.commit_hash.as_str())
7104 != accepted_hash
7105 {
7106 return Err(LinkError::RemoteAdvancedDuringSync);
7107 }
7108 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
7109 let rebased = result
7110 .get("rebased")
7111 .and_then(Value::as_bool)
7112 .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
7113 let (refreshed_files, refreshed_assets) = if rebased {
7114 (
7115 files_for_v2_view(
7116 &refreshed,
7117 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
7118 ),
7119 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
7120 )
7121 } else {
7122 let asset_changed = apply_generated_v2_operations(
7123 &operations,
7124 &local_assets,
7125 &mut remote,
7126 &mut remote_assets,
7127 )?;
7128 let assets = if asset_changed {
7129 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
7132 } else {
7133 remote_assets
7134 };
7135 (remote, assets)
7136 };
7137 let mut final_local = v2_local_files(store)?;
7138 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
7139 let final_assets = v2_local_asset_records(store)?;
7140 let local_dirty = final_local.riding != local_view.riding
7141 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
7142 final_local.policy.keeps_home(path)
7143 })
7144 || final_assets != local_assets
7145 || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
7146 let next = v2_baseline_from_head(
7147 cfg,
7148 &refreshed,
7149 refreshed_files,
7150 refreshed_assets,
7151 Some(&final_local),
7152 Some(&checkout_pseudonym),
7153 )?;
7154 let split_count = next.remote_copy_remains.len();
7155 accept_v2_head(cfg, &refreshed)?;
7156 if !local_dirty {
7157 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
7158 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
7159 }
7160 if let Some(object) = result.as_object_mut() {
7161 object.insert(
7162 "local_policy".to_string(),
7163 json!({ "remote_copy_remains": split_count }),
7164 );
7165 object.insert(
7166 "sync_status".to_string(),
7167 Value::String(if local_dirty {
7168 "remote_committed_local_dirty".to_string()
7169 } else {
7170 "synced".to_string()
7171 }),
7172 );
7173 }
7174 Ok(result)
7175}
7176
7177pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
7180 sync_push_incremental_with_policy(cfg, brain, store, false)
7181}
7182
7183pub fn sync_push_incremental_with_policy(
7186 cfg: &HubConfig,
7187 brain: &str,
7188 store: &Store,
7189 resume_local_policy: bool,
7190) -> LinkResult<Value> {
7191 sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
7192}
7193
7194pub fn sync_push_incremental_with_options(
7197 cfg: &HubConfig,
7198 brain: &str,
7199 store: &Store,
7200 resume_local_policy: bool,
7201 bulk_confirmation: Option<&V2BulkConfirmation>,
7202) -> LinkResult<Value> {
7203 sync_push_incremental_with_controls(
7204 cfg,
7205 brain,
7206 store,
7207 resume_local_policy,
7208 bulk_confirmation,
7209 &[],
7210 None,
7211 )
7212}
7213
7214pub fn sync_push_incremental_with_controls(
7216 cfg: &HubConfig,
7217 brain: &str,
7218 store: &Store,
7219 resume_local_policy: bool,
7220 bulk_confirmation: Option<&V2BulkConfirmation>,
7221 withdrawal_paths: &[String],
7222 withdrawal_reason: Option<&str>,
7223) -> LinkResult<Value> {
7224 require_safe_ref(brain)?;
7225 if let Some(head) = v2_verified_head(cfg, brain)? {
7226 return v2_sync_push(
7227 cfg,
7228 brain,
7229 store,
7230 head,
7231 V2SyncPushOptions {
7232 resume_local_policy,
7233 bulk_confirmation,
7234 resolution: None,
7235 pulled: None,
7236 withdrawal_paths,
7237 withdrawal_reason,
7238 },
7239 );
7240 }
7241 if !withdrawal_paths.is_empty() {
7242 return Err(LinkError::InvalidPack {
7243 message: "explicit withdrawal requires a link.md v2 brain".to_string(),
7244 });
7245 }
7246 legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
7247}
7248
7249pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
7253 require_safe_ref(brain)?;
7254 Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
7255}
7256
7257#[cfg(windows)]
7258fn legacy_sync_push_incremental(
7259 _cfg: &HubConfig,
7260 _brain: &str,
7261 _store: &Store,
7262 _resume_local_policy: bool,
7263 _bulk_confirmation: Option<&V2BulkConfirmation>,
7264) -> LinkResult<Value> {
7265 Err(LinkError::UnsupportedPlatform {
7266 operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
7267 })
7268}
7269
7270#[cfg(not(windows))]
7271fn legacy_sync_push_incremental(
7272 cfg: &HubConfig,
7273 brain: &str,
7274 store: &Store,
7275 resume_local_policy: bool,
7276 bulk_confirmation: Option<&V2BulkConfirmation>,
7277) -> LinkResult<Value> {
7278 if resume_local_policy || bulk_confirmation.is_some() {
7279 return Err(LinkError::InvalidPack {
7280 message: "v2 sync options require a link.md v2 brain".to_string(),
7281 });
7282 }
7283 let files = collect_push_files(store)?;
7284 sync_push(cfg, brain, &files)
7285}
7286
7287#[derive(Debug, Clone)]
7289pub enum V2ConflictChoice {
7290 KeepLocal,
7291 TakeRemote,
7292 From(PathBuf),
7293}
7294
7295fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
7296 if !crate::ulid::is_ulid(bundle) {
7297 return Err(LinkError::InvalidPack {
7298 message: "conflict bundle must be a lowercase ULID".to_string(),
7299 });
7300 }
7301 let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
7302 let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
7303 .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
7304 if plan.v != 2
7305 || plan.class != "content_resolution_required"
7306 || plan.bundle != bundle
7307 || !crate::ulid::is_ulid(&plan.brain)
7308 || plan.files.is_empty()
7309 || plan.files.len() > 100
7310 || plan.files.iter().any(|file| {
7311 crate::linkmd_v2::normalize_path(&file.path).is_err()
7312 || [&file.base, &file.local, &file.remote]
7313 .into_iter()
7314 .any(|coordinate| {
7315 coordinate
7316 .sha256
7317 .as_deref()
7318 .is_some_and(|hash| !is_sha256(hash))
7319 || coordinate.file.as_deref().is_some_and(|name| {
7320 name.starts_with('/')
7321 || name
7322 .split('/')
7323 .any(|part| part.is_empty() || part == "." || part == "..")
7324 })
7325 })
7326 })
7327 {
7328 return Err(invalid_feed("private conflict plan failed validation"));
7329 }
7330 Ok(plan)
7331}
7332
7333pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
7338 require_hardened_filesystem("private conflict maintenance")?;
7339 if all && !prune {
7340 return Err(LinkError::InvalidPack {
7341 message: "discarding all conflict bundles requires prune=true".to_string(),
7342 });
7343 }
7344 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7345 message: format!("conflict checkout is not a valid db.md store: {error}"),
7346 })?;
7347 let _transaction = store.transaction()?;
7348 let root = Path::new(".dbmd/conflicts");
7349 let names = match store.directory_names(root) {
7350 Ok(names) => names,
7351 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
7352 Err(error) => return Err(error.into()),
7353 };
7354 let now = SystemTime::now()
7355 .duration_since(UNIX_EPOCH)
7356 .unwrap_or_default()
7357 .as_secs();
7358 let mut bundles = Vec::new();
7359 let mut pruned = 0_u64;
7360 for name in names {
7361 let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
7362 continue;
7363 };
7364 let plan_path = v2_conflict_relative(bundle, "plan.json");
7365 let plan_exists = store.regular_file_exists(&plan_path)?;
7366 let expired = if plan_exists {
7367 match load_v2_conflict_plan(&store, bundle) {
7368 Ok(plan) => plan.expires_unix < now,
7369 Err(error) if all => {
7370 let _ = error;
7371 true
7372 }
7373 Err(error) => return Err(error),
7374 }
7375 } else {
7376 true
7377 };
7378 if prune && (all || expired) {
7379 store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
7380 pruned += 1;
7381 continue;
7382 }
7383 bundles.push(json!({
7384 "bundle": bundle,
7385 "complete": plan_exists,
7386 "expired": expired,
7387 }));
7388 }
7389 Ok(json!({
7390 "v": 2,
7391 "class": "private_conflict_state",
7392 "bundles": bundles.len(),
7393 "pruned": pruned,
7394 "items": bundles,
7395 }))
7396}
7397
7398pub fn sync_resolve_conflict(
7402 cfg: &HubConfig,
7403 checkout: &Path,
7404 bundle: &str,
7405 choice: V2ConflictChoice,
7406 bulk_confirmation: Option<&V2BulkConfirmation>,
7407) -> LinkResult<Value> {
7408 require_hardened_filesystem("conflict resolution")?;
7409 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7410 message: format!("conflict checkout is not a valid db.md store: {error}"),
7411 })?;
7412 let plan = load_v2_conflict_plan(&store, bundle)?;
7413 if plan.origin != normalized_origin(&cfg.hub)? {
7414 return Err(invalid_feed(
7415 "conflict bundle belongs to another hub origin",
7416 ));
7417 }
7418 let now = SystemTime::now()
7419 .duration_since(UNIX_EPOCH)
7420 .unwrap_or_default()
7421 .as_secs();
7422 if now > plan.expires_unix {
7423 return Err(LinkError::InvalidPack {
7424 message: "conflict bundle expired; rerun sync to obtain current coordinates"
7425 .to_string(),
7426 });
7427 }
7428 let head = v2_verified_head(cfg, &plan.brain)?
7429 .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
7430 let pointer = head.pointer.as_ref();
7431 if pointer.map_or(0, |value| value.seq) != plan.remote_seq
7432 || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
7433 || pointer.and_then(|value| value.content_root.as_deref())
7434 != plan.remote_content_root.as_deref()
7435 || head.view_kind != plan.view_kind
7436 || head.view_revision != plan.view_revision
7437 {
7438 return Err(LinkError::RemoteAdvancedDuringSync);
7439 }
7440
7441 for file in &plan.files {
7443 let actual = match store.regular_file_exists(Path::new(&file.path))? {
7444 true => Some(content_sha256(&store.read_bounded(
7445 Path::new(&file.path),
7446 file.local.bytes.unwrap_or(MAX_STORE_BYTES),
7447 )?)),
7448 false => None,
7449 };
7450 if actual.as_deref() != file.local.sha256.as_deref() {
7451 return Err(LinkError::InvalidPack {
7452 message: format!(
7453 "local conflict path `{}` changed after the bundle was created",
7454 file.path
7455 ),
7456 });
7457 }
7458 }
7459
7460 let from_source = match &choice {
7461 V2ConflictChoice::From(source) => Some(source.clone()),
7462 _ => None,
7463 };
7464 let result = match choice {
7465 V2ConflictChoice::TakeRemote => {
7466 if bulk_confirmation.is_some() {
7467 return Err(LinkError::InvalidPack {
7468 message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
7469 });
7470 }
7471 let current_remote =
7475 files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
7476 let _ = v2_take_remote_selection(&plan.files, ¤t_remote)?;
7477 let selected = plan
7478 .files
7479 .iter()
7480 .map(|file| file.path.clone())
7481 .collect::<std::collections::BTreeSet<_>>();
7482 serde_json::to_value(
7483 v2_sync_pull_with_resolution(
7484 cfg,
7485 &plan.brain,
7486 head,
7487 Some(checkout),
7488 Some(&selected),
7489 )?
7490 .report,
7491 )
7492 .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
7493 }
7494 V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
7495 if let Some(source) = from_source.as_ref() {
7496 if plan.files.len() != 1 {
7497 return Err(LinkError::InvalidPack {
7498 message: "--from requires a bundle with exactly one conflict".to_string(),
7499 });
7500 }
7501 let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
7502 if std::str::from_utf8(&candidate).is_err() {
7503 return Err(LinkError::NotUtf8 {
7504 path: source.display().to_string(),
7505 });
7506 }
7507 store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
7508 }
7509 let refreshed_store =
7510 Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7511 message: format!("resolved checkout is not a valid db.md store: {error}"),
7512 })?;
7513 let mut overrides = std::collections::BTreeMap::new();
7514 for file in &plan.files {
7515 let selected_local = match refreshed_store
7516 .regular_file_exists(Path::new(&file.path))?
7517 {
7518 true => Some(content_sha256(
7519 &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
7520 )),
7521 false => None,
7522 };
7523 overrides.insert(
7524 file.path.clone(),
7525 V2ResolutionOverride {
7526 expected_remote: file.remote.sha256.clone(),
7527 selected_local,
7528 },
7529 );
7530 }
7531 v2_sync_push(
7532 cfg,
7533 &plan.brain,
7534 &refreshed_store,
7535 head,
7536 V2SyncPushOptions {
7537 resume_local_policy: true,
7538 bulk_confirmation,
7539 resolution: Some(&overrides),
7540 pulled: None,
7541 withdrawal_paths: &[],
7542 withdrawal_reason: None,
7543 },
7544 )?
7545 }
7546 };
7547
7548 if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
7549 let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7550 message: format!("resolved checkout is not a valid db.md store: {error}"),
7551 })?;
7552 installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
7553 }
7554 Ok(json!({
7555 "v": 2,
7556 "class": "auto_converged",
7557 "bundle": bundle,
7558 "receipt": result,
7559 }))
7560}
7561
7562pub fn sync_converge(
7573 cfg: &HubConfig,
7574 brain: &str,
7575 checkout: &Path,
7576 resume_local_policy: bool,
7577) -> LinkResult<Value> {
7578 sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
7579}
7580
7581pub fn sync_converge_with_options(
7583 cfg: &HubConfig,
7584 brain: &str,
7585 checkout: &Path,
7586 resume_local_policy: bool,
7587 bulk_confirmation: Option<&V2BulkConfirmation>,
7588) -> LinkResult<Value> {
7589 sync_converge_with_controls(
7590 cfg,
7591 brain,
7592 checkout,
7593 resume_local_policy,
7594 bulk_confirmation,
7595 &[],
7596 None,
7597 )
7598}
7599
7600pub fn sync_converge_with_controls(
7602 cfg: &HubConfig,
7603 brain: &str,
7604 checkout: &Path,
7605 resume_local_policy: bool,
7606 bulk_confirmation: Option<&V2BulkConfirmation>,
7607 withdrawal_paths: &[String],
7608 withdrawal_reason: Option<&str>,
7609) -> LinkResult<Value> {
7610 require_hardened_filesystem("bidirectional sync")?;
7611 require_safe_ref(brain)?;
7612 let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
7613 message:
7614 "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
7615 .to_string(),
7616 })?;
7617 let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
7618 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7619 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
7620 })?;
7621 let _transaction = store.transaction()?;
7622 let pulled_report = pulled.report.clone();
7623 let pulled_head = pulled.head.clone();
7624 let mut result = v2_sync_push(
7625 cfg,
7626 brain,
7627 &store,
7628 pulled_head,
7629 V2SyncPushOptions {
7630 resume_local_policy,
7631 bulk_confirmation,
7632 resolution: None,
7633 pulled: Some(pulled),
7634 withdrawal_paths,
7635 withdrawal_reason,
7636 },
7637 )?;
7638 if let Some(object) = result.as_object_mut() {
7639 object.insert("pulled_files".to_string(), json!(pulled_report.files));
7640 object.insert("checkout".to_string(), Value::String(pulled_report.dest));
7641 object.insert(
7642 "mode".to_string(),
7643 Value::String("bidirectional".to_string()),
7644 );
7645 }
7646 Ok(result)
7647}
7648
7649pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7655 require_hardened_filesystem("sync pull")?;
7656 require_safe_ref(brain)?;
7657 if let Some(head) = v2_verified_head(cfg, brain)? {
7658 return v2_sync_pull(cfg, brain, head, out);
7659 }
7660 legacy_sync_pull(cfg, brain, out)
7661}
7662
7663#[cfg(windows)]
7664fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
7665 Err(LinkError::UnsupportedPlatform {
7666 operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
7667 })
7668}
7669
7670#[cfg(not(windows))]
7671fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7672 let remote = verified_remote_head(cfg, brain, false)?;
7673 if !remote.head.verified {
7674 return Err(invalid_feed(
7675 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
7676 ));
7677 }
7678 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
7679 let path = format!(
7680 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
7681 remote.head.seq
7682 );
7683 let body = ensure_ok(
7684 request(cfg, "GET", &path, None, Auth::Required)?,
7685 "sync pull",
7686 )?;
7687 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
7688 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
7689 {
7690 return Err(invalid_feed(
7691 "export response is not bound to the verified snapshot token",
7692 ));
7693 }
7694
7695 let remote_slug = body
7696 .get("slug")
7697 .and_then(Value::as_str)
7698 .filter(|slug| is_safe_slug(slug));
7699 let slug = remote_slug
7700 .or_else(|| is_safe_slug(brain).then_some(brain))
7701 .unwrap_or("brain")
7702 .to_string();
7703 let brain_id = body
7704 .get("brain")
7705 .and_then(Value::as_str)
7706 .unwrap_or(&remote.head.brain)
7707 .to_string();
7708 if brain_id != remote.head.brain {
7709 return Err(invalid_feed(
7710 "export response names a different brain than the verified head",
7711 ));
7712 }
7713 let head_seq = remote.head.seq;
7714 let dest: PathBuf = match out {
7715 Some(p) => p.to_path_buf(),
7716 None => PathBuf::from(&slug),
7717 };
7718 let entries = if head_seq == 0 {
7719 let files = body
7720 .get("files")
7721 .and_then(Value::as_array)
7722 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
7723 if !files.is_empty() || body.get("url").is_some() {
7724 return Err(invalid_feed(
7725 "empty signed feed cannot authorize non-empty exported content",
7726 ));
7727 }
7728 Vec::new()
7729 } else {
7730 let signed_head = remote
7731 .head_entry
7732 .as_ref()
7733 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
7734 let expected = &signed_head.entry.pack_sha256;
7735 if !is_sha256(expected) {
7736 return Err(invalid_feed(
7737 "signed head carries an invalid snapshot pack digest",
7738 ));
7739 }
7740 if let Some(url) = body.get("url").and_then(Value::as_str) {
7741 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
7742 return Err(invalid_feed(
7743 "export pack digest does not match the signed head entry",
7744 ));
7745 }
7746 let bytes = get_presigned(cfg, url)?;
7747 let actual = format!("{:x}", Sha256::digest(&bytes));
7748 if actual != *expected {
7749 return Err(LinkError::InvalidPack {
7750 message: "downloaded pack does not match the signed snapshot digest"
7751 .to_string(),
7752 });
7753 }
7754 let entries = parse_store_pack(bytes)?;
7755 if signed_head.entry.kind == "push" {
7756 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7757 }
7758 entries
7759 } else {
7760 if signed_head.entry.kind != "push" {
7761 return Err(invalid_feed(
7762 "delta snapshots must export the exact signed pack",
7763 ));
7764 }
7765 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
7766 invalid_feed("verified snapshot export carried neither a pack nor files")
7767 })?;
7768 let mut entries = Vec::with_capacity(files.len());
7769 for file in files {
7770 let path = file
7771 .get("path")
7772 .and_then(Value::as_str)
7773 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
7774 let content = file
7775 .get("content")
7776 .and_then(Value::as_str)
7777 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
7778 entries.push((path.to_string(), content.as_bytes().to_vec()));
7779 }
7780 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7781 entries
7782 }
7783 };
7784
7785 let mut seen = std::collections::HashSet::new();
7787 for (path, _) in &entries {
7788 if !safe_store_rel_path(path) {
7789 return Err(LinkError::UnsafePath { path: path.clone() });
7790 }
7791 if !seen.insert(path) {
7792 return Err(LinkError::InvalidPack {
7793 message: format!("duplicate path `{path}`"),
7794 });
7795 }
7796 }
7797 let pulled: std::collections::BTreeSet<&str> =
7800 entries.iter().map(|(p, _)| p.as_str()).collect();
7801 let mut extra_local = Vec::new();
7802 if let Ok(store) = Store::open(&dest) {
7803 if let Ok(walked) = store.walk() {
7804 for rel in walked {
7805 let rel_str = rel.to_string_lossy().replace('\\', "/");
7806 if !pulled.contains(rel_str.as_str()) {
7807 extra_local.push(rel_str);
7808 }
7809 }
7810 }
7811 }
7812 #[cfg(unix)]
7813 install_pulled_snapshot(&dest, &entries)?;
7814
7815 Ok(PullReport {
7816 brain: brain_id,
7817 slug,
7818 head_seq,
7819 files: entries.len(),
7820 dest: dest.to_string_lossy().into_owned(),
7821 extra_local,
7822 sync_status: "synced".to_string(),
7823 })
7824}
7825
7826#[cfg(unix)]
7827fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
7828 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
7829 path: display.to_string(),
7830 })
7831}
7832
7833#[cfg(unix)]
7834fn open_dir_at(
7835 parent: std::os::fd::RawFd,
7836 name: &std::ffi::CStr,
7837 display: &str,
7838) -> LinkResult<std::fs::File> {
7839 use std::os::fd::FromRawFd as _;
7840 let fd = unsafe {
7841 libc::openat(
7842 parent,
7843 name.as_ptr(),
7844 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7845 )
7846 };
7847 if fd < 0 {
7848 return Err(LinkError::UnsafePath {
7849 path: display.to_string(),
7850 });
7851 }
7852 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
7853}
7854
7855#[cfg(unix)]
7859fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
7860 use std::os::fd::AsRawFd as _;
7861
7862 #[cfg(target_os = "macos")]
7866 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
7867 .into_iter()
7868 .find_map(|(alias, real)| {
7869 path.strip_prefix(alias)
7870 .ok()
7871 .map(|rest| Path::new(real).join(rest))
7872 })
7873 .unwrap_or_else(|| path.to_path_buf());
7874 #[cfg(not(target_os = "macos"))]
7875 let normalized = path.to_path_buf();
7876
7877 let start = if normalized.is_absolute() {
7878 std::fs::File::open("/")?
7879 } else {
7880 std::fs::File::open(".")?
7881 };
7882 let mut directory = start;
7883 for component in normalized.components() {
7884 use std::path::Component;
7885 let name = match component {
7886 Component::RootDir | Component::CurDir => continue,
7887 Component::Normal(name) => name,
7888 Component::ParentDir | Component::Prefix(_) => {
7889 return Err(LinkError::UnsafePath {
7890 path: path.display().to_string(),
7891 });
7892 }
7893 };
7894 use std::os::unix::ffi::OsStrExt as _;
7895 let name = c_name(name.as_bytes(), &path.display().to_string())?;
7896 if create {
7897 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
7898 if made != 0 {
7899 let error = std::io::Error::last_os_error();
7900 if error.raw_os_error() != Some(libc::EEXIST) {
7901 return Err(error.into());
7902 }
7903 }
7904 }
7905 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
7906 }
7907 Ok(directory)
7908}
7909
7910#[cfg(unix)]
7911fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
7912 open_dir_path_nofollow(path, true)
7913}
7914
7915#[cfg(unix)]
7916fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
7917 open_dir_path_nofollow(path, false)
7918}
7919
7920#[cfg(unix)]
7921fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
7922 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
7923 let result =
7924 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
7925 if result == 0 {
7926 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
7927 }
7928 let error = std::io::Error::last_os_error();
7929 if error.kind() == std::io::ErrorKind::NotFound {
7930 Ok(None)
7931 } else {
7932 Err(error.into())
7933 }
7934}
7935
7936#[cfg(unix)]
7937fn create_dir_exclusive_at(
7938 parent: std::os::fd::RawFd,
7939 name: &std::ffi::CStr,
7940 display: &str,
7941) -> LinkResult<std::fs::File> {
7942 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
7943 if made != 0 {
7944 return Err(LinkError::UnsafePath {
7945 path: display.to_string(),
7946 });
7947 }
7948 open_dir_at(parent, name, display)
7949}
7950
7951#[cfg(unix)]
7952fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
7953 use std::os::fd::AsRawFd as _;
7954
7955 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
7956 if duplicate < 0 {
7957 return Err(std::io::Error::last_os_error().into());
7958 }
7959 let stream = unsafe { libc::fdopendir(duplicate) };
7960 if stream.is_null() {
7961 let error = std::io::Error::last_os_error();
7962 unsafe {
7963 libc::close(duplicate);
7964 }
7965 return Err(error.into());
7966 }
7967 let mut names = Vec::new();
7968 loop {
7969 let entry = unsafe { libc::readdir(stream) };
7970 if entry.is_null() {
7971 break;
7972 }
7973 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
7974 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
7975 names.push(raw.to_owned());
7976 }
7977 }
7978 if unsafe { libc::closedir(stream) } != 0 {
7979 return Err(std::io::Error::last_os_error().into());
7980 }
7981 Ok(names)
7982}
7983
7984#[cfg(unix)]
7987fn remove_tree_at(
7988 parent: std::os::fd::RawFd,
7989 name: &std::ffi::CStr,
7990 display: &str,
7991) -> LinkResult<()> {
7992 use std::os::fd::AsRawFd as _;
7993
7994 match entry_is_dir_at(parent, name)? {
7995 None => return Ok(()),
7996 Some(false) => {
7997 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
7998 return Err(std::io::Error::last_os_error().into());
7999 }
8000 }
8001 Some(true) => {
8002 let directory = open_dir_at(parent, name, display)?;
8003 for child in directory_entry_names(&directory)? {
8004 let child_display =
8005 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
8006 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
8007 }
8008 drop(directory);
8009 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
8010 return Err(std::io::Error::last_os_error().into());
8011 }
8012 }
8013 }
8014 Ok(())
8015}
8016
8017#[cfg(unix)]
8021fn clone_tree_contents(
8022 source: &std::fs::File,
8023 destination: &std::fs::File,
8024 display: &str,
8025) -> LinkResult<()> {
8026 use std::os::fd::{AsRawFd as _, FromRawFd as _};
8027
8028 for name in directory_entry_names(source)? {
8029 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
8030 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
8031 if unsafe {
8032 libc::fstatat(
8033 source.as_raw_fd(),
8034 name.as_ptr(),
8035 &mut stat,
8036 libc::AT_SYMLINK_NOFOLLOW,
8037 )
8038 } != 0
8039 {
8040 return Err(std::io::Error::last_os_error().into());
8041 }
8042 match stat.st_mode & libc::S_IFMT {
8043 libc::S_IFDIR => {
8044 if unsafe {
8045 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
8046 } != 0
8047 {
8048 return Err(std::io::Error::last_os_error().into());
8049 }
8050 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
8051 let destination_child =
8052 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
8053 clone_tree_contents(&source_child, &destination_child, &child_display)?;
8054 destination_child.sync_all()?;
8055 }
8056 libc::S_IFREG => {
8057 let source_fd = unsafe {
8058 libc::openat(
8059 source.as_raw_fd(),
8060 name.as_ptr(),
8061 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8062 )
8063 };
8064 if source_fd < 0 {
8065 return Err(std::io::Error::last_os_error().into());
8066 }
8067 let destination_fd = unsafe {
8068 libc::openat(
8069 destination.as_raw_fd(),
8070 name.as_ptr(),
8071 libc::O_WRONLY
8072 | libc::O_CREAT
8073 | libc::O_EXCL
8074 | libc::O_CLOEXEC
8075 | libc::O_NOFOLLOW,
8076 (stat.st_mode & 0o777) as libc::c_uint,
8077 )
8078 };
8079 if destination_fd < 0 {
8080 unsafe {
8081 libc::close(source_fd);
8082 }
8083 return Err(std::io::Error::last_os_error().into());
8084 }
8085 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
8086 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
8087 std::io::copy(&mut input, &mut output)?;
8088 output.sync_all()?;
8089 }
8090 libc::S_IFLNK => {
8091 let mut target = vec![0_u8; 4097];
8092 let length = unsafe {
8093 libc::readlinkat(
8094 source.as_raw_fd(),
8095 name.as_ptr(),
8096 target.as_mut_ptr().cast(),
8097 target.len(),
8098 )
8099 };
8100 if length < 0 || length as usize >= target.len() {
8101 return Err(LinkError::UnsafePath {
8102 path: child_display,
8103 });
8104 }
8105 target.truncate(length as usize);
8106 let target = c_name(&target, &child_display)?;
8107 if unsafe {
8108 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
8109 } != 0
8110 {
8111 return Err(std::io::Error::last_os_error().into());
8112 }
8113 }
8114 _ => {
8115 return Err(LinkError::UnsafePath {
8116 path: child_display,
8117 });
8118 }
8119 }
8120 }
8121 destination.sync_all()?;
8122 Ok(())
8123}
8124
8125#[cfg(target_os = "linux")]
8126fn install_stage_at(
8127 parent: std::os::fd::RawFd,
8128 stage: &std::ffi::CStr,
8129 dest: &std::ffi::CStr,
8130 dest_exists: bool,
8131) -> LinkResult<()> {
8132 let flags = if dest_exists {
8133 libc::RENAME_EXCHANGE
8134 } else {
8135 libc::RENAME_NOREPLACE
8136 };
8137 let result = unsafe {
8141 libc::syscall(
8142 libc::SYS_renameat2,
8143 parent,
8144 stage.as_ptr(),
8145 parent,
8146 dest.as_ptr(),
8147 flags,
8148 )
8149 };
8150 if result == 0 {
8151 Ok(())
8152 } else {
8153 Err(std::io::Error::last_os_error().into())
8154 }
8155}
8156
8157#[cfg(target_os = "macos")]
8158fn install_stage_at(
8159 parent: std::os::fd::RawFd,
8160 stage: &std::ffi::CStr,
8161 dest: &std::ffi::CStr,
8162 dest_exists: bool,
8163) -> LinkResult<()> {
8164 let flags = if dest_exists {
8165 libc::RENAME_SWAP
8166 } else {
8167 libc::RENAME_EXCL
8168 };
8169 let result =
8170 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
8171 if result == 0 {
8172 Ok(())
8173 } else {
8174 Err(std::io::Error::last_os_error().into())
8175 }
8176}
8177
8178#[cfg(unix)]
8179fn write_pull_entries_beneath_dir(
8180 root: &std::fs::File,
8181 entries: &[(String, Vec<u8>)],
8182) -> LinkResult<()> {
8183 use std::os::fd::{AsRawFd as _, FromRawFd as _};
8184
8185 for (path, content) in entries {
8186 let components: Vec<&str> = path.split('/').collect();
8187 let (leaf, parents) = components
8188 .split_last()
8189 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8190 let mut directory = root.try_clone()?;
8191 for component in parents {
8192 let name = c_name(component.as_bytes(), path)?;
8193 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8194 if made != 0 {
8195 let error = std::io::Error::last_os_error();
8196 if error.raw_os_error() != Some(libc::EEXIST) {
8197 return Err(error.into());
8198 }
8199 }
8200 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8201 }
8202
8203 let leaf_name = c_name(leaf.as_bytes(), path)?;
8204 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
8205 let inspected = unsafe {
8206 libc::fstatat(
8207 directory.as_raw_fd(),
8208 leaf_name.as_ptr(),
8209 &mut existing,
8210 libc::AT_SYMLINK_NOFOLLOW,
8211 )
8212 };
8213 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
8214 return Err(LinkError::UnsafePath { path: path.clone() });
8215 }
8216
8217 let nonce = std::time::SystemTime::now()
8218 .duration_since(std::time::UNIX_EPOCH)
8219 .unwrap_or_default()
8220 .as_nanos();
8221 let temp_name = format!(
8222 ".dbmd-pull-{}-{nonce}-{}",
8223 std::process::id(),
8224 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
8225 );
8226 let temp = c_name(temp_name.as_bytes(), path)?;
8227 let fd = unsafe {
8228 libc::openat(
8229 directory.as_raw_fd(),
8230 temp.as_ptr(),
8231 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8232 0o600,
8233 )
8234 };
8235 if fd < 0 {
8236 return Err(std::io::Error::last_os_error().into());
8237 }
8238 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
8239 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
8240 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8241 return Err(error.into());
8242 }
8243 drop(file);
8244 let renamed = unsafe {
8245 libc::renameat(
8246 directory.as_raw_fd(),
8247 temp.as_ptr(),
8248 directory.as_raw_fd(),
8249 leaf_name.as_ptr(),
8250 )
8251 };
8252 if renamed != 0 {
8253 let error = std::io::Error::last_os_error();
8254 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8255 return Err(error.into());
8256 }
8257 directory.sync_all()?;
8258 }
8259 root.sync_all()?;
8260 Ok(())
8261}
8262
8263#[cfg(unix)]
8264fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
8265 use std::os::fd::{AsRawFd as _, FromRawFd as _};
8266
8267 let path = &entry.path;
8268 let components: Vec<&str> = path.split('/').collect();
8269 let (leaf, parents) = components
8270 .split_last()
8271 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8272 let mut directory = root.try_clone()?;
8273 for component in parents {
8274 let name = c_name(component.as_bytes(), path)?;
8275 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8276 if made != 0 {
8277 let error = std::io::Error::last_os_error();
8278 if error.raw_os_error() != Some(libc::EEXIST) {
8279 return Err(error.into());
8280 }
8281 }
8282 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8283 }
8284 let leaf_name = c_name(leaf.as_bytes(), path)?;
8285 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
8286 if unsafe {
8287 libc::fstatat(
8288 directory.as_raw_fd(),
8289 leaf_name.as_ptr(),
8290 &mut existing,
8291 libc::AT_SYMLINK_NOFOLLOW,
8292 )
8293 } == 0
8294 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
8295 {
8296 return Err(LinkError::UnsafePath { path: path.clone() });
8297 }
8298 let nonce = SystemTime::now()
8299 .duration_since(UNIX_EPOCH)
8300 .unwrap_or_default()
8301 .as_nanos();
8302 let temp_name = format!(
8303 ".dbmd-pull-{}-{nonce}-{}",
8304 std::process::id(),
8305 content_sha256(path.as_bytes())
8306 );
8307 let temp = c_name(temp_name.as_bytes(), path)?;
8308 let fd = unsafe {
8309 libc::openat(
8310 directory.as_raw_fd(),
8311 temp.as_ptr(),
8312 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8313 0o600,
8314 )
8315 };
8316 if fd < 0 {
8317 return Err(std::io::Error::last_os_error().into());
8318 }
8319 let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
8320 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
8321 let mut digest = Sha256::new();
8322 let mut total = 0_u64;
8323 let mut buffer = [0_u8; 64 * 1024];
8324 let copied = (|| -> std::io::Result<()> {
8325 loop {
8326 let read = input.read(&mut buffer)?;
8327 if read == 0 {
8328 break;
8329 }
8330 total = total.saturating_add(read as u64);
8331 if total > entry.bytes {
8332 return Err(std::io::Error::new(
8333 std::io::ErrorKind::InvalidData,
8334 "staged sync source grew beyond its verified length",
8335 ));
8336 }
8337 digest.update(&buffer[..read]);
8338 output.write_all(&buffer[..read])?;
8339 }
8340 Ok(())
8341 })();
8342 if let Err(error) = copied {
8343 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8344 return Err(error.into());
8345 }
8346 drop(output);
8347 if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
8348 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8349 return Err(invalid_feed(
8350 "private staged sync source failed final integrity verification",
8351 ));
8352 }
8353 if unsafe {
8354 libc::renameat(
8355 directory.as_raw_fd(),
8356 temp.as_ptr(),
8357 directory.as_raw_fd(),
8358 leaf_name.as_ptr(),
8359 )
8360 } != 0
8361 {
8362 let error = std::io::Error::last_os_error();
8363 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8364 return Err(error.into());
8365 }
8366 Ok(())
8367}
8368
8369#[cfg(unix)]
8370fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
8371 use std::os::fd::{AsRawFd as _, FromRawFd as _};
8372
8373 let path = &entry.path;
8374 let components: Vec<&str> = path.split('/').collect();
8375 let (leaf, parents) = components
8376 .split_last()
8377 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8378 let mut directory = root.try_clone()?;
8379 for component in parents {
8380 directory = open_dir_at(
8381 directory.as_raw_fd(),
8382 &c_name(component.as_bytes(), path)?,
8383 path,
8384 )?;
8385 }
8386 let leaf = c_name(leaf.as_bytes(), path)?;
8387 let fd = unsafe {
8388 libc::openat(
8389 directory.as_raw_fd(),
8390 leaf.as_ptr(),
8391 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8392 )
8393 };
8394 if fd < 0 {
8395 return Err(std::io::Error::last_os_error().into());
8396 }
8397 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
8398 if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
8399 return Err(invalid_feed(
8400 "private pull stage changed before its durability barrier",
8401 ));
8402 }
8403 file.sync_all()?;
8404 Ok(())
8405}
8406
8407#[cfg(unix)]
8408fn run_pull_source_workers(
8409 root: &std::fs::File,
8410 entries: &[V2StagedFile],
8411 operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
8412) -> LinkResult<()> {
8413 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
8414
8415 let next = AtomicUsize::new(0);
8416 let failed = AtomicBool::new(false);
8417 let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
8418 let mut first_error = None;
8419 std::thread::scope(|scope| {
8420 let (sender, receiver) = std::sync::mpsc::channel();
8421 for _ in 0..worker_count {
8422 let sender = sender.clone();
8423 let next = &next;
8424 let failed = &failed;
8425 scope.spawn(move || {
8426 while !failed.load(Ordering::Acquire) {
8427 let index = next.fetch_add(1, Ordering::Relaxed);
8428 let Some(entry) = entries.get(index) else {
8429 break;
8430 };
8431 let result = operation(root, entry);
8432 if result.is_err() {
8433 failed.store(true, Ordering::Release);
8434 }
8435 if sender.send(result).is_err() {
8436 break;
8437 }
8438 }
8439 });
8440 }
8441 drop(sender);
8442 for result in receiver {
8443 if let Err(error) = result {
8444 if first_error.is_none() {
8445 first_error = Some(error);
8446 }
8447 }
8448 }
8449 });
8450 if let Some(error) = first_error {
8451 return Err(error);
8452 }
8453 if next.load(Ordering::Relaxed) < entries.len() {
8454 return Err(invalid_feed(
8455 "a bounded pull worker stopped before reporting every file",
8456 ));
8457 }
8458 Ok(())
8459}
8460
8461#[cfg(unix)]
8462fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
8463 use std::os::fd::AsRawFd as _;
8464
8465 for name in directory_entry_names(root)? {
8466 if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
8467 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
8468 let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
8469 sync_pull_directory_tree(&child, &child_display)?;
8470 }
8471 }
8472 root.sync_all()?;
8473 Ok(())
8474}
8475
8476#[cfg(unix)]
8477fn write_pull_sources_beneath_dir(
8478 root: &std::fs::File,
8479 entries: &[V2StagedFile],
8480) -> LinkResult<()> {
8481 run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
8488 run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
8489 sync_pull_directory_tree(root, "v2 pull stage")
8490}
8491
8492#[cfg(unix)]
8493fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
8494 use std::os::fd::AsRawFd as _;
8495 for path in paths {
8496 if !safe_store_rel_path(path) {
8497 return Err(LinkError::UnsafePath { path: path.clone() });
8498 }
8499 let components = path.split('/').collect::<Vec<_>>();
8500 let Some((leaf, parents)) = components.split_last() else {
8501 return Err(LinkError::UnsafePath { path: path.clone() });
8502 };
8503 let mut directory = root.try_clone()?;
8504 let mut missing = false;
8505 for component in parents {
8506 let name = c_name(component.as_bytes(), path)?;
8507 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
8508 None => {
8509 missing = true;
8510 break;
8511 }
8512 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
8513 Some(true) => {
8514 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8515 }
8516 }
8517 }
8518 if missing {
8519 continue;
8520 }
8521 let leaf = c_name(leaf.as_bytes(), path)?;
8522 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
8523 None => {}
8524 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
8525 Some(false) => {
8526 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
8527 return Err(std::io::Error::last_os_error().into());
8528 }
8529 directory.sync_all()?;
8530 }
8531 }
8532 }
8533 Ok(())
8534}
8535
8536#[cfg(unix)]
8537fn install_pulled_delta(
8538 dest: &Path,
8539 entries: &[(String, Vec<u8>)],
8540 deleted: &[String],
8541 rebuild_indexes: bool,
8542) -> LinkResult<()> {
8543 use ring::rand::SecureRandom as _;
8544 use std::os::fd::AsRawFd as _;
8545 use std::os::unix::ffi::OsStrExt as _;
8546
8547 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8548 let name = dest
8549 .file_name()
8550 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
8551 .ok_or_else(|| LinkError::UnsafePath {
8552 path: dest.display().to_string(),
8553 })?;
8554 let parent_dir = open_or_create_dir_nofollow(parent)?;
8555 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
8556 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
8557 None => false,
8558 Some(true) => true,
8559 Some(false) => {
8560 return Err(LinkError::UnsafePath {
8561 path: dest.display().to_string(),
8562 });
8563 }
8564 };
8565
8566 let mut nonce = [0_u8; 16];
8567 ring::rand::SystemRandom::new()
8568 .fill(&mut nonce)
8569 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
8570 let stage_label = format!(
8571 ".{}.dbmd-pull-stage-{}",
8572 name.to_string_lossy(),
8573 URL_SAFE_NO_PAD.encode(nonce)
8574 );
8575 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
8576 let stage_dir = create_dir_exclusive_at(
8577 parent_dir.as_raw_fd(),
8578 &stage_name,
8579 &dest.display().to_string(),
8580 )?;
8581
8582 let prepared = (|| -> LinkResult<()> {
8583 if dest_exists {
8584 let live = open_dir_at(
8585 parent_dir.as_raw_fd(),
8586 &dest_name,
8587 &dest.display().to_string(),
8588 )?;
8589 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
8590 }
8591 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
8592 write_pull_entries_beneath_dir(&stage_dir, entries)?;
8593 if rebuild_indexes {
8594 let stage_store =
8595 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
8596 .map_err(|error| LinkError::InvalidPack {
8597 message: format!("v2 staging tree is not a valid db.md store: {error}"),
8598 })?;
8599 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
8600 LinkError::InvalidPack {
8601 message: format!("could not materialize v2 local catalogs: {error}"),
8602 }
8603 })?;
8604 }
8605 stage_dir.sync_all()?;
8606 Ok(())
8607 })();
8608 if let Err(error) = prepared {
8609 let _ = remove_tree_at(
8610 parent_dir.as_raw_fd(),
8611 &stage_name,
8612 &dest.display().to_string(),
8613 );
8614 return Err(error);
8615 }
8616
8617 if let Err(error) =
8618 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
8619 {
8620 let _ = remove_tree_at(
8621 parent_dir.as_raw_fd(),
8622 &stage_name,
8623 &dest.display().to_string(),
8624 );
8625 return Err(error);
8626 }
8627 parent_dir.sync_all()?;
8628 if dest_exists {
8629 let _ = remove_tree_at(
8633 parent_dir.as_raw_fd(),
8634 &stage_name,
8635 &dest.display().to_string(),
8636 );
8637 let _ = parent_dir.sync_all();
8638 }
8639 Ok(())
8640}
8641
8642#[cfg(unix)]
8643fn install_pulled_delta_sources(
8644 dest: &Path,
8645 entries: &[V2StagedFile],
8646 deleted: &[String],
8647 rebuild_indexes: bool,
8648 _previous: Option<&V2SyncBaseline>,
8649 _next: &V2VerifiedHead,
8650) -> LinkResult<()> {
8651 use ring::rand::SecureRandom as _;
8652 use std::os::fd::AsRawFd as _;
8653 use std::os::unix::ffi::OsStrExt as _;
8654
8655 if let Ok(store) = Store::open_strict(dest) {
8659 return install_established_v2_delta(
8660 store,
8661 entries,
8662 deleted,
8663 rebuild_indexes,
8664 _previous,
8665 _next,
8666 );
8667 }
8668
8669 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8670 let name = dest
8671 .file_name()
8672 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
8673 .ok_or_else(|| LinkError::UnsafePath {
8674 path: dest.display().to_string(),
8675 })?;
8676 let parent_dir = open_or_create_dir_nofollow(parent)?;
8677 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
8678 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
8679 None => false,
8680 Some(true) => true,
8681 Some(false) => {
8682 return Err(LinkError::UnsafePath {
8683 path: dest.display().to_string(),
8684 })
8685 }
8686 };
8687 let mut nonce = [0_u8; 16];
8688 ring::rand::SystemRandom::new()
8689 .fill(&mut nonce)
8690 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
8691 let stage_label = format!(
8692 ".{}.dbmd-pull-stage-{}",
8693 name.to_string_lossy(),
8694 URL_SAFE_NO_PAD.encode(nonce)
8695 );
8696 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
8697 let stage_dir = create_dir_exclusive_at(
8698 parent_dir.as_raw_fd(),
8699 &stage_name,
8700 &dest.display().to_string(),
8701 )?;
8702 let prepared = (|| -> LinkResult<()> {
8703 if dest_exists {
8704 let live = open_dir_at(
8705 parent_dir.as_raw_fd(),
8706 &dest_name,
8707 &dest.display().to_string(),
8708 )?;
8709 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
8710 }
8711 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
8712 write_pull_sources_beneath_dir(&stage_dir, entries)?;
8713 if rebuild_indexes {
8714 let stage_store =
8715 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
8716 .map_err(|error| LinkError::InvalidPack {
8717 message: format!("v2 staging tree is not a valid db.md store: {error}"),
8718 })?;
8719 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
8720 LinkError::InvalidPack {
8721 message: format!("could not materialize v2 local catalogs: {error}"),
8722 }
8723 })?;
8724 }
8725 stage_dir.sync_all()?;
8726 Ok(())
8727 })();
8728 if let Err(error) = prepared {
8729 let _ = remove_tree_at(
8730 parent_dir.as_raw_fd(),
8731 &stage_name,
8732 &dest.display().to_string(),
8733 );
8734 return Err(error);
8735 }
8736 if let Err(error) =
8737 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
8738 {
8739 let _ = remove_tree_at(
8740 parent_dir.as_raw_fd(),
8741 &stage_name,
8742 &dest.display().to_string(),
8743 );
8744 return Err(error);
8745 }
8746 parent_dir.sync_all()?;
8747 if dest_exists {
8748 let _ = remove_tree_at(
8749 parent_dir.as_raw_fd(),
8750 &stage_name,
8751 &dest.display().to_string(),
8752 );
8753 let _ = parent_dir.sync_all();
8754 }
8755 Ok(())
8756}
8757
8758#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8759struct V2PullCoordinate {
8760 head_seq: Option<u64>,
8761 commit_hash: Option<String>,
8762 view_kind: Option<String>,
8763 view_revision: Option<String>,
8764}
8765
8766#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8767struct V2PullFileCoordinate {
8768 sha256: String,
8769 bytes: u64,
8770}
8771
8772#[derive(Debug, Clone, Deserialize, Serialize)]
8773struct V2PullJournalEntry {
8774 path: String,
8775 old: Option<V2PullFileCoordinate>,
8776 new: Option<V2PullFileCoordinate>,
8777 backup: Option<String>,
8778}
8779
8780#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8781#[serde(rename_all = "snake_case")]
8782enum V2PullPhase {
8783 Preparing,
8784 Ready,
8785}
8786
8787#[derive(Debug, Clone, Deserialize, Serialize)]
8788struct V2PullJournal {
8789 v: u8,
8790 phase: V2PullPhase,
8791 brain: String,
8792 previous: V2PullCoordinate,
8793 next: V2PullCoordinate,
8794 backup_dir: String,
8795 entries: Vec<V2PullJournalEntry>,
8796}
8797
8798const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
8799
8800fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
8801 V2PullCoordinate {
8802 head_seq: baseline.and_then(|value| value.head_seq),
8803 commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
8804 view_kind: baseline.and_then(|value| value.view_kind.clone()),
8805 view_revision: baseline.and_then(|value| value.view_revision.clone()),
8806 }
8807}
8808
8809fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
8810 V2PullCoordinate {
8811 head_seq: head.pointer.as_ref().map(|value| value.seq),
8812 commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
8813 view_kind: Some(head.view_kind.clone()),
8814 view_revision: Some(head.view_revision.clone()),
8815 }
8816}
8817
8818fn v2_pull_file_coordinate(
8819 store: &Store,
8820 path: &str,
8821 limit: u64,
8822) -> LinkResult<Option<V2PullFileCoordinate>> {
8823 let file = match store.open_regular(Path::new(path)) {
8824 Ok(file) => file,
8825 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8826 Err(error) => return Err(error.into()),
8827 };
8828 let bytes = file.metadata()?.len();
8829 if bytes > limit || bytes > MAX_STORE_BYTES {
8830 return Err(invalid_feed(
8831 "pull transaction file exceeds its declared bound",
8832 ));
8833 }
8834 Ok(Some(V2PullFileCoordinate {
8835 sha256: content_sha256_reader(file)?,
8836 bytes,
8837 }))
8838}
8839
8840fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
8841 let mut bytes = serde_json::to_vec_pretty(journal)
8842 .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
8843 bytes.push(b'\n');
8844 Ok(bytes)
8845}
8846
8847fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
8848 let backup_prefix = ".dbmd/pull-backup-";
8849 let suffix = journal
8850 .backup_dir
8851 .strip_prefix(backup_prefix)
8852 .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
8853 let mut paths = std::collections::BTreeSet::new();
8854 if journal.v != 1
8855 || !crate::ulid::is_ulid(&journal.brain)
8856 || !crate::ulid::is_ulid(suffix)
8857 || journal.entries.is_empty()
8858 || journal.entries.len() > MAX_PUSH_FILES + 4
8859 || journal.previous == journal.next
8860 {
8861 return Err(invalid_feed("v2 pull journal failed validation"));
8862 }
8863 for (index, entry) in journal.entries.iter().enumerate() {
8864 if !safe_store_rel_path(&entry.path)
8865 || entry.path == V2_PULL_JOURNAL
8866 || entry.path.starts_with(backup_prefix)
8867 || !paths.insert(entry.path.clone())
8868 || (entry.old.is_none() && entry.new.is_none())
8869 || entry
8870 .old
8871 .iter()
8872 .chain(entry.new.iter())
8873 .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
8874 || entry.backup.as_deref()
8875 != entry
8876 .old
8877 .as_ref()
8878 .map(|_| format!("{index:08x}"))
8879 .as_deref()
8880 {
8881 return Err(invalid_feed("v2 pull journal entry failed validation"));
8882 }
8883 }
8884 Ok(())
8885}
8886
8887fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
8888 #[cfg(unix)]
8889 {
8890 use std::os::unix::fs::PermissionsExt as _;
8891 match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
8892 Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
8893 return Err(invalid_feed(
8894 "v2 pull journal is accessible to group/other; set mode 0600",
8895 ));
8896 }
8897 Ok(_) => {}
8898 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8899 Err(error) => return Err(error.into()),
8900 }
8901 }
8902 let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
8903 Ok(bytes) => bytes,
8904 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8905 Err(error) => return Err(error.into()),
8906 };
8907 let journal: V2PullJournal =
8908 serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
8909 validate_v2_pull_journal(&journal)?;
8910 Ok(Some(journal))
8911}
8912
8913fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
8914 match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
8918 Ok(()) => {}
8919 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8920 Err(error) => return Err(error.into()),
8921 }
8922 match store.remove_private_tree(Path::new(&journal.backup_dir)) {
8923 Ok(()) => Ok(()),
8924 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
8925 Err(error) => Err(error.into()),
8926 }
8927}
8928
8929fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
8930 let names = match store.directory_names(Path::new(".dbmd")) {
8931 Ok(names) => names,
8932 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
8933 Err(error) => return Err(error.into()),
8934 };
8935 for name in names {
8936 let Some(name) = name.to_str() else {
8937 continue;
8938 };
8939 let Some(suffix) = name.strip_prefix("pull-backup-") else {
8940 continue;
8941 };
8942 if crate::ulid::is_ulid(suffix) {
8943 store.remove_private_tree(&Path::new(".dbmd").join(name))?;
8944 }
8945 }
8946 Ok(())
8947}
8948
8949fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
8950 for entry in &journal.entries {
8952 let limit = entry
8953 .old
8954 .as_ref()
8955 .into_iter()
8956 .chain(entry.new.iter())
8957 .map(|value| value.bytes)
8958 .max()
8959 .unwrap_or(0);
8960 let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
8961 if current != entry.old && current != entry.new {
8962 return Err(LinkError::InvalidPack {
8963 message: format!(
8964 "cannot recover interrupted pull because `{}` changed afterward",
8965 entry.path
8966 ),
8967 });
8968 }
8969 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
8970 let path = Path::new(&journal.backup_dir).join(backup);
8971 let file = store.open_regular(&path)?;
8972 if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
8973 return Err(invalid_feed("v2 pull recovery backup failed verification"));
8974 }
8975 }
8976 }
8977 for entry in journal.entries.iter().rev() {
8978 match (&entry.old, &entry.backup) {
8979 (Some(old), Some(backup)) => {
8980 let bytes =
8981 store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
8982 store.write_atomic(Path::new(&entry.path), &bytes)?;
8983 }
8984 (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
8985 store.remove_file(Path::new(&entry.path))?;
8986 }
8987 (None, None) => {}
8988 _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
8989 }
8990 }
8991 crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
8992 message: format!("could not rebuild catalogs after pull recovery: {error}"),
8993 })?;
8994 cleanup_v2_pull_journal(store, journal)
8995}
8996
8997fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
8998 let Ok(store) = Store::open_strict(dest) else {
8999 return Ok(());
9000 };
9001 if let Some(journal) = load_v2_pull_journal(&store)? {
9002 if journal.brain != brain {
9003 return Err(invalid_feed("v2 pull journal belongs to another brain"));
9004 }
9005 if journal.phase == V2PullPhase::Preparing {
9006 cleanup_v2_pull_journal(&store, &journal)?;
9007 } else {
9008 let baseline = load_v2_baseline(cfg, brain, dest)?;
9009 let current = v2_pull_baseline_coordinate(baseline.as_ref());
9010 if current == journal.next {
9011 cleanup_v2_pull_journal(&store, &journal)?;
9012 } else {
9013 if current != journal.previous {
9014 return Err(invalid_feed(
9015 "cannot recover interrupted pull because its baseline changed afterward",
9016 ));
9017 }
9018 rollback_v2_pull(&store, &journal)?;
9019 }
9020 }
9021 }
9022 prune_orphan_v2_pull_backups(&store)
9027}
9028
9029fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
9030 let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
9031 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9032 })?;
9033 if let Some(journal) = load_v2_pull_journal(&store)? {
9034 cleanup_v2_pull_journal(&store, &journal)?;
9035 }
9036 Ok(())
9037}
9038
9039#[cfg(windows)]
9040fn install_windows_initial_sources(
9041 dest: &Path,
9042 entries: &[V2StagedFile],
9043 rebuild_indexes: bool,
9044) -> LinkResult<()> {
9045 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9046 let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
9047 path: dest.display().to_string(),
9048 })?;
9049 let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
9050 if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
9051 return Err(LinkError::UnsafePath {
9052 path: dest.display().to_string(),
9053 });
9054 }
9055 let stage_name = format!(
9056 ".{}.dbmd-pull-stage-{}",
9057 name.to_string_lossy(),
9058 crate::ulid::mint()
9059 );
9060 let stage_path = parent.join(&stage_name);
9061 let stage_capability =
9062 crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
9063 let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
9064 let prepared = (|| -> LinkResult<()> {
9065 for entry in entries {
9066 let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
9067 if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
9068 return Err(invalid_feed(
9069 "private staged sync source failed final integrity verification",
9070 ));
9071 }
9072 stage.write_atomic(Path::new(&entry.path), &bytes)?;
9073 }
9074 let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
9075 .map_err(|error| LinkError::InvalidPack {
9076 message: format!("v2 staging tree is not a valid db.md store: {error}"),
9077 })?;
9078 if rebuild_indexes {
9079 crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
9080 message: format!("could not materialize v2 local catalogs: {error}"),
9081 })?;
9082 }
9083 Ok(())
9084 })();
9085 if let Err(error) = prepared {
9086 let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
9087 return Err(error);
9088 }
9089 crate::fsx::rename_directory_beneath(
9090 &parent_capability,
9091 Path::new(&stage_name),
9092 Path::new(name),
9093 )?;
9094 Ok(())
9095}
9096
9097fn install_established_v2_delta(
9098 store: Store,
9099 entries: &[V2StagedFile],
9100 deleted: &[String],
9101 rebuild_indexes: bool,
9102 previous: Option<&V2SyncBaseline>,
9103 next: &V2VerifiedHead,
9104) -> LinkResult<()> {
9105 if load_v2_pull_journal(&store)?.is_some() {
9106 return Err(invalid_feed(
9107 "an interrupted pull must be recovered before installing",
9108 ));
9109 }
9110 let mut sources = std::collections::BTreeMap::new();
9111 for entry in entries {
9112 if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
9113 return Err(invalid_feed("pull mutation repeats a path"));
9114 }
9115 }
9116 let mut paths = sources.keys().cloned().collect::<Vec<_>>();
9117 paths.extend(deleted.iter().cloned());
9118 paths.sort();
9119 paths.dedup();
9120 if paths.is_empty() {
9121 return Ok(());
9122 }
9123 let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
9124 let mut journal = V2PullJournal {
9125 v: 1,
9126 phase: V2PullPhase::Preparing,
9127 brain: next.brain_id.clone(),
9128 previous: v2_pull_baseline_coordinate(previous),
9129 next: v2_pull_head_coordinate(next),
9130 backup_dir: backup_dir.clone(),
9131 entries: Vec::with_capacity(paths.len()),
9132 };
9133 for path in &paths {
9134 let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
9135 let new = sources.get(path).map(|entry| V2PullFileCoordinate {
9136 sha256: entry.sha256.clone(),
9137 bytes: entry.bytes,
9138 });
9139 if old == new {
9140 continue;
9141 }
9142 let index = journal.entries.len();
9143 journal.entries.push(V2PullJournalEntry {
9144 path: path.clone(),
9145 backup: old.as_ref().map(|_| format!("{index:08x}")),
9146 old,
9147 new,
9148 });
9149 }
9150 if journal.entries.is_empty() {
9151 return Ok(());
9152 }
9153 let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
9154 entry
9155 .old
9156 .as_ref()
9157 .map_or(Some(total), |old| total.checked_add(old.bytes))
9158 });
9159 if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
9160 return Err(LinkError::InvalidPack {
9161 message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
9162 });
9163 }
9164 validate_v2_pull_journal(&journal)?;
9165 store.write_private_atomic_new(
9166 Path::new(V2_PULL_JOURNAL),
9167 &v2_pull_journal_bytes(&journal)?,
9168 )?;
9169 let prepared = (|| -> LinkResult<()> {
9170 store.create_private_dir_all(Path::new(&backup_dir))?;
9171 for entry in &journal.entries {
9172 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
9173 let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
9174 if content_sha256(&bytes) != old.sha256 {
9175 return Err(invalid_feed("live pull source changed during backup"));
9176 }
9177 store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
9178 }
9179 }
9180 journal.phase = V2PullPhase::Ready;
9181 store.write_private_atomic(
9182 Path::new(V2_PULL_JOURNAL),
9183 &v2_pull_journal_bytes(&journal)?,
9184 )?;
9185 Ok(())
9186 })();
9187 if let Err(error) = prepared {
9188 let cleanup = cleanup_v2_pull_journal(&store, &journal);
9189 return match cleanup {
9190 Ok(()) => Err(error),
9191 Err(cleanup) => Err(LinkError::InvalidPack {
9192 message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
9193 }),
9194 };
9195 }
9196 let installed = (|| -> LinkResult<()> {
9197 for entry in &journal.entries {
9198 if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
9199 return Err(LinkError::InvalidPack {
9200 message: format!("local path `{}` changed during pull", entry.path),
9201 });
9202 }
9203 if let Some(source) = sources.get(&entry.path) {
9204 let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
9205 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
9206 return Err(invalid_feed(
9207 "private staged sync source failed final integrity verification",
9208 ));
9209 }
9210 store.write_atomic(Path::new(&entry.path), &bytes)?;
9211 } else if entry.old.is_some() {
9212 store.remove_file(Path::new(&entry.path))?;
9213 }
9214 }
9215 if rebuild_indexes {
9216 crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
9217 message: format!("could not materialize v2 local catalogs: {error}"),
9218 })?;
9219 }
9220 Ok(())
9221 })();
9222 if let Err(error) = installed {
9223 return match rollback_v2_pull(&store, &journal) {
9224 Ok(()) => Err(error),
9225 Err(rollback) => Err(LinkError::InvalidPack {
9226 message: format!("{error}; durable pull rollback also failed: {rollback}"),
9227 }),
9228 };
9229 }
9230 Ok(())
9231}
9232
9233#[cfg(windows)]
9234fn install_pulled_delta_sources(
9235 dest: &Path,
9236 entries: &[V2StagedFile],
9237 deleted: &[String],
9238 rebuild_indexes: bool,
9239 previous: Option<&V2SyncBaseline>,
9240 next: &V2VerifiedHead,
9241) -> LinkResult<()> {
9242 match Store::open_strict(dest) {
9243 Ok(store) => {
9244 install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
9245 }
9246 Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
9247 }
9248}
9249
9250#[cfg(not(any(unix, windows)))]
9251fn install_pulled_delta_sources(
9252 _dest: &Path,
9253 _entries: &[V2StagedFile],
9254 _deleted: &[String],
9255 _rebuild_indexes: bool,
9256 _previous: Option<&V2SyncBaseline>,
9257 _next: &V2VerifiedHead,
9258) -> LinkResult<()> {
9259 Err(LinkError::UnsupportedPlatform {
9260 operation: "atomic v2 pull install",
9261 })
9262}
9263
9264#[cfg(unix)]
9265fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
9266 install_pulled_delta(dest, entries, &[], false)
9267}
9268
9269#[cfg(not(windows))]
9270fn is_safe_slug(slug: &str) -> bool {
9271 !slug.is_empty()
9272 && slug.len() <= 63
9273 && !slug.starts_with('-')
9274 && !slug.ends_with('-')
9275 && slug
9276 .bytes()
9277 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
9278}
9279
9280fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
9281 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
9282}
9283
9284fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
9285 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
9286}
9287
9288fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
9289 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
9290}
9291
9292fn preflight_zip_central_directory(
9293 bytes: &[u8],
9294 offset: usize,
9295 size: usize,
9296 count: u64,
9297) -> LinkResult<()> {
9298 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
9299 let end = offset
9300 .checked_add(size)
9301 .filter(|end| *end <= bytes.len())
9302 .ok_or_else(|| LinkError::InvalidPack {
9303 message: "ZIP central directory is out of bounds".to_string(),
9304 })?;
9305 let mut cursor = offset;
9306 for _ in 0..count {
9307 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
9308 return Err(LinkError::InvalidPack {
9309 message: "ZIP central directory entry count is inconsistent".to_string(),
9310 });
9311 }
9312 if le_u16(bytes, cursor + 34) != Some(0) {
9313 return Err(LinkError::InvalidPack {
9314 message: "multi-disk ZIP archives are not supported".to_string(),
9315 });
9316 }
9317 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
9318 total.checked_add(le_u16(bytes, cursor + at)? as usize)
9319 });
9320 cursor = cursor
9321 .checked_add(46)
9322 .and_then(|fixed| fixed.checked_add(variable?))
9323 .filter(|cursor| *cursor <= end)
9324 .ok_or_else(|| LinkError::InvalidPack {
9325 message: "ZIP central directory entry is truncated".to_string(),
9326 })?;
9327 }
9328 if cursor != end {
9329 return Err(LinkError::InvalidPack {
9330 message: "ZIP central directory size is inconsistent".to_string(),
9331 });
9332 }
9333 Ok(())
9334}
9335
9336fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
9340 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
9341 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
9342 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
9343 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
9344 let eocd = bytes[search_start..]
9345 .windows(4)
9346 .rposition(|window| window == EOCD_SIG)
9347 .map(|offset| search_start + offset)
9348 .ok_or_else(|| LinkError::InvalidPack {
9349 message: "ZIP has no end-of-central-directory record".to_string(),
9350 })?;
9351 let invalid_end = || LinkError::InvalidPack {
9352 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
9353 };
9354 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
9355 if eocd
9356 .checked_add(22)
9357 .and_then(|end| end.checked_add(comment_len))
9358 != Some(bytes.len())
9359 {
9360 return Err(invalid_end());
9364 }
9365 let disk = le_u16(bytes, eocd + 4);
9366 let central_disk = le_u16(bytes, eocd + 6);
9367 if disk != Some(0) || central_disk != Some(0) {
9368 return Err(LinkError::InvalidPack {
9369 message: "multi-disk ZIP archives are not supported".to_string(),
9370 });
9371 }
9372 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
9373 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
9374 if entries_on_disk != ordinary {
9375 return Err(LinkError::InvalidPack {
9376 message: "multi-disk ZIP archives are not supported".to_string(),
9377 });
9378 }
9379 let zip64_locator = eocd
9380 .checked_sub(20)
9381 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
9382 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
9383 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
9384 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
9385 if central_offset
9386 .checked_add(central_size)
9387 .filter(|end| *end == eocd)
9388 .is_none()
9389 {
9390 return Err(invalid_end());
9391 }
9392 (ordinary as u64, central_offset, central_size)
9393 } else {
9394 let Some(locator) = zip64_locator else {
9395 return Err(invalid_end());
9396 };
9397 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
9398 return Err(LinkError::InvalidPack {
9399 message: "multi-disk ZIP64 archives are not supported".to_string(),
9400 });
9401 }
9402 let record = le_u64(bytes, locator + 8)
9403 .and_then(|offset| usize::try_from(offset).ok())
9404 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
9405 .ok_or_else(|| LinkError::InvalidPack {
9406 message: "ZIP64 archive has an invalid end record".to_string(),
9407 })?;
9408 let record_size = le_u64(bytes, record + 4)
9409 .and_then(|size| usize::try_from(size).ok())
9410 .filter(|size| *size >= 44)
9411 .ok_or_else(invalid_end)?;
9412 if record
9413 .checked_add(12)
9414 .and_then(|end| end.checked_add(record_size))
9415 != Some(locator)
9416 || le_u32(bytes, record + 16) != Some(0)
9417 || le_u32(bytes, record + 20) != Some(0)
9418 {
9419 return Err(invalid_end());
9420 }
9421 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
9422 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
9423 let central_size = le_u64(bytes, record + 40)
9424 .and_then(|size| usize::try_from(size).ok())
9425 .ok_or_else(invalid_end)?;
9426 let central_offset = le_u64(bytes, record + 48)
9427 .and_then(|offset| usize::try_from(offset).ok())
9428 .ok_or_else(invalid_end)?;
9429 if zip64_on_disk != zip64_total
9430 || central_offset
9431 .checked_add(central_size)
9432 .filter(|end| *end == record)
9433 .is_none()
9434 {
9435 return Err(invalid_end());
9436 }
9437 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
9438 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
9439 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
9440 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
9441 {
9442 return Err(invalid_end());
9443 }
9444 (zip64_total, central_offset, central_size)
9445 };
9446 if count == 0 || count > max_entries as u64 {
9447 return Err(LinkError::InvalidPack {
9448 message: format!("invalid file count {count}"),
9449 });
9450 }
9451 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
9452 Ok(())
9453}
9454
9455fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
9456 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
9457 let mut archive =
9458 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
9459 message: format!("ZIP parse failed: {err}"),
9460 })?;
9461 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
9462 return Err(LinkError::InvalidPack {
9463 message: format!("invalid file count {}", archive.len()),
9464 });
9465 }
9466 let mut total = 0u64;
9467 let mut seen = std::collections::HashSet::new();
9468 let mut entries = Vec::with_capacity(archive.len());
9469 for index in 0..archive.len() {
9470 let mut file = archive
9471 .by_index(index)
9472 .map_err(|err| LinkError::InvalidPack {
9473 message: format!("ZIP entry failed: {err}"),
9474 })?;
9475 if file.is_dir() {
9476 continue;
9477 }
9478 let path = file.name().to_string();
9479 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
9480 return Err(LinkError::UnsafePath { path });
9481 }
9482 if file
9483 .unix_mode()
9484 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
9485 {
9486 return Err(LinkError::InvalidPack {
9487 message: format!("non-file entry `{path}`"),
9488 });
9489 }
9490 if !seen.insert(path.clone()) {
9491 return Err(LinkError::InvalidPack {
9492 message: format!("duplicate path `{path}`"),
9493 });
9494 }
9495 let remaining = MAX_STORE_BYTES.saturating_sub(total);
9496 if file.size() > remaining {
9497 return Err(LinkError::InvalidPack {
9498 message: "expanded content exceeds the 512 MB limit".to_string(),
9499 });
9500 }
9501 let mut content = Vec::new();
9502 (&mut file)
9503 .take(remaining + 1)
9504 .read_to_end(&mut content)
9505 .map_err(|err| LinkError::InvalidPack {
9506 message: format!("could not decompress `{path}`: {err}"),
9507 })?;
9508 if content.len() as u64 > remaining {
9509 return Err(LinkError::InvalidPack {
9510 message: "expanded content exceeds the 512 MB limit".to_string(),
9511 });
9512 }
9513 if content.len() as u64 != file.size() {
9514 return Err(LinkError::InvalidPack {
9515 message: format!("length mismatch for `{path}`"),
9516 });
9517 }
9518 total += content.len() as u64;
9519 entries.push((path, content));
9520 }
9521 if entries.is_empty() {
9522 return Err(LinkError::InvalidPack {
9523 message: "pack contains no files".to_string(),
9524 });
9525 }
9526 Ok(entries)
9527}
9528
9529fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
9530 let mut expected = std::collections::BTreeMap::new();
9531 for file in signed {
9532 if !safe_store_rel_path(&file.path) {
9533 return Err(LinkError::UnsafePath {
9534 path: file.path.clone(),
9535 });
9536 }
9537 if !is_sha256(&file.sha256)
9538 || expected
9539 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
9540 .is_some()
9541 {
9542 return Err(invalid_feed(
9543 "signed snapshot manifest contains an invalid or duplicate file",
9544 ));
9545 }
9546 }
9547 if expected.len() != entries.len() {
9548 return Err(invalid_feed(
9549 "downloaded pack file set differs from the signed snapshot manifest",
9550 ));
9551 }
9552 for (path, bytes) in entries {
9553 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
9554 return Err(invalid_feed(format!(
9555 "downloaded pack contains unsigned path `{path}`"
9556 )));
9557 };
9558 if *declared_bytes != bytes.len() as u64
9559 || *sha256 != format!("{:x}", Sha256::digest(bytes))
9560 {
9561 return Err(invalid_feed(format!(
9562 "downloaded file `{path}` differs from its signed manifest"
9563 )));
9564 }
9565 }
9566 Ok(())
9567}
9568
9569pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
9576 require_hardened_filesystem("sync push")?;
9577 preflight_push_ownership(store)?;
9578 let mut out: Vec<(String, String)> = Vec::new();
9579 let mut total = 0u64;
9580
9581 let mut read_text = |rel: &str| -> LinkResult<String> {
9582 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
9583 total = total
9584 .checked_add(bytes.len() as u64)
9585 .ok_or_else(|| LinkError::PushTooLarge {
9586 detail: "uncompressed byte count overflow".to_string(),
9587 })?;
9588 if total > MAX_STORE_BYTES {
9589 return Err(LinkError::PushTooLarge {
9590 detail: format!("{total} uncompressed bytes"),
9591 });
9592 }
9593 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
9594 path: rel.to_string(),
9595 })
9596 };
9597
9598 out.push(("DB.md".to_string(), read_text("DB.md")?));
9599 if store
9600 .regular_file_exists(Path::new("assets.jsonl"))
9601 .unwrap_or(false)
9602 {
9603 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
9604 }
9605
9606 for rel in store.walk()? {
9607 let rel_str = rel.to_string_lossy().replace('\\', "/");
9608 if !safe_store_rel_path(&rel_str) {
9609 return Err(LinkError::UnsafePath { path: rel_str });
9612 }
9613 let content = read_text(&rel_str)?;
9614 out.push((rel_str, content));
9615 }
9616
9617 out.sort_by(|a, b| a.0.cmp(&b.0));
9618 Ok(out)
9619}
9620
9621fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
9625 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
9626 return Err(LinkError::from(std::io::Error::new(
9627 std::io::ErrorKind::PermissionDenied,
9628 format!("cannot push: nested db.md store at {}", nested.display()),
9629 )));
9630 }
9631
9632 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
9633 return Err(LinkError::from(std::io::Error::new(
9634 std::io::ErrorKind::PermissionDenied,
9635 format!(
9636 "cannot push: {} is a symlink outside the store ownership model",
9637 symlink.display()
9638 ),
9639 )));
9640 }
9641 Ok(())
9642}
9643
9644pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
9650 require_safe_ref(brain)?;
9651 let remote = verified_remote_head(cfg, brain, false)?;
9652 if files.len() > MAX_PUSH_FILES {
9653 return Err(LinkError::PushTooLarge {
9654 detail: format!("{} files", files.len()),
9655 });
9656 }
9657 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
9658 if raw_total > MAX_STORE_BYTES {
9659 return Err(LinkError::PushTooLarge {
9660 detail: format!("{raw_total} uncompressed bytes"),
9661 });
9662 }
9663
9664 if cfg.brain_key.is_none() {
9668 let body = json!({
9669 "files": files
9670 .iter()
9671 .map(|(p, c)| json!({ "path": p, "content": c }))
9672 .collect::<Vec<_>>(),
9673 });
9674 if body.to_string().len() <= MAX_PUSH_BYTES {
9675 let path = format!("/api/hub/brains/{brain}/push");
9676 let pushed = ensure_ok(
9677 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9678 "sync push",
9679 )?;
9680 return Ok(pushed);
9681 }
9682 }
9683
9684 let pack = build_store_pack(files)?;
9685 if pack.len() as u64 > MAX_PACK_BYTES {
9686 return Err(LinkError::PushTooLarge {
9687 detail: format!("{} pack bytes", pack.len()),
9688 });
9689 }
9690 let sha256 = format!("{:x}", Sha256::digest(&pack));
9691 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
9692 if let Some(key) = &cfg.brain_key {
9693 if !remote.head.verified {
9694 return Err(invalid_feed(
9695 "self-custody push requires a fully verified, unscoped feed head",
9696 ));
9697 }
9698 let identity = remote
9699 .identity
9700 .as_ref()
9701 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
9702 let current_multikey = format!("ed25519:{}", identity.fingerprint);
9703 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
9704 return Err(invalid_feed(
9705 "configured brain key is not the verified current brain identity",
9706 ));
9707 }
9708 let next_seq = remote
9711 .head
9712 .seq
9713 .checked_add(1)
9714 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
9715 let mut manifest: Vec<WireFeedFile> = files
9716 .iter()
9717 .map(|(path, content)| WireFeedFile {
9718 path: path.clone(),
9719 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
9720 bytes: content.len() as u64,
9721 })
9722 .collect();
9723 manifest.sort_by(|a, b| a.path.cmp(&b.path));
9724 let ts = crate::now()
9725 .with_timezone(&chrono::Utc)
9726 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
9727 .to_string();
9728 let entry = self_custody_entry(
9729 key,
9730 next_seq,
9731 ts,
9732 &sha256,
9733 &manifest,
9734 remote.head.feed_hash.as_deref(),
9735 )?;
9736 meta["entry"] = Value::String(entry);
9737 }
9738 let presigned = ensure_ok(
9739 request(
9740 cfg,
9741 "POST",
9742 &format!("/api/hub/brains/{brain}/packs/presign"),
9743 Some(&meta),
9744 Auth::Required,
9745 )?,
9746 "prepare pack upload",
9747 )?;
9748 let url = presigned
9749 .get("url")
9750 .and_then(Value::as_str)
9751 .ok_or_else(|| LinkError::InvalidPack {
9752 message: "the hub returned no upload URL".to_string(),
9753 })?;
9754 put_presigned(
9755 cfg,
9756 url,
9757 presigned.get("headers").unwrap_or(&Value::Null),
9758 &pack,
9759 )?;
9760 let committed = ensure_ok(
9761 request(
9762 cfg,
9763 "POST",
9764 &format!("/api/hub/brains/{brain}/packs/commit"),
9765 Some(&meta),
9766 Auth::Required,
9767 )?,
9768 "commit pack",
9769 )?;
9770 Ok(committed)
9771}
9772
9773fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
9774 const LOCAL_HEADER: u32 = 0x0403_4b50;
9775 const CENTRAL_HEADER: u32 = 0x0201_4b50;
9776 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
9777 const VERSION_20: u16 = 20;
9778 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
9779 const UTF8_FLAG: u16 = 1 << 11;
9780 const STORED: u16 = 0;
9781 const DOS_TIME_MIDNIGHT: u16 = 0;
9782 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
9783 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
9784
9785 struct CentralEntry<'a> {
9786 name: &'a [u8],
9787 crc32: u32,
9788 size: u32,
9789 local_offset: u32,
9790 }
9791
9792 fn push_u16(out: &mut Vec<u8>, value: u16) {
9793 out.extend_from_slice(&value.to_le_bytes());
9794 }
9795
9796 fn push_u32(out: &mut Vec<u8>, value: u32) {
9797 out.extend_from_slice(&value.to_le_bytes());
9798 }
9799
9800 if files.is_empty() {
9801 return Err(LinkError::InvalidPack {
9802 message: "cannot create an empty snapshot pack".to_string(),
9803 });
9804 }
9805 if files.len() > u16::MAX as usize {
9806 return Err(LinkError::PushTooLarge {
9807 detail: format!(
9808 "{} files (canonical ZIP32 packs cap at {})",
9809 files.len(),
9810 u16::MAX
9811 ),
9812 });
9813 }
9814
9815 let mut sorted: Vec<_> = files.iter().collect();
9816 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
9817 let mut previous: Option<&str> = None;
9818 for (path, content) in &sorted {
9819 if !safe_store_rel_path(path) {
9820 return Err(LinkError::UnsafePath {
9821 path: (*path).clone(),
9822 });
9823 }
9824 if previous == Some(path.as_str()) {
9825 return Err(LinkError::InvalidPack {
9826 message: format!("duplicate path `{path}`"),
9827 });
9828 }
9829 previous = Some(path.as_str());
9830 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
9831 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9832 })?;
9833 }
9834
9835 let mut out = Vec::new();
9836 let mut central = Vec::with_capacity(sorted.len());
9837 for (path, content) in sorted {
9838 let name = path.as_bytes();
9839 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
9840 message: format!("ZIP entry name is too long: `{path}`"),
9841 })?;
9842 let bytes = content.as_bytes();
9843 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
9844 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9845 })?;
9846 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
9847 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
9848 })?;
9849 let crc32 = crc32fast::hash(bytes);
9850
9851 push_u32(&mut out, LOCAL_HEADER);
9854 push_u16(&mut out, VERSION_20);
9855 push_u16(&mut out, UTF8_FLAG);
9856 push_u16(&mut out, STORED);
9857 push_u16(&mut out, DOS_TIME_MIDNIGHT);
9858 push_u16(&mut out, DOS_DATE_1980_01_01);
9859 push_u32(&mut out, crc32);
9860 push_u32(&mut out, size);
9861 push_u32(&mut out, size);
9862 push_u16(&mut out, name_len);
9863 push_u16(&mut out, 0); out.extend_from_slice(name);
9865 out.extend_from_slice(bytes);
9866
9867 central.push(CentralEntry {
9868 name,
9869 crc32,
9870 size,
9871 local_offset,
9872 });
9873 }
9874
9875 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
9876 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
9877 })?;
9878 for entry in ¢ral {
9879 push_u32(&mut out, CENTRAL_HEADER);
9880 push_u16(&mut out, MADE_BY_UNIX_20);
9881 push_u16(&mut out, VERSION_20);
9882 push_u16(&mut out, UTF8_FLAG);
9883 push_u16(&mut out, STORED);
9884 push_u16(&mut out, DOS_TIME_MIDNIGHT);
9885 push_u16(&mut out, DOS_DATE_1980_01_01);
9886 push_u32(&mut out, entry.crc32);
9887 push_u32(&mut out, entry.size);
9888 push_u32(&mut out, entry.size);
9889 push_u16(&mut out, entry.name.len() as u16);
9890 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);
9895 push_u32(&mut out, entry.local_offset);
9896 out.extend_from_slice(entry.name);
9897 }
9898 let central_size = u32::try_from(out.len())
9899 .ok()
9900 .and_then(|end| end.checked_sub(central_offset))
9901 .ok_or_else(|| LinkError::PushTooLarge {
9902 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
9903 })?;
9904 let entry_count = central.len() as u16;
9905
9906 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
9907 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
9910 push_u16(&mut out, entry_count);
9911 push_u32(&mut out, central_size);
9912 push_u32(&mut out, central_offset);
9913 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
9916 return Err(LinkError::PushTooLarge {
9917 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
9918 });
9919 }
9920 Ok(out)
9921}
9922
9923#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9929pub enum Capability {
9930 Read,
9932 Write,
9934}
9935
9936impl Capability {
9937 pub fn as_str(self) -> &'static str {
9939 match self {
9940 Capability::Read => "read",
9941 Capability::Write => "write",
9942 }
9943 }
9944}
9945
9946pub fn grant_issue(
9952 cfg: &HubConfig,
9953 brain: &str,
9954 grantee: &str,
9955 can: Capability,
9956 scope: Option<&str>,
9957 until: Option<&str>,
9958) -> LinkResult<Value> {
9959 require_safe_ref(brain)?;
9960 let _ = verified_remote_head(cfg, brain, false)?;
9961 let is_key_grantee = URL_SAFE_NO_PAD
9966 .decode(grantee)
9967 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
9968 .unwrap_or(false);
9969 let mut body = if is_key_grantee {
9970 json!({ "keySpki": grantee, "capability": can.as_str() })
9971 } else {
9972 json!({ "email": grantee, "capability": can.as_str() })
9973 };
9974 if let Some(s) = scope {
9975 body["scopePrefix"] = json!(s);
9976 }
9977 if let Some(u) = until {
9978 body["expiresAt"] = json!(u);
9979 }
9980 let path = format!("/api/hub/brains/{brain}/grants");
9981 ensure_ok(
9982 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9983 "grant issue",
9984 )
9985}
9986
9987pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
9989 require_safe_ref(brain)?;
9990 let _ = verified_remote_head(cfg, brain, false)?;
9991 let path = format!("/api/hub/brains/{brain}/grants");
9992 ensure_ok(
9993 request(cfg, "GET", &path, None, Auth::Required)?,
9994 "grant list",
9995 )
9996}
9997
9998pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
10001 require_safe_ref(brain)?;
10002 require_safe_grant_id(grant_id)?;
10003 let _ = verified_remote_head(cfg, brain, false)?;
10004 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
10005 ensure_ok(
10006 request(cfg, "DELETE", &path, None, Auth::Required)?,
10007 "grant revoke",
10008 )
10009}
10010
10011#[derive(Debug)]
10016struct VerifiedV2Proposal {
10017 value: Value,
10018 changes: Value,
10019 blobs: Vec<(String, u64, String)>,
10020}
10021
10022fn require_proposal_id(id: &str) -> LinkResult<()> {
10023 if crate::ulid::is_ulid(id) {
10024 Ok(())
10025 } else {
10026 Err(invalid_feed("proposal id is not a lowercase ULID"))
10027 }
10028}
10029
10030fn verified_v2_proposal(
10031 cfg: &HubConfig,
10032 head: &V2VerifiedHead,
10033 proposal_id: &str,
10034) -> LinkResult<VerifiedV2Proposal> {
10035 require_proposal_id(proposal_id)?;
10036 if head.view_kind != "full" {
10037 return Err(invalid_feed(
10038 "proposal review requires a full readable view",
10039 ));
10040 }
10041 let path = format!(
10042 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
10043 head.brain_id
10044 );
10045 let value = ensure_ok(
10046 request_capped(
10047 cfg,
10048 "GET",
10049 &path,
10050 None,
10051 Auth::Required,
10052 MAX_FEED_RESPONSE_BYTES,
10053 )?,
10054 "v2 proposal",
10055 )?;
10056 verify_v2_proposal_value(head, proposal_id, value)
10057}
10058
10059fn verify_v2_proposal_value(
10060 head: &V2VerifiedHead,
10061 proposal_id: &str,
10062 value: Value,
10063) -> LinkResult<VerifiedV2Proposal> {
10064 if value.get("v").and_then(Value::as_u64) != Some(2) {
10065 return Err(invalid_feed("proposal response has an invalid version"));
10066 }
10067 let proposal = value
10068 .get("proposal")
10069 .and_then(Value::as_object)
10070 .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
10071 if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
10072 return Err(invalid_feed("proposal response changed its id"));
10073 }
10074 let payload_hash = proposal
10075 .get("payload_sha256")
10076 .and_then(Value::as_str)
10077 .filter(|hash| is_sha256(hash))
10078 .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
10079 let clear_hash = proposal
10080 .get("clear_sha256")
10081 .and_then(Value::as_str)
10082 .filter(|hash| is_sha256(hash))
10083 .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
10084 let submission_hash = proposal
10085 .get("submission_claim_sha256")
10086 .and_then(Value::as_str)
10087 .filter(|hash| is_sha256(hash))
10088 .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
10089 let submission = STANDARD
10090 .decode(
10091 proposal
10092 .get("submission_claim_base64")
10093 .and_then(Value::as_str)
10094 .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
10095 )
10096 .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
10097 let submission_value: Value = serde_json::from_slice(&submission)
10098 .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
10099 if crate::linkmd_v2::canonical_bytes(&submission_value)
10100 .map_err(|error| invalid_feed(error.to_string()))?
10101 != submission
10102 || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
10103 .map_err(|error| invalid_feed(error.to_string()))?
10104 != submission_hash
10105 {
10106 return Err(invalid_feed(
10107 "proposal submission claim is not canonical or addressed",
10108 ));
10109 }
10110 let envelope = submission_value
10111 .as_object()
10112 .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
10113 let claim = envelope
10114 .get("claim")
10115 .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
10116 let claim_object = claim
10117 .as_object()
10118 .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
10119 let actor_root = claim_object
10120 .get("actor_root")
10121 .and_then(Value::as_object)
10122 .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
10123 let public_key = envelope
10124 .get("public_key")
10125 .and_then(Value::as_str)
10126 .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
10127 let fingerprint = envelope
10128 .get("fingerprint")
10129 .and_then(Value::as_str)
10130 .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
10131 let signature = envelope
10132 .get("sig")
10133 .and_then(Value::as_str)
10134 .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
10135 let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
10136 .map_err(|error| invalid_feed(error.to_string()))?;
10137 let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
10138 let signer = format!("{fingerprint}:{public_key}");
10139 let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
10140 let grants = actor_root.get("grants").and_then(Value::as_array);
10141 let grants_are_canonical = grants.is_some_and(|items| {
10142 let mut prior: Option<&str> = None;
10143 items.iter().all(|item| {
10144 let Some(grant) = item.as_str() else {
10145 return false;
10146 };
10147 if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
10148 return false;
10149 }
10150 prior = Some(grant);
10151 true
10152 })
10153 });
10154 let optional_actor_field = |name: &str| {
10155 actor_root.get(name).is_some_and(|value| {
10156 value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
10157 })
10158 };
10159 let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
10160 if claim_object.get("v").and_then(Value::as_u64) != Some(2)
10161 || format!("{:x}", Sha256::digest(&der)) != fingerprint
10162 || head
10163 .trust
10164 .hub_signer
10165 .as_ref()
10166 .is_some_and(|known| known != &signer)
10167 || !matches!(
10168 actor_class,
10169 Some(
10170 "user"
10171 | "owned_agent"
10172 | "foreign_key"
10173 | "curation"
10174 | "inbox"
10175 | "restore"
10176 | "migration"
10177 | "operator_recovery"
10178 )
10179 )
10180 || actor_root
10181 .get("principal")
10182 .and_then(Value::as_str)
10183 .is_none_or(|value| value.is_empty())
10184 || actor_root
10185 .get("credential")
10186 .and_then(Value::as_str)
10187 .is_none_or(|value| value.is_empty())
10188 || !optional_actor_field("organization")
10189 || !optional_actor_field("role")
10190 || !grants_are_canonical
10191 || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
10192 || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
10193 || !claim_object
10194 .get("mutation_id")
10195 .and_then(Value::as_str)
10196 .is_some_and(|value| {
10197 !value.is_empty()
10198 && value.len() <= 128
10199 && value.chars().enumerate().all(|(index, char)| {
10200 char.is_ascii_alphanumeric()
10201 || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
10202 })
10203 })
10204 || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
10205 || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
10206 || !claim_object
10207 .get("control_revision")
10208 .and_then(Value::as_str)
10209 .is_some_and(is_sha256)
10210 || submitted_at.is_none_or(|value| {
10211 chrono::DateTime::parse_from_rfc3339(value).is_err()
10212 || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
10213 })
10214 || !proposal
10215 .get("state")
10216 .and_then(Value::as_str)
10217 .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
10218 || proposal
10219 .get("expires_at")
10220 .and_then(Value::as_str)
10221 .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
10222 || proposal
10223 .get("proposer")
10224 .and_then(Value::as_object)
10225 .and_then(|value| value.get("class"))
10226 .and_then(Value::as_str)
10227 != actor_class
10228 {
10229 return Err(invalid_feed(
10230 "proposal submission claim does not bind the verified proposal",
10231 ));
10232 }
10233 let changes_b64 = proposal
10234 .get("changes_base64")
10235 .and_then(Value::as_str)
10236 .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
10237 let changes_bytes = STANDARD
10238 .decode(changes_b64)
10239 .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
10240 let changes: Value = serde_json::from_slice(&changes_bytes)
10241 .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
10242 if crate::linkmd_v2::canonical_bytes(&changes)
10243 .map_err(|error| invalid_feed(error.to_string()))?
10244 != changes_bytes
10245 || changes.get("v").and_then(Value::as_u64) != Some(2)
10246 || !changes.get("operations").is_some_and(Value::is_array)
10247 {
10248 return Err(invalid_feed("proposal changeset is not canonical v2"));
10249 }
10250 let blob_values = proposal
10251 .get("blobs")
10252 .and_then(Value::as_array)
10253 .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
10254 let mut blobs = Vec::with_capacity(blob_values.len());
10255 let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
10256 let mut prior_hash: Option<String> = None;
10257 for item in blob_values {
10258 let hash = item
10259 .get("sha256")
10260 .and_then(Value::as_str)
10261 .filter(|hash| is_sha256(hash))
10262 .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
10263 let bytes = item
10264 .get("bytes")
10265 .and_then(Value::as_u64)
10266 .filter(|bytes| *bytes <= MAX_STORE_BYTES)
10267 .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
10268 if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
10269 return Err(invalid_feed(
10270 "proposal blob declarations are not unique and sorted",
10271 ));
10272 }
10273 prior_hash = Some(hash.to_string());
10274 let endpoint = item
10275 .get("endpoint")
10276 .and_then(Value::as_str)
10277 .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
10278 let expected_endpoint = format!(
10279 "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
10280 head.brain_id
10281 );
10282 if endpoint != expected_endpoint {
10283 return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
10284 }
10285 descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
10286 blobs.push((hash.to_string(), bytes, endpoint.to_string()));
10287 }
10288 let descriptor = json!({
10289 "base": proposal.get("base").cloned().unwrap_or(Value::Null),
10290 "blobs": descriptor_blobs,
10291 "changes_base64": changes_b64,
10292 "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
10293 "v": 2,
10294 });
10295 let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
10296 .map_err(|error| invalid_feed(error.to_string()))?;
10297 if content_sha256(&descriptor_bytes) != clear_hash {
10298 return Err(invalid_feed(
10299 "proposal clear payload differs from its signed submission claim",
10300 ));
10301 }
10302 Ok(VerifiedV2Proposal {
10303 value,
10304 changes,
10305 blobs,
10306 })
10307}
10308
10309pub fn proposal_list(
10310 cfg: &HubConfig,
10311 brain: &str,
10312 state: &str,
10313 after: Option<&str>,
10314 limit: usize,
10315) -> LinkResult<Value> {
10316 require_safe_ref(brain)?;
10317 if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
10318 return Err(invalid_feed("proposal state is invalid"));
10319 }
10320 if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
10321 return Err(invalid_feed("proposal cursor is invalid"));
10322 }
10323 let head = v2_verified_head(cfg, brain)?
10324 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10325 let path = format!(
10326 "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
10327 head.brain_id,
10328 limit.clamp(1, 100),
10329 after.map_or_else(String::new, |value| format!("&after={value}"))
10330 );
10331 ensure_ok(
10332 request_capped(
10333 cfg,
10334 "GET",
10335 &path,
10336 None,
10337 Auth::Required,
10338 MAX_FEED_RESPONSE_BYTES,
10339 )?,
10340 "v2 proposal list",
10341 )
10342}
10343
10344pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
10345 require_safe_ref(brain)?;
10346 let head = v2_verified_head(cfg, brain)?
10347 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10348 Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
10349}
10350
10351pub fn proposal_reject(
10352 cfg: &HubConfig,
10353 brain: &str,
10354 proposal_id: &str,
10355 mutation_id: &str,
10356 reason: &str,
10357) -> LinkResult<Value> {
10358 require_safe_ref(brain)?;
10359 require_proposal_id(proposal_id)?;
10360 let head = v2_verified_head(cfg, brain)?
10361 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10362 let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
10363 let body = json!({
10364 "mutation_id": mutation_id,
10365 "control_revision": head.control_revision,
10366 "reason": reason,
10367 });
10368 let path = format!(
10369 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
10370 head.brain_id
10371 );
10372 ensure_ok(
10373 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
10374 "v2 proposal rejection",
10375 )
10376}
10377
10378pub fn proposal_accept_exact(
10379 cfg: &HubConfig,
10380 brain: &str,
10381 proposal_id: &str,
10382 mutation_id: &str,
10383 reason: &str,
10384) -> LinkResult<Value> {
10385 require_safe_ref(brain)?;
10386 require_proposal_id(proposal_id)?;
10387 let head = v2_verified_head(cfg, brain)?
10388 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10389 let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
10390 let operations = proposal
10391 .changes
10392 .get("operations")
10393 .and_then(Value::as_array)
10394 .cloned()
10395 .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
10396 if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
10397 return Err(invalid_feed("proposal operation count is invalid"));
10398 }
10399 let mut downloaded = std::collections::BTreeMap::new();
10400 for (hash, bytes, endpoint) in &proposal.blobs {
10401 let body = ensure_raw_ok(
10402 request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
10403 "v2 proposal blob",
10404 )?;
10405 if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
10406 return Err(invalid_feed("proposal blob does not match its declaration"));
10407 }
10408 downloaded.insert(hash.clone(), body);
10409 }
10410 let remote = files_for_v2_view(
10411 &head,
10412 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
10413 );
10414 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
10415 let mut expected_candidate = remote.clone();
10416 let mut expected_candidate_assets = remote_assets;
10417 for operation in &operations {
10418 let op = operation
10419 .get("op")
10420 .and_then(Value::as_str)
10421 .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
10422 match op {
10423 "put" | "restore" => {
10424 let path = operation
10425 .get("path")
10426 .and_then(Value::as_str)
10427 .ok_or_else(|| invalid_feed("proposal write has no path"))?;
10428 crate::linkmd_v2::normalize_path(path)
10429 .map_err(|error| invalid_feed(error.to_string()))?;
10430 let hash = operation
10431 .get("blob")
10432 .and_then(Value::as_str)
10433 .filter(|hash| is_sha256(hash))
10434 .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
10435 let bytes = operation
10436 .get("bytes")
10437 .and_then(Value::as_u64)
10438 .ok_or_else(|| invalid_feed("proposal write has no size"))?;
10439 expected_candidate.insert(
10440 path.to_string(),
10441 V2BaselineFile {
10442 sha256: hash.to_string(),
10443 bytes,
10444 proof: None,
10445 },
10446 );
10447 }
10448 "delete" | "withdraw_from_hosting" => {
10449 let path = operation
10450 .get("path")
10451 .and_then(Value::as_str)
10452 .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
10453 crate::linkmd_v2::normalize_path(path)
10454 .map_err(|error| invalid_feed(error.to_string()))?;
10455 expected_candidate.remove(path);
10456 }
10457 "rename" => {
10458 let from = operation
10459 .get("from")
10460 .and_then(Value::as_str)
10461 .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
10462 let to = operation
10463 .get("to")
10464 .and_then(Value::as_str)
10465 .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
10466 crate::linkmd_v2::normalize_path(from)
10467 .and_then(|_| crate::linkmd_v2::normalize_path(to))
10468 .map_err(|error| invalid_feed(error.to_string()))?;
10469 let hash = operation
10470 .get("blob")
10471 .and_then(Value::as_str)
10472 .filter(|hash| is_sha256(hash))
10473 .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
10474 let bytes = operation
10475 .get("bytes")
10476 .and_then(Value::as_u64)
10477 .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
10478 expected_candidate.remove(from);
10479 expected_candidate.insert(
10480 to.to_string(),
10481 V2BaselineFile {
10482 sha256: hash.to_string(),
10483 bytes,
10484 proof: None,
10485 },
10486 );
10487 }
10488 "asset_delete" => {
10489 let path = operation
10490 .get("path")
10491 .and_then(Value::as_str)
10492 .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
10493 expected_candidate_assets.remove(path);
10494 }
10495 "asset_withdraw" => {
10496 let path = operation
10497 .get("path")
10498 .and_then(Value::as_str)
10499 .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
10500 let asset = expected_candidate_assets
10501 .get_mut(path)
10502 .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
10503 asset.disposition = "withheld".to_string();
10504 asset.leaf_hash.clear();
10505 }
10506 "asset_put" | "asset_resume" => {
10507 let path = operation
10508 .get("path")
10509 .and_then(Value::as_str)
10510 .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
10511 let asset = operation
10512 .get("asset")
10513 .and_then(Value::as_object)
10514 .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
10515 let blob_sha256 = asset
10516 .get("blob_sha256")
10517 .and_then(Value::as_str)
10518 .filter(|hash| is_sha256(hash))
10519 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
10520 let bytes = asset
10521 .get("bytes")
10522 .and_then(Value::as_u64)
10523 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
10524 let media_type = asset
10525 .get("media_type")
10526 .and_then(Value::as_str)
10527 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
10528 let wrappers = asset
10529 .get("wrappers")
10530 .and_then(Value::as_array)
10531 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
10532 .iter()
10533 .map(|wrapper| {
10534 wrapper
10535 .as_str()
10536 .map(str::to_string)
10537 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
10538 })
10539 .collect::<LinkResult<Vec<_>>>()?;
10540 let required = asset
10541 .get("required")
10542 .and_then(Value::as_bool)
10543 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
10544 let disposition = asset
10545 .get("disposition")
10546 .and_then(Value::as_str)
10547 .filter(|value| matches!(*value, "hosted" | "withheld"))
10548 .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
10549 expected_candidate_assets.insert(
10550 path.to_string(),
10551 V2BaselineAsset {
10552 blob_sha256: blob_sha256.to_string(),
10553 bytes,
10554 media_type: media_type.to_string(),
10555 wrappers,
10556 required,
10557 disposition: disposition.to_string(),
10558 leaf_hash: String::new(),
10559 },
10560 );
10561 }
10562 _ => return Err(invalid_feed("proposal operation kind is unsupported")),
10563 }
10564 }
10565 let base = head.pointer.as_ref().map(|pointer| {
10566 json!({
10567 "seq": pointer.seq,
10568 "commit_hash": pointer.commit_hash,
10569 "content_root": pointer.content_root,
10570 "asset_root": pointer.asset_root,
10571 })
10572 });
10573 let mut body = json!({
10574 "mutation_id": mutation_id,
10575 "base": base,
10576 "rebase": "strict",
10577 "reason": reason,
10578 "operations": operations,
10579 "blobs": downloaded
10580 .iter()
10581 .map(|(sha256, bytes)| json!({
10582 "sha256": sha256,
10583 "bytes": bytes.len(),
10584 "content_base64": STANDARD.encode(bytes),
10585 }))
10586 .collect::<Vec<_>>(),
10587 "proposal_id": proposal_id,
10588 "proposal_mode": "exact",
10589 });
10590 let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
10591 total
10592 .checked_add(bytes.len())
10593 .ok_or_else(|| LinkError::PushTooLarge {
10594 detail: "proposal changed-byte total overflow".to_string(),
10595 })
10596 })?;
10597 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
10598 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10599 for operation in &operations {
10600 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
10601 return Err(invalid_feed("proposal upload operation has no kind"));
10602 };
10603 let hash = match kind {
10604 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
10605 "asset_put" | "asset_resume" => operation
10606 .get("asset")
10607 .and_then(|asset| asset.get("blob_sha256"))
10608 .and_then(Value::as_str),
10609 _ => None,
10610 };
10611 let Some(hash) = hash else { continue };
10612 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
10613 if kind == "rename" {
10614 for field in ["from", "to"] {
10615 coordinates.insert(
10616 operation
10617 .get(field)
10618 .and_then(Value::as_str)
10619 .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
10620 .to_string(),
10621 );
10622 }
10623 } else {
10624 let path = operation
10625 .get("path")
10626 .and_then(Value::as_str)
10627 .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
10628 coordinates.insert(if kind.starts_with("asset_") {
10629 format!("assets/{path}")
10630 } else {
10631 path.to_string()
10632 });
10633 }
10634 }
10635 let declarations = downloaded
10636 .iter()
10637 .map(|(sha256, bytes)| {
10638 json!({
10639 "sha256": sha256,
10640 "bytes": bytes.len(),
10641 "coordinates": coordinates_by_hash
10642 .get(sha256)
10643 .into_iter()
10644 .flatten()
10645 .collect::<Vec<_>>(),
10646 })
10647 })
10648 .collect::<Vec<_>>();
10649 let reserved = ensure_ok(
10650 request(
10651 cfg,
10652 "POST",
10653 &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
10654 Some(&json!({ "blobs": declarations })),
10655 Auth::Required,
10656 )?,
10657 "prepare proposal blob transport",
10658 )?;
10659 let items = reserved
10660 .get("uploads")
10661 .and_then(Value::as_array)
10662 .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
10663 if items.len() != downloaded.len() {
10664 return Err(invalid_feed("proposal upload reservation changed the set"));
10665 }
10666 let mut references = Vec::with_capacity(items.len());
10667 for item in items {
10668 let hash = item
10669 .get("sha256")
10670 .and_then(Value::as_str)
10671 .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
10672 let bytes = downloaded
10673 .get(hash)
10674 .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
10675 let reservation_id = item
10676 .get("reservation_id")
10677 .and_then(Value::as_str)
10678 .filter(|id| crate::ulid::is_ulid(id))
10679 .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
10680 let expected_coordinates = coordinates_by_hash
10681 .get(hash)
10682 .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
10683 let returned_coordinates = item
10684 .get("coordinates")
10685 .and_then(Value::as_array)
10686 .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
10687 if returned_coordinates.len() != expected_coordinates.len()
10688 || returned_coordinates
10689 .iter()
10690 .zip(expected_coordinates)
10691 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
10692 {
10693 return Err(invalid_feed(
10694 "proposal upload reservation changed its coordinates",
10695 ));
10696 }
10697 match item.get("status").and_then(Value::as_str) {
10698 Some("upload") => put_presigned(
10699 cfg,
10700 item.get("url")
10701 .and_then(Value::as_str)
10702 .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
10703 item.get("headers").unwrap_or(&Value::Null),
10704 bytes,
10705 )?,
10706 Some("already_present") => {}
10707 _ => return Err(invalid_feed("proposal upload status is invalid")),
10708 }
10709 references.push(json!({
10710 "sha256": hash,
10711 "bytes": bytes.len(),
10712 "reservation_id": reservation_id,
10713 }));
10714 }
10715 body["blobs"] = Value::Array(references);
10716 }
10717 if body.to_string().len() > MAX_PUSH_BYTES {
10718 return Err(LinkError::PushTooLarge {
10719 detail: "proposal operation metadata exceeds the commit request cap".to_string(),
10720 });
10721 }
10722 let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
10723 let mut result = ensure_ok(
10724 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10725 "exact proposal acceptance",
10726 )?;
10727 let mut candidate_hub_signer = None;
10728 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
10729 let challenge = result
10730 .get("signing_challenge")
10731 .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
10732 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
10733 cfg,
10734 &head,
10735 &expected_candidate,
10736 &expected_candidate_assets,
10737 mutation_id,
10738 &body,
10739 challenge,
10740 )?;
10741 body["signing_challenge_id"] = Value::String(challenge_id);
10742 body["signature_base64url"] = Value::String(signature);
10743 candidate_hub_signer = Some(actor_signer);
10744 result = ensure_ok(
10745 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10746 "signed exact proposal acceptance",
10747 )?;
10748 }
10749 let refreshed = v2_verified_head(cfg, brain)?
10750 .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
10751 if candidate_hub_signer
10752 .as_ref()
10753 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
10754 || refreshed
10755 .pointer
10756 .as_ref()
10757 .map(|pointer| pointer.commit_hash.as_str())
10758 != result.get("commit_hash").and_then(Value::as_str)
10759 || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
10760 || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
10761 {
10762 return Err(LinkError::RemoteAdvancedDuringSync);
10763 }
10764 accept_v2_head(cfg, &refreshed)?;
10765 Ok(result)
10766}
10767
10768pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
10779 require_valid_handle(handle)?;
10780 if body.len() as u64 > MAX_PROPOSE_BYTES {
10781 return Err(LinkError::ProposeTooLarge {
10782 bytes: body.len() as u64,
10783 });
10784 }
10785 let payload = json!({ "app": app, "body": body });
10786 let (path, auth) = if crate::ulid::is_ulid(handle) {
10791 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
10792 } else {
10793 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
10794 };
10795 ensure_ok(
10796 request(cfg, "POST", &path, Some(&payload), auth)?,
10797 "propose",
10798 )
10799}
10800
10801#[derive(Debug, serde::Serialize)]
10807pub struct Head {
10808 pub brain: String,
10810 pub seq: u64,
10812 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
10814 pub updated_at: Option<String>,
10815 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
10817 pub feed_hash: Option<String>,
10818 pub verified: bool,
10821}
10822
10823struct BoundedVecVisitor<T, const MAX: usize> {
10824 label: &'static str,
10825 marker: std::marker::PhantomData<T>,
10826}
10827
10828impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
10829where
10830 T: Deserialize<'de>,
10831{
10832 type Value = Vec<T>;
10833
10834 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10835 write!(formatter, "at most {MAX} {}", self.label)
10836 }
10837
10838 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
10839 where
10840 A: serde::de::SeqAccess<'de>,
10841 {
10842 if sequence.size_hint().is_some_and(|size| size > MAX) {
10843 return Err(serde::de::Error::custom(format!(
10844 "{} exceeds the {MAX}-item limit",
10845 self.label
10846 )));
10847 }
10848 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
10849 while let Some(value) = sequence.next_element()? {
10850 if values.len() == MAX {
10851 return Err(serde::de::Error::custom(format!(
10852 "{} exceeds the {MAX}-item limit",
10853 self.label
10854 )));
10855 }
10856 values.push(value);
10857 }
10858 Ok(values)
10859 }
10860}
10861
10862fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
10863 deserializer: D,
10864 label: &'static str,
10865) -> Result<Vec<T>, D::Error>
10866where
10867 D: serde::Deserializer<'de>,
10868 T: Deserialize<'de>,
10869{
10870 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
10871 label,
10872 marker: std::marker::PhantomData,
10873 })
10874}
10875
10876fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
10877where
10878 D: serde::Deserializer<'de>,
10879{
10880 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
10881}
10882
10883fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
10884where
10885 D: serde::Deserializer<'de>,
10886{
10887 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
10888}
10889
10890fn deserialize_previous_identities<'de, D>(
10891 deserializer: D,
10892) -> Result<Vec<PreviousIdentity>, D::Error>
10893where
10894 D: serde::Deserializer<'de>,
10895{
10896 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
10897 deserializer,
10898 "previous identities",
10899 )
10900}
10901
10902fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
10903where
10904 D: serde::Deserializer<'de>,
10905{
10906 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
10907 deserializer,
10908 "rotation statements",
10909 )
10910}
10911
10912fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
10913where
10914 D: serde::Deserializer<'de>,
10915{
10916 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
10917}
10918
10919#[derive(Debug, Clone, Deserialize, Serialize)]
10920struct FeedFile {
10921 path: String,
10922 sha256: String,
10923 bytes: u64,
10924}
10925
10926#[cfg(test)]
10927#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10928enum V1DisclosureError {
10929 DuplicateFile,
10930 DuplicateRemoved,
10931 PushManifestMismatch,
10932 EditMissingChange,
10933 EditFalseFile,
10934 RemovedMismatch,
10935}
10936
10937#[cfg(test)]
10941fn verify_v1_manifest_disclosure(
10942 kind: &str,
10943 previous: &[FeedFile],
10944 resulting: &[FeedFile],
10945 files: &[FeedFile],
10946 removed: &[String],
10947) -> Result<(), V1DisclosureError> {
10948 fn as_map(
10949 files: &[FeedFile],
10950 ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
10951 let mut result = std::collections::BTreeMap::new();
10952 for file in files {
10953 if result
10954 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10955 .is_some()
10956 {
10957 return Err(V1DisclosureError::DuplicateFile);
10958 }
10959 }
10960 Ok(result)
10961 }
10962 let previous = as_map(previous)?;
10963 let resulting = as_map(resulting)?;
10964 let disclosed = as_map(files)?;
10965 let removed_set: std::collections::BTreeSet<&str> =
10966 removed.iter().map(String::as_str).collect();
10967 if removed_set.len() != removed.len() {
10968 return Err(V1DisclosureError::DuplicateRemoved);
10969 }
10970 let expected_removed: std::collections::BTreeSet<&str> = previous
10971 .keys()
10972 .copied()
10973 .filter(|path| !resulting.contains_key(path))
10974 .collect();
10975 if removed_set != expected_removed {
10976 return Err(V1DisclosureError::RemovedMismatch);
10977 }
10978 if kind == "push" {
10979 return if disclosed == resulting {
10980 Ok(())
10981 } else {
10982 Err(V1DisclosureError::PushManifestMismatch)
10983 };
10984 }
10985 if kind != "edit" {
10986 return Err(V1DisclosureError::EditFalseFile);
10987 }
10988 if disclosed
10989 .iter()
10990 .any(|(path, value)| resulting.get(path) != Some(value))
10991 {
10992 return Err(V1DisclosureError::EditFalseFile);
10993 }
10994 for (path, value) in &resulting {
10995 if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
10996 return Err(V1DisclosureError::EditMissingChange);
10997 }
10998 }
10999 Ok(())
11000}
11001
11002#[derive(Debug, Clone, Deserialize, Serialize)]
11003struct FeedEntry {
11004 v: u8,
11005 seq: u64,
11006 ts: String,
11007 brain: String,
11008 public_key: String,
11009 kind: String,
11010 op: String,
11011 pack_sha256: String,
11012 #[serde(deserialize_with = "deserialize_feed_files")]
11013 files: Vec<FeedFile>,
11014 #[serde(deserialize_with = "deserialize_removed_paths")]
11015 removed: Vec<String>,
11016 prev_entry_hash: Option<String>,
11017 sig: String,
11018}
11019
11020#[derive(Serialize)]
11021struct UnsignedFeedEntry<'a> {
11022 v: u8,
11023 seq: u64,
11024 ts: &'a str,
11025 brain: &'a str,
11026 public_key: &'a str,
11027 kind: &'a str,
11028 op: &'a str,
11029 pack_sha256: &'a str,
11030 files: &'a [FeedFile],
11031 removed: &'a [String],
11032 prev_entry_hash: &'a Option<String>,
11033}
11034
11035#[derive(Debug, Clone, Deserialize, Serialize)]
11036struct FeedItem {
11037 hash: String,
11038 entry: FeedEntry,
11039}
11040
11041#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
11042struct FeedIdentity {
11043 fingerprint: String,
11044 #[serde(rename = "publicKeySpki")]
11045 public_key_spki: String,
11046 #[serde(default, deserialize_with = "deserialize_previous_identities")]
11050 previous: Vec<PreviousIdentity>,
11051 #[serde(default, deserialize_with = "deserialize_rotations")]
11054 rotations: Vec<String>,
11055}
11056
11057#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
11058struct PreviousIdentity {
11059 fingerprint: String,
11060 #[serde(rename = "publicKeySpki")]
11061 public_key_spki: String,
11062}
11063
11064#[derive(Debug, Deserialize)]
11065struct FeedResponse {
11066 #[serde(rename = "headSeq")]
11067 head_seq: u64,
11068 #[serde(rename = "feedHash")]
11069 feed_hash: Option<String>,
11070 identity: Option<FeedIdentity>,
11071 #[serde(deserialize_with = "deserialize_feed_items")]
11072 entries: Vec<FeedItem>,
11073 #[serde(rename = "scopeLimited")]
11074 scope_limited: bool,
11075}
11076
11077#[derive(Debug, Deserialize, Serialize)]
11078#[serde(deny_unknown_fields)]
11079struct RotationStatement {
11080 v: u8,
11081 op: String,
11082 brain: String,
11083 public_key: String,
11084 new_brain: String,
11085 new_public_key: String,
11086 prior_head_seq: u64,
11087 prior_feed_hash: Option<String>,
11088 ts: String,
11089 sig: String,
11090}
11091
11092#[derive(Debug, Clone, Deserialize, Serialize)]
11093struct TrustState {
11094 v: u8,
11095 origin: String,
11096 #[serde(default)]
11100 requested: String,
11101 brain: String,
11103 #[serde(default, skip_serializing_if = "Option::is_none")]
11106 home: Option<String>,
11107 anchor: String,
11108 current: String,
11109 #[serde(rename = "headSeq")]
11110 head_seq: u64,
11111 #[serde(rename = "feedHash")]
11112 feed_hash: Option<String>,
11113 #[serde(default)]
11117 rotations: Vec<String>,
11118 #[serde(default, skip_serializing_if = "Option::is_none")]
11121 hub_signer: Option<String>,
11122 #[serde(default, skip_serializing_if = "Option::is_none")]
11125 protocol_profile: Option<String>,
11126}
11127
11128fn accepted_as_v2(state: &TrustState) -> bool {
11129 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
11130}
11131
11132fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
11133 let directory = open_trust_dir(cfg)?;
11134 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
11135 return Ok(true);
11136 }
11137 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
11138 return Ok(false);
11139 };
11140 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
11141}
11142
11143#[derive(Debug, Clone, Deserialize, Serialize)]
11144struct AliasBinding {
11145 v: u8,
11146 origin: String,
11147 requested: String,
11148 brain: String,
11149 #[serde(default, skip_serializing_if = "Option::is_none")]
11150 home: Option<String>,
11151}
11152
11153struct VerifiedRemote {
11154 head: Head,
11155 identity: Option<FeedIdentity>,
11156 head_entry: Option<FeedItem>,
11157 entries: Vec<FeedItem>,
11159 anchor: Option<String>,
11160}
11161
11162fn invalid_feed(message: impl Into<String>) -> LinkError {
11163 LinkError::InvalidFeed {
11164 message: message.into(),
11165 }
11166}
11167
11168fn is_sha256(value: &str) -> bool {
11169 value.len() == 64
11170 && value
11171 .bytes()
11172 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
11173}
11174
11175fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
11176 let der = URL_SAFE_NO_PAD
11177 .decode(public_key_spki)
11178 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
11179 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
11180 return Err(invalid_feed(
11181 "identity public key is not a valid Ed25519 SPKI",
11182 ));
11183 }
11184 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
11185}
11186
11187fn verify_identity_chain(
11191 identity: &FeedIdentity,
11192 pinned: Option<&TrustState>,
11193) -> LinkResult<String> {
11194 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
11195 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
11196 {
11197 return Err(invalid_feed(
11198 "identity rotation history exceeds the client cap",
11199 ));
11200 }
11201 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
11202 return Err(invalid_feed(
11203 "current identity fingerprint does not match its public key",
11204 ));
11205 }
11206 for previous in &identity.previous {
11207 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
11208 return Err(invalid_feed(
11209 "previous identity fingerprint does not match its public key",
11210 ));
11211 }
11212 }
11213 if identity.rotations.len() != identity.previous.len() {
11214 return Err(invalid_feed(
11215 "identity history is missing an old-key-signed rotation statement",
11216 ));
11217 }
11218
11219 let mut chain: Vec<(&str, &str)> = identity
11223 .previous
11224 .iter()
11225 .rev()
11226 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
11227 .collect();
11228 chain.push((&identity.fingerprint, &identity.public_key_spki));
11229
11230 for (index, raw) in identity.rotations.iter().enumerate() {
11231 let statement: RotationStatement = serde_json::from_str(raw)
11232 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
11233 let (old_fingerprint, old_spki) = chain[index];
11234 let (new_fingerprint, new_spki) = chain[index + 1];
11235 if statement.v != 1
11236 || statement.op != "rotate"
11237 || statement.brain != format!("ed25519:{old_fingerprint}")
11238 || statement.public_key != old_spki
11239 || statement.new_brain != format!("ed25519:{new_fingerprint}")
11240 || statement.new_public_key != new_spki
11241 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
11242 || (statement.prior_head_seq > 0
11243 && statement
11244 .prior_feed_hash
11245 .as_deref()
11246 .is_none_or(|hash| !is_sha256(hash)))
11247 {
11248 return Err(invalid_feed(
11249 "rotation statement does not connect adjacent identities",
11250 ));
11251 }
11252 let unsigned = serde_json::to_string(&UnsignedRotation {
11253 v: statement.v,
11254 op: &statement.op,
11255 brain: &statement.brain,
11256 public_key: &statement.public_key,
11257 new_brain: &statement.new_brain,
11258 new_public_key: &statement.new_public_key,
11259 prior_head_seq: statement.prior_head_seq,
11260 prior_feed_hash: statement.prior_feed_hash.as_deref(),
11261 ts: statement.ts.clone(),
11262 })
11263 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
11264 let exact = format!(
11265 "{},\"sig\":\"{}\"}}",
11266 &unsigned[..unsigned.len() - 1],
11267 statement.sig
11268 );
11269 if exact != *raw {
11270 return Err(invalid_feed(
11271 "rotation statement is not in normative serialization",
11272 ));
11273 }
11274 let der = URL_SAFE_NO_PAD
11275 .decode(old_spki)
11276 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
11277 let signature = URL_SAFE_NO_PAD
11278 .decode(&statement.sig)
11279 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
11280 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
11281 .verify(unsigned.as_bytes(), &signature)
11282 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
11283 if index > 0 {
11284 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
11285 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
11286 if statement.prior_head_seq < prior.prior_head_seq {
11287 return Err(invalid_feed("rotation feed boundaries move backward"));
11288 }
11289 }
11290 }
11291
11292 let anchor = format!("ed25519:{}", chain[0].0);
11293 let current = format!("ed25519:{}", identity.fingerprint);
11294 if let Some(pin) = pinned {
11295 if pin.anchor != anchor {
11296 return Err(invalid_feed(
11297 "served identity chain does not descend from the pinned anchor",
11298 ));
11299 }
11300 if !chain
11301 .iter()
11302 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
11303 {
11304 return Err(invalid_feed(
11305 "served identity chain forked away from the last pinned identity",
11306 ));
11307 }
11308 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
11309 return Err(invalid_feed("served identity discarded its rotation chain"));
11310 }
11311 if pin.v >= 2
11312 && (identity.rotations.len() < pin.rotations.len()
11313 || identity.rotations[..pin.rotations.len()] != pin.rotations)
11314 {
11315 return Err(invalid_feed(
11316 "served identity rewrote the locally accepted rotation history",
11317 ));
11318 }
11319 }
11320 Ok(anchor)
11321}
11322
11323fn verify_rotation_feed_boundaries(
11324 identity: &FeedIdentity,
11325 pinned: Option<&TrustState>,
11326 observed: &[FeedItem],
11327 advertised_seq: u64,
11328) -> LinkResult<()> {
11329 let mut chain: Vec<String> = identity
11330 .previous
11331 .iter()
11332 .rev()
11333 .map(|previous| format!("ed25519:{}", previous.fingerprint))
11334 .collect();
11335 chain.push(format!("ed25519:{}", identity.fingerprint));
11336 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
11337
11338 for (index, raw) in identity.rotations.iter().enumerate() {
11339 let rotation: RotationStatement = serde_json::from_str(raw)
11340 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
11341 if rotation.prior_head_seq > advertised_seq {
11342 return Err(invalid_feed(
11343 "rotation claims a feed boundary beyond the advertised head",
11344 ));
11345 }
11346 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
11347 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
11348 return Err(invalid_feed(
11349 "newly disclosed rotation predates the local feed checkpoint",
11350 ));
11351 }
11352 }
11353 let actual = if rotation.prior_head_seq == 0 {
11354 None
11355 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
11356 pinned.and_then(|pin| pin.feed_hash.as_deref())
11357 } else {
11358 observed
11359 .iter()
11360 .find(|item| item.entry.seq == rotation.prior_head_seq)
11361 .map(|item| item.hash.as_str())
11362 };
11363 if let Some(actual) = actual {
11364 if rotation.prior_feed_hash.as_deref() != Some(actual) {
11365 return Err(invalid_feed(
11366 "rotation statement does not commit the verified feed boundary",
11367 ));
11368 }
11369 } else if rotation.prior_head_seq == 0 {
11370 } else if pinned.is_some_and(|pin| {
11373 pinned_index.is_some_and(|pin_index| index >= pin_index)
11374 || rotation.prior_head_seq >= pin.head_seq
11375 }) {
11376 return Err(invalid_feed(
11377 "rotation feed boundary was not present in the verified chain",
11378 ));
11379 }
11380 }
11381 Ok(())
11382}
11383
11384fn reject_retired_signer_after_checkpoint(
11389 identity: &FeedIdentity,
11390 pinned: Option<&TrustState>,
11391 item: &FeedItem,
11392) -> LinkResult<()> {
11393 let Some(pin) = pinned else {
11394 return Ok(());
11395 };
11396 if item.entry.seq <= pin.head_seq {
11397 return Ok(());
11398 }
11399 let mut chain: Vec<String> = identity
11400 .previous
11401 .iter()
11402 .rev()
11403 .map(|previous| format!("ed25519:{}", previous.fingerprint))
11404 .collect();
11405 chain.push(format!("ed25519:{}", identity.fingerprint));
11406 let pinned_index = chain
11407 .iter()
11408 .position(|key| key == &pin.current)
11409 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
11410 let signer_index = chain
11411 .iter()
11412 .position(|key| key == &item.entry.brain)
11413 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
11414 if signer_index < pinned_index {
11415 return Err(invalid_feed(
11416 "a retired identity attempted to sign after the local checkpoint",
11417 ));
11418 }
11419 Ok(())
11420}
11421
11422fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
11423 let origin = normalized_origin(&cfg.hub)?;
11424 let key = format!(
11425 "{:x}",
11426 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
11427 );
11428 Ok(format!("{key}.json"))
11429}
11430
11431fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
11432 let origin = normalized_origin(&cfg.hub)?;
11433 let key = format!(
11434 "{:x}",
11435 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
11436 );
11437 Ok(format!("alias-{key}.json"))
11438}
11439
11440#[cfg(any(unix, windows))]
11441struct TrustLock {
11442 _file: std::fs::File,
11443}
11444
11445#[cfg(unix)]
11446fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
11447 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11448
11449 let lock_string = format!(".{state_name}.lock");
11450 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
11451 let fd = unsafe {
11452 libc::openat(
11453 directory.as_raw_fd(),
11454 lock_name.as_ptr(),
11455 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11456 0o600,
11457 )
11458 };
11459 if fd < 0 {
11460 return Err(std::io::Error::last_os_error().into());
11461 }
11462 let file = unsafe { std::fs::File::from_raw_fd(fd) };
11463 if !file.metadata()?.is_file() {
11464 return Err(LinkError::UnsafePath { path: lock_string });
11465 }
11466 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
11467 return Err(std::io::Error::last_os_error().into());
11468 }
11469 Ok(TrustLock { _file: file })
11470}
11471
11472#[cfg(windows)]
11473fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
11474 let lock_name = format!(".{state_name}.lock");
11475 let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
11476 Ok(TrustLock { _file: file })
11477}
11478
11479#[cfg(any(unix, windows))]
11480fn lock_trust_many(
11481 cfg: &HubConfig,
11482 directory: &std::fs::File,
11483 refs: &[&str],
11484) -> LinkResult<Vec<TrustLock>> {
11485 let mut names = refs
11486 .iter()
11487 .map(|reference| trust_file_name(cfg, reference))
11488 .collect::<LinkResult<Vec<_>>>()?;
11489 names.sort();
11490 names.dedup();
11491 names
11492 .iter()
11493 .map(|name| lock_trust_name(directory, name))
11494 .collect()
11495}
11496
11497#[cfg(not(any(unix, windows)))]
11498fn lock_trust_many(
11499 _cfg: &HubConfig,
11500 _directory: &TrustDirectory,
11501 _refs: &[&str],
11502) -> LinkResult<Vec<()>> {
11503 Err(LinkError::UnsupportedPlatform {
11504 operation: "verified link.md state",
11505 })
11506}
11507
11508#[cfg(any(unix, windows))]
11509type TrustDirectory = std::fs::File;
11510
11511#[cfg(not(any(unix, windows)))]
11512struct TrustDirectory;
11513
11514#[cfg(unix)]
11515fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11516 use std::os::fd::AsRawFd as _;
11517
11518 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
11519 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
11520 return Err(std::io::Error::last_os_error().into());
11521 }
11522 directory.sync_all()?;
11523 Ok(directory)
11524}
11525
11526#[cfg(windows)]
11527fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11528 let marker = cfg.state_dir.join("trust").join(".directory");
11529 crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
11530 Ok(crate::fsx::open_directory_nofollow(
11531 marker.parent().expect("trust marker has a parent"),
11532 )?)
11533}
11534
11535#[cfg(not(any(unix, windows)))]
11536fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11537 Err(LinkError::UnsupportedPlatform {
11538 operation: "verified link.md state",
11539 })
11540}
11541
11542#[cfg(unix)]
11543fn load_trust_in(
11544 cfg: &HubConfig,
11545 directory: &TrustDirectory,
11546 requested: &str,
11547) -> LinkResult<Option<TrustState>> {
11548 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11549
11550 let name_string = trust_file_name(cfg, requested)?;
11551 let name = c_name(name_string.as_bytes(), &name_string)?;
11552 let fd = unsafe {
11553 libc::openat(
11554 directory.as_raw_fd(),
11555 name.as_ptr(),
11556 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11557 )
11558 };
11559 if fd < 0 {
11560 let error = std::io::Error::last_os_error();
11561 if error.kind() == std::io::ErrorKind::NotFound {
11562 return Ok(None);
11563 }
11564 return Err(LinkError::UnsafePath { path: name_string });
11565 }
11566 let file = unsafe { std::fs::File::from_raw_fd(fd) };
11567 if !file.metadata()?.is_file() {
11568 return Err(LinkError::UnsafePath { path: name_string });
11569 }
11570 let mut bytes = Vec::new();
11571 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
11572 if bytes.len() > 1024 * 1024 {
11573 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
11574 }
11575 let mut state: TrustState = serde_json::from_slice(&bytes)
11576 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
11577 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
11578 return Err(invalid_feed(
11579 "local identity/feed checkpoint does not match this hub and brain",
11580 ));
11581 }
11582 if state.v == 1 {
11583 if state.brain != requested {
11587 return Err(invalid_feed(
11588 "legacy checkpoint is not bound to the requested brain id",
11589 ));
11590 }
11591 state.requested = requested.to_string();
11592 } else if state.requested != requested {
11593 return Err(invalid_feed(
11594 "local identity/feed checkpoint is bound to a different requested ref",
11595 ));
11596 }
11597 Ok(Some(state))
11598}
11599
11600#[cfg(windows)]
11601fn load_trust_in(
11602 cfg: &HubConfig,
11603 directory: &TrustDirectory,
11604 requested: &str,
11605) -> LinkResult<Option<TrustState>> {
11606 let name = trust_file_name(cfg, requested)?;
11607 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
11608 let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
11609 Ok(bytes) => bytes,
11610 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11611 Err(_) => return Err(LinkError::UnsafePath { path: name }),
11612 };
11613 let mut state: TrustState = serde_json::from_slice(&bytes)
11614 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
11615 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
11616 return Err(invalid_feed(
11617 "local identity/feed checkpoint does not match this hub and brain",
11618 ));
11619 }
11620 if state.v == 1 {
11621 if state.brain != requested {
11622 return Err(invalid_feed(
11623 "legacy checkpoint is not bound to the requested brain id",
11624 ));
11625 }
11626 state.requested = requested.to_string();
11627 } else if state.requested != requested {
11628 return Err(invalid_feed(
11629 "local identity/feed checkpoint is bound to a different requested ref",
11630 ));
11631 }
11632 Ok(Some(state))
11633}
11634
11635#[cfg(not(any(unix, windows)))]
11636fn load_trust_in(
11637 _cfg: &HubConfig,
11638 _directory: &TrustDirectory,
11639 _brain: &str,
11640) -> LinkResult<Option<TrustState>> {
11641 Err(LinkError::UnsupportedPlatform {
11642 operation: "verified link.md state",
11643 })
11644}
11645
11646#[cfg(all(test, any(unix, windows)))]
11647fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
11648 let directory = open_trust_dir(cfg)?;
11649 load_trust_in(cfg, &directory, requested)
11650}
11651
11652#[cfg(unix)]
11653fn save_trust_in(
11654 cfg: &HubConfig,
11655 directory: &TrustDirectory,
11656 state: &TrustState,
11657) -> LinkResult<()> {
11658 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11659
11660 let name_string = trust_file_name(cfg, &state.requested)?;
11661 let name = c_name(name_string.as_bytes(), &name_string)?;
11662 let mut bytes = serde_json::to_vec(state)
11663 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
11664 bytes.push(b'\n');
11665
11666 let nonce = std::time::SystemTime::now()
11667 .duration_since(std::time::UNIX_EPOCH)
11668 .unwrap_or_default()
11669 .as_nanos();
11670 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
11671 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
11672 let fd = unsafe {
11673 libc::openat(
11674 directory.as_raw_fd(),
11675 temp.as_ptr(),
11676 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11677 0o600,
11678 )
11679 };
11680 if fd < 0 {
11681 return Err(std::io::Error::last_os_error().into());
11682 }
11683 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
11684 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
11685 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11686 return Err(error.into());
11687 }
11688 drop(file);
11689 if unsafe {
11690 libc::renameat(
11691 directory.as_raw_fd(),
11692 temp.as_ptr(),
11693 directory.as_raw_fd(),
11694 name.as_ptr(),
11695 )
11696 } != 0
11697 {
11698 let error = std::io::Error::last_os_error();
11699 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11700 return Err(error.into());
11701 }
11702 directory.sync_all()?;
11703 Ok(())
11704}
11705
11706#[cfg(windows)]
11707fn save_trust_in(
11708 cfg: &HubConfig,
11709 directory: &TrustDirectory,
11710 state: &TrustState,
11711) -> LinkResult<()> {
11712 let name = trust_file_name(cfg, &state.requested)?;
11713 let mut bytes = serde_json::to_vec(state)
11714 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
11715 bytes.push(b'\n');
11716 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
11717 Ok(())
11718}
11719
11720#[cfg(not(any(unix, windows)))]
11721fn save_trust_in(
11722 _cfg: &HubConfig,
11723 _directory: &TrustDirectory,
11724 _state: &TrustState,
11725) -> LinkResult<()> {
11726 Err(LinkError::UnsupportedPlatform {
11727 operation: "verified link.md state",
11728 })
11729}
11730
11731#[cfg(unix)]
11732fn load_alias_in(
11733 cfg: &HubConfig,
11734 directory: &TrustDirectory,
11735 requested: &str,
11736) -> LinkResult<Option<AliasBinding>> {
11737 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11738
11739 let name_string = alias_file_name(cfg, requested)?;
11740 let name = c_name(name_string.as_bytes(), &name_string)?;
11741 let fd = unsafe {
11742 libc::openat(
11743 directory.as_raw_fd(),
11744 name.as_ptr(),
11745 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11746 )
11747 };
11748 if fd < 0 {
11749 let error = std::io::Error::last_os_error();
11750 if error.kind() == std::io::ErrorKind::NotFound {
11751 return Ok(None);
11752 }
11753 return Err(LinkError::UnsafePath { path: name_string });
11754 }
11755 let file = unsafe { std::fs::File::from_raw_fd(fd) };
11756 if !file.metadata()?.is_file() {
11757 return Err(LinkError::UnsafePath { path: name_string });
11758 }
11759 let mut bytes = Vec::new();
11760 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
11761 if bytes.len() > 64 * 1024 {
11762 return Err(invalid_feed("local alias binding is oversized"));
11763 }
11764 let alias: AliasBinding = serde_json::from_slice(&bytes)
11765 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
11766 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
11767 {
11768 return Err(invalid_feed(
11769 "local alias binding does not match this hub and requested ref",
11770 ));
11771 }
11772 Ok(Some(alias))
11773}
11774
11775#[cfg(windows)]
11776fn load_alias_in(
11777 cfg: &HubConfig,
11778 directory: &TrustDirectory,
11779 requested: &str,
11780) -> LinkResult<Option<AliasBinding>> {
11781 let name = alias_file_name(cfg, requested)?;
11782 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
11783 let bytes = match reader.read(Path::new(&name), 64 * 1024) {
11784 Ok(bytes) => bytes,
11785 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11786 Err(_) => return Err(LinkError::UnsafePath { path: name }),
11787 };
11788 let alias: AliasBinding = serde_json::from_slice(&bytes)
11789 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
11790 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
11791 {
11792 return Err(invalid_feed(
11793 "local alias binding does not match this hub and requested ref",
11794 ));
11795 }
11796 Ok(Some(alias))
11797}
11798
11799#[cfg(not(any(unix, windows)))]
11800fn load_alias_in(
11801 _cfg: &HubConfig,
11802 _directory: &TrustDirectory,
11803 _requested: &str,
11804) -> LinkResult<Option<AliasBinding>> {
11805 Err(LinkError::UnsupportedPlatform {
11806 operation: "verified link.md state",
11807 })
11808}
11809
11810#[cfg(unix)]
11811fn save_alias_in(
11812 cfg: &HubConfig,
11813 directory: &TrustDirectory,
11814 alias: &AliasBinding,
11815) -> LinkResult<()> {
11816 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11817
11818 let name_string = alias_file_name(cfg, &alias.requested)?;
11819 let name = c_name(name_string.as_bytes(), &name_string)?;
11820 let mut bytes = serde_json::to_vec(alias)
11821 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
11822 bytes.push(b'\n');
11823 let nonce = std::time::SystemTime::now()
11824 .duration_since(std::time::UNIX_EPOCH)
11825 .unwrap_or_default()
11826 .as_nanos();
11827 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
11828 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
11829 let fd = unsafe {
11830 libc::openat(
11831 directory.as_raw_fd(),
11832 temp.as_ptr(),
11833 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11834 0o600,
11835 )
11836 };
11837 if fd < 0 {
11838 return Err(std::io::Error::last_os_error().into());
11839 }
11840 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
11841 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
11842 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11843 return Err(error.into());
11844 }
11845 drop(file);
11846 if unsafe {
11847 libc::renameat(
11848 directory.as_raw_fd(),
11849 temp.as_ptr(),
11850 directory.as_raw_fd(),
11851 name.as_ptr(),
11852 )
11853 } != 0
11854 {
11855 let error = std::io::Error::last_os_error();
11856 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11857 return Err(error.into());
11858 }
11859 directory.sync_all()?;
11860 Ok(())
11861}
11862
11863#[cfg(windows)]
11864fn save_alias_in(
11865 cfg: &HubConfig,
11866 directory: &TrustDirectory,
11867 alias: &AliasBinding,
11868) -> LinkResult<()> {
11869 let name = alias_file_name(cfg, &alias.requested)?;
11870 let mut bytes = serde_json::to_vec(alias)
11871 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
11872 bytes.push(b'\n');
11873 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
11874 Ok(())
11875}
11876
11877#[cfg(not(any(unix, windows)))]
11878fn save_alias_in(
11879 _cfg: &HubConfig,
11880 _directory: &TrustDirectory,
11881 _alias: &AliasBinding,
11882) -> LinkResult<()> {
11883 Err(LinkError::UnsupportedPlatform {
11884 operation: "verified link.md state",
11885 })
11886}
11887
11888fn load_canonical_pin(
11893 cfg: &HubConfig,
11894 directory: &TrustDirectory,
11895 requested: &str,
11896 resolved_brain: &str,
11897) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
11898 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
11899 if requested == resolved_brain {
11900 return Ok((canonical, None));
11901 }
11902
11903 let mut alias = load_alias_in(cfg, directory, requested)?;
11904 if let Some(binding) = &alias {
11905 if binding.brain != resolved_brain {
11906 return Err(LinkError::AliasRebindRequired {
11907 alias: requested.to_string(),
11908 from: binding.brain.clone(),
11909 to: resolved_brain.to_string(),
11910 });
11911 }
11912 return Ok((canonical, alias));
11913 }
11914
11915 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
11919 if legacy.brain != resolved_brain {
11920 return Err(invalid_feed(
11921 "legacy alias checkpoint names a different canonical brain",
11922 ));
11923 }
11924 if let Some(existing) = &canonical {
11925 if existing.brain != legacy.brain
11926 || existing.anchor != legacy.anchor
11927 || existing.current != legacy.current
11928 || existing.head_seq != legacy.head_seq
11929 || existing.feed_hash != legacy.feed_hash
11930 || existing.rotations != legacy.rotations
11931 {
11932 return Err(invalid_feed(
11933 "legacy alias checkpoint conflicts with the canonical checkpoint",
11934 ));
11935 }
11936 } else {
11937 let mut promoted = legacy.clone();
11938 promoted.requested = resolved_brain.to_string();
11939 promoted.home = None;
11940 save_trust_in(cfg, directory, &promoted)?;
11941 canonical = Some(promoted);
11942 }
11943 alias = Some(AliasBinding {
11944 v: 1,
11945 origin: normalized_origin(&cfg.hub)?,
11946 requested: requested.to_string(),
11947 brain: resolved_brain.to_string(),
11948 home: legacy.home,
11949 });
11950 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
11951 }
11952 Ok((canonical, alias))
11953}
11954
11955pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
11960 require_hardened_filesystem("verified alias rebind")?;
11961 require_safe_ref(alias)?;
11962 require_safe_ref(from)?;
11963 require_safe_ref(to)?;
11964 if crate::ulid::is_ulid(alias)
11965 || !crate::ulid::is_ulid(from)
11966 || !crate::ulid::is_ulid(to)
11967 || from == to
11968 {
11969 return Err(LinkError::InvalidPack {
11970 message:
11971 "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
11972 .to_string(),
11973 });
11974 }
11975
11976 let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
11977 message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
11978 })?;
11979 accept_v2_head(cfg, &verified)?;
11980
11981 let alias_response = ensure_ok(
11982 request(
11983 cfg,
11984 "GET",
11985 &format!("/api/hub/brains/{alias}/v2/head"),
11986 None,
11987 Auth::Required,
11988 )?,
11989 "resolve alias for explicit rebind",
11990 )?;
11991 let resolved: V2HeadResponse = serde_json::from_value(alias_response)
11992 .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
11993 if resolved.v != 2 || resolved.brain_id != to {
11994 return Err(LinkError::RemoteAdvancedDuringSync);
11995 }
11996
11997 let directory = open_trust_dir(cfg)?;
11998 let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
11999 let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
12000 message: "the requested alias has no existing local binding to replace".to_string(),
12001 })?;
12002 if binding.brain != from {
12003 return Err(LinkError::AliasRebindRequired {
12004 alias: alias.to_string(),
12005 from: binding.brain,
12006 to: to.to_string(),
12007 });
12008 }
12009 save_alias_in(
12010 cfg,
12011 &directory,
12012 &AliasBinding {
12013 v: 1,
12014 origin: normalized_origin(&cfg.hub)?,
12015 requested: alias.to_string(),
12016 brain: to.to_string(),
12017 home: binding.home,
12018 },
12019 )?;
12020 Ok(json!({
12021 "v": 2,
12022 "alias": alias,
12023 "from": from,
12024 "to": to,
12025 "outcome": "alias_rebound",
12026 }))
12027}
12028
12029fn save_canonical_pin_and_alias(
12030 cfg: &HubConfig,
12031 directory: &TrustDirectory,
12032 requested: &str,
12033 resolved_brain: &str,
12034 mut state: TrustState,
12035 existing_alias: Option<&AliasBinding>,
12036) -> LinkResult<()> {
12037 state.requested = resolved_brain.to_string();
12038 state.brain = resolved_brain.to_string();
12039 state.home = None;
12040 save_trust_in(cfg, directory, &state)?;
12041 if requested != resolved_brain {
12042 save_alias_in(
12043 cfg,
12044 directory,
12045 &AliasBinding {
12046 v: 1,
12047 origin: normalized_origin(&cfg.hub)?,
12048 requested: requested.to_string(),
12049 brain: resolved_brain.to_string(),
12050 home: existing_alias.and_then(|alias| alias.home.clone()),
12051 },
12052 )?;
12053 }
12054 Ok(())
12055}
12056
12057fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
12058 const ED25519_SPKI_PREFIX: &[u8] = &[
12059 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
12060 ];
12061 let entry = &item.entry;
12062 let public_der = URL_SAFE_NO_PAD
12063 .decode(&entry.public_key)
12064 .map_err(|_| invalid_feed("public key is not base64url"))?;
12065 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
12066 || !public_der.starts_with(ED25519_SPKI_PREFIX)
12067 {
12068 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
12069 }
12070 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
12071 if entry.brain != format!("ed25519:{fingerprint}") {
12072 return Err(invalid_feed(
12073 "brain fingerprint does not match its public key",
12074 ));
12075 }
12076 let _ = verify_identity_chain(identity, None)?;
12078 let mut chain: Vec<(&str, &str)> = identity
12079 .previous
12080 .iter()
12081 .rev()
12082 .map(|previous| {
12083 (
12084 previous.fingerprint.as_str(),
12085 previous.public_key_spki.as_str(),
12086 )
12087 })
12088 .collect();
12089 chain.push((&identity.fingerprint, &identity.public_key_spki));
12090 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
12091 *known_fingerprint == fingerprint && *spki == entry.public_key
12092 });
12093 let Some(signer_index) = signer_index else {
12094 return Err(invalid_feed(
12095 "entry signer is not this brain's identity (current or rotated-from)",
12096 ));
12097 };
12098 let lower_boundary = if signer_index == 0 {
12099 None
12100 } else {
12101 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
12102 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12103 Some(prior.prior_head_seq)
12104 };
12105 let upper_boundary = if signer_index == identity.rotations.len() {
12106 None
12107 } else {
12108 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
12109 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12110 Some(next.prior_head_seq)
12111 };
12112 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
12113 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
12114 {
12115 return Err(invalid_feed(
12116 "entry signer is outside its authenticated rotation epoch",
12117 ));
12118 }
12119 let unsigned = UnsignedFeedEntry {
12120 v: entry.v,
12121 seq: entry.seq,
12122 ts: &entry.ts,
12123 brain: &entry.brain,
12124 public_key: &entry.public_key,
12125 kind: &entry.kind,
12126 op: &entry.op,
12127 pack_sha256: &entry.pack_sha256,
12128 files: &entry.files,
12129 removed: &entry.removed,
12130 prev_entry_hash: &entry.prev_entry_hash,
12131 };
12132 let message =
12133 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
12134 let signature = URL_SAFE_NO_PAD
12135 .decode(&entry.sig)
12136 .map_err(|_| invalid_feed("signature is not base64url"))?;
12137 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
12138 .verify(&message, &signature)
12139 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
12140
12141 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
12142 exact.push(b'\n');
12143 let actual_hash = format!("{:x}", Sha256::digest(&exact));
12144 if actual_hash != item.hash {
12145 return Err(invalid_feed("entry SHA-256 does not match"));
12146 }
12147 Ok(())
12148}
12149
12150#[derive(Serialize)]
12156struct UnsignedRotation<'a> {
12157 v: u8,
12158 op: &'a str,
12159 brain: &'a str,
12160 public_key: &'a str,
12161 new_brain: &'a str,
12162 new_public_key: &'a str,
12163 prior_head_seq: u64,
12164 prior_feed_hash: Option<&'a str>,
12165 ts: String,
12166}
12167
12168#[derive(Debug, Deserialize, Serialize)]
12173#[serde(deny_unknown_fields)]
12174struct RotationJournal {
12175 v: u8,
12176 origin: String,
12177 brain: String,
12178 old_brain: String,
12179 new_brain: String,
12180 prior_head_seq: u64,
12181 prior_feed_hash: Option<String>,
12182 statement: String,
12183}
12184
12185fn rotation_journal_path(key_path: &Path) -> PathBuf {
12186 let mut path = key_path.as_os_str().to_os_string();
12187 path.push(".rotation.json");
12188 PathBuf::from(path)
12189}
12190
12191fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
12192 #[cfg(unix)]
12193 let file = {
12194 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12195 use std::os::unix::ffi::OsStrExt as _;
12196 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
12197 .map_err(|error| {
12198 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
12199 })?;
12200 let leaf_name = path
12201 .file_name()
12202 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
12203 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
12204 let fd = unsafe {
12205 libc::openat(
12206 parent.as_raw_fd(),
12207 leaf.as_ptr(),
12208 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12209 )
12210 };
12211 if fd < 0 {
12212 return Err(bad_agent_key(
12213 "the rotation journal must be an existing regular file without symlink ancestors",
12214 ));
12215 }
12216 unsafe { std::fs::File::from_raw_fd(fd) }
12217 };
12218 #[cfg(not(unix))]
12219 let file = std::fs::File::open(path)
12220 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
12221 let metadata = file
12222 .metadata()
12223 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
12224 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
12225 return Err(bad_agent_key(
12226 "the rotation journal must be a bounded regular file",
12227 ));
12228 }
12229 #[cfg(unix)]
12230 {
12231 use std::os::unix::fs::PermissionsExt as _;
12232 if metadata.permissions().mode() & 0o077 != 0 {
12233 return Err(bad_agent_key(
12234 "the rotation journal is accessible to group/other; set mode 0600",
12235 ));
12236 }
12237 }
12238 serde_json::from_reader(file)
12239 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
12240}
12241
12242fn remove_rotation_journal(path: &Path) {
12243 #[cfg(unix)]
12244 {
12245 use std::os::fd::AsRawFd as _;
12246 use std::os::unix::ffi::OsStrExt as _;
12247 let Ok(parent) =
12248 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
12249 else {
12250 return;
12251 };
12252 let Some(leaf_name) = path.file_name() else {
12253 return;
12254 };
12255 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
12256 return;
12257 };
12258 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
12259 let _ = parent.sync_all();
12260 }
12261 }
12262 #[cfg(not(unix))]
12263 {
12264 let _ = std::fs::remove_file(path);
12265 }
12266}
12267
12268fn validate_rotation_journal(
12269 journal: &RotationJournal,
12270 cfg: &HubConfig,
12271 canonical_brain: &str,
12272 old_key: &AgentSigningKey,
12273 new_key: &AgentSigningKey,
12274 head: &Head,
12275) -> LinkResult<()> {
12276 if journal.v != 1
12277 || journal.origin != normalized_origin(&cfg.hub)?
12278 || journal.brain != canonical_brain
12279 || journal.old_brain != old_key.multikey
12280 || journal.new_brain != new_key.multikey
12281 || journal.prior_head_seq != head.seq
12282 || journal.prior_feed_hash != head.feed_hash
12283 {
12284 return Err(invalid_feed(
12285 "rotation journal does not match the verified key and feed boundary",
12286 ));
12287 }
12288 let statement: RotationStatement = serde_json::from_str(&journal.statement)
12289 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
12290 if statement.prior_head_seq != journal.prior_head_seq
12291 || statement.prior_feed_hash != journal.prior_feed_hash
12292 || statement.brain != old_key.multikey
12293 || statement.public_key != old_key.public_key_spki
12294 || statement.new_brain != new_key.multikey
12295 || statement.new_public_key != new_key.public_key_spki
12296 {
12297 return Err(invalid_feed(
12298 "rotation journal statement does not match its durable intent",
12299 ));
12300 }
12301 let identity = FeedIdentity {
12302 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
12303 public_key_spki: new_key.public_key_spki.clone(),
12304 previous: vec![PreviousIdentity {
12305 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
12306 public_key_spki: old_key.public_key_spki.clone(),
12307 }],
12308 rotations: vec![journal.statement.clone()],
12309 };
12310 verify_identity_chain(&identity, None)?;
12311 Ok(())
12312}
12313
12314#[derive(Debug, Serialize)]
12316pub struct RotationReport {
12317 pub brain: String,
12319 pub multikey: String,
12321 #[serde(rename = "keyFile")]
12323 pub key_file: String,
12324 pub previous: Vec<String>,
12326}
12327
12328pub fn rotate_brain_key(
12334 cfg: &HubConfig,
12335 brain: &str,
12336 old_key: &AgentSigningKey,
12337 out: &Path,
12338) -> LinkResult<RotationReport> {
12339 require_hardened_filesystem("key rotation")?;
12340 require_safe_ref(brain)?;
12341 let new_key = if out.exists() {
12345 load_signing_key(out)?
12346 } else {
12347 let rng = ring::rand::SystemRandom::new();
12348 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
12349 .map_err(|_| bad_agent_key("key generation failed"))?;
12350 let pair = agent_keypair(pkcs8.as_ref())?;
12351 let (public_key_spki, multikey) = public_identity_for(&pair);
12352 write_secret_new(
12353 out,
12354 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
12355 )?;
12356 AgentSigningKey {
12357 pkcs8: pkcs8.as_ref().to_vec(),
12358 multikey,
12359 public_key_spki,
12360 }
12361 };
12362 let new_spki = new_key.public_key_spki.clone();
12363 let new_multikey = new_key.multikey.clone();
12364 let journal_path = rotation_journal_path(out);
12365 let before = verified_remote_head(cfg, brain, false)?;
12366 let served_identity = before
12367 .identity
12368 .as_ref()
12369 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
12370 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
12371 if served_multikey == new_multikey {
12372 remove_rotation_journal(&journal_path);
12373 return Ok(RotationReport {
12374 brain: brain.to_string(),
12375 multikey: new_multikey,
12376 key_file: out.display().to_string(),
12377 previous: served_identity
12378 .previous
12379 .iter()
12380 .map(|identity| format!("ed25519:{}", identity.fingerprint))
12381 .collect(),
12382 });
12383 }
12384 if served_multikey != old_key.multikey {
12385 return Err(invalid_feed(
12386 "the supplied old key is not the brain's verified current identity",
12387 ));
12388 }
12389
12390 let journal = if journal_path.exists() {
12391 read_rotation_journal(&journal_path)?
12392 } else {
12393 let ts = crate::now()
12394 .with_timezone(&chrono::Utc)
12395 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
12396 .to_string();
12397 let unsigned = serde_json::to_string(&UnsignedRotation {
12398 v: 1,
12399 op: "rotate",
12400 brain: &old_key.multikey,
12401 public_key: &old_key.public_key_spki,
12402 new_brain: &new_multikey,
12403 new_public_key: &new_spki,
12404 prior_head_seq: before.head.seq,
12405 prior_feed_hash: before.head.feed_hash.as_deref(),
12406 ts,
12407 })
12408 .expect("serialize rotation");
12409 let old_pair = agent_keypair(&old_key.pkcs8)?;
12410 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
12411 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
12412 let journal = RotationJournal {
12413 v: 1,
12414 origin: normalized_origin(&cfg.hub)?,
12415 brain: before.head.brain.clone(),
12416 old_brain: old_key.multikey.clone(),
12417 new_brain: new_multikey.clone(),
12418 prior_head_seq: before.head.seq,
12419 prior_feed_hash: before.head.feed_hash.clone(),
12420 statement,
12421 };
12422 let mut exact = serde_json::to_vec(&journal)
12423 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
12424 exact.push(b'\n');
12425 if write_secret_new(&journal_path, &exact).is_err() {
12426 read_rotation_journal(&journal_path)?
12429 } else {
12430 journal
12431 }
12432 };
12433 validate_rotation_journal(
12434 &journal,
12435 cfg,
12436 &before.head.brain,
12437 old_key,
12438 &new_key,
12439 &before.head,
12440 )?;
12441
12442 let body = json!({ "statement": journal.statement });
12443 let path = format!("/api/hub/brains/{brain}/rotate");
12444 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
12445 let attempted_failure = match attempted {
12446 Ok(response) if (200..300).contains(&response.status) => None,
12447 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
12448 Err(error) => Some(error),
12449 };
12450
12451 let after = match verified_remote_head(cfg, brain, false) {
12455 Ok(after) => after,
12456 Err(error) => return Err(attempted_failure.unwrap_or(error)),
12457 };
12458 let identity = after
12459 .identity
12460 .as_ref()
12461 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?;
12462 if format!("ed25519:{}", identity.fingerprint) != new_multikey
12463 || identity.public_key_spki != new_spki
12464 {
12465 return Err(attempted_failure.unwrap_or_else(|| {
12466 invalid_feed("hub acknowledged rotation without committing the verified new identity")
12467 }));
12468 }
12469 let previous = identity
12470 .previous
12471 .iter()
12472 .map(|prior| format!("ed25519:{}", prior.fingerprint))
12473 .collect();
12474 remove_rotation_journal(&journal_path);
12475
12476 Ok(RotationReport {
12477 brain: brain.to_string(),
12478 multikey: new_multikey,
12479 key_file: out.display().to_string(),
12480 previous,
12481 })
12482}
12483
12484#[derive(Debug, Serialize)]
12490pub struct MirrorReport {
12491 pub brain: String,
12493 #[serde(rename = "headSeq")]
12495 pub head_seq: u64,
12496 #[serde(rename = "feedHash")]
12498 pub feed_hash: Option<String>,
12499 pub entries: u64,
12501 pub pinned: String,
12503 pub files: usize,
12505}
12506
12507pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
12509
12510#[derive(Debug)]
12512pub struct VerifiedMirrorMaterial {
12513 pub brain: String,
12514 pub head_seq: u64,
12515 pub feed_hash: Option<String>,
12516 pub identity: serde_json::Value,
12517 pub entries: Vec<(u64, String, String)>,
12519 pub pack_sha256: Option<String>,
12520}
12521
12522#[derive(Deserialize)]
12523#[serde(deny_unknown_fields)]
12524struct StoredMirrorHead {
12525 brain: String,
12526 #[serde(rename = "headSeq")]
12527 head_seq: u64,
12528 #[serde(rename = "feedHash")]
12529 feed_hash: Option<String>,
12530}
12531
12532pub fn verify_mirror_material(
12535 head_bytes: &[u8],
12536 identity_bytes: &[u8],
12537 feed_bytes: &[Vec<u8>],
12538 snapshot_pack: Option<&[u8]>,
12539 expected_anchor: &str,
12540) -> LinkResult<VerifiedMirrorMaterial> {
12541 let snapshot_hash = snapshot_pack
12542 .filter(|pack| !pack.is_empty())
12543 .map(content_sha256);
12544 verify_mirror_material_with_pack_hash(
12545 head_bytes,
12546 identity_bytes,
12547 feed_bytes,
12548 snapshot_hash.as_deref(),
12549 expected_anchor,
12550 )
12551}
12552
12553pub fn verify_mirror_material_with_pack_hash(
12557 head_bytes: &[u8],
12558 identity_bytes: &[u8],
12559 feed_bytes: &[Vec<u8>],
12560 snapshot_pack_sha256: Option<&str>,
12561 expected_anchor: &str,
12562) -> LinkResult<VerifiedMirrorMaterial> {
12563 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
12564 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
12565 require_safe_ref(&head.brain)?;
12566 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
12567 return Err(invalid_feed(
12568 "stored mirror feed count does not match its bounded head sequence",
12569 ));
12570 }
12571 let aggregate = feed_bytes
12572 .iter()
12573 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
12574 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
12575 if aggregate > MAX_FEED_REPLAY_BYTES {
12576 return Err(invalid_feed(
12577 "stored mirror feed metadata exceeds the aggregate limit",
12578 ));
12579 }
12580 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
12581 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
12582 let anchor = verify_identity_chain(&identity, None)?;
12583 if anchor != expected_anchor {
12584 return Err(invalid_feed(
12585 "stored mirror identity does not descend from the explicitly trusted anchor",
12586 ));
12587 }
12588
12589 let mut entries = Vec::with_capacity(feed_bytes.len());
12590 let mut items = Vec::with_capacity(feed_bytes.len());
12591 let mut previous_hash = None;
12592 let mut pack_sha256 = None;
12593 for (index, bytes) in feed_bytes.iter().enumerate() {
12594 let exact = bytes
12595 .strip_suffix(b"\n")
12596 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
12597 if exact.ends_with(b"\n") {
12598 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
12599 }
12600 let entry: FeedEntry = serde_json::from_slice(exact)
12601 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
12602 let expected_seq = index as u64 + 1;
12603 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
12604 return Err(invalid_feed(
12605 "stored mirror feed is not contiguous and hash-chained",
12606 ));
12607 }
12608 let canonical = serde_json::to_vec(&entry)
12609 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
12610 if canonical != exact {
12611 return Err(invalid_feed(
12612 "stored feed entry is not in normative serialization",
12613 ));
12614 }
12615 let hash = content_sha256(bytes);
12616 let item = FeedItem {
12617 hash: hash.clone(),
12618 entry,
12619 };
12620 verify_feed_item(&item, &identity)?;
12621 previous_hash = Some(hash.clone());
12622 if expected_seq == head.head_seq {
12623 pack_sha256 = Some(item.entry.pack_sha256.clone());
12624 }
12625 entries.push((
12626 expected_seq,
12627 std::str::from_utf8(exact)
12628 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
12629 .to_string(),
12630 hash,
12631 ));
12632 items.push(item);
12633 }
12634 if previous_hash != head.feed_hash {
12635 return Err(invalid_feed(
12636 "stored mirror feed does not converge on its advertised head",
12637 ));
12638 }
12639 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
12640 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
12641 (0, None, None) => {}
12642 (_, Some(actual), Some(expected)) if actual == expected => {}
12643 _ => {
12644 return Err(LinkError::InvalidPack {
12645 message: "stored snapshot pack does not match the signed head digest".to_string(),
12646 });
12647 }
12648 }
12649 let identity_value = serde_json::to_value(&identity)
12650 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
12651 Ok(VerifiedMirrorMaterial {
12652 brain: head.brain,
12653 head_seq: head.head_seq,
12654 feed_hash: head.feed_hash,
12655 identity: identity_value,
12656 entries,
12657 pack_sha256,
12658 })
12659}
12660
12661pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
12664 format!(
12665 "{:x}",
12666 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
12667 )
12668}
12669
12670pub fn content_sha256(bytes: &[u8]) -> String {
12673 format!("{:x}", Sha256::digest(bytes))
12674}
12675
12676pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
12678 let mut digest = Sha256::new();
12679 let mut buffer = [0u8; 64 * 1024];
12680 loop {
12681 let read = reader.read(&mut buffer)?;
12682 if read == 0 {
12683 break;
12684 }
12685 digest.update(&buffer[..read]);
12686 }
12687 Ok(format!("{:x}", digest.finalize()))
12688}
12689
12690#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
12698pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
12699 require_hardened_filesystem("mirror")?;
12700 require_safe_ref(brain)?;
12701 #[cfg(windows)]
12702 {
12703 let _ = (cfg, dest);
12704 return Err(LinkError::UnsupportedPlatform {
12705 operation: "atomic whole-mirror replacement on Windows",
12706 });
12707 }
12708 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
12709 let name = dest
12710 .file_name()
12711 .and_then(|name| name.to_str())
12712 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
12713 .ok_or_else(|| LinkError::UnsafePath {
12714 path: dest.display().to_string(),
12715 })?;
12716 #[cfg(unix)]
12717 let parent_dir = open_or_create_dir_nofollow(parent)?;
12718 #[cfg(unix)]
12719 use std::os::fd::AsRawFd as _;
12720 #[cfg(unix)]
12721 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
12722 #[cfg(unix)]
12723 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
12724 None => false,
12725 Some(true) => true,
12726 Some(false) => {
12727 return Err(LinkError::UnsafePath {
12728 path: dest.display().to_string(),
12729 });
12730 }
12731 };
12732
12733 #[cfg(unix)]
12736 let legacy_backup_name = c_name(
12737 format!(".{name}.dbmd-backup").as_bytes(),
12738 &dest.display().to_string(),
12739 )?;
12740 #[cfg(unix)]
12741 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
12742 return Err(LinkError::UnsafePath {
12743 path: parent
12744 .join(format!(".{name}.dbmd-backup"))
12745 .display()
12746 .to_string(),
12747 });
12748 }
12749
12750 let nonce = std::time::SystemTime::now()
12751 .duration_since(std::time::UNIX_EPOCH)
12752 .unwrap_or_default()
12753 .as_nanos();
12754 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
12755 #[cfg(unix)]
12756 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
12757 #[cfg(unix)]
12758 let stage_dir = create_dir_exclusive_at(
12759 parent_dir.as_raw_fd(),
12760 &stage_name,
12761 &dest.display().to_string(),
12762 )?;
12763
12764 let assembled = (|| -> LinkResult<MirrorReport> {
12765 let remote = verified_remote_head(cfg, brain, true)?;
12766 let brain_id = remote.head.brain.clone();
12767 let identity = remote
12768 .identity
12769 .as_ref()
12770 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
12771 let anchor = remote
12772 .anchor
12773 .clone()
12774 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
12775 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
12776 let snapshot_entries = parse_store_pack(pack.clone())?;
12777 let snapshot_count = snapshot_entries.len();
12778 let mut staged_entries = snapshot_entries;
12779 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
12780 for item in &remote.entries {
12781 let mut exact = serde_json::to_vec(&item.entry)
12782 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
12783 exact.push(b'\n');
12784 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
12785 return Err(invalid_feed(
12786 "serialized mirror entry differs from its verified hash",
12787 ));
12788 }
12789 staged_entries.push((
12790 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
12791 exact,
12792 ));
12793 }
12794 let mut identity_bytes = serde_json::to_vec(identity)
12795 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
12796 identity_bytes.push(b'\n');
12797 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
12798 let mut head_bytes = serde_json::to_vec(&json!({
12799 "brain": brain_id,
12800 "headSeq": remote.head.seq,
12801 "feedHash": remote.head.feed_hash,
12802 }))
12803 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
12804 head_bytes.push(b'\n');
12805 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
12806 staged_entries.push((
12807 CONFIG_REL_PATH.to_string(),
12808 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
12809 ));
12810 #[cfg(unix)]
12811 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
12812
12813 Ok(MirrorReport {
12814 brain: brain_id,
12815 head_seq: remote.head.seq,
12816 feed_hash: remote.head.feed_hash,
12817 entries: remote.entries.len() as u64,
12818 pinned: anchor,
12819 files: snapshot_count,
12820 })
12821 })();
12822
12823 let report = match assembled {
12824 Ok(report) => report,
12825 Err(error) => {
12826 #[cfg(unix)]
12827 let _ = remove_tree_at(
12828 parent_dir.as_raw_fd(),
12829 &stage_name,
12830 &dest.display().to_string(),
12831 );
12832 return Err(error);
12833 }
12834 };
12835
12836 #[cfg(unix)]
12837 if let Err(error) =
12838 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
12839 {
12840 let _ = remove_tree_at(
12841 parent_dir.as_raw_fd(),
12842 &stage_name,
12843 &dest.display().to_string(),
12844 );
12845 return Err(error);
12846 }
12847 #[cfg(unix)]
12850 if dest_exists {
12851 remove_tree_at(
12852 parent_dir.as_raw_fd(),
12853 &stage_name,
12854 &dest.display().to_string(),
12855 )?;
12856 }
12857 #[cfg(unix)]
12858 parent_dir.sync_all()?;
12859 Ok(report)
12860}
12861
12862fn verified_remote_head(
12863 cfg: &HubConfig,
12864 brain: &str,
12865 require_full_chain: bool,
12866) -> LinkResult<VerifiedRemote> {
12867 require_hardened_filesystem("verified link.md state")?;
12868 require_safe_ref(brain)?;
12869 let trust_directory = open_trust_dir(cfg)?;
12873 let path = format!("/api/hub/brains/{brain}");
12874 let body = ensure_ok(
12875 request(cfg, "GET", &path, None, Auth::Required)?,
12876 "subscribe",
12877 )?;
12878 let resolved_brain = body
12879 .get("id")
12880 .and_then(Value::as_str)
12881 .filter(|id| crate::ulid::is_ulid(id))
12882 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
12883 .to_string();
12884 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
12885 return Err(invalid_feed(
12886 "brain card id differs from the explicitly requested brain id",
12887 ));
12888 }
12889 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
12894 let (pinned, alias_binding) =
12895 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
12896 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
12897 let advertised_hash = body
12898 .get("feedHash")
12899 .and_then(Value::as_str)
12900 .map(str::to_string);
12901 let updated_at = body
12902 .get("updatedAt")
12903 .and_then(Value::as_str)
12904 .map(str::to_string);
12905 if let Some(pin) = &pinned {
12906 if seq < pin.head_seq {
12907 return Err(invalid_feed(format!(
12908 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
12909 pin.head_seq
12910 )));
12911 }
12912 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
12913 return Err(invalid_feed(
12914 "feed equivocation: the checkpoint sequence now has a different hash",
12915 ));
12916 }
12917 }
12918 if seq == 0 {
12919 if advertised_hash.is_some() {
12920 return Err(invalid_feed("an empty feed advertised a head hash"));
12921 }
12922 let identity: FeedIdentity = serde_json::from_value(
12923 body.get("identity")
12924 .cloned()
12925 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
12926 )
12927 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
12928 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
12929 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
12934 save_canonical_pin_and_alias(
12935 cfg,
12936 &trust_directory,
12937 brain,
12938 &resolved_brain,
12939 TrustState {
12940 v: 2,
12941 origin: normalized_origin(&cfg.hub)?,
12942 requested: resolved_brain.clone(),
12943 brain: resolved_brain.clone(),
12944 home: None,
12945 anchor: anchor.clone(),
12946 current: format!("ed25519:{}", identity.fingerprint),
12947 head_seq: 0,
12948 feed_hash: None,
12949 rotations: identity.rotations.clone(),
12950 hub_signer: None,
12951 protocol_profile: None,
12952 },
12953 alias_binding.as_ref(),
12954 )?;
12955 return Ok(VerifiedRemote {
12956 head: Head {
12957 brain: resolved_brain,
12958 seq,
12959 updated_at,
12960 feed_hash: None,
12961 verified: true,
12962 },
12963 identity: Some(identity),
12964 head_entry: None,
12965 entries: Vec::new(),
12966 anchor: Some(anchor),
12967 });
12968 }
12969 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
12970 return Err(invalid_feed(
12971 "non-empty feed did not advertise a valid SHA-256 head",
12972 ));
12973 }
12974
12975 let replay_head_only = !require_full_chain
12979 && pinned
12980 .as_ref()
12981 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
12982 let mut after = if replay_head_only {
12983 seq - 1
12984 } else if require_full_chain || pinned.is_none() {
12985 0
12986 } else {
12987 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
12988 };
12989 let mut expected_seq = after + 1;
12990 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
12991 None
12992 } else {
12993 pinned
12994 .as_ref()
12995 .and_then(|checkpoint| checkpoint.feed_hash.clone())
12996 };
12997 let mut identity: Option<FeedIdentity> = None;
12998 let mut anchor: Option<String> = None;
12999 let mut head_entry: Option<FeedItem> = None;
13000 let mut all_entries = Vec::new();
13001 let mut observed_entries = Vec::new();
13002 let replay_count = seq
13003 .checked_sub(after)
13004 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
13005 if replay_count > MAX_FEED_REPLAY_ENTRIES {
13006 return Err(invalid_feed(format!(
13007 "feed replay requires {replay_count} entries, over the client cap"
13008 )));
13009 }
13010 let mut replay_bytes = 0u64;
13011
13012 loop {
13013 let feed_bytes = ensure_raw_ok(
13014 request_raw(
13015 cfg,
13016 "GET",
13017 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
13018 None,
13019 Auth::Required,
13020 MAX_FEED_RESPONSE_BYTES,
13021 )?,
13022 "subscribe feed",
13023 )?;
13024 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
13025 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
13026 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
13027 return Err(invalid_feed("brain card and feed head disagree"));
13028 }
13029 if feed.entries.len() > FEED_PAGE_LIMIT {
13030 return Err(invalid_feed("feed page exceeds the requested entry limit"));
13031 }
13032 if feed.scope_limited {
13033 if require_full_chain {
13034 return Err(invalid_feed(
13035 "path-scoped grants cannot verify a full snapshot chain",
13036 ));
13037 }
13038 return Ok(VerifiedRemote {
13039 head: Head {
13040 brain: resolved_brain,
13041 seq,
13042 updated_at,
13043 feed_hash: advertised_hash,
13044 verified: false,
13045 },
13046 identity: None,
13047 head_entry: None,
13048 entries: Vec::new(),
13049 anchor: None,
13050 });
13051 }
13052 let page_identity = feed
13053 .identity
13054 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
13055 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
13056 if identity
13057 .as_ref()
13058 .is_some_and(|existing| existing != &page_identity)
13059 {
13060 return Err(invalid_feed("identity changed while reading the feed"));
13061 }
13062 if anchor
13063 .as_ref()
13064 .is_some_and(|existing| existing != &page_anchor)
13065 {
13066 return Err(invalid_feed(
13067 "identity anchor changed while reading the feed",
13068 ));
13069 }
13070 identity = Some(page_identity.clone());
13071 if anchor.is_none() {
13072 anchor = Some(page_anchor);
13073 }
13074 if feed.entries.is_empty() {
13075 return Err(invalid_feed("feed page was empty before the signed head"));
13076 }
13077
13078 for item in feed.entries {
13079 if item.entry.seq != expected_seq {
13080 return Err(invalid_feed(format!(
13081 "expected entry {expected_seq}, feed served {}",
13082 item.entry.seq
13083 )));
13084 }
13085 if item.entry.seq > seq {
13086 return Err(invalid_feed("feed advanced past the card snapshot"));
13087 }
13088 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
13089 return Err(invalid_feed(format!(
13090 "entry {} does not chain to the local checkpoint",
13091 item.entry.seq
13092 )));
13093 }
13094 verify_feed_item(&item, &page_identity)?;
13095 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
13096 replay_bytes = replay_bytes.saturating_add(
13097 serde_json::to_vec(&item)
13098 .map_err(|_| invalid_feed("could not size feed entry"))?
13099 .len() as u64,
13100 );
13101 if replay_bytes > MAX_FEED_REPLAY_BYTES {
13102 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
13103 }
13104 previous_hash = Some(item.hash.clone());
13105 after = item.entry.seq;
13106 expected_seq = expected_seq
13107 .checked_add(1)
13108 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
13109 if require_full_chain {
13110 all_entries.push(item.clone());
13111 }
13112 observed_entries.push(item.clone());
13113 head_entry = Some(item);
13114 }
13115 if after == seq {
13116 break;
13117 }
13118 }
13119
13120 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
13121 return Err(invalid_feed(
13122 "verified chain does not converge on the advertised head",
13123 ));
13124 }
13125 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
13126 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
13127 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
13128 save_canonical_pin_and_alias(
13129 cfg,
13130 &trust_directory,
13131 brain,
13132 &resolved_brain,
13133 TrustState {
13134 v: 2,
13135 origin: normalized_origin(&cfg.hub)?,
13136 requested: resolved_brain.clone(),
13137 brain: resolved_brain.clone(),
13138 home: None,
13139 anchor: anchor.clone(),
13140 current: format!("ed25519:{}", identity.fingerprint),
13141 head_seq: seq,
13142 feed_hash: advertised_hash.clone(),
13143 rotations: identity.rotations.clone(),
13144 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
13145 protocol_profile: pinned
13146 .as_ref()
13147 .and_then(|state| state.protocol_profile.clone()),
13148 },
13149 alias_binding.as_ref(),
13150 )?;
13151 Ok(VerifiedRemote {
13152 head: Head {
13153 brain: resolved_brain,
13154 seq,
13155 updated_at,
13156 feed_hash: advertised_hash,
13157 verified: true,
13158 },
13159 identity: Some(identity),
13160 head_entry,
13161 entries: all_entries,
13162 anchor: Some(anchor),
13163 })
13164}
13165
13166pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
13171 Ok(verified_remote_head(cfg, brain, false)?.head)
13172}
13173
13174#[cfg(test)]
13175mod tests {
13176 use super::*;
13177
13178 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
13179
13180 fn merge_fixture(
13181 base: Option<&str>,
13182 remote: Option<&str>,
13183 local: Option<&str>,
13184 keep_local: bool,
13185 ) -> V2PulledMerge<String> {
13186 let map = |value: Option<&str>| {
13187 value
13188 .map(|value| [("records/a.md".to_string(), value.to_string())])
13189 .into_iter()
13190 .flatten()
13191 .collect::<std::collections::BTreeMap<_, _>>()
13192 };
13193 merge_v2_pulled_records(
13194 &map(base),
13195 &map(remote),
13196 &map(local),
13197 |value, _| value.clone(),
13198 |value, _| value.clone(),
13199 |_| keep_local,
13200 )
13201 }
13202
13203 #[test]
13204 fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
13205 let path = "records/a.md".to_string();
13206
13207 let local_add = merge_fixture(None, None, Some("local"), false);
13208 assert_eq!(
13209 local_add.records.get(&path).map(String::as_str),
13210 Some("local")
13211 );
13212 assert!(local_add.accept_remote.is_empty());
13213 assert!(local_add.conflicts.is_empty());
13214
13215 let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
13216 assert_eq!(
13217 local_edit.records.get(&path).map(String::as_str),
13218 Some("local")
13219 );
13220 assert!(local_edit.accept_remote.is_empty());
13221 assert!(local_edit.conflicts.is_empty());
13222
13223 let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
13224 assert!(!local_delete.records.contains_key(&path));
13225 assert!(local_delete.accept_remote.is_empty());
13226 assert!(local_delete.conflicts.is_empty());
13227
13228 let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
13229 assert_eq!(
13230 remote_edit.records.get(&path).map(String::as_str),
13231 Some("remote")
13232 );
13233 assert!(remote_edit.accept_remote.contains(&path));
13234 assert!(remote_edit.conflicts.is_empty());
13235
13236 let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
13237 assert!(!remote_delete.records.contains_key(&path));
13238 assert!(remote_delete.accept_remote.contains(&path));
13239 assert!(remote_delete.conflicts.is_empty());
13240
13241 let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
13242 assert_eq!(
13243 same_edit.records.get(&path).map(String::as_str),
13244 Some("same")
13245 );
13246 assert!(same_edit.accept_remote.contains(&path));
13247 assert!(same_edit.conflicts.is_empty());
13248
13249 let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
13250 assert_eq!(conflict.conflicts, vec![path.clone()]);
13251 assert_eq!(
13252 conflict.records.get(&path).map(String::as_str),
13253 Some("local")
13254 );
13255 assert!(conflict.accept_remote.is_empty());
13256
13257 let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
13258 assert_eq!(
13259 kept_home.records.get(&path).map(String::as_str),
13260 Some("local")
13261 );
13262 assert!(kept_home.accept_remote.is_empty());
13263 assert!(kept_home.conflicts.is_empty());
13264 }
13265
13266 #[test]
13267 fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
13268 let path = "sources/report.pdf";
13269 let record = crate::AssetRecord {
13270 path: path.to_string(),
13271 sha256: "a".repeat(64),
13272 bytes: 42,
13273 media_type: "application/pdf".to_string(),
13274 wrappers: vec!["gzip".to_string()],
13275 required: true,
13276 };
13277 let mut remote = V2BaselineAsset {
13278 blob_sha256: record.sha256.clone(),
13279 bytes: record.bytes,
13280 media_type: record.media_type.clone(),
13281 wrappers: record.wrappers.clone(),
13282 required: record.required,
13283 disposition: "withheld".to_string(),
13284 leaf_hash: "b".repeat(64),
13285 };
13286
13287 assert!(v2_asset_resumes_hosting(
13288 Some(&remote),
13289 path,
13290 &record,
13291 "hosted"
13292 ));
13293 assert!(!v2_asset_resumes_hosting(
13294 Some(&remote),
13295 path,
13296 &record,
13297 "withheld"
13298 ));
13299
13300 remote.disposition = "hosted".to_string();
13301 assert!(!v2_asset_resumes_hosting(
13302 Some(&remote),
13303 path,
13304 &record,
13305 "hosted"
13306 ));
13307
13308 remote.disposition = "withheld".to_string();
13309 remote.blob_sha256 = "c".repeat(64);
13310 assert!(!v2_asset_resumes_hosting(
13311 Some(&remote),
13312 path,
13313 &record,
13314 "hosted"
13315 ));
13316 assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
13317 }
13318
13319 #[test]
13320 fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
13321 let path = "records/team/alpha.md".to_string();
13322 let deleted_path = "records/team/deleted.md".to_string();
13323 let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
13324 sha256,
13325 bytes,
13326 file: None,
13327 };
13328 let files = vec![
13329 V2ConflictFile {
13330 path: path.clone(),
13331 base: coordinate(None, None),
13332 local: coordinate(Some("b".repeat(64)), Some(7)),
13333 remote: coordinate(Some("a".repeat(64)), Some(5)),
13334 },
13335 V2ConflictFile {
13336 path: deleted_path.clone(),
13337 base: coordinate(Some("c".repeat(64)), Some(9)),
13338 local: coordinate(Some("d".repeat(64)), Some(11)),
13339 remote: coordinate(None, None),
13340 },
13341 ];
13342 let proven = V2BaselineFile {
13343 sha256: "a".repeat(64),
13344 bytes: 5,
13345 proof: None,
13346 };
13347 let current = [(path.clone(), proven.clone())]
13348 .into_iter()
13349 .collect::<std::collections::BTreeMap<_, _>>();
13350
13351 let (selected, deleted) = v2_take_remote_selection(&files, ¤t).unwrap();
13352 assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
13353 assert_eq!(deleted, vec![deleted_path.clone()]);
13354
13355 let changed = [(
13356 path.clone(),
13357 V2BaselineFile {
13358 sha256: "e".repeat(64),
13359 bytes: 5,
13360 proof: None,
13361 },
13362 )]
13363 .into_iter()
13364 .collect::<std::collections::BTreeMap<_, _>>();
13365 assert!(v2_take_remote_selection(&files, &changed).is_err());
13366
13367 let resurrected = [
13368 (path, proven),
13369 (
13370 deleted_path,
13371 V2BaselineFile {
13372 sha256: "f".repeat(64),
13373 bytes: 13,
13374 proof: None,
13375 },
13376 ),
13377 ]
13378 .into_iter()
13379 .collect::<std::collections::BTreeMap<_, _>>();
13380 assert!(v2_take_remote_selection(&files, &resurrected).is_err());
13381 }
13382
13383 #[cfg(target_os = "linux")]
13384 #[test]
13385 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
13386 use std::os::fd::AsRawFd as _;
13387
13388 let sandbox = tempfile::TempDir::new().unwrap();
13389 let parent = std::fs::File::open(sandbox.path()).unwrap();
13390 let stage = std::ffi::CString::new("stage").unwrap();
13391 let destination = std::ffi::CString::new("brain").unwrap();
13392
13393 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
13394 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
13395 install_stage_at(
13396 parent.as_raw_fd(),
13397 stage.as_c_str(),
13398 destination.as_c_str(),
13399 false,
13400 )
13401 .unwrap();
13402 assert!(!sandbox.path().join("stage").exists());
13403 assert_eq!(
13404 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
13405 b"created"
13406 );
13407
13408 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
13409 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
13410 install_stage_at(
13411 parent.as_raw_fd(),
13412 stage.as_c_str(),
13413 destination.as_c_str(),
13414 true,
13415 )
13416 .unwrap();
13417 assert_eq!(
13418 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
13419 b"replacement"
13420 );
13421 assert_eq!(
13422 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
13423 b"created",
13424 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
13425 );
13426 }
13427
13428 struct SignedRemoteFixture {
13429 card: String,
13430 feed: String,
13431 key: AgentSigningKey,
13432 identity: FeedIdentity,
13433 }
13434
13435 fn signed_remote_fixture() -> SignedRemoteFixture {
13436 let rng = ring::rand::SystemRandom::new();
13437 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13438 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13439 let (public_key, multikey) = public_identity_for(&pair);
13440 let identity = FeedIdentity {
13441 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
13442 public_key_spki: public_key.clone(),
13443 previous: Vec::new(),
13444 rotations: Vec::new(),
13445 };
13446 let mut entry = FeedEntry {
13447 v: 1,
13448 seq: 1,
13449 ts: "2026-07-30T12:00:00.000Z".to_string(),
13450 brain: multikey.clone(),
13451 public_key: public_key.clone(),
13452 kind: "push".to_string(),
13453 op: "snapshot".to_string(),
13454 pack_sha256: "a".repeat(64),
13455 files: Vec::new(),
13456 removed: Vec::new(),
13457 prev_entry_hash: None,
13458 sig: String::new(),
13459 };
13460 let unsigned = UnsignedFeedEntry {
13461 v: entry.v,
13462 seq: entry.seq,
13463 ts: &entry.ts,
13464 brain: &entry.brain,
13465 public_key: &entry.public_key,
13466 kind: &entry.kind,
13467 op: &entry.op,
13468 pack_sha256: &entry.pack_sha256,
13469 files: &entry.files,
13470 removed: &entry.removed,
13471 prev_entry_hash: &entry.prev_entry_hash,
13472 };
13473 entry.sig =
13474 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
13475 let mut exact = serde_json::to_vec(&entry).unwrap();
13476 exact.push(b'\n');
13477 let hash = content_sha256(&exact);
13478 let card = json!({
13479 "id": TEST_BRAIN_ID,
13480 "headSeq": 1,
13481 "feedHash": hash,
13482 "identity": identity.clone(),
13483 })
13484 .to_string();
13485 let feed = json!({
13486 "headSeq": 1,
13487 "feedHash": hash,
13488 "identity": identity.clone(),
13489 "entries": [{"hash": hash, "entry": entry}],
13490 "scopeLimited": false,
13491 })
13492 .to_string();
13493 SignedRemoteFixture {
13494 card,
13495 feed,
13496 key: AgentSigningKey {
13497 pkcs8: pkcs8.as_ref().to_vec(),
13498 multikey,
13499 public_key_spki: public_key,
13500 },
13501 identity,
13502 }
13503 }
13504
13505 #[test]
13506 fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
13507 let file = |path: &str, byte: char| FeedFile {
13508 path: path.to_string(),
13509 sha256: byte.to_string().repeat(64),
13510 bytes: 1,
13511 };
13512 let a0 = file("records/a.md", 'a');
13513 let a1 = file("records/a.md", 'b');
13514 let stable = file("records/stable.md", 'c');
13515 let added = file("records/added.md", 'd');
13516 let removed_file = file("records/removed.md", 'e');
13517 let previous = vec![a0, stable.clone(), removed_file.clone()];
13518 let resulting = vec![a1.clone(), stable.clone(), added.clone()];
13519 let removed = vec![removed_file.path.clone()];
13520
13521 assert_eq!(
13522 verify_v1_manifest_disclosure(
13523 "edit",
13524 &previous,
13525 &resulting,
13526 &[a1.clone(), added.clone()],
13527 &removed,
13528 ),
13529 Ok(())
13530 );
13531 assert_eq!(
13532 verify_v1_manifest_disclosure(
13533 "edit",
13534 &previous,
13535 &resulting,
13536 &[stable.clone(), added.clone(), a1.clone()],
13537 &removed,
13538 ),
13539 Ok(())
13540 );
13541 assert_eq!(
13542 verify_v1_manifest_disclosure(
13543 "edit",
13544 &previous,
13545 &resulting,
13546 std::slice::from_ref(&added),
13547 &removed,
13548 ),
13549 Err(V1DisclosureError::EditMissingChange)
13550 );
13551 assert_eq!(
13552 verify_v1_manifest_disclosure(
13553 "edit",
13554 &previous,
13555 &resulting,
13556 &[file("records/a.md", 'f'), added.clone()],
13557 &removed,
13558 ),
13559 Err(V1DisclosureError::EditFalseFile)
13560 );
13561 assert_eq!(
13562 verify_v1_manifest_disclosure(
13563 "edit",
13564 &previous,
13565 &resulting,
13566 &[a1.clone(), added.clone()],
13567 &[],
13568 ),
13569 Err(V1DisclosureError::RemovedMismatch)
13570 );
13571 assert_eq!(
13572 verify_v1_manifest_disclosure(
13573 "push",
13574 &previous,
13575 &resulting,
13576 &[added.clone(), stable, a1],
13577 &removed,
13578 ),
13579 Ok(())
13580 );
13581 assert_eq!(
13582 verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
13583 Err(V1DisclosureError::PushManifestMismatch)
13584 );
13585 }
13586
13587 #[test]
13588 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
13589 let fixture = signed_remote_fixture();
13590 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
13591 let item = feed["entries"][0].to_string();
13592 let oversized_page = format!(
13593 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
13594 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
13595 .collect::<Vec<_>>()
13596 .join(",")
13597 );
13598 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
13599
13600 let oversized_identity = format!(
13601 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
13602 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
13603 .collect::<Vec<_>>()
13604 .join(",")
13605 );
13606 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
13607
13608 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
13609 let oversized_entry = format!(
13610 "{{\"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\"}}",
13611 "a".repeat(64),
13612 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
13613 .collect::<Vec<_>>()
13614 .join(",")
13615 );
13616 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
13617 }
13618
13619 #[test]
13620 fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
13621 let id = "01arz3ndektsv4rrffq69g5fav";
13622 let digest = "a".repeat(64);
13623 assert_eq!(
13624 V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
13625 V2BulkConfirmation {
13626 id: id.to_string(),
13627 digest,
13628 }
13629 );
13630 for invalid in [
13631 "",
13632 "01arz3ndektsv4rrffq69g5fav",
13633 "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
13634 "01arz3ndektsv4rrffq69g5fav:ABCDEF",
13635 "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
13636 ] {
13637 assert!(matches!(
13638 V2BulkConfirmation::parse(invalid),
13639 Err(LinkError::InvalidPack { .. })
13640 ));
13641 }
13642 }
13643
13644 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
13645 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
13646 use std::net::TcpListener;
13647
13648 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
13649 let url = format!("http://{}", listener.local_addr().unwrap());
13650 let handle = std::thread::spawn(move || {
13651 for (status, body) in responses {
13652 let (stream, _) = listener.accept().unwrap();
13653 let mut reader = BufReader::new(stream);
13654 let mut line = String::new();
13655 reader.read_line(&mut line).unwrap();
13656 let mut content_length = 0usize;
13657 loop {
13658 line.clear();
13659 reader.read_line(&mut line).unwrap();
13660 if line == "\r\n" || line == "\n" || line.is_empty() {
13661 break;
13662 }
13663 if let Some((name, value)) = line.split_once(':') {
13664 if name.eq_ignore_ascii_case("content-length") {
13665 content_length = value.trim().parse().unwrap();
13666 }
13667 }
13668 }
13669 let mut request_body = vec![0_u8; content_length];
13670 reader.read_exact(&mut request_body).unwrap();
13671 let response = format!(
13672 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
13673 body.len()
13674 );
13675 reader.get_mut().write_all(response.as_bytes()).unwrap();
13676 }
13677 });
13678 (url, handle)
13679 }
13680
13681 fn routed_json_hub(
13682 requests: usize,
13683 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
13684 ) -> (String, std::thread::JoinHandle<()>) {
13685 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
13686 use std::net::TcpListener;
13687
13688 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
13689 let url = format!("http://{}", listener.local_addr().unwrap());
13690 let handle = std::thread::spawn(move || {
13691 for _ in 0..requests {
13692 let (stream, _) = listener.accept().unwrap();
13693 let mut reader = BufReader::new(stream);
13694 let mut line = String::new();
13695 reader.read_line(&mut line).unwrap();
13696 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
13697 let mut content_length = 0usize;
13698 loop {
13699 line.clear();
13700 reader.read_line(&mut line).unwrap();
13701 if line == "\r\n" || line == "\n" || line.is_empty() {
13702 break;
13703 }
13704 if let Some((name, value)) = line.split_once(':') {
13705 if name.eq_ignore_ascii_case("content-length") {
13706 content_length = value.trim().parse().unwrap();
13707 }
13708 }
13709 }
13710 let mut request_body = vec![0_u8; content_length];
13711 reader.read_exact(&mut request_body).unwrap();
13712 let (status, body) = respond(&path);
13713 let response = format!(
13714 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
13715 body.len()
13716 );
13717 reader.get_mut().write_all(response.as_bytes()).unwrap();
13718 }
13719 });
13720 (url, handle)
13721 }
13722
13723 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
13724 HubConfig {
13725 hub,
13726 key: Some("test-key".to_string()),
13727 agent_key: None,
13728 brain_key: None,
13729 state_dir,
13730 store_selected: false,
13731 }
13732 }
13733
13734 #[test]
13735 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
13736 use ring::signature::KeyPair as _;
13737
13738 let rng = ring::rand::SystemRandom::new();
13739 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13740 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13741 let (spki, multikey) = public_identity_for(&pair);
13742 let key = AgentSigningKey {
13743 pkcs8: pkcs8.as_ref().to_vec(),
13744 multikey,
13745 public_key_spki: spki,
13746 };
13747 let header = linkmd_sig_header(
13748 &key,
13749 "https://hub-a.example",
13750 "post",
13751 "/api/hub/brains/brain/push?mode=exact",
13752 Some("{\"ok\":true}"),
13753 )
13754 .unwrap();
13755 assert!(header.starts_with("LinkMD-Sig v2,"));
13756 let ts = header
13757 .split(",ts=")
13758 .nth(1)
13759 .unwrap()
13760 .split(',')
13761 .next()
13762 .unwrap();
13763 let signature = URL_SAFE_NO_PAD
13764 .decode(header.rsplit(",sig=").next().unwrap())
13765 .unwrap();
13766 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
13767 let accepted = format!(
13768 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
13769 );
13770 let replayed = format!(
13771 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
13772 );
13773 let public = pair.public_key().as_ref();
13774 assert!(UnparsedPublicKey::new(&ED25519, public)
13775 .verify(accepted.as_bytes(), &signature)
13776 .is_ok());
13777 assert!(
13778 UnparsedPublicKey::new(&ED25519, public)
13779 .verify(replayed.as_bytes(), &signature)
13780 .is_err(),
13781 "a proof captured at hub A must not authenticate at hub B"
13782 );
13783 }
13784
13785 #[test]
13786 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
13787 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
13788 let card = json!({
13789 "id": other,
13790 "headSeq": 0,
13791 "identity": signed_remote_fixture().identity,
13792 })
13793 .to_string();
13794 let (hub, server) = scripted_json_hub(vec![(200, card)]);
13795 let state = tempfile::tempdir().unwrap();
13796 let cfg = test_hub_config(hub, state.path().to_path_buf());
13797 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13798 assert!(
13799 error.contains("differs from the explicitly requested"),
13800 "{error}"
13801 );
13802 server.join().unwrap();
13803 }
13804
13805 #[test]
13806 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
13807 let first = signed_remote_fixture().identity;
13808 let second = signed_remote_fixture().identity;
13809 let card = |identity: FeedIdentity| {
13810 json!({
13811 "id": TEST_BRAIN_ID,
13812 "headSeq": 0,
13813 "identity": identity,
13814 })
13815 .to_string()
13816 };
13817 let (hub, server) = scripted_json_hub(vec![(200, card(first)), (200, card(second))]);
13818 let state = tempfile::tempdir().unwrap();
13819 let cfg = test_hub_config(hub, state.path().to_path_buf());
13820 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
13821 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13822 assert!(
13823 error.contains("pinned anchor") || error.contains("forked away"),
13824 "{error}"
13825 );
13826 server.join().unwrap();
13827 }
13828
13829 #[test]
13830 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
13831 let old = signed_remote_fixture();
13832 let new = signed_remote_fixture();
13833 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
13834 let unsigned = serde_json::to_string(&UnsignedRotation {
13835 v: 1,
13836 op: "rotate",
13837 brain: &old.key.multikey,
13838 public_key: &old.key.public_key_spki,
13839 new_brain: &new.key.multikey,
13840 new_public_key: &new.key.public_key_spki,
13841 prior_head_seq: 1,
13842 prior_feed_hash: Some(&"a".repeat(64)),
13843 ts: "2026-07-30T12:00:00.000Z".to_string(),
13844 })
13845 .unwrap();
13846 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
13847 let rotation = format!(
13848 "{},\"sig\":\"{}\"}}",
13849 &unsigned[..unsigned.len() - 1],
13850 signature
13851 );
13852 let identity = FeedIdentity {
13853 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
13854 public_key_spki: new.key.public_key_spki,
13855 previous: vec![PreviousIdentity {
13856 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
13857 public_key_spki: old.key.public_key_spki,
13858 }],
13859 rotations: vec![rotation],
13860 };
13861 let card = json!({
13862 "id": TEST_BRAIN_ID,
13863 "headSeq": 0,
13864 "feedHash": null,
13865 "identity": identity,
13866 })
13867 .to_string();
13868 let (hub, server) = scripted_json_hub(vec![(200, card)]);
13869 let state = tempfile::tempdir().unwrap();
13870 let cfg = test_hub_config(hub, state.path().to_path_buf());
13871 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13872 assert!(
13873 error.contains("rotation claims a feed boundary beyond the advertised head"),
13874 "{error}"
13875 );
13876 assert!(
13877 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
13878 "an inconsistent empty-head identity must not become the TOFU checkpoint"
13879 );
13880 server.join().unwrap();
13881 }
13882
13883 #[test]
13884 fn trust_checkpoint_rejects_a_later_fork() {
13885 let fixture = signed_remote_fixture();
13886 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
13887 fork["feedHash"] = Value::String("b".repeat(64));
13888 let (hub, server) = scripted_json_hub(vec![
13889 (200, fixture.card),
13890 (200, fixture.feed),
13891 (200, fork.to_string()),
13892 ]);
13893 let state = tempfile::tempdir().unwrap();
13894 let cfg = test_hub_config(hub, state.path().to_path_buf());
13895 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
13896 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
13897 server.join().unwrap();
13898 }
13899
13900 #[test]
13901 fn alias_and_canonical_id_share_one_identity_checkpoint() {
13902 let trusted = signed_remote_fixture();
13903 let attacker = signed_remote_fixture();
13904 let (hub, server) = scripted_json_hub(vec![
13905 (200, trusted.card),
13906 (200, trusted.feed),
13907 (200, attacker.card),
13908 ]);
13909 let state = tempfile::tempdir().unwrap();
13910 let cfg = test_hub_config(hub, state.path().to_path_buf());
13911 assert!(head(&cfg, "trusted-slug").unwrap().verified);
13912 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13913 assert!(
13914 error.contains("equivocation")
13915 || error.contains("pinned")
13916 || error.contains("identity"),
13917 "{error}"
13918 );
13919 server.join().unwrap();
13920 }
13921
13922 #[test]
13923 fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
13924 let state = tempfile::tempdir().unwrap();
13925 let cfg = test_hub_config(
13926 "https://hub.example".to_string(),
13927 state.path().to_path_buf(),
13928 );
13929 let directory = open_trust_dir(&cfg).unwrap();
13930 let old = TEST_BRAIN_ID;
13931 let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
13932 save_alias_in(
13933 &cfg,
13934 &directory,
13935 &AliasBinding {
13936 v: 1,
13937 origin: normalized_origin(&cfg.hub).unwrap(),
13938 requested: "company-brain".to_string(),
13939 brain: old.to_string(),
13940 home: Some("company-brain".to_string()),
13941 },
13942 )
13943 .unwrap();
13944
13945 let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
13946 assert!(matches!(
13947 error,
13948 LinkError::AliasRebindRequired {
13949 alias,
13950 from,
13951 to
13952 } if alias == "company-brain" && from == old && to == new
13953 ));
13954 let unchanged = load_alias_in(&cfg, &directory, "company-brain")
13955 .unwrap()
13956 .unwrap();
13957 assert_eq!(unchanged.brain, old);
13958 assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
13959 }
13960
13961 #[test]
13962 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
13963 let alpha = signed_remote_fixture();
13964 let beta = signed_remote_fixture();
13965 let alpha_card = alpha.card.clone();
13966 let alpha_feed = alpha.feed.clone();
13967 let beta_card = beta.card.clone();
13968 let beta_feed = beta.feed.clone();
13969 let (hub, server) = routed_json_hub(3, move |path| {
13970 if path.contains("/alpha/feed?") {
13971 (200, alpha_feed.clone())
13972 } else if path.contains("/beta/feed?") {
13973 (200, beta_feed.clone())
13974 } else if path.ends_with("/alpha") {
13975 (200, alpha_card.clone())
13976 } else if path.ends_with("/beta") {
13977 (200, beta_card.clone())
13978 } else {
13979 (500, r#"{"error":"unexpected path"}"#.to_string())
13980 }
13981 });
13982 let state = tempfile::tempdir().unwrap();
13983 let cfg = test_hub_config(hub, state.path().to_path_buf());
13984 let alpha_cfg = cfg.clone();
13985 let beta_cfg = cfg;
13986 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
13987 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
13988 let results = [first.join().unwrap(), second.join().unwrap()];
13989 assert_eq!(
13990 results.iter().filter(|result| result.is_ok()).count(),
13991 1,
13992 "only one alias identity may establish canonical TOFU: {results:?}"
13993 );
13994 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
13995 server.join().unwrap();
13996 }
13997
13998 #[cfg(unix)]
13999 #[test]
14000 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
14001 use std::os::unix::fs::symlink;
14002
14003 let fixture = signed_remote_fixture();
14004 let card = json!({
14005 "id": TEST_BRAIN_ID,
14006 "headSeq": 0,
14007 "feedHash": Value::Null,
14008 "identity": fixture.identity,
14009 })
14010 .to_string();
14011 let work = tempfile::tempdir().unwrap();
14012 let outside = tempfile::tempdir().unwrap();
14013 let state = work.path().join("state");
14014 let moved = work.path().join("state-held");
14015 let swap_state = state.clone();
14016 let swap_moved = moved.clone();
14017 let outside_path = outside.path().to_path_buf();
14018 let (hub, server) = routed_json_hub(1, move |_| {
14019 std::fs::rename(&swap_state, &swap_moved).unwrap();
14021 symlink(&outside_path, &swap_state).unwrap();
14022 (200, card.clone())
14023 });
14024 let cfg = test_hub_config(hub, state);
14025
14026 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
14027 assert_eq!(verified.head.seq, 0);
14028 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
14029 assert!(std::fs::read_dir(moved.join("trust"))
14030 .unwrap()
14031 .flatten()
14032 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
14033 server.join().unwrap();
14034 }
14035
14036 #[test]
14037 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
14038 let remote = signed_remote_fixture();
14039 let unrelated = signed_remote_fixture().key;
14040 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
14041 let state = tempfile::tempdir().unwrap();
14042 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
14043 cfg.brain_key = Some(unrelated);
14044 let error = sync_push(
14045 &cfg,
14046 TEST_BRAIN_ID,
14047 &[("DB.md".to_string(), "signed local content".to_string())],
14048 )
14049 .unwrap_err()
14050 .to_string();
14051 assert!(
14052 error.contains("not the verified current brain identity"),
14053 "{error}"
14054 );
14055 server.join().unwrap();
14056 }
14057
14058 #[test]
14059 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
14060 let remote = signed_remote_fixture();
14061 let new = signed_remote_fixture().key;
14062 let state = tempfile::tempdir().unwrap();
14063 let new_file = state.path().join("new.key");
14064 std::fs::write(
14065 &new_file,
14066 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
14067 )
14068 .unwrap();
14069 #[cfg(unix)]
14070 {
14071 use std::os::unix::fs::PermissionsExt as _;
14072 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
14073 }
14074 let forged = json!({
14075 "brain": TEST_BRAIN_ID,
14076 "identity": {
14077 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
14078 "publicKeySpki": new.public_key_spki,
14079 }
14080 })
14081 .to_string();
14082 let (hub, server) = scripted_json_hub(vec![
14083 (200, remote.card.clone()),
14084 (200, remote.feed.clone()),
14085 (200, forged),
14086 (200, remote.card),
14087 (200, remote.feed),
14088 ]);
14089 let cfg = test_hub_config(hub, state.path().to_path_buf());
14090 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
14091 .unwrap_err()
14092 .to_string();
14093 assert!(
14094 error.contains("without committing the verified new identity"),
14095 "{error}"
14096 );
14097 server.join().unwrap();
14098 }
14099
14100 #[test]
14101 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
14102 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
14103 let raw = format!(
14104 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
14105 );
14106 let pack = build_store_pack(&[
14107 (
14108 "DB.md".to_string(),
14109 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
14110 ),
14111 ("records/clients/truth.md".to_string(), raw.clone()),
14112 ])
14113 .unwrap();
14114 let by_id = resolve_from_verified_pack(
14115 "01j5qc3v9k4ym8rwbn2tqe6f7d",
14116 &AddressTarget::Id(record_id.to_string()),
14117 pack.clone(),
14118 )
14119 .unwrap();
14120 assert_eq!(by_id["document"]["summary"], "Signed truth");
14121 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
14122 assert_eq!(
14123 by_id["document"]["contentSha"],
14124 content_sha256(raw.as_bytes())
14125 );
14126
14127 let by_path = resolve_from_verified_pack(
14128 "01j5qc3v9k4ym8rwbn2tqe6f7d",
14129 &AddressTarget::Path("records/clients/truth.md".to_string()),
14130 pack,
14131 )
14132 .unwrap();
14133 assert_eq!(by_path["document"]["id"], record_id);
14134 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
14135 }
14136
14137 #[test]
14138 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
14139 let unsorted = vec![
14140 ("records/a.md".to_string(), "alpha\n".to_string()),
14141 ("DB.md".to_string(), "# db\n".to_string()),
14142 ];
14143 let sorted = vec![
14144 ("DB.md".to_string(), "# db\n".to_string()),
14145 ("records/a.md".to_string(), "alpha\n".to_string()),
14146 ];
14147 let pack = build_store_pack(&unsorted).unwrap();
14148
14149 assert_eq!(pack.len(), 219);
14154 assert_eq!(
14155 content_sha256(&pack),
14156 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
14157 );
14158 assert_eq!(pack, build_store_pack(&sorted).unwrap());
14159 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
14160 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
14161 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
14162
14163 assert_eq!(
14164 parse_store_pack(pack).unwrap(),
14165 vec![
14166 ("DB.md".to_string(), b"# db\n".to_vec()),
14167 ("records/a.md".to_string(), b"alpha\n".to_vec()),
14168 ]
14169 );
14170 }
14171
14172 #[test]
14173 fn canonical_store_pack_validates_every_path_before_writing() {
14174 let duplicate = vec![
14175 ("DB.md".to_string(), "first".to_string()),
14176 ("DB.md".to_string(), "second".to_string()),
14177 ];
14178 assert!(build_store_pack(&duplicate)
14179 .unwrap_err()
14180 .to_string()
14181 .contains("duplicate path"));
14182 assert!(matches!(
14183 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
14184 Err(LinkError::UnsafePath { .. })
14185 ));
14186 }
14187
14188 #[test]
14189 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
14190 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
14191 let mut bytes = vec![0_u8];
14194 let zip64_offset = bytes.len() as u64;
14195 bytes.extend_from_slice(b"PK\x06\x06");
14196 bytes.extend_from_slice(&44_u64.to_le_bytes());
14197 bytes.extend_from_slice(&[0_u8; 12]);
14198 bytes.extend_from_slice(&COUNT.to_le_bytes());
14199 bytes.extend_from_slice(&COUNT.to_le_bytes());
14200 bytes.extend_from_slice(&1_u64.to_le_bytes());
14201 bytes.extend_from_slice(&0_u64.to_le_bytes());
14202 bytes.extend_from_slice(b"PK\x06\x07");
14203 bytes.extend_from_slice(&0_u32.to_le_bytes());
14204 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
14205 bytes.extend_from_slice(&1_u32.to_le_bytes());
14206 bytes.extend_from_slice(b"PK\x05\x06");
14207 bytes.extend_from_slice(&0_u16.to_le_bytes());
14208 bytes.extend_from_slice(&0_u16.to_le_bytes());
14209 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14210 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14211 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14212 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14213 bytes.extend_from_slice(&0_u16.to_le_bytes());
14214
14215 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
14216 .unwrap_err()
14217 .to_string();
14218 assert!(error.contains("invalid file count"), "{error}");
14219 }
14220
14221 #[test]
14222 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
14223 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
14224 let mut bytes = vec![0_u8];
14225 let zip64_offset = bytes.len() as u64;
14226 bytes.extend_from_slice(b"PK\x06\x06");
14227 bytes.extend_from_slice(&44_u64.to_le_bytes());
14228 bytes.extend_from_slice(&[0_u8; 12]);
14229 bytes.extend_from_slice(&COUNT.to_le_bytes());
14230 bytes.extend_from_slice(&COUNT.to_le_bytes());
14231 bytes.extend_from_slice(&1_u64.to_le_bytes());
14232 bytes.extend_from_slice(&0_u64.to_le_bytes());
14233 bytes.extend_from_slice(b"PK\x06\x07");
14234 bytes.extend_from_slice(&0_u32.to_le_bytes());
14235 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
14236 bytes.extend_from_slice(&1_u32.to_le_bytes());
14237 bytes.extend_from_slice(b"PK\x05\x06");
14238 bytes.extend_from_slice(&0_u16.to_le_bytes());
14239 bytes.extend_from_slice(&0_u16.to_le_bytes());
14240 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14241 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14242 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14243 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14244 bytes.extend_from_slice(&0_u16.to_le_bytes());
14245 let fake_eocd = bytes.len() as u32;
14249 bytes.extend_from_slice(b"PK\x05\x06");
14250 bytes.extend_from_slice(&0_u16.to_le_bytes());
14251 bytes.extend_from_slice(&0_u16.to_le_bytes());
14252 bytes.extend_from_slice(&1_u16.to_le_bytes());
14253 bytes.extend_from_slice(&1_u16.to_le_bytes());
14254 bytes.extend_from_slice(&0_u32.to_le_bytes());
14255 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
14256 bytes.extend_from_slice(&0_u16.to_le_bytes());
14257
14258 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
14259 .unwrap_err()
14260 .to_string();
14261 assert!(error.contains("central directory"), "{error}");
14262 }
14263
14264 #[test]
14265 fn strict_http_status_handling_rejects_redirects_without_panicking() {
14266 let error = ensure_ok(
14267 HubResponse {
14268 status: 302,
14269 body: Some(json!({"redirect": "/elsewhere"})),
14270 },
14271 "mutation",
14272 )
14273 .unwrap_err();
14274 assert!(matches!(error, LinkError::Http { status: 302, .. }));
14275
14276 let error = ensure_raw_ok(
14277 RawHubResponse {
14278 status: 302,
14279 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
14280 },
14281 "feed",
14282 )
14283 .unwrap_err();
14284 assert!(matches!(error, LinkError::Http { status: 302, .. }));
14285 }
14286
14287 #[cfg(unix)]
14288 #[test]
14289 fn collect_push_files_refuses_external_symlink_and_nested_store() {
14290 use std::os::unix::fs::symlink;
14291
14292 let root = tempfile::tempdir().unwrap();
14293 std::fs::write(
14294 root.path().join("DB.md"),
14295 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
14296 )
14297 .unwrap();
14298 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
14299
14300 let external = tempfile::tempdir().unwrap();
14301 let secret = external.path().join("secret.md");
14302 std::fs::write(&secret, "TOP SECRET").unwrap();
14303 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
14304
14305 let store = Store::open_strict(root.path()).unwrap();
14306 let err = collect_push_files(&store).unwrap_err().to_string();
14307 assert!(err.contains("cannot push"), "{err}");
14308 assert!(
14309 !err.contains("TOP SECRET"),
14310 "external bytes must never leak"
14311 );
14312
14313 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
14314 let nested = root.path().join("records/nested");
14315 std::fs::create_dir_all(&nested).unwrap();
14316 std::fs::write(
14317 nested.join("DB.md"),
14318 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
14319 )
14320 .unwrap();
14321 let err = collect_push_files(&store).unwrap_err().to_string();
14322 assert!(err.contains("nested db.md store"), "{err}");
14323 }
14324
14325 #[cfg(unix)]
14326 #[test]
14327 fn remote_push_uses_opened_root_after_path_replacement() {
14328 use std::os::unix::fs::symlink;
14329
14330 let sandbox = tempfile::tempdir().unwrap();
14331 let root = sandbox.path().join("store");
14332 std::fs::create_dir_all(root.join("records/notes")).unwrap();
14333 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
14334 std::fs::write(
14335 root.join("records/notes/owned.md"),
14336 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
14337 )
14338 .unwrap();
14339 let store = Store::open_strict(&root).unwrap();
14340 let detached = sandbox.path().join("detached");
14341 std::fs::rename(&root, &detached).unwrap();
14342
14343 let replacement = sandbox.path().join("replacement");
14344 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
14345 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
14346 std::fs::write(
14347 replacement.join("records/notes/secret.md"),
14348 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
14349 )
14350 .unwrap();
14351 symlink(&replacement, &root).unwrap();
14352
14353 let files = collect_push_files(&store).unwrap();
14354 let wire_text = files
14355 .iter()
14356 .map(|(path, content)| format!("{path}\n{content}"))
14357 .collect::<Vec<_>>()
14358 .join("\n");
14359 assert!(wire_text.contains("owned upload"));
14360 assert!(!wire_text.contains("replacement sentinel"));
14361 assert!(!wire_text.contains("records/notes/secret.md"));
14362
14363 let remote = signed_remote_fixture();
14364 let (hub, server) = scripted_json_hub(vec![
14365 (200, remote.card),
14366 (200, remote.feed),
14367 (200, json!({"ok": true}).to_string()),
14368 ]);
14369 let state = tempfile::tempdir().unwrap();
14370 let cfg = test_hub_config(hub, state.path().to_path_buf());
14371 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
14372 assert_eq!(pushed, json!({"ok": true}));
14373 server.join().unwrap();
14374 }
14375
14376 #[test]
14377 fn signed_feed_item_verifies_identity_hash_and_signature() {
14378 use ring::rand::SystemRandom;
14379 use ring::signature::{Ed25519KeyPair, KeyPair};
14380
14381 const PREFIX: &[u8] = &[
14382 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
14383 ];
14384 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
14385 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14386 let mut spki = PREFIX.to_vec();
14387 spki.extend_from_slice(pair.public_key().as_ref());
14388 let public_key = URL_SAFE_NO_PAD.encode(&spki);
14389 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
14390 let mut entry = FeedEntry {
14391 v: 1,
14392 seq: 1,
14393 ts: "2026-07-14T00:00:00.000Z".to_string(),
14394 brain: format!("ed25519:{fingerprint}"),
14395 public_key: public_key.clone(),
14396 kind: "push".to_string(),
14397 op: "snapshot".to_string(),
14398 pack_sha256: "a".repeat(64),
14399 files: vec![FeedFile {
14400 path: "DB.md".to_string(),
14401 sha256: "b".repeat(64),
14402 bytes: 3,
14403 }],
14404 removed: vec![],
14405 prev_entry_hash: None,
14406 sig: String::new(),
14407 };
14408 let unsigned = UnsignedFeedEntry {
14409 v: entry.v,
14410 seq: entry.seq,
14411 ts: &entry.ts,
14412 brain: &entry.brain,
14413 public_key: &entry.public_key,
14414 kind: &entry.kind,
14415 op: &entry.op,
14416 pack_sha256: &entry.pack_sha256,
14417 files: &entry.files,
14418 removed: &entry.removed,
14419 prev_entry_hash: &entry.prev_entry_hash,
14420 };
14421 entry.sig =
14422 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
14423 let mut exact = serde_json::to_vec(&entry).unwrap();
14424 exact.push(b'\n');
14425 let item = FeedItem {
14426 hash: format!("{:x}", Sha256::digest(&exact)),
14427 entry,
14428 };
14429 let identity = FeedIdentity {
14430 fingerprint,
14431 public_key_spki: public_key,
14432 previous: Vec::new(),
14433 rotations: Vec::new(),
14434 };
14435 assert!(verify_feed_item(&item, &identity).is_ok());
14436 let mut tampered = item;
14437 tampered.entry.pack_sha256 = "c".repeat(64);
14438 assert!(verify_feed_item(&tampered, &identity).is_err());
14439 }
14440
14441 #[test]
14442 fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
14443 let rng = ring::rand::SystemRandom::new();
14444 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14445 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14446 let (spki, multikey) = public_identity_for(&pair);
14447 let identity = V2HeadIdentity {
14448 custody: "self".to_string(),
14449 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
14450 public_key_spki: spki.clone(),
14451 previous: Vec::new(),
14452 rotations: Vec::new(),
14453 };
14454 let unsigned = json!({
14455 "actor_ref": "a".repeat(64),
14456 "asset_root": Value::Null,
14457 "brain": multikey,
14458 "changes_sha256": "b".repeat(64),
14459 "control_revision": "c".repeat(64),
14460 "materializer": "dbmd-projection-v1",
14461 "op": "changeset",
14462 "parent_asset_root": Value::Null,
14463 "parent_commit": Value::Null,
14464 "parent_root": Value::Null,
14465 "prev_entry_hash": Value::Null,
14466 "public_key": spki,
14467 "seq": 1,
14468 "signer_epoch": 1,
14469 "state_root": "d".repeat(64),
14470 "ts": "2026-08-19T12:00:00.000Z",
14471 "v": 2,
14472 "v1_bridge": {
14473 "feed_hash": "e".repeat(64),
14474 "head_seq": 7,
14475 "pack_sha256": "f".repeat(64),
14476 },
14477 });
14478 let sign_value = |value: Value| {
14479 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
14480 let mut object = value.as_object().unwrap().clone();
14481 object.insert(
14482 "sig".to_string(),
14483 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14484 );
14485 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
14486 };
14487 assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
14488
14489 let mut extra = unsigned.clone();
14490 extra
14491 .as_object_mut()
14492 .unwrap()
14493 .insert("future".to_string(), Value::Bool(true));
14494 assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
14495
14496 let mut missing = unsigned.clone();
14497 missing.as_object_mut().unwrap().remove("v1_bridge");
14498 assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
14499
14500 let mut invalid_bridge = unsigned;
14501 invalid_bridge.as_object_mut().unwrap().insert(
14502 "v1_bridge".to_string(),
14503 json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
14504 );
14505 assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
14506 }
14507
14508 #[test]
14509 fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
14510 let vector: Value = serde_json::from_str(include_str!(
14511 "../tests/vectors/linkmd-v2-commit-bridge.json"
14512 ))
14513 .unwrap();
14514 let identity_value = vector.get("identity").unwrap();
14515 let identity = V2HeadIdentity {
14516 custody: "self".to_string(),
14517 fingerprint: identity_value
14518 .get("fingerprint")
14519 .and_then(Value::as_str)
14520 .unwrap()
14521 .to_string(),
14522 public_key_spki: identity_value
14523 .get("public_key_spki")
14524 .and_then(Value::as_str)
14525 .unwrap()
14526 .to_string(),
14527 previous: Vec::new(),
14528 rotations: Vec::new(),
14529 };
14530 let private = URL_SAFE_NO_PAD
14531 .decode(
14532 identity_value
14533 .get("private_key_pkcs8")
14534 .and_then(Value::as_str)
14535 .unwrap(),
14536 )
14537 .unwrap();
14538 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
14539 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
14540 .unwrap();
14541 let base = vector.get("body").unwrap().as_object().unwrap();
14542
14543 for item in vector.get("valid").unwrap().as_array().unwrap() {
14544 let mut body = base.clone();
14545 body.insert(
14546 "v1_bridge".to_string(),
14547 item.get("v1_bridge").unwrap().clone(),
14548 );
14549 body.insert(
14550 "sig".to_string(),
14551 item.get("signature_base64url").unwrap().clone(),
14552 );
14553 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
14554 assert!(verified_v2_commit_object(&signed, &identity).is_ok());
14555 assert_eq!(
14556 crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
14557 item.get("commit_hash").and_then(Value::as_str).unwrap()
14558 );
14559 assert_eq!(
14560 format!("{:x}", Sha256::digest(&signed)),
14561 item.get("feed_hash").and_then(Value::as_str).unwrap()
14562 );
14563 }
14564
14565 for item in vector.get("invalid").unwrap().as_array().unwrap() {
14566 let mut body = base.clone();
14567 if let Some(remove) = item.get("remove").and_then(Value::as_array) {
14568 for field in remove {
14569 body.remove(field.as_str().unwrap());
14570 }
14571 }
14572 if let Some(set) = item.get("set").and_then(Value::as_object) {
14573 for (field, value) in set {
14574 body.insert(field.clone(), value.clone());
14575 }
14576 }
14577 let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
14578 body.insert(
14579 "sig".to_string(),
14580 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14581 );
14582 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
14583 assert!(
14584 verified_v2_commit_object(&signed, &identity).is_err(),
14585 "accepted invalid shared vector {}",
14586 item.get("reason").and_then(Value::as_str).unwrap()
14587 );
14588 }
14589 }
14590
14591 #[test]
14592 fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
14593 let vector: Value = serde_json::from_str(include_str!(
14594 "../tests/vectors/linkmd-v2-changeset-withheld.json"
14595 ))
14596 .unwrap();
14597 assert_eq!(
14598 vector.get("profile").and_then(Value::as_str),
14599 Some("link.md-v2-changeset-withheld")
14600 );
14601 let canonical =
14602 crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
14603 let expected = STANDARD
14604 .decode(
14605 vector
14606 .get("canonical_base64")
14607 .and_then(Value::as_str)
14608 .unwrap(),
14609 )
14610 .unwrap();
14611 assert_eq!(canonical, expected);
14612 assert_eq!(
14613 crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
14614 vector.get("domain_hash").and_then(Value::as_str).unwrap()
14615 );
14616 }
14617
14618 #[test]
14619 fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
14620 let remote = signed_remote_fixture();
14621 let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
14622 let legacy_item = legacy.entries.first().unwrap();
14623 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
14624 let body = json!({
14625 "actor_ref": "a".repeat(64),
14626 "asset_root": Value::Null,
14627 "brain": remote.key.multikey,
14628 "changes_sha256": "b".repeat(64),
14629 "control_revision": "c".repeat(64),
14630 "materializer": "dbmd-projection-v1",
14631 "op": "changeset",
14632 "parent_asset_root": Value::Null,
14633 "parent_commit": Value::Null,
14634 "parent_root": Value::Null,
14635 "prev_entry_hash": Value::Null,
14636 "public_key": remote.key.public_key_spki,
14637 "seq": 1,
14638 "signer_epoch": 1,
14639 "state_root": "d".repeat(64),
14640 "ts": "2026-08-19T12:00:00.000Z",
14641 "v": 2,
14642 "v1_bridge": {
14643 "feed_hash": legacy_item.hash,
14644 "head_seq": legacy_item.entry.seq,
14645 "pack_sha256": legacy_item.entry.pack_sha256,
14646 },
14647 });
14648 let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
14649 let mut signed = body.as_object().unwrap().clone();
14650 signed.insert(
14651 "sig".to_string(),
14652 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14653 );
14654 let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
14655 let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
14656 let feed_hash = content_sha256(&raw);
14657 let pointer = V2PointerBody {
14658 v: 2,
14659 brain: TEST_BRAIN_ID.to_string(),
14660 seq: 1,
14661 commit_hash: commit_hash.clone(),
14662 feed_hash: feed_hash.clone(),
14663 content_root: Some("d".repeat(64)),
14664 asset_root: None,
14665 materializer: "dbmd-projection-v1".to_string(),
14666 signer_epoch: 1,
14667 control_revision: "c".repeat(64),
14668 backup_preparation: "e".repeat(64),
14669 prior_pointer_hash: None,
14670 signed_at: "2026-08-19T12:00:00.000Z".to_string(),
14671 };
14672 let v2_page = json!({
14673 "v": 2,
14674 "head_seq": 1,
14675 "head_commit_hash": commit_hash,
14676 "head_feed_hash": feed_hash,
14677 "entries": [{
14678 "seq": 1,
14679 "commit_hash": pointer.commit_hash,
14680 "feed_hash": pointer.feed_hash,
14681 "bytes_base64": STANDARD.encode(&raw),
14682 }],
14683 "next_after": 1,
14684 "complete": true,
14685 })
14686 .to_string();
14687 let identity = V2HeadIdentity {
14688 custody: "self".to_string(),
14689 fingerprint: remote.identity.fingerprint.clone(),
14690 public_key_spki: remote.identity.public_key_spki.clone(),
14691 previous: Vec::new(),
14692 rotations: Vec::new(),
14693 };
14694 let checkpoint = TrustState {
14695 v: 2,
14696 origin: "unused".to_string(),
14697 requested: TEST_BRAIN_ID.to_string(),
14698 brain: TEST_BRAIN_ID.to_string(),
14699 home: None,
14700 anchor: remote.key.multikey.clone(),
14701 current: remote.key.multikey,
14702 head_seq: legacy_item.entry.seq,
14703 feed_hash: Some(legacy_item.hash.clone()),
14704 rotations: Vec::new(),
14705 hub_signer: None,
14706 protocol_profile: None,
14707 };
14708 let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
14709 let state = tempfile::tempdir().unwrap();
14710 let cfg = test_hub_config(hub, state.path().to_path_buf());
14711 verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
14712 server.join().unwrap();
14713
14714 let mut wrong = checkpoint;
14715 wrong.feed_hash = Some("0".repeat(64));
14716 let (hub, server) = scripted_json_hub(vec![(
14717 200,
14718 json!({
14719 "v": 2,
14720 "head_seq": 1,
14721 "head_commit_hash": pointer.commit_hash,
14722 "head_feed_hash": pointer.feed_hash,
14723 "entries": [{
14724 "seq": 1,
14725 "commit_hash": pointer.commit_hash,
14726 "feed_hash": pointer.feed_hash,
14727 "bytes_base64": STANDARD.encode(&raw),
14728 }],
14729 "next_after": 1,
14730 "complete": true,
14731 })
14732 .to_string(),
14733 )]);
14734 let state = tempfile::tempdir().unwrap();
14735 let cfg = test_hub_config(hub, state.path().to_path_buf());
14736 assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
14737 server.join().unwrap();
14738 }
14739
14740 #[test]
14741 fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
14742 let rng = ring::rand::SystemRandom::new();
14743 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14744 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
14745 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14746 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
14747 let (old_spki, old_multikey) = public_identity_for(&old);
14748 let (new_spki, new_multikey) = public_identity_for(&new);
14749 let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
14750 v: 1,
14751 op: "rotate",
14752 brain: &old_multikey,
14753 public_key: &old_spki,
14754 new_brain: &new_multikey,
14755 new_public_key: &new_spki,
14756 prior_head_seq: 1,
14757 prior_feed_hash: Some(&"9".repeat(64)),
14758 ts: "2026-08-19T12:01:00.000Z".to_string(),
14759 })
14760 .unwrap();
14761 let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
14762 let rotation = format!(
14763 "{},\"sig\":\"{}\"}}",
14764 &rotation_unsigned[..rotation_unsigned.len() - 1],
14765 rotation_sig
14766 );
14767 let identity = V2HeadIdentity {
14768 custody: "self".to_string(),
14769 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
14770 public_key_spki: new_spki.clone(),
14771 previous: vec![V2PreviousIdentity {
14772 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
14773 public_key_spki: old_spki.clone(),
14774 }],
14775 rotations: vec![rotation],
14776 };
14777 let commit = |seq: u64,
14778 epoch: u64,
14779 multikey: &str,
14780 spki: &str,
14781 pair: &ring::signature::Ed25519KeyPair| {
14782 let value = json!({
14783 "actor_ref": "a".repeat(64),
14784 "asset_root": Value::Null,
14785 "brain": multikey,
14786 "changes_sha256": "b".repeat(64),
14787 "control_revision": "c".repeat(64),
14788 "materializer": "dbmd-projection-v1",
14789 "op": "changeset",
14790 "parent_asset_root": Value::Null,
14791 "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
14792 "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
14793 "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
14794 "public_key": spki,
14795 "seq": seq,
14796 "signer_epoch": epoch,
14797 "state_root": "1".repeat(64),
14798 "ts": "2026-08-19T12:00:00.000Z",
14799 "v": 2,
14800 "v1_bridge": Value::Null,
14801 });
14802 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
14803 let mut object = value.as_object().unwrap().clone();
14804 object.insert(
14805 "sig".to_string(),
14806 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14807 );
14808 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
14809 };
14810
14811 assert!(verified_v2_commit_object(
14812 &commit(1, 1, &old_multikey, &old_spki, &old),
14813 &identity,
14814 )
14815 .is_ok());
14816 assert!(verified_v2_commit_object(
14817 &commit(2, 2, &new_multikey, &new_spki, &new),
14818 &identity,
14819 )
14820 .is_ok());
14821 assert!(verified_v2_commit_object(
14822 &commit(2, 1, &old_multikey, &old_spki, &old),
14823 &identity,
14824 )
14825 .is_err());
14826 assert!(verified_v2_commit_object(
14827 &commit(1, 2, &new_multikey, &new_spki, &new),
14828 &identity,
14829 )
14830 .is_err());
14831 }
14832
14833 #[test]
14834 fn a_self_custody_entry_verifies_like_any_hub_entry() {
14835 let rng = ring::rand::SystemRandom::new();
14836 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14837 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14838 let (spki, multikey) = public_identity_for(&pair);
14839 let key = AgentSigningKey {
14840 pkcs8: pkcs8.as_ref().to_vec(),
14841 multikey: multikey.clone(),
14842 public_key_spki: spki.clone(),
14843 };
14844 let files = vec![WireFeedFile {
14845 path: "DB.md".to_string(),
14846 sha256: "a".repeat(64),
14847 bytes: 3,
14848 }];
14849 let raw = self_custody_entry(
14850 &key,
14851 1,
14852 "2026-07-23T12:00:00.000Z".to_string(),
14853 &"c".repeat(64),
14854 &files,
14855 None,
14856 )
14857 .unwrap();
14858 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
14862 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
14863 let item = FeedItem { hash, entry };
14864 let identity = FeedIdentity {
14865 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
14866 public_key_spki: spki,
14867 previous: Vec::new(),
14868 rotations: Vec::new(),
14869 };
14870 assert!(verify_feed_item(&item, &identity).is_ok());
14871 }
14872
14873 #[test]
14874 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
14875 let rng = ring::rand::SystemRandom::new();
14876 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14877 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
14878 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14879 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
14880 let (old_spki, old_multikey) = public_identity_for(&old);
14881 let (new_spki, new_multikey) = public_identity_for(&new);
14882 let unsigned = serde_json::to_string(&UnsignedRotation {
14883 v: 1,
14884 op: "rotate",
14885 brain: &old_multikey,
14886 public_key: &old_spki,
14887 new_brain: &new_multikey,
14888 new_public_key: &new_spki,
14889 prior_head_seq: 1,
14890 prior_feed_hash: Some(&"a".repeat(64)),
14891 ts: "2026-07-30T12:00:00.000Z".to_string(),
14892 })
14893 .unwrap();
14894 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
14895 let rotation = format!(
14896 "{},\"sig\":\"{}\"}}",
14897 &unsigned[..unsigned.len() - 1],
14898 signature
14899 );
14900 let identity = FeedIdentity {
14901 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
14902 public_key_spki: new_spki,
14903 previous: vec![PreviousIdentity {
14904 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
14905 public_key_spki: old_spki,
14906 }],
14907 rotations: vec![rotation],
14908 };
14909 let pin = TrustState {
14910 v: 2,
14911 origin: "https://hub.example".to_string(),
14912 requested: "brain".to_string(),
14913 brain: "brain".to_string(),
14914 home: None,
14915 anchor: old_multikey.clone(),
14916 current: old_multikey.clone(),
14917 head_seq: 1,
14918 feed_hash: Some("a".repeat(64)),
14919 rotations: Vec::new(),
14920 hub_signer: None,
14921 protocol_profile: None,
14922 };
14923 assert_eq!(
14924 verify_identity_chain(&identity, Some(&pin)).unwrap(),
14925 old_multikey
14926 );
14927 let mut accepted = pin.clone();
14928 accepted.current = new_multikey.clone();
14929 accepted.rotations = identity.rotations.clone();
14930 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
14931 v: 1,
14932 op: "rotate",
14933 brain: &old_multikey,
14934 public_key: &identity.previous[0].public_key_spki,
14935 new_brain: &new_multikey,
14936 new_public_key: &identity.public_key_spki,
14937 prior_head_seq: 1,
14938 prior_feed_hash: Some(&"a".repeat(64)),
14939 ts: "2026-07-30T12:00:01.000Z".to_string(),
14940 })
14941 .unwrap();
14942 let alternate_signature =
14943 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
14944 let mut rewritten = identity.clone();
14945 rewritten.rotations[0] = format!(
14946 "{},\"sig\":\"{}\"}}",
14947 &alternate_unsigned[..alternate_unsigned.len() - 1],
14948 alternate_signature
14949 );
14950 assert!(
14951 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
14952 "an alternate valid statement must not rewrite accepted history"
14953 );
14954
14955 let mut stale_entry = FeedEntry {
14956 v: 1,
14957 seq: 2,
14958 ts: "2026-07-30T12:01:00.000Z".to_string(),
14959 brain: pin.current.clone(),
14960 public_key: identity.previous[0].public_key_spki.clone(),
14961 kind: "push".to_string(),
14962 op: "snapshot".to_string(),
14963 pack_sha256: "b".repeat(64),
14964 files: Vec::new(),
14965 removed: Vec::new(),
14966 prev_entry_hash: pin.feed_hash.clone(),
14967 sig: String::new(),
14968 };
14969 let stale_unsigned = UnsignedFeedEntry {
14970 v: stale_entry.v,
14971 seq: stale_entry.seq,
14972 ts: &stale_entry.ts,
14973 brain: &stale_entry.brain,
14974 public_key: &stale_entry.public_key,
14975 kind: &stale_entry.kind,
14976 op: &stale_entry.op,
14977 pack_sha256: &stale_entry.pack_sha256,
14978 files: &stale_entry.files,
14979 removed: &stale_entry.removed,
14980 prev_entry_hash: &stale_entry.prev_entry_hash,
14981 };
14982 stale_entry.sig = URL_SAFE_NO_PAD.encode(
14983 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
14984 .as_ref(),
14985 );
14986 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
14987 stale_exact.push(b'\n');
14988 let stale_item = FeedItem {
14989 hash: content_sha256(&stale_exact),
14990 entry: stale_entry,
14991 };
14992 assert!(
14993 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
14994 .is_err(),
14995 "a key retired before the checkpoint must never append after it"
14996 );
14997 assert!(
14998 verify_feed_item(&stale_item, &identity).is_err(),
14999 "an old key must never append after its signed rotation boundary"
15000 );
15001
15002 let mut missing = identity.clone();
15003 missing.rotations.clear();
15004 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
15005
15006 let mut tampered = identity;
15007 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
15008 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
15009 }
15010
15011 #[cfg(unix)]
15012 #[test]
15013 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
15014 use std::os::unix::fs::symlink;
15015
15016 let dir = tempfile::tempdir().unwrap();
15017 let target = dir.path().join("valuable.txt");
15018 let planted = dir.path().join("agent.key");
15019 std::fs::write(&target, "do not overwrite").unwrap();
15020 symlink(&target, &planted).unwrap();
15021
15022 assert!(matches!(
15023 generate_agent_key(&planted),
15024 Err(LinkError::BadAgentKey { .. })
15025 ));
15026 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
15027 }
15028
15029 #[cfg(unix)]
15030 #[test]
15031 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
15032 use std::os::unix::fs::symlink;
15033
15034 let root = tempfile::tempdir().unwrap();
15035 let outside = tempfile::tempdir().unwrap();
15036 symlink(outside.path(), root.path().join("redirect")).unwrap();
15037
15038 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
15039 assert!(!outside.path().join("agent.key").exists());
15040 }
15041
15042 #[test]
15045 fn address_bare_brain_with_and_without_sigil() {
15046 for raw in ["@acme-ops", "acme-ops"] {
15047 let a = Address::parse(raw).expect(raw);
15048 assert_eq!(a.brain, "acme-ops");
15049 assert_eq!(a.target, None);
15050 }
15051 }
15052
15053 #[test]
15054 fn address_ulid_target_parses_as_id() {
15055 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
15056 assert_eq!(a.brain, "acme");
15057 assert_eq!(
15058 a.target,
15059 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
15060 );
15061 }
15062
15063 #[test]
15064 fn address_md_path_target_parses_as_path() {
15065 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
15066 assert_eq!(
15067 a.target,
15068 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
15069 );
15070 }
15071
15072 #[test]
15073 fn address_rejects_malformed_forms() {
15074 for raw in [
15075 "",
15076 "@",
15077 "@/x",
15078 "@acme/",
15079 "@acme/../etc/passwd",
15080 "@acme/records/.hidden.md",
15081 "@ACME", "@acme/notes/x.txt", "@a b", ] {
15085 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
15086 }
15087 }
15088
15089 #[test]
15092 fn safe_paths_accept_store_shapes_and_reject_escapes() {
15093 for ok in [
15094 "DB.md",
15095 "assets.jsonl",
15096 "records/clients/lumio.md",
15097 "sources/emails/2026/07/x.md",
15098 ] {
15099 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
15100 }
15101 for bad in [
15102 "",
15103 "/etc/passwd",
15104 "../up.md",
15105 "records/../../up.md",
15106 "records//x.md",
15107 ".dbmd/config",
15108 "records/.hidden/x.md",
15109 "records/a b.md",
15110 "records\\win.md",
15111 ] {
15112 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
15113 }
15114 }
15115
15116 #[cfg(unix)]
15117 #[test]
15118 fn opened_destination_capability_survives_an_ancestor_path_swap() {
15119 use std::os::unix::fs::symlink;
15120
15121 let work = tempfile::tempdir().unwrap();
15122 let outside = tempfile::tempdir().unwrap();
15123 let original = work.path().join("destination");
15124 let moved = work.path().join("destination-moved");
15125 let directory = open_or_create_dir_nofollow(&original).unwrap();
15126
15127 std::fs::rename(&original, &moved).unwrap();
15128 symlink(outside.path(), &original).unwrap();
15129 write_pull_entries_beneath_dir(
15130 &directory,
15131 &[("records/note.md".to_string(), b"held inode".to_vec())],
15132 )
15133 .unwrap();
15134
15135 assert_eq!(
15136 std::fs::read(moved.join("records/note.md")).unwrap(),
15137 b"held inode"
15138 );
15139 assert!(!outside.path().join("records/note.md").exists());
15140 }
15141
15142 #[test]
15146 fn hub_config_flag_beats_file_and_requires_some_source() {
15147 let dir = tempfile::tempdir().unwrap();
15148 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
15149 std::fs::write(
15150 dir.path().join(CONFIG_REL_PATH),
15151 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
15152 )
15153 .unwrap();
15154
15155 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
15156 assert_eq!(from_flag.hub, "https://flag.example.com");
15157
15158 let from_file = hub_config(None, dir.path()).unwrap();
15159 assert_eq!(from_file.hub, "https://file.example.com");
15160
15161 let none = hub_config(None, tempfile::tempdir().unwrap().path());
15162 assert!(matches!(none, Err(LinkError::NoHub)));
15163 }
15164
15165 #[test]
15166 fn https_guard_allows_loopback_only_for_plain_http() {
15167 assert!(assert_safe_hub("https://hub.example.com").is_ok());
15168 assert!(assert_safe_hub("http://localhost:3000").is_ok());
15169 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
15170 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
15171 assert!(matches!(
15172 assert_safe_hub("http://hub.example.com"),
15173 Err(LinkError::UnsafeHub { .. })
15174 ));
15175 assert!(matches!(
15176 assert_safe_hub("hub.example.com"),
15177 Err(LinkError::UnsafeHub { .. })
15178 ));
15179 assert!(matches!(
15180 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
15181 Err(LinkError::UnsafeHub { .. })
15182 ));
15183 assert!(matches!(
15184 assert_safe_hub("https://hub.example.com@attacker.example"),
15185 Err(LinkError::UnsafeHub { .. })
15186 ));
15187 assert!(matches!(
15188 assert_safe_hub("https://hub.example.com/base"),
15189 Err(LinkError::UnsafeHub { .. })
15190 ));
15191 }
15192
15193 #[test]
15194 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
15195 for blocked in [
15196 "127.0.0.1",
15197 "10.0.0.1",
15198 "100.64.0.1",
15199 "169.254.169.254",
15200 "172.16.0.1",
15201 "192.168.0.1",
15202 "192.88.99.1",
15203 "198.18.0.1",
15204 "203.0.113.1",
15205 "::1",
15206 "fe80::1",
15207 "fd00::1",
15208 "2001:db8::1",
15209 "2001:1::1",
15210 "2002:7f00:1::",
15211 "3fff::1",
15212 ] {
15213 assert!(
15214 !is_public_registry_ip(blocked.parse().unwrap()),
15215 "must block {blocked}"
15216 );
15217 }
15218 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
15219 assert!(is_public_registry_ip(
15220 "2606:4700:4700::1111".parse().unwrap()
15221 ));
15222 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
15223 }
15224
15225 #[test]
15226 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
15227 use ureq::Resolver as _;
15228
15229 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
15230 let resolver = PinnedRegistryResolver {
15231 netloc: "home.example:443".to_string(),
15232 addresses: vec![pinned],
15233 };
15234 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
15235 assert!(resolver.resolve("127.0.0.1:443").is_err());
15236 assert_eq!(
15237 resolver.resolve("home.example:443").unwrap(),
15238 vec![pinned],
15239 "subsequent connects reuse the validated answer instead of DNS"
15240 );
15241 }
15242
15243 #[test]
15244 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
15245 let cfg = HubConfig {
15246 hub: "https://hub.example".to_string(),
15247 key: None,
15248 agent_key: None,
15249 brain_key: None,
15250 state_dir: tempfile::tempdir().unwrap().keep(),
15251 store_selected: false,
15252 };
15253 assert!(
15254 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
15255 "a production hub must not turn its presigned URL into an SSRF primitive"
15256 );
15257
15258 let store_selected = HubConfig {
15259 hub: "https://127.0.0.1".to_string(),
15260 store_selected: true,
15261 ..cfg
15262 };
15263 assert!(
15264 hub_agent(&store_selected).is_err(),
15265 "bytes in a cloned store must not select a private-network hub"
15266 );
15267 }
15268
15269 #[test]
15270 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
15271 assert_eq!(
15272 one_past_bounded_limit(MAX_PACK_BYTES),
15273 Some(MAX_PACK_BYTES + 1),
15274 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
15275 );
15276 assert_eq!(
15277 presigned_download_read_limit(),
15278 MAX_PACK_BYTES + 1,
15279 "the presigned reader is capped by the client constant, not a hub response"
15280 );
15281 assert_eq!(
15282 one_past_bounded_limit(u64::MAX),
15283 None,
15284 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
15285 );
15286 }
15287
15288 #[test]
15289 fn https_guard_matches_the_scheme_case_insensitively() {
15290 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
15293 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
15294 assert!(matches!(
15296 assert_safe_hub("HTTP://hub.example.com"),
15297 Err(LinkError::UnsafeHub { .. })
15298 ));
15299 }
15300
15301 #[test]
15302 fn clean_key_refuses_paste_artifacts_without_echoing() {
15303 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
15304 for bad in ["vc account", "vc\naccount", "ключ", ""] {
15305 let err = clean_key(bad).unwrap_err();
15306 assert!(matches!(err, LinkError::BadKey));
15307 assert!(
15308 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
15309 "error must not echo the key"
15310 );
15311 }
15312 }
15313
15314 fn dead_hub() -> HubConfig {
15320 HubConfig {
15321 hub: "http://127.0.0.1:9".to_string(),
15322 key: Some("k".to_string()),
15323 agent_key: None,
15324 brain_key: None,
15325 state_dir: PathBuf::from("."),
15326 store_selected: false,
15327 }
15328 }
15329
15330 #[test]
15331 fn request_retries_a_connection_failure_before_sending() {
15332 use std::io::{Read as _, Write as _};
15333 use std::net::TcpListener;
15334 use std::thread;
15335 use std::time::Duration;
15336
15337 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
15338 let address = probe.local_addr().unwrap();
15339 drop(probe);
15340 let server = thread::spawn(move || {
15341 thread::sleep(Duration::from_millis(40));
15342 let listener = TcpListener::bind(address).unwrap();
15343 let (mut stream, _) = listener.accept().unwrap();
15344 let mut request_bytes = [0_u8; 1024];
15345 let _ = stream.read(&mut request_bytes).unwrap();
15346 stream
15347 .write_all(
15348 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
15349 )
15350 .unwrap();
15351 });
15352 let cfg = HubConfig {
15353 hub: format!("http://{address}"),
15354 key: None,
15355 agent_key: None,
15356 brain_key: None,
15357 state_dir: tempfile::tempdir().unwrap().keep(),
15358 store_selected: false,
15359 };
15360
15361 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
15362 assert_eq!(response.status, 200);
15363 assert_eq!(response.body, Some(json!({ "ok": true })));
15364 server.join().unwrap();
15365 }
15366
15367 #[test]
15368 fn endpoint_cap_refuses_a_body_before_json_parsing() {
15369 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
15370 let cfg = HubConfig {
15371 hub,
15372 key: None,
15373 agent_key: None,
15374 brain_key: None,
15375 state_dir: tempfile::tempdir().unwrap().keep(),
15376 store_selected: false,
15377 };
15378
15379 assert!(matches!(
15380 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
15381 Err(LinkError::ResponseTooLarge { .. })
15382 ));
15383 server.join().unwrap();
15384 }
15385
15386 #[test]
15387 fn overall_deadline_stops_a_dribbled_response_body() {
15388 use std::io::{Read as _, Write as _};
15389 use std::net::TcpListener;
15390 use std::time::{Duration, Instant};
15391
15392 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15393 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
15394 let server = std::thread::spawn(move || {
15395 let (mut stream, _) = listener.accept().unwrap();
15396 let mut request = [0_u8; 1024];
15397 let _ = stream.read(&mut request);
15398 stream
15399 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
15400 .unwrap();
15401 for byte in [b'x'; 32] {
15402 if stream.write_all(&[byte]).is_err() {
15403 break;
15404 }
15405 std::thread::sleep(Duration::from_millis(40));
15406 }
15407 });
15408 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
15409 let started = Instant::now();
15410 let response = http.get(&url).call().unwrap();
15411 let mut body = Vec::new();
15412 let error = response
15413 .into_reader()
15414 .read_to_end(&mut body)
15415 .expect_err("per-read progress must not reset the overall deadline");
15416 assert!(
15417 started.elapsed() < Duration::from_millis(700),
15418 "dribbled body exceeded the wall-clock budget: {error}"
15419 );
15420 server.join().unwrap();
15421 }
15422
15423 #[test]
15424 fn overall_deadline_stops_a_stalled_upload() {
15425 use std::net::TcpListener;
15426 use std::time::{Duration, Instant};
15427
15428 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15429 let url = format!("http://{}/upload", listener.local_addr().unwrap());
15430 let server = std::thread::spawn(move || {
15431 let (_stream, _) = listener.accept().unwrap();
15432 std::thread::sleep(Duration::from_millis(600));
15435 });
15436 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
15437 let body = vec![0x5a; 32 * 1024 * 1024];
15438 let started = Instant::now();
15439 let error = http
15440 .put(&url)
15441 .send_bytes(&body)
15442 .expect_err("stalled request-body writes must time out");
15443 assert!(
15444 started.elapsed() < Duration::from_millis(700),
15445 "stalled upload exceeded the wall-clock budget: {error}"
15446 );
15447 server.join().unwrap();
15448 }
15449
15450 #[test]
15451 fn verb_entry_gates_accept_the_hub_ref_shapes() {
15452 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
15453 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
15454 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
15455 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
15456 }
15457 }
15458
15459 #[test]
15460 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
15461 let cfg = dead_hub();
15462 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
15463 assert!(
15464 matches!(
15465 sync_pull(&cfg, bad, None),
15466 Err(LinkError::BadAddress { .. })
15467 ),
15468 "sync_pull must refuse {bad:?}"
15469 );
15470 assert!(
15471 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
15472 "sync_push must refuse {bad:?}"
15473 );
15474 assert!(
15475 matches!(
15476 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
15477 Err(LinkError::BadAddress { .. })
15478 ),
15479 "grant_issue must refuse {bad:?}"
15480 );
15481 assert!(
15482 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
15483 "grant_list must refuse {bad:?}"
15484 );
15485 assert!(
15486 matches!(
15487 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
15488 Err(LinkError::BadAddress { .. })
15489 ),
15490 "grant_revoke must refuse brain {bad:?}"
15491 );
15492 assert!(
15493 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
15494 "head must refuse {bad:?}"
15495 );
15496 }
15497 }
15498
15499 #[test]
15500 fn grant_revoke_refuses_url_reshaping_grant_ids() {
15501 let cfg = dead_hub();
15502 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
15503 assert!(
15504 matches!(
15505 grant_revoke(&cfg, "acme", bad),
15506 Err(LinkError::BadGrantId { .. })
15507 ),
15508 "grant_revoke must refuse grant id {bad:?}"
15509 );
15510 }
15511 }
15512
15513 #[test]
15514 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
15515 let cfg = dead_hub();
15516 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
15517 assert!(
15518 matches!(
15519 propose(&cfg, bad, "intake", "hi"),
15520 Err(LinkError::BadAddress { .. })
15521 ),
15522 "propose must refuse handle {bad:?}"
15523 );
15524 }
15525 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
15526 assert!(matches!(
15527 propose(&cfg, "acme-site", "intake", &oversize),
15528 Err(LinkError::ProposeTooLarge { .. })
15529 ));
15530 assert!(matches!(
15533 propose(&cfg, "acme-site", "intake", "hi"),
15534 Err(LinkError::Transport { .. })
15535 ));
15536 }
15537
15538 #[test]
15539 fn resolve_refuses_a_hand_built_unsafe_address() {
15540 let cfg = dead_hub();
15541 for brain in ["../up", "a/b", "a?x", "a#f"] {
15542 let addr = Address {
15543 brain: brain.to_string(),
15544 target: None,
15545 };
15546 assert!(
15547 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
15548 "resolve must refuse brain {brain:?}"
15549 );
15550 }
15551 for target in [
15552 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
15553 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
15555 AddressTarget::Path("records/x.md#frag".to_string()),
15556 ] {
15557 let addr = Address {
15558 brain: "acme".to_string(),
15559 target: Some(target.clone()),
15560 };
15561 assert!(
15562 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
15563 "resolve must refuse target {target:?}"
15564 );
15565 }
15566 }
15567
15568 #[test]
15569 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
15570 let mut local = std::collections::BTreeMap::new();
15571 local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
15572 local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
15573 let mut remote = std::collections::BTreeMap::new();
15574 remote.insert(
15575 "records/a.md".to_string(),
15576 V2BaselineFile {
15577 sha256: "c".repeat(64),
15578 bytes: 1,
15579 proof: None,
15580 },
15581 );
15582 remote.insert(
15583 "records/b.md".to_string(),
15584 V2BaselineFile {
15585 sha256: "b".repeat(64),
15586 bytes: 1,
15587 proof: None,
15588 },
15589 );
15590 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
15591 }
15592
15593 #[test]
15594 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
15595 let local = std::collections::BTreeMap::new();
15596 let mut remote = std::collections::BTreeMap::new();
15597 remote.insert(
15598 "private/local.md".to_string(),
15599 V2BaselineFile {
15600 sha256: "d".repeat(64),
15601 bytes: 1,
15602 proof: None,
15603 },
15604 );
15605 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
15606 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
15607 }
15608
15609 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
15610 V2VerifiedHead {
15611 requested: TEST_BRAIN_ID.to_string(),
15612 brain_id: TEST_BRAIN_ID.to_string(),
15613 view_kind: "scoped".to_string(),
15614 view_revision: revision.to_string(),
15615 control_revision: revision.to_string(),
15616 identity: V2HeadIdentity {
15617 custody: "hub".to_string(),
15618 fingerprint: "test".to_string(),
15619 public_key_spki: "test".to_string(),
15620 previous: Vec::new(),
15621 rotations: Vec::new(),
15622 },
15623 pointer: None,
15624 trust: TrustState {
15625 v: 2,
15626 origin: "https://hub.example".to_string(),
15627 requested: TEST_BRAIN_ID.to_string(),
15628 brain: TEST_BRAIN_ID.to_string(),
15629 home: None,
15630 anchor: "ed25519:test".to_string(),
15631 current: "ed25519:test".to_string(),
15632 head_seq: 0,
15633 feed_hash: None,
15634 rotations: Vec::new(),
15635 hub_signer: None,
15636 protocol_profile: Some("link-v2".to_string()),
15637 },
15638 alias: None,
15639 }
15640 }
15641
15642 #[test]
15643 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
15644 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
15645 assert!(accepted_as_v2(&trust));
15646
15647 trust.protocol_profile = None;
15648 trust.hub_signer = Some("ed25519:hub".to_string());
15649 assert!(accepted_as_v2(&trust));
15650
15651 trust.hub_signer = None;
15652 assert!(!accepted_as_v2(&trust));
15653 }
15654
15655 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
15656 V2SyncBaseline {
15657 v: 2,
15658 origin: "https://hub.example".to_string(),
15659 brain: TEST_BRAIN_ID.to_string(),
15660 checkout_id: Some("c".repeat(64)),
15661 head_seq: Some(0),
15662 commit_hash: None,
15663 content_root: None,
15664 asset_root: None,
15665 assets: std::collections::BTreeMap::new(),
15666 view_kind: Some("scoped".to_string()),
15667 view_revision: Some(revision.to_string()),
15668 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
15669 files: std::collections::BTreeMap::new(),
15670 local_policy_digest: None,
15671 local_eligibility: std::collections::BTreeMap::new(),
15672 remote_copy_remains: std::collections::BTreeMap::new(),
15673 }
15674 }
15675
15676 #[test]
15677 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
15678 let directory = tempfile::tempdir().unwrap();
15679 std::fs::write(
15680 directory.path().join("DB.md"),
15681 scoped_projection_bytes(TEST_BRAIN_ID),
15682 )
15683 .unwrap();
15684 let store = Store::open_strict(directory.path()).unwrap();
15685 let head = scoped_test_head(&"a".repeat(64));
15686 let baseline = scoped_test_baseline(&"a".repeat(64));
15687 let mut view = v2_local_files(&store).unwrap();
15688 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
15689 assert!(!view.riding.contains_key("DB.md"));
15690 assert!(!view.eligibility.contains_key("DB.md"));
15691 }
15692
15693 #[test]
15694 fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
15695 let directory = tempfile::tempdir().unwrap();
15696 std::fs::write(
15697 directory.path().join("DB.md"),
15698 scoped_projection_bytes(TEST_BRAIN_ID),
15699 )
15700 .unwrap();
15701 let store = Store::open_strict(directory.path()).unwrap();
15702 let head = scoped_test_head(&"a".repeat(64));
15703 let baseline = scoped_test_baseline(&"a".repeat(64));
15704
15705 let mut carried = v2_local_files(&store).unwrap();
15706 remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
15707 let handed_off =
15708 local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
15709 assert!(!handed_off.riding.contains_key("DB.md"));
15710
15711 let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
15712 assert!(!freshly_scanned.riding.contains_key("DB.md"));
15713
15714 std::fs::write(
15715 directory.path().join("DB.md"),
15716 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
15717 )
15718 .unwrap();
15719 let tampered = Store::open_strict(directory.path()).unwrap();
15720 assert!(matches!(
15721 local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
15722 Err(LinkError::ScopedProjectionModified)
15723 ));
15724 }
15725
15726 #[test]
15727 fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
15728 let directory = tempfile::tempdir().unwrap();
15729 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
15730 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
15731 std::fs::write(
15732 directory.path().join("DB.md"),
15733 b"---\nname: Kept home test\n---\n",
15734 )
15735 .unwrap();
15736 std::fs::write(
15737 directory.path().join("records/notes/a.md"),
15738 b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
15739 )
15740 .unwrap();
15741 std::fs::write(
15742 directory.path().join("sources/private/secret.md"),
15743 b"---\ntype: note\n---\nlocal only\n",
15744 )
15745 .unwrap();
15746 std::fs::write(
15747 directory.path().join("sources/private/unlinked.md"),
15748 b"---\ntype: note\n---\nnot disclosed\n",
15749 )
15750 .unwrap();
15751 std::fs::write(
15752 directory.path().join(".sevralocal"),
15753 b"sources/private/**\n",
15754 )
15755 .unwrap();
15756
15757 let store = Store::open_strict(directory.path()).unwrap();
15758 let view = v2_local_files(&store).unwrap();
15759 assert!(!view.riding.contains_key("sources/private/secret.md"));
15760 assert_eq!(
15761 view.withheld_links,
15762 vec![V2WithheldLink {
15763 source: "records/notes/a.md".to_string(),
15764 target: "sources/private/secret.md".to_string(),
15765 }]
15766 );
15767 }
15768
15769 #[test]
15770 fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
15771 let directory = tempfile::tempdir().unwrap();
15772 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
15773 std::fs::write(
15774 directory.path().join("DB.md"),
15775 b"---\nname: Withdrawal test\n---\n",
15776 )
15777 .unwrap();
15778 let source = b"---\ntype: note\n---\nlocal evidence\n";
15779 std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
15780 std::fs::write(
15781 directory.path().join(".sevralocal"),
15782 b"sources/private/**\n",
15783 )
15784 .unwrap();
15785 let store = Store::open_strict(directory.path()).unwrap();
15786 let view = v2_local_files(&store).unwrap();
15787 let mut remote = std::collections::BTreeMap::new();
15788 remote.insert(
15789 "sources/private/evidence.md".to_string(),
15790 V2BaselineFile {
15791 sha256: content_sha256(source),
15792 bytes: source.len() as u64,
15793 proof: None,
15794 },
15795 );
15796 assert_eq!(
15797 v2_content_withdrawal_operation(
15798 &store,
15799 &view,
15800 &remote,
15801 "sources/private/evidence.md",
15802 "approved retention change",
15803 )
15804 .unwrap(),
15805 json!({
15806 "op": "withdraw_from_hosting",
15807 "path": "sources/private/evidence.md",
15808 "expected": { "kind": "blob", "hash": content_sha256(source) },
15809 "reason": "approved retention change",
15810 })
15811 );
15812
15813 std::fs::write(
15814 directory.path().join("sources/private/evidence.md"),
15815 b"changed after review",
15816 )
15817 .unwrap();
15818 assert!(matches!(
15819 v2_content_withdrawal_operation(
15820 &store,
15821 &view,
15822 &remote,
15823 "sources/private/evidence.md",
15824 "approved retention change",
15825 ),
15826 Err(LinkError::InvalidPack { .. })
15827 ));
15828 }
15829
15830 #[test]
15831 fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
15832 let directory = tempfile::tempdir().unwrap();
15833 std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
15834 std::fs::write(
15835 directory.path().join("DB.md"),
15836 b"---\nname: Asset withdrawal test\n---\n",
15837 )
15838 .unwrap();
15839 let bytes = b"private binary";
15840 std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
15841 std::fs::write(
15842 directory.path().join(".sevralocal"),
15843 b"sources/files/private.pdf\n",
15844 )
15845 .unwrap();
15846 let store = Store::open_strict(directory.path()).unwrap();
15847 let view = v2_local_files(&store).unwrap();
15848 let local = crate::AssetRecord {
15849 path: "sources/files/private.pdf".to_string(),
15850 sha256: content_sha256(bytes),
15851 bytes: bytes.len() as u64,
15852 media_type: "application/pdf".to_string(),
15853 wrappers: vec!["sources/files/private.md".to_string()],
15854 required: true,
15855 };
15856 let current = V2BaselineAsset {
15857 blob_sha256: local.sha256.clone(),
15858 bytes: local.bytes,
15859 media_type: local.media_type.clone(),
15860 wrappers: local.wrappers.clone(),
15861 required: local.required,
15862 disposition: "hosted".to_string(),
15863 leaf_hash: "d".repeat(64),
15864 };
15865 assert_eq!(
15866 v2_asset_withdrawal_operation(
15867 &store,
15868 &view,
15869 &local.path,
15870 &local,
15871 ¤t,
15872 "approved retention change",
15873 )
15874 .unwrap(),
15875 json!({
15876 "op": "asset_withdraw",
15877 "path": local.path,
15878 "expected": { "kind": "asset", "hash": "d".repeat(64) },
15879 "reason": "approved retention change",
15880 })
15881 );
15882
15883 let mut mismatched = current.clone();
15884 mismatched.required = false;
15885 assert!(matches!(
15886 v2_asset_withdrawal_operation(
15887 &store,
15888 &view,
15889 &local.path,
15890 &local,
15891 &mismatched,
15892 "approved retention change",
15893 ),
15894 Err(LinkError::InvalidPack { .. })
15895 ));
15896 }
15897
15898 #[test]
15899 fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
15900 let first = v2_checkout_id(None).unwrap();
15901 assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
15902 assert_ne!(first, v2_checkout_id(None).unwrap());
15903 assert!(is_sha256(&first));
15904 }
15905
15906 #[test]
15907 fn scoped_projection_edit_and_scope_transition_fail_closed() {
15908 let directory = tempfile::tempdir().unwrap();
15909 std::fs::write(
15910 directory.path().join("DB.md"),
15911 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
15912 )
15913 .unwrap();
15914 let store = Store::open_strict(directory.path()).unwrap();
15915 let head = scoped_test_head(&"a".repeat(64));
15916 let baseline = scoped_test_baseline(&"a".repeat(64));
15917 let mut view = v2_local_files(&store).unwrap();
15918 assert!(matches!(
15919 remove_scoped_projection(&head, Some(&baseline), &mut view),
15920 Err(LinkError::ScopedProjectionModified)
15921 ));
15922
15923 let changed = scoped_test_head(&"b".repeat(64));
15924 assert!(matches!(
15925 ensure_v2_view_compatible(&changed, Some(&baseline)),
15926 Err(LinkError::ScopedViewChanged)
15927 ));
15928
15929 let mut same_view_new_control = head.clone();
15930 same_view_new_control.control_revision = "c".repeat(64);
15931 assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
15932 assert!(!same_v2_head(&head, &same_view_new_control));
15933 }
15934
15935 #[test]
15936 fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
15937 let scoped = scoped_test_head(&"a".repeat(64));
15938 let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
15939 assert!(matches!(
15940 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
15941 Err(LinkError::ScopedProjectionModified)
15942 ));
15943
15944 let mut full = scoped.clone();
15945 full.view_kind = "full".to_string();
15946 let mut full_baseline = scoped_baseline.clone();
15947 full_baseline.view_kind = Some("full".to_string());
15948 full_baseline.projection_sha256 = None;
15949 assert!(matches!(
15950 ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
15951 Err(LinkError::InvalidPack { .. })
15952 ));
15953
15954 assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
15955 assert!(
15956 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
15957 );
15958 }
15959
15960 #[test]
15961 fn scoped_view_metadata_is_explicitly_non_authoritative() {
15962 let head = scoped_test_head(&"a".repeat(64));
15963 let value: Value =
15964 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
15965 assert_eq!(value["kind"], "link.md-scoped-view");
15966 assert_eq!(value["authoritative"], false);
15967 assert_eq!(value["visible_files"], 7);
15968 assert_eq!(value["brain"], TEST_BRAIN_ID);
15969 }
15970
15971 #[test]
15972 fn local_scoped_marker_requires_the_exact_generated_projection() {
15973 let directory = tempfile::tempdir().unwrap();
15974 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
15975 std::fs::write(
15976 directory.path().join("DB.md"),
15977 scoped_projection_bytes(TEST_BRAIN_ID),
15978 )
15979 .unwrap();
15980 let head = scoped_test_head(&"a".repeat(64));
15981 std::fs::write(
15982 directory.path().join(".dbmd/view.json"),
15983 scoped_view_metadata(&head, 0).unwrap(),
15984 )
15985 .unwrap();
15986 let store = Store::open_strict(directory.path()).unwrap();
15987 assert!(has_verified_local_scoped_view(&store));
15988
15989 std::fs::write(
15990 directory.path().join("DB.md"),
15991 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
15992 )
15993 .unwrap();
15994 let altered = Store::open_strict(directory.path()).unwrap();
15995 assert!(!has_verified_local_scoped_view(&altered));
15996 }
15997
15998 fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
15999 use ring::signature::KeyPair as _;
16000
16001 let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
16002 let rng = ring::rand::SystemRandom::new();
16003 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16004 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16005 let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
16006 let public_key = URL_SAFE_NO_PAD.encode(&public_der);
16007 let fingerprint = format!("{:x}", Sha256::digest(&public_der));
16008 let blob = b"new";
16009 let blob_hash = content_sha256(blob);
16010 let changes = json!({
16011 "mutation_id": "sync:proposal-fixture",
16012 "operations": [{
16013 "blob": blob_hash,
16014 "bytes": blob.len(),
16015 "expected": null,
16016 "op": "put",
16017 "path": "records/new.md",
16018 }],
16019 "reason": "fixture",
16020 "v": 2,
16021 });
16022 let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
16023 let changes_base64 = STANDARD.encode(&changes_bytes);
16024 let descriptor = json!({
16025 "base": null,
16026 "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
16027 "changes_base64": changes_base64,
16028 "rebase": "strict",
16029 "v": 2,
16030 });
16031 let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
16032 let payload_hash = "b".repeat(64);
16033 let submitted_at = "2026-08-19T12:00:00.000Z";
16034 let claim = json!({
16035 "actor_root": {
16036 "actor_class": "foreign_key",
16037 "credential": "ed25519:fixture",
16038 "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
16039 "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
16040 "principal": "key:fixture",
16041 "role": null,
16042 },
16043 "brain": TEST_BRAIN_ID,
16044 "clear_sha256": clear_hash,
16045 "control_revision": "c".repeat(64),
16046 "mutation_id": "sync:proposal-fixture",
16047 "payload_sha256": payload_hash,
16048 "proposal_id": proposal_id,
16049 "submitted_at": submitted_at,
16050 "v": 2,
16051 });
16052 let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
16053 let envelope = json!({
16054 "claim": claim,
16055 "fingerprint": fingerprint,
16056 "public_key": public_key,
16057 "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
16058 });
16059 let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
16060 let submission_hash =
16061 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
16062 let mut head = scoped_test_head(&"c".repeat(64));
16063 head.view_kind = "full".to_string();
16064 head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
16065 let value = json!({
16066 "proposal": {
16067 "base": null,
16068 "blobs": [{
16069 "bytes": blob.len(),
16070 "endpoint": format!(
16071 "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
16072 ),
16073 "sha256": blob_hash,
16074 }],
16075 "changes_base64": changes_base64,
16076 "clear_sha256": clear_hash,
16077 "expires_at": "2026-08-26T12:00:00.000Z",
16078 "id": proposal_id,
16079 "payload_sha256": payload_hash,
16080 "proposer": { "class": "foreign_key" },
16081 "rebase": "strict",
16082 "state": "pending",
16083 "submission_claim_base64": STANDARD.encode(envelope_bytes),
16084 "submission_claim_sha256": submission_hash,
16085 "submitted_at": submitted_at,
16086 },
16087 "v": 2,
16088 });
16089 (head, proposal_id, value)
16090 }
16091
16092 #[test]
16093 fn v2_proposal_verifier_accepts_exact_signed_payload() {
16094 let (head, proposal_id, value) = signed_proposal_fixture();
16095 let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
16096 assert_eq!(verified.blobs.len(), 1);
16097 assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
16098 }
16099
16100 #[test]
16101 fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
16102 let (head, proposal_id, value) = signed_proposal_fixture();
16103
16104 let mut changed = value.clone();
16105 changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
16106 assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
16107
16108 let mut redirected = value.clone();
16109 redirected["proposal"]["blobs"][0]["endpoint"] =
16110 Value::String("https://attacker.example/blob".to_string());
16111 assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
16112
16113 let mut forged = value;
16114 let encoded = forged["proposal"]["submission_claim_base64"]
16115 .as_str()
16116 .unwrap();
16117 let mut envelope: Value =
16118 serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
16119 envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
16120 let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
16121 forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
16122 forged["proposal"]["submission_claim_sha256"] = Value::String(
16123 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
16124 );
16125 assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
16126 }
16127
16128 #[cfg(unix)]
16129 #[test]
16130 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
16131 let sandbox = tempfile::tempdir().unwrap();
16132 let destination = sandbox.path().join("brain");
16133 let entries = vec![
16134 (
16135 "DB.md".to_string(),
16136 scoped_projection_bytes(TEST_BRAIN_ID),
16137 ),
16138 (
16139 "records/contacts/a.md".to_string(),
16140 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
16141 .to_vec(),
16142 ),
16143 ];
16144 install_pulled_delta(&destination, &entries, &[], true).unwrap();
16145 assert!(destination.join("index.md").is_file());
16146 assert!(destination.join("records/index.md").is_file());
16147 assert!(destination.join("records/contacts/index.md").is_file());
16148 assert!(destination.join("records/contacts/index.jsonl").is_file());
16149 }
16150
16151 #[cfg(unix)]
16152 #[test]
16153 fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
16154 let sandbox = tempfile::tempdir().unwrap();
16155 let destination = sandbox.path().join("brain");
16156 let cache = sandbox.path().join("cache");
16157 std::fs::create_dir(&cache).unwrap();
16158 let db = scoped_projection_bytes(TEST_BRAIN_ID);
16159 let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
16160 let db_source = cache.join("db");
16161 let shared_source = cache.join("shared");
16162 crate::fsx::write_atomic(&db_source, &db).unwrap();
16163 crate::fsx::write_atomic(&shared_source, shared).unwrap();
16164 let mut entries = vec![V2StagedFile {
16165 path: "DB.md".to_string(),
16166 source: db_source,
16167 sha256: content_sha256(&db),
16168 bytes: db.len() as u64,
16169 }];
16170 for index in 0..512 {
16171 entries.push(V2StagedFile {
16172 path: format!("records/items/{index:05}.md"),
16173 source: shared_source.clone(),
16174 sha256: content_sha256(shared),
16175 bytes: shared.len() as u64,
16176 });
16177 }
16178 install_pulled_delta_sources(
16179 &destination,
16180 &entries,
16181 &[],
16182 false,
16183 None,
16184 &scoped_test_head(&"c".repeat(64)),
16185 )
16186 .unwrap();
16187 assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
16188 for index in 0..512 {
16189 assert_eq!(
16190 std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
16191 shared
16192 );
16193 }
16194 assert!(
16195 std::fs::read_dir(sandbox.path())
16196 .unwrap()
16197 .all(|entry| !entry
16198 .unwrap()
16199 .file_name()
16200 .to_string_lossy()
16201 .contains("pull-stage")),
16202 "the private stage must be atomically installed or removed"
16203 );
16204 }
16205
16206 #[cfg(unix)]
16207 #[test]
16208 fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
16209 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
16210
16211 let sandbox = tempfile::tempdir().unwrap();
16212 let root = sandbox.path().join("brain");
16213 std::fs::create_dir_all(root.join("records/items")).unwrap();
16214 let db = scoped_projection_bytes(TEST_BRAIN_ID);
16215 let old = b"---\ntype: note\n---\n\nold\n";
16216 let new = b"---\ntype: note\n---\n\nnew\n";
16217 let removed = b"---\ntype: note\n---\n\nremove me\n";
16218 std::fs::write(root.join("DB.md"), &db).unwrap();
16219 std::fs::write(root.join("records/items/change.md"), old).unwrap();
16220 std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
16221 for index in 0..512 {
16222 std::fs::write(
16223 root.join(format!("records/items/untouched-{index:04}.md")),
16224 old,
16225 )
16226 .unwrap();
16227 }
16228 let untouched = root.join("records/items/untouched-0256.md");
16229 let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
16230 let source = sandbox.path().join("changed-source");
16231 crate::fsx::write_atomic(&source, new).unwrap();
16232 let same_source = sandbox.path().join("unchanged-source");
16233 crate::fsx::write_atomic(&same_source, old).unwrap();
16234 let same_entry = V2StagedFile {
16235 path: "records/items/change.md".to_string(),
16236 source: same_source,
16237 sha256: content_sha256(old),
16238 bytes: old.len() as u64,
16239 };
16240 let entry = V2StagedFile {
16241 path: "records/items/change.md".to_string(),
16242 source,
16243 sha256: content_sha256(new),
16244 bytes: new.len() as u64,
16245 };
16246 let head = scoped_test_head(&"c".repeat(64));
16247
16248 install_established_v2_delta(
16252 Store::open_strict(&root).unwrap(),
16253 &[same_entry],
16254 &["records/items/already-absent.md".to_string()],
16255 true,
16256 None,
16257 &head,
16258 )
16259 .unwrap();
16260 assert_eq!(
16261 std::fs::metadata(&untouched).unwrap().ino(),
16262 untouched_inode
16263 );
16264 assert!(!root.join(V2_PULL_JOURNAL).exists());
16265
16266 install_established_v2_delta(
16267 Store::open_strict(&root).unwrap(),
16268 &[entry],
16269 &["records/items/delete.md".to_string()],
16270 false,
16271 None,
16272 &head,
16273 )
16274 .unwrap();
16275 assert_eq!(
16276 std::fs::read(root.join("records/items/change.md")).unwrap(),
16277 new
16278 );
16279 assert!(!root.join("records/items/delete.md").exists());
16280 assert_eq!(
16281 std::fs::metadata(&untouched).unwrap().ino(),
16282 untouched_inode
16283 );
16284 assert!(root.join(V2_PULL_JOURNAL).is_file());
16285 assert_eq!(
16286 std::fs::metadata(root.join(V2_PULL_JOURNAL))
16287 .unwrap()
16288 .permissions()
16289 .mode()
16290 & 0o777,
16291 0o600
16292 );
16293 let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
16294 .unwrap()
16295 .unwrap();
16296 assert_eq!(
16297 std::fs::metadata(root.join(&journal.backup_dir))
16298 .unwrap()
16299 .permissions()
16300 .mode()
16301 & 0o777,
16302 0o700
16303 );
16304 for entry in &journal.entries {
16305 if let Some(backup) = &entry.backup {
16306 assert_eq!(
16307 std::fs::metadata(root.join(&journal.backup_dir).join(backup))
16308 .unwrap()
16309 .permissions()
16310 .mode()
16311 & 0o777,
16312 0o600
16313 );
16314 }
16315 }
16316
16317 let cfg = test_hub_config(
16318 "https://example.test".to_string(),
16319 sandbox.path().join("state"),
16320 );
16321 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16322 assert_eq!(
16323 std::fs::read(root.join("records/items/change.md")).unwrap(),
16324 old
16325 );
16326 assert_eq!(
16327 std::fs::read(root.join("records/items/delete.md")).unwrap(),
16328 removed
16329 );
16330 assert_eq!(
16331 std::fs::metadata(&untouched).unwrap().ino(),
16332 untouched_inode
16333 );
16334 assert!(!root.join(V2_PULL_JOURNAL).exists());
16335 }
16336
16337 #[test]
16338 fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
16339 let body = b"bounded bytes";
16340 let path = "records/example.md".to_string();
16341 let file = V2BaselineFile {
16342 sha256: content_sha256(body),
16343 bytes: body.len() as u64,
16344 proof: None,
16345 };
16346 let header = serde_json::to_vec(&json!({
16347 "bytes": body.len(),
16348 "path": path,
16349 "sha256": file.sha256,
16350 "v": 2,
16351 }))
16352 .unwrap();
16353 let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
16354 stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
16355 stream.extend_from_slice(&header);
16356 stream.extend_from_slice(body);
16357 stream.extend_from_slice(&0_u32.to_be_bytes());
16358 let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
16359 assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
16360
16361 let mut tampered = stream.clone();
16362 let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
16363 tampered[body_offset] ^= 1;
16364 assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
16365
16366 let mut trailing = stream;
16367 trailing.push(0);
16368 assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
16369 }
16370
16371 #[test]
16372 fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
16373 let sandbox = tempfile::TempDir::new().unwrap();
16374 let root = sandbox.path().join("brain");
16375 std::fs::create_dir_all(&root).unwrap();
16376 std::fs::write(
16377 root.join("DB.md"),
16378 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16379 )
16380 .unwrap();
16381 let store = Store::open_strict(&root).unwrap();
16382 let incomplete = crate::ulid::mint();
16383 store
16384 .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
16385 .unwrap();
16386 let expired = crate::ulid::mint();
16387 store
16388 .create_dir_all(&v2_conflict_relative(&expired, "files"))
16389 .unwrap();
16390 let plan = V2ConflictPlan {
16391 v: 2,
16392 class: "content_resolution_required".to_string(),
16393 bundle: expired.clone(),
16394 brain: TEST_BRAIN_ID.to_string(),
16395 origin: "https://example.test".to_string(),
16396 created_unix: 0,
16397 expires_unix: 0,
16398 base_seq: None,
16399 base_commit: None,
16400 remote_seq: 0,
16401 remote_commit: None,
16402 remote_content_root: None,
16403 view_kind: "full".to_string(),
16404 view_revision: "a".repeat(64),
16405 files: vec![V2ConflictFile {
16406 path: "records/value.md".to_string(),
16407 base: V2ConflictCoordinate {
16408 sha256: None,
16409 bytes: None,
16410 file: None,
16411 },
16412 local: V2ConflictCoordinate {
16413 sha256: None,
16414 bytes: None,
16415 file: None,
16416 },
16417 remote: V2ConflictCoordinate {
16418 sha256: None,
16419 bytes: None,
16420 file: None,
16421 },
16422 }],
16423 };
16424 let mut bytes = serde_json::to_vec(&plan).unwrap();
16425 bytes.push(b'\n');
16426 store
16427 .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
16428 .unwrap();
16429
16430 let listed = sync_conflicts(&root, false, false).unwrap();
16431 assert_eq!(listed["bundles"], 2);
16432 assert_eq!(listed["pruned"], 0);
16433 let pruned = sync_conflicts(&root, true, false).unwrap();
16434 assert_eq!(pruned["bundles"], 0);
16435 assert_eq!(pruned["pruned"], 2);
16436 assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
16437 assert!(!root.join(".dbmd/conflicts").join(expired).exists());
16438 }
16439
16440 #[test]
16441 fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
16442 let sandbox = tempfile::TempDir::new().unwrap();
16443 let root = sandbox.path().join("brain");
16444 std::fs::create_dir_all(&root).unwrap();
16445 std::fs::write(
16446 root.join("DB.md"),
16447 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16448 )
16449 .unwrap();
16450 let store = Store::open_strict(&root).unwrap();
16451 let bundle = crate::ulid::mint();
16452 store
16453 .create_dir_all(&v2_conflict_relative(&bundle, "files"))
16454 .unwrap();
16455 store
16456 .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
16457 .unwrap();
16458
16459 assert!(sync_conflicts(&root, true, false).is_err());
16460 assert!(sync_conflicts(&root, false, true).is_err());
16461 let pruned = sync_conflicts(&root, true, true).unwrap();
16462 assert_eq!(pruned["pruned"], 1);
16463 assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
16464 }
16465
16466 #[test]
16467 fn ready_pull_journal_rolls_back_exact_preimages() {
16468 let sandbox = tempfile::TempDir::new().unwrap();
16469 let root = sandbox.path().join("brain");
16470 std::fs::create_dir_all(root.join("records")).unwrap();
16471 std::fs::write(
16472 root.join("DB.md"),
16473 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16474 )
16475 .unwrap();
16476 let path = "records/value.md";
16477 let old = b"---\ntype: note\n---\n\nold\n";
16478 let new = b"---\ntype: note\n---\n\nnew\n";
16479 std::fs::write(root.join(path), old).unwrap();
16480 let store = Store::open_strict(&root).unwrap();
16481 let bundle = crate::ulid::mint();
16482 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
16483 store
16484 .create_private_dir_all(Path::new(&backup_dir))
16485 .unwrap();
16486 store
16487 .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
16488 .unwrap();
16489 let journal = V2PullJournal {
16490 v: 1,
16491 phase: V2PullPhase::Ready,
16492 brain: TEST_BRAIN_ID.to_string(),
16493 previous: V2PullCoordinate {
16494 head_seq: None,
16495 commit_hash: None,
16496 view_kind: None,
16497 view_revision: None,
16498 },
16499 next: V2PullCoordinate {
16500 head_seq: Some(2),
16501 commit_hash: Some("c".repeat(64)),
16502 view_kind: Some("full".to_string()),
16503 view_revision: Some("d".repeat(64)),
16504 },
16505 backup_dir: backup_dir.clone(),
16506 entries: vec![V2PullJournalEntry {
16507 path: path.to_string(),
16508 old: Some(V2PullFileCoordinate {
16509 sha256: content_sha256(old),
16510 bytes: old.len() as u64,
16511 }),
16512 new: Some(V2PullFileCoordinate {
16513 sha256: content_sha256(new),
16514 bytes: new.len() as u64,
16515 }),
16516 backup: Some("00000000".to_string()),
16517 }],
16518 };
16519 validate_v2_pull_journal(&journal).unwrap();
16520 store
16521 .write_private_atomic_new(
16522 Path::new(V2_PULL_JOURNAL),
16523 &v2_pull_journal_bytes(&journal).unwrap(),
16524 )
16525 .unwrap();
16526 store.write_atomic(Path::new(path), new).unwrap();
16527
16528 let cfg = test_hub_config(
16529 "https://example.test".to_string(),
16530 sandbox.path().join("state"),
16531 );
16532 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16533 assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
16534 assert!(!root.join(V2_PULL_JOURNAL).exists());
16535 assert!(!root.join(backup_dir).exists());
16536 }
16537
16538 #[test]
16539 fn preparing_pull_journal_discards_only_private_staging() {
16540 let sandbox = tempfile::TempDir::new().unwrap();
16541 let root = sandbox.path().join("brain");
16542 std::fs::create_dir_all(root.join(".dbmd")).unwrap();
16543 std::fs::write(
16544 root.join("DB.md"),
16545 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16546 )
16547 .unwrap();
16548 let store = Store::open_strict(&root).unwrap();
16549 let bundle = crate::ulid::mint();
16550 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
16551 store
16552 .create_private_dir_all(Path::new(&backup_dir))
16553 .unwrap();
16554 let journal = V2PullJournal {
16555 v: 1,
16556 phase: V2PullPhase::Preparing,
16557 brain: TEST_BRAIN_ID.to_string(),
16558 previous: V2PullCoordinate {
16559 head_seq: None,
16560 commit_hash: None,
16561 view_kind: None,
16562 view_revision: None,
16563 },
16564 next: V2PullCoordinate {
16565 head_seq: Some(1),
16566 commit_hash: Some("a".repeat(64)),
16567 view_kind: Some("full".to_string()),
16568 view_revision: Some("b".repeat(64)),
16569 },
16570 backup_dir: backup_dir.clone(),
16571 entries: vec![V2PullJournalEntry {
16572 path: "records/new.md".to_string(),
16573 old: None,
16574 new: Some(V2PullFileCoordinate {
16575 sha256: "c".repeat(64),
16576 bytes: 1,
16577 }),
16578 backup: None,
16579 }],
16580 };
16581 store
16582 .write_private_atomic_new(
16583 Path::new(V2_PULL_JOURNAL),
16584 &v2_pull_journal_bytes(&journal).unwrap(),
16585 )
16586 .unwrap();
16587 let cfg = test_hub_config(
16588 "https://example.test".to_string(),
16589 sandbox.path().join("state"),
16590 );
16591
16592 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16593
16594 assert!(root.join("DB.md").is_file());
16595 assert!(!root.join(V2_PULL_JOURNAL).exists());
16596 assert!(!root.join(backup_dir).exists());
16597 }
16598
16599 #[test]
16600 fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
16601 let sandbox = tempfile::TempDir::new().unwrap();
16602 let root = sandbox.path().join("brain");
16603 std::fs::create_dir_all(root.join("records")).unwrap();
16604 std::fs::write(
16605 root.join("DB.md"),
16606 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16607 )
16608 .unwrap();
16609 let new = b"---\ntype: note\n---\n\nnew\n";
16610 std::fs::write(root.join("records/value.md"), new).unwrap();
16611 let store = Store::open_strict(&root).unwrap();
16612 let bundle = crate::ulid::mint();
16613 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
16614 store
16615 .create_private_dir_all(Path::new(&backup_dir))
16616 .unwrap();
16617 let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
16618 store.create_private_dir_all(Path::new(&orphan)).unwrap();
16619 let next = V2PullCoordinate {
16620 head_seq: Some(2),
16621 commit_hash: Some("c".repeat(64)),
16622 view_kind: Some("full".to_string()),
16623 view_revision: Some("d".repeat(64)),
16624 };
16625 let journal = V2PullJournal {
16626 v: 1,
16627 phase: V2PullPhase::Ready,
16628 brain: TEST_BRAIN_ID.to_string(),
16629 previous: V2PullCoordinate {
16630 head_seq: Some(1),
16631 commit_hash: Some("a".repeat(64)),
16632 view_kind: Some("full".to_string()),
16633 view_revision: Some("b".repeat(64)),
16634 },
16635 next: next.clone(),
16636 backup_dir: backup_dir.clone(),
16637 entries: vec![V2PullJournalEntry {
16638 path: "records/value.md".to_string(),
16639 old: Some(V2PullFileCoordinate {
16640 sha256: "e".repeat(64),
16641 bytes: new.len() as u64,
16642 }),
16643 new: Some(V2PullFileCoordinate {
16644 sha256: content_sha256(new),
16645 bytes: new.len() as u64,
16646 }),
16647 backup: Some("00000000".to_string()),
16648 }],
16649 };
16650 store
16651 .write_private_atomic_new(
16652 Path::new(V2_PULL_JOURNAL),
16653 &v2_pull_journal_bytes(&journal).unwrap(),
16654 )
16655 .unwrap();
16656 let cfg = test_hub_config(
16657 "https://example.test".to_string(),
16658 sandbox.path().join("state"),
16659 );
16660 save_v2_baseline(
16661 &cfg,
16662 TEST_BRAIN_ID,
16663 &root,
16664 &V2SyncBaseline {
16665 v: 2,
16666 origin: "https://example.test".to_string(),
16667 brain: TEST_BRAIN_ID.to_string(),
16668 checkout_id: Some("c".repeat(64)),
16669 head_seq: next.head_seq,
16670 commit_hash: next.commit_hash.clone(),
16671 content_root: Some("f".repeat(64)),
16672 asset_root: None,
16673 assets: Default::default(),
16674 view_kind: next.view_kind.clone(),
16675 view_revision: next.view_revision.clone(),
16676 projection_sha256: None,
16677 files: Default::default(),
16678 local_policy_digest: None,
16679 local_eligibility: Default::default(),
16680 remote_copy_remains: Default::default(),
16681 },
16682 )
16683 .unwrap();
16684
16685 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16686
16687 assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
16688 assert!(!root.join(V2_PULL_JOURNAL).exists());
16689 assert!(!root.join(backup_dir).exists());
16690 assert!(!root.join(orphan).exists());
16691 }
16692}