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 infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
6153 let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
6154 let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
6155 for (index, operation) in operations.iter().enumerate() {
6156 match operation.get("op").and_then(Value::as_str) {
6157 Some("delete") => {
6158 let Some(path) = operation.get("path").and_then(Value::as_str) else {
6159 continue;
6160 };
6161 let Some(hash) = operation
6162 .get("expected")
6163 .and_then(|value| value.get("hash"))
6164 .and_then(Value::as_str)
6165 else {
6166 continue;
6167 };
6168 if path.starts_with("sources/") {
6169 deletes
6170 .entry(hash.to_string())
6171 .or_default()
6172 .push((index, path.to_string()));
6173 }
6174 }
6175 Some("put") => {
6176 let Some(path) = operation.get("path").and_then(Value::as_str) else {
6177 continue;
6178 };
6179 let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
6180 continue;
6181 };
6182 let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
6183 continue;
6184 };
6185 let destination_absent = operation
6186 .get("expected")
6187 .and_then(|value| value.get("kind"))
6188 .and_then(Value::as_str)
6189 == Some("absent");
6190 if path.starts_with("sources/") && destination_absent {
6191 puts.entry(hash.to_string()).or_default().push((
6192 index,
6193 path.to_string(),
6194 bytes,
6195 ));
6196 }
6197 }
6198 _ => {}
6199 }
6200 }
6201 let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
6202 let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
6203 for (hash, source) in deletes {
6204 let Some(destination) = puts.get(&hash) else {
6205 continue;
6206 };
6207 if source.len() != 1 || destination.len() != 1 {
6208 continue;
6209 }
6210 let (delete_index, from) = &source[0];
6211 let (put_index, to, bytes) = &destination[0];
6212 if from == to {
6213 continue;
6214 }
6215 rename_at.insert(
6216 *delete_index,
6217 json!({
6218 "op": "rename",
6219 "from": from,
6220 "to": to,
6221 "expected_from": { "kind": "blob", "hash": hash },
6222 "expected_to": { "kind": "absent" },
6223 "blob": hash,
6224 "bytes": bytes,
6225 }),
6226 );
6227 consumed_puts.insert(*put_index);
6228 }
6229 operations
6230 .into_iter()
6231 .enumerate()
6232 .filter_map(|(index, operation)| {
6233 if let Some(rename) = rename_at.remove(&index) {
6234 Some(rename)
6235 } else if consumed_puts.contains(&index) {
6236 None
6237 } else {
6238 Some(operation)
6239 }
6240 })
6241 .collect()
6242}
6243
6244fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
6245 json!({
6246 "blob_sha256": record.sha256,
6247 "bytes": record.bytes,
6248 "media_type": record.media_type,
6249 "wrappers": record.wrappers,
6250 "required": record.required,
6251 "disposition": disposition,
6252 })
6253}
6254
6255fn apply_generated_v2_operations(
6259 operations: &[Value],
6260 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
6261 candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
6262 candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
6263) -> LinkResult<bool> {
6264 let mut asset_changed = false;
6265 for operation in operations {
6266 match operation.get("op").and_then(Value::as_str) {
6267 Some("put") => {
6268 let path = operation
6269 .get("path")
6270 .and_then(Value::as_str)
6271 .ok_or_else(|| invalid_feed("v2 put has no path"))?;
6272 let sha256 = operation
6273 .get("blob")
6274 .and_then(Value::as_str)
6275 .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
6276 let bytes = operation
6277 .get("bytes")
6278 .and_then(Value::as_u64)
6279 .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
6280 candidate.insert(
6281 path.to_string(),
6282 V2BaselineFile {
6283 sha256: sha256.to_string(),
6284 bytes,
6285 proof: None,
6286 },
6287 );
6288 }
6289 Some("rename") => {
6290 let from = operation
6291 .get("from")
6292 .and_then(Value::as_str)
6293 .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
6294 let to = operation
6295 .get("to")
6296 .and_then(Value::as_str)
6297 .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
6298 let sha256 = operation
6299 .get("blob")
6300 .and_then(Value::as_str)
6301 .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
6302 let bytes = operation
6303 .get("bytes")
6304 .and_then(Value::as_u64)
6305 .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
6306 let expected_from = operation
6307 .get("expected_from")
6308 .and_then(|expected| expected.get("hash"))
6309 .and_then(Value::as_str);
6310 let expected_to_absent = operation
6311 .get("expected_to")
6312 .and_then(|expected| expected.get("kind"))
6313 .and_then(Value::as_str)
6314 == Some("absent");
6315 if from == to
6316 || !from.starts_with("sources/")
6317 || !to.starts_with("sources/")
6318 || expected_from != Some(sha256)
6319 || !expected_to_absent
6320 || candidate.contains_key(to)
6321 {
6322 return Err(invalid_feed("generated v2 source rename is malformed"));
6323 }
6324 let source = candidate
6325 .remove(from)
6326 .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
6327 if source.sha256 != sha256 || source.bytes != bytes {
6328 return Err(invalid_feed(
6329 "v2 rename source differs from its exact-byte claim",
6330 ));
6331 }
6332 candidate.insert(
6333 to.to_string(),
6334 V2BaselineFile {
6335 sha256: sha256.to_string(),
6336 bytes,
6337 proof: None,
6338 },
6339 );
6340 }
6341 Some("delete" | "withdraw_from_hosting") => {
6342 let path = operation
6343 .get("path")
6344 .and_then(Value::as_str)
6345 .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
6346 candidate.remove(path);
6347 }
6348 Some("asset_delete") => {
6349 let path = operation
6350 .get("path")
6351 .and_then(Value::as_str)
6352 .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
6353 candidate_assets.remove(path);
6354 asset_changed = true;
6355 }
6356 Some("asset_put" | "asset_resume" | "asset_withdraw") => {
6357 let path = operation
6358 .get("path")
6359 .and_then(Value::as_str)
6360 .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
6361 let record = local_assets
6362 .get(path)
6363 .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
6364 let disposition =
6365 if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
6366 "withheld"
6367 } else {
6368 operation
6369 .get("asset")
6370 .and_then(|asset| asset.get("disposition"))
6371 .and_then(Value::as_str)
6372 .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
6373 };
6374 candidate_assets.insert(
6375 path.to_string(),
6376 V2BaselineAsset {
6377 blob_sha256: record.sha256.clone(),
6378 bytes: record.bytes,
6379 media_type: record.media_type.clone(),
6380 wrappers: record.wrappers.clone(),
6381 required: record.required,
6382 disposition: disposition.to_string(),
6383 leaf_hash: String::new(),
6386 },
6387 );
6388 asset_changed = true;
6389 }
6390 _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
6391 }
6392 }
6393 Ok(asset_changed)
6394}
6395
6396fn v2_riding_matches_remote(
6397 local: &std::collections::BTreeMap<String, (String, u64)>,
6398 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6399 keeps_home: impl Fn(&str) -> bool,
6400) -> bool {
6401 remote.iter().all(|(path, file)| {
6402 keeps_home(path)
6403 || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
6404 }) && local.iter().all(|(path, (hash, _))| {
6405 remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
6406 })
6407}
6408
6409#[derive(Debug, Clone)]
6410struct V2ResolutionOverride {
6411 expected_remote: Option<String>,
6412 selected_local: Option<String>,
6413}
6414
6415#[derive(Debug, Clone)]
6416struct V2UploadSource {
6417 path: String,
6418 bytes: u64,
6419}
6420
6421struct V2SyncPushOptions<'a> {
6422 resume_local_policy: bool,
6423 bulk_confirmation: Option<&'a V2BulkConfirmation>,
6424 resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
6425 pulled: Option<V2PulledSnapshot>,
6426 withdrawal_paths: &'a [String],
6427 withdrawal_reason: Option<&'a str>,
6428}
6429
6430fn verify_v2_upload_source(
6431 store: &Store,
6432 path: &str,
6433 sha256: &str,
6434 expected_bytes: u64,
6435) -> LinkResult<()> {
6436 let file = store.open_regular(Path::new(path))?;
6437 if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
6438 return Err(LinkError::InvalidPack {
6439 message: format!("local path `{path}` changed during sync planning"),
6440 });
6441 }
6442 Ok(())
6443}
6444
6445fn put_presigned_source(
6446 cfg: &HubConfig,
6447 raw: &str,
6448 headers: &Value,
6449 store: &Store,
6450 source: &V2UploadSource,
6451) -> LinkResult<()> {
6452 let http = presigned_agent(cfg, raw)?;
6453 let mut attempt = 0;
6454 let result = loop {
6455 let file = store.open_regular(Path::new(&source.path))?;
6456 if file.metadata()?.len() != source.bytes {
6457 return Err(LinkError::InvalidPack {
6458 message: format!("local path `{}` changed before upload", source.path),
6459 });
6460 }
6461 let mut req = http
6462 .put(raw)
6463 .set("Content-Length", &source.bytes.to_string());
6464 if let Some(map) = headers.as_object() {
6465 for (name, value) in map {
6466 if let Some(value) = value.as_str() {
6467 req = req.set(name, value);
6468 }
6469 }
6470 }
6471 match req.send(file) {
6472 Err(ureq::Error::Transport(error))
6473 if is_pre_request_transport(error.kind()) && attempt + 1 < CONNECT_ATTEMPTS =>
6474 {
6475 std::thread::sleep(std::time::Duration::from_millis(
6476 CONNECT_RETRY_BACKOFF_MS[attempt],
6477 ));
6478 attempt += 1;
6479 }
6480 result => break result,
6481 }
6482 };
6483 match result {
6484 Ok(response) if (200..300).contains(&response.status()) => Ok(()),
6485 Ok(response) => Err(LinkError::Http {
6486 what: "v2 changed-byte upload",
6487 status: response.status(),
6488 message: "object store rejected the upload".to_string(),
6489 code: None,
6490 details: None,
6491 }),
6492 Err(error) => match error {
6493 ureq::Error::Status(412, _) => Ok(()),
6494 ureq::Error::Status(_, response) => Err(LinkError::Http {
6495 what: "v2 changed-byte upload",
6496 status: response.status(),
6497 message: "object store rejected the upload".to_string(),
6498 code: None,
6499 details: None,
6500 }),
6501 ureq::Error::Transport(error) => Err(LinkError::Transport {
6502 hub: "the object store".to_string(),
6503 message: error.to_string(),
6504 }),
6505 },
6506 }
6507}
6508
6509fn v2_sync_push(
6510 cfg: &HubConfig,
6511 requested_brain: &str,
6512 store: &Store,
6513 head: V2VerifiedHead,
6514 options: V2SyncPushOptions<'_>,
6515) -> LinkResult<Value> {
6516 let V2SyncPushOptions {
6517 resume_local_policy,
6518 bulk_confirmation,
6519 resolution,
6520 pulled,
6521 withdrawal_paths,
6522 withdrawal_reason,
6523 } = options;
6524 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
6525 let head = v2_verified_head(cfg, requested_brain)?
6526 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6527 let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
6528 match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
6529 Some(snapshot) => (
6530 snapshot.files,
6531 snapshot.assets,
6532 Some(snapshot.local),
6533 Some(snapshot.local_assets),
6534 ),
6535 None => (
6536 files_for_v2_view(
6537 &head,
6538 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6539 ),
6540 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6541 None,
6542 None,
6543 ),
6544 };
6545 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
6546 ensure_v2_view_compatible(&head, baseline.as_ref())?;
6547 if head.view_kind == "scoped" && baseline.is_none() {
6548 return Err(LinkError::ScopedViewChanged);
6549 }
6550 let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
6551 let local = &local_view.riding;
6552 let local_assets = match carried_local_assets {
6553 Some(assets) => assets,
6554 None => v2_local_asset_records(store)?,
6555 };
6556 if withdrawal_paths.len() > MAX_PUSH_FILES {
6557 return Err(LinkError::PushTooLarge {
6558 detail: "too many explicit withdrawal paths".to_string(),
6559 });
6560 }
6561 let withdrawal_reason = if withdrawal_paths.is_empty() {
6562 None
6563 } else {
6564 let reason = withdrawal_reason
6565 .map(str::trim)
6566 .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
6567 .ok_or_else(|| LinkError::InvalidPack {
6568 message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
6569 })?;
6570 Some(reason)
6571 };
6572 let mut withdrawals = withdrawal_paths
6573 .iter()
6574 .map(|path| {
6575 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
6576 path: error.to_string(),
6577 })
6578 })
6579 .collect::<LinkResult<Vec<_>>>()?;
6580 withdrawals.sort();
6581 withdrawals.dedup();
6582 if withdrawals.len() != withdrawal_paths.len() {
6583 return Err(LinkError::InvalidPack {
6584 message: "explicit withdrawal paths must be unique".to_string(),
6585 });
6586 }
6587 let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
6588 let mut consumed_withdrawals = BTreeSet::new();
6589 if let Some(previous) = baseline.as_ref() {
6590 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
6591 && !resume_local_policy
6592 {
6593 let mut newly_eligible = previous
6594 .local_eligibility
6595 .iter()
6596 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
6597 .map(|(path, _)| path.clone())
6598 .collect::<Vec<_>>();
6599 if !newly_eligible.is_empty() {
6600 newly_eligible.truncate(100);
6601 return Err(LinkError::LocalPolicyTransition {
6602 paths: newly_eligible,
6603 });
6604 }
6605 }
6606 }
6607 let base = match baseline.as_ref() {
6608 Some(state) => &state.files,
6609 None if remote.is_empty() => &remote,
6610 None => {
6611 let mut conflicts = remote
6612 .iter()
6613 .filter(|(path, file)| {
6614 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
6615 })
6616 .map(|(path, _)| path.clone())
6617 .collect::<Vec<_>>();
6618 if !conflicts.is_empty() {
6619 conflicts.truncate(100);
6620 let (bundle, paths) =
6621 create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
6622 return Err(LinkError::ConflictBundle { bundle, paths });
6623 }
6624 &remote
6625 }
6626 };
6627 let all_paths = base
6628 .keys()
6629 .chain(remote.keys())
6630 .chain(local.keys())
6631 .cloned()
6632 .collect::<std::collections::BTreeSet<_>>();
6633 let mut conflicts = Vec::new();
6634 let mut operations = Vec::new();
6635 let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
6636 for path in all_paths {
6637 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
6638 let remote_file = remote.get(&path);
6639 let remote_hash = remote_file.map(|file| file.sha256.as_str());
6640 let local_file = local.get(&path);
6641 let local_hash = local_file.map(|file| file.0.as_str());
6642 if local_hash == base_hash {
6643 continue;
6644 }
6645 if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
6646 continue;
6647 }
6648 if local_view.policy.keeps_home(&path) {
6649 continue;
6652 }
6653 if remote_hash != base_hash && local_hash != remote_hash {
6654 let explicitly_resolved = resolution
6655 .and_then(|allowed| allowed.get(&path))
6656 .is_some_and(|selected| {
6657 selected.expected_remote.as_deref() == remote_hash
6658 && selected.selected_local.as_deref() == local_hash
6659 });
6660 if !explicitly_resolved {
6661 conflicts.push(path);
6662 continue;
6663 }
6664 }
6665 match local_file {
6666 Some((sha256, byte_count)) => {
6667 verify_v2_upload_source(store, &path, sha256, *byte_count)?;
6668 operations.push(json!({
6669 "op": "put",
6670 "path": path,
6671 "expected": v2_expected(remote_file),
6672 "blob": sha256,
6673 "bytes": byte_count,
6674 }));
6675 upload_sources
6676 .entry(sha256.clone())
6677 .or_insert_with(|| V2UploadSource {
6678 path: path.clone(),
6679 bytes: *byte_count,
6680 });
6681 }
6682 None => {
6683 let Some(current) = remote_file else {
6684 continue;
6685 };
6686 operations.push(json!({
6687 "op": "delete",
6688 "path": path,
6689 "expected": { "kind": "blob", "hash": current.sha256 },
6690 }));
6691 }
6692 }
6693 }
6694 operations = infer_exact_source_promotions(operations);
6695 for path in &withdrawals {
6696 if local_assets.contains_key(path) {
6697 continue;
6698 }
6699 operations.push(v2_content_withdrawal_operation(
6700 store,
6701 &local_view,
6702 &remote,
6703 path,
6704 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
6705 )?);
6706 consumed_withdrawals.insert(path.clone());
6707 }
6708 if !conflicts.is_empty() {
6709 conflicts.truncate(100);
6710 let (bundle, paths) = create_v2_conflict_bundle(
6711 cfg,
6712 store,
6713 &head,
6714 baseline.as_ref(),
6715 local,
6716 &remote,
6717 &conflicts,
6718 )?;
6719 return Err(LinkError::ConflictBundle { bundle, paths });
6720 }
6721 let base_assets = match baseline.as_ref() {
6722 Some(state) => &state.assets,
6723 None if remote_assets.is_empty() => &remote_assets,
6724 None => {
6725 let mismatched = remote_assets.iter().any(|(path, remote)| {
6726 local_assets.get(path) != Some(&v2_asset_record(remote, path))
6727 }) || local_assets.len() != remote_assets.len();
6728 if mismatched {
6729 return Err(LinkError::Conflict {
6730 paths: vec!["assets.jsonl".to_string()],
6731 });
6732 }
6733 &remote_assets
6734 }
6735 };
6736 let asset_paths = base_assets
6737 .keys()
6738 .chain(remote_assets.keys())
6739 .chain(local_assets.keys())
6740 .cloned()
6741 .collect::<std::collections::BTreeSet<_>>();
6742 let mut asset_policy_transitions = Vec::new();
6743 for path in asset_paths {
6744 let base_record = base_assets
6745 .get(&path)
6746 .map(|asset| v2_asset_record(asset, &path));
6747 let remote = remote_assets.get(&path);
6748 let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
6749 let local_record = local_assets.get(&path);
6750 if withdrawal_set.contains(&path) {
6751 let record = local_record.ok_or_else(|| LinkError::InvalidPack {
6752 message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
6753 })?;
6754 let current = remote.ok_or_else(|| LinkError::InvalidPack {
6755 message: format!(
6756 "asset withdrawal path `{path}` has no readable hosted coordinate"
6757 ),
6758 })?;
6759 operations.push(v2_asset_withdrawal_operation(
6760 store,
6761 &local_view,
6762 &path,
6763 record,
6764 current,
6765 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
6766 )?);
6767 consumed_withdrawals.insert(path.clone());
6768 continue;
6769 }
6770 let mut raw_present = false;
6771 let mut disposition = "withheld";
6772 let mut resumes_hosting = false;
6773 if let Some(record) = local_record {
6774 crate::linkmd_v2::normalize_path(&record.path)
6775 .map_err(|error| invalid_feed(error.to_string()))?;
6776 let kept_home = local_view.policy.keeps_home(&path);
6777 raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
6778 disposition = if kept_home || !raw_present {
6779 "withheld"
6780 } else {
6781 "hosted"
6782 };
6783 if !raw_present && record.required && !kept_home {
6784 return Err(LinkError::InvalidPack {
6785 message: format!("required asset {path} is missing"),
6786 });
6787 }
6788 resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
6789 }
6790 if local_record == base_record.as_ref() && !resumes_hosting {
6791 continue;
6792 }
6793 if remote_record != base_record && local_record != remote_record.as_ref() {
6794 conflicts.push(path);
6795 continue;
6796 }
6797 let Some(record) = local_record else {
6798 if let Some(remote) = remote {
6799 operations.push(json!({
6800 "op": "asset_delete",
6801 "path": path,
6802 "expected": v2_asset_expected(Some(remote)),
6803 }));
6804 }
6805 continue;
6806 };
6807 let raw = if raw_present {
6808 verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
6809 Some(())
6810 } else {
6811 None
6812 };
6813 let op = if resumes_hosting {
6814 if !resume_local_policy {
6815 asset_policy_transitions.push(path);
6816 continue;
6817 }
6818 "asset_resume"
6819 } else {
6820 "asset_put"
6821 };
6822 operations.push(json!({
6823 "op": op,
6824 "path": path,
6825 "expected": v2_asset_expected(remote),
6826 "asset": v2_asset_value(record, disposition),
6827 }));
6828 if disposition == "hosted" {
6829 raw.expect("hosted asset was checked present");
6830 upload_sources
6831 .entry(record.sha256.clone())
6832 .or_insert_with(|| V2UploadSource {
6833 path: path.clone(),
6834 bytes: record.bytes,
6835 });
6836 }
6837 }
6838 if consumed_withdrawals != withdrawal_set {
6839 let missing = withdrawal_set
6840 .difference(&consumed_withdrawals)
6841 .next()
6842 .expect("different withdrawal sets have one member");
6843 return Err(LinkError::InvalidPack {
6844 message: format!(
6845 "withdrawal path `{missing}` is not a readable content or asset coordinate"
6846 ),
6847 });
6848 }
6849 if !conflicts.is_empty() {
6850 conflicts.truncate(100);
6851 return Err(LinkError::Conflict { paths: conflicts });
6852 }
6853 if !asset_policy_transitions.is_empty() {
6854 asset_policy_transitions.truncate(100);
6855 return Err(LinkError::LocalPolicyTransition {
6856 paths: asset_policy_transitions,
6857 });
6858 }
6859 let touched_sources = operations
6860 .iter()
6861 .filter_map(
6862 |operation| match operation.get("op").and_then(Value::as_str) {
6863 Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
6864 Some("rename") => operation.get("to").and_then(Value::as_str),
6865 _ => None,
6866 },
6867 )
6868 .collect::<std::collections::BTreeSet<_>>();
6869 let withheld_links = local_view
6870 .withheld_links
6871 .iter()
6872 .filter(|link| touched_sources.contains(link.source.as_str()))
6873 .collect::<Vec<_>>();
6874 let checkout_pseudonym = v2_checkout_id(
6875 baseline
6876 .as_ref()
6877 .and_then(|current| current.checkout_id.as_deref()),
6878 )?;
6879 let checkout_id = if withheld_links.is_empty() {
6880 None
6881 } else {
6882 Some(checkout_pseudonym.clone())
6883 };
6884 if operations.is_empty() {
6885 let final_head = v2_verified_head(cfg, requested_brain)?
6886 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
6887 if !same_v2_head(&head, &final_head) {
6888 return Err(LinkError::RemoteAdvancedDuringSync);
6889 }
6890 let mut final_local = v2_local_files(store)?;
6891 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
6892 let final_assets = v2_local_asset_records(store)?;
6893 let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
6894 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
6895 final_local.policy.keeps_home(path)
6896 }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
6897 let next = v2_baseline_from_head(
6898 cfg,
6899 &head,
6900 remote,
6901 remote_assets,
6902 Some(&final_local),
6903 Some(&checkout_pseudonym),
6904 )?;
6905 let split_count = next.remote_copy_remains.len();
6906 accept_v2_head(cfg, &final_head)?;
6907 if !local_changed && !remote_ahead {
6908 refresh_scoped_view_marker(store, &head, next.files.len())?;
6909 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
6910 }
6911 return Ok(json!({
6912 "v": 2,
6913 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
6914 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
6915 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
6916 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
6917 "local_policy": {
6918 "remote_copy_remains": split_count,
6919 },
6920 }));
6921 }
6922 let includes_contract = operations
6923 .iter()
6924 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
6925 let rebase = if head.pointer.is_none() || includes_contract {
6926 "strict"
6927 } else {
6928 "disjoint"
6929 };
6930 let base_value = head.pointer.as_ref().map(|pointer| {
6931 json!({
6932 "seq": pointer.seq,
6933 "commit_hash": pointer.commit_hash,
6934 "content_root": pointer.content_root,
6935 "asset_root": pointer.asset_root,
6936 })
6937 });
6938 let entropy = format!(
6942 "{}\0{}\0{}\0{}\0{}\0{}",
6943 normalized_origin(&cfg.hub)?,
6944 head.brain_id,
6945 serde_json::to_string(&base_value).unwrap_or_default(),
6946 serde_json::to_string(&operations).unwrap_or_default(),
6947 serde_json::to_string(&withheld_links).unwrap_or_default(),
6948 checkout_id.as_deref().unwrap_or("")
6949 );
6950 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
6951 let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
6952 total
6953 .checked_add(source.bytes)
6954 .ok_or_else(|| LinkError::PushTooLarge {
6955 detail: "v2 changed-byte total overflow".to_string(),
6956 })
6957 })?;
6958 let inline = changed_bytes <= 3 * 1024 * 1024;
6959 let inline_blobs = if inline {
6960 upload_sources
6961 .iter()
6962 .map(|(sha256, source)| {
6963 let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
6964 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
6965 return Err(LinkError::InvalidPack {
6966 message: format!("local path `{}` changed before upload", source.path),
6967 });
6968 }
6969 Ok(json!({
6970 "sha256": sha256,
6971 "bytes": source.bytes,
6972 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
6973 }))
6974 })
6975 .collect::<LinkResult<Vec<_>>>()?
6976 } else {
6977 Vec::new()
6978 };
6979 let mut body = json!({
6980 "mutation_id": mutation_id,
6981 "base": base_value,
6982 "rebase": rebase,
6983 "reason": "dbmd sync",
6984 "operations": operations,
6985 "blobs": inline_blobs,
6986 });
6987 if !withheld_links.is_empty() {
6988 body["withheld_links"] = serde_json::to_value(&withheld_links)
6989 .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
6990 body["checkout_id"] =
6991 Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
6992 }
6993 if let Some(confirmation) = bulk_confirmation {
6994 if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
6995 return Err(LinkError::InvalidPack {
6996 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
6997 .to_string(),
6998 });
6999 }
7000 body["rebase"] = Value::String("strict".to_string());
7004 body["bulk_preview_id"] = Value::String(confirmation.id.clone());
7005 body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
7006 }
7007 if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
7008 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
7009 for operation in &operations {
7010 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
7011 return Err(invalid_feed("v2 upload operation has no kind"));
7012 };
7013 let hash = match kind {
7014 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
7015 "asset_put" | "asset_resume" => operation
7016 .get("asset")
7017 .and_then(|asset| asset.get("blob_sha256"))
7018 .and_then(Value::as_str),
7019 _ => None,
7020 };
7021 let Some(hash) = hash else { continue };
7022 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
7023 if kind == "rename" {
7024 for field in ["from", "to"] {
7025 coordinates.insert(
7026 operation
7027 .get(field)
7028 .and_then(Value::as_str)
7029 .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
7030 .to_string(),
7031 );
7032 }
7033 } else {
7034 let path = operation
7035 .get("path")
7036 .and_then(Value::as_str)
7037 .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
7038 coordinates.insert(if kind.starts_with("asset_") {
7039 format!("assets/{path}")
7040 } else {
7041 path.to_string()
7042 });
7043 }
7044 }
7045 let declarations = upload_sources
7046 .iter()
7047 .map(|(sha256, source)| {
7048 json!({
7049 "sha256": sha256,
7050 "bytes": source.bytes,
7051 "coordinates": coordinates_by_hash
7052 .get(sha256)
7053 .into_iter()
7054 .flatten()
7055 .collect::<Vec<_>>(),
7056 })
7057 })
7058 .collect::<Vec<_>>();
7059 let reserved = ensure_ok(
7060 request(
7061 cfg,
7062 "POST",
7063 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7064 Some(&json!({ "blobs": declarations })),
7065 Auth::Required,
7066 )?,
7067 "prepare v2 changed-byte uploads",
7068 )?;
7069 let items = reserved
7070 .get("uploads")
7071 .and_then(Value::as_array)
7072 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
7073 if items.len() != upload_sources.len() {
7074 return Err(invalid_feed(
7075 "v2 upload reservation response changed the requested set",
7076 ));
7077 }
7078 let mut references = Vec::with_capacity(items.len());
7079 let mut seen = std::collections::BTreeSet::new();
7080 for item in items {
7081 let sha256 = item
7082 .get("sha256")
7083 .and_then(Value::as_str)
7084 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
7085 let source = upload_sources
7086 .get(sha256)
7087 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
7088 let declared_bytes = item
7089 .get("bytes")
7090 .and_then(Value::as_u64)
7091 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
7092 let reservation_id = item
7093 .get("reservation_id")
7094 .and_then(Value::as_str)
7095 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
7096 let expected_coordinates = coordinates_by_hash
7097 .get(sha256)
7098 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinate binding"))?;
7099 let returned_coordinates = item
7100 .get("coordinates")
7101 .and_then(Value::as_array)
7102 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
7103 if declared_bytes != source.bytes
7104 || !crate::ulid::is_ulid(reservation_id)
7105 || !seen.insert(sha256.to_string())
7106 || returned_coordinates.len() != expected_coordinates.len()
7107 || returned_coordinates
7108 .iter()
7109 .zip(expected_coordinates)
7110 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
7111 {
7112 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
7113 }
7114 match item.get("status").and_then(Value::as_str) {
7115 Some("upload") => {
7116 let url = item
7117 .get("url")
7118 .and_then(Value::as_str)
7119 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
7120 put_presigned_source(
7121 cfg,
7122 url,
7123 item.get("headers").unwrap_or(&Value::Null),
7124 store,
7125 source,
7126 )?;
7127 verify_v2_upload_source(store, &source.path, sha256, source.bytes)?;
7128 }
7129 Some("already_present") => {}
7130 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
7131 }
7132 references.push(json!({
7133 "sha256": sha256,
7134 "bytes": source.bytes,
7135 "reservation_id": reservation_id,
7136 }));
7137 }
7138 body["blobs"] = Value::Array(references);
7139 }
7140 if body.to_string().len() > MAX_PUSH_BYTES {
7141 return Err(LinkError::PushTooLarge {
7142 detail: "v2 operation metadata exceeds the bounded commit request".to_string(),
7143 });
7144 }
7145 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
7146 let mut candidate_hub_signer: Option<String> = None;
7147 let mut response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
7148 let bulk_preview_required = !(200..300).contains(&response.status)
7149 && response.body.as_ref().is_some_and(|value| {
7150 value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
7151 || value
7152 .get("details")
7153 .and_then(|details| details.get("code"))
7154 .and_then(Value::as_str)
7155 == Some("bulk_preview_required")
7156 });
7157 if bulk_preview_required && bulk_confirmation.is_none() {
7158 body["rebase"] = Value::String("strict".to_string());
7159 body["preview_only"] = Value::Bool(true);
7160 let preview = ensure_ok(
7161 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
7162 "v2 bulk preview",
7163 )?;
7164 let preview_code = preview.get("code").and_then(Value::as_str);
7165 let required = preview.get("required").and_then(Value::as_bool);
7166 if preview.get("v").and_then(Value::as_u64) != Some(2)
7167 || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
7168 || !matches!(
7169 preview_code,
7170 Some("bulk_preview_created" | "bulk_preview_not_required")
7171 )
7172 || required.is_none()
7173 {
7174 return Err(invalid_feed(
7175 "bulk preview response is not bound to the requested mutation",
7176 ));
7177 }
7178 if required == Some(true) {
7179 let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
7180 let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
7181 if preview_code != Some("bulk_preview_created")
7182 || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
7183 || preview_digest.is_none_or(|value| !is_sha256(value))
7184 || preview.get("expires_at").and_then(Value::as_str).is_none()
7185 || !preview.get("impact").is_some_and(Value::is_object)
7186 {
7187 return Err(invalid_feed("bulk preview receipt is malformed"));
7188 }
7189 return Err(LinkError::BulkPreviewRequired { preview });
7190 }
7191 if preview_code != Some("bulk_preview_not_required") {
7192 return Err(invalid_feed("bulk preview requirement is inconsistent"));
7193 }
7194 body.as_object_mut()
7197 .expect("v2 commit request is an object")
7198 .remove("preview_only");
7199 response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
7200 }
7201 let mut result = ensure_ok(response, "v2 sync push")?;
7202 if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
7203 if let Some(object) = result.as_object_mut() {
7204 object.insert(
7205 "sync_status".to_string(),
7206 Value::String("proposal_pending".to_string()),
7207 );
7208 }
7209 return Ok(result);
7210 }
7211 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
7212 let challenge = result
7213 .get("signing_challenge")
7214 .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
7215 let mut expected_candidate = remote.clone();
7216 let mut expected_candidate_assets = remote_assets.clone();
7217 apply_generated_v2_operations(
7218 &operations,
7219 &local_assets,
7220 &mut expected_candidate,
7221 &mut expected_candidate_assets,
7222 )?;
7223 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
7224 cfg,
7225 &head,
7226 &expected_candidate,
7227 &expected_candidate_assets,
7228 &mutation_id,
7229 &body,
7230 challenge,
7231 )?;
7232 body["signing_challenge_id"] = Value::String(challenge_id);
7233 body["signature_base64url"] = Value::String(signature);
7234 candidate_hub_signer = Some(actor_signer);
7235 result = ensure_ok(
7236 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
7237 "v2 self-custody commit",
7238 )?;
7239 }
7240 let refreshed = v2_verified_head(cfg, requested_brain)?
7241 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
7242 if candidate_hub_signer
7243 .as_ref()
7244 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
7245 {
7246 return Err(invalid_feed(
7247 "self-custody actor signer differs from the committed hub pointer signer",
7248 ));
7249 }
7250 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
7251 if refreshed
7252 .pointer
7253 .as_ref()
7254 .map(|pointer| pointer.commit_hash.as_str())
7255 != accepted_hash
7256 {
7257 return Err(LinkError::RemoteAdvancedDuringSync);
7258 }
7259 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
7260 let rebased = result
7261 .get("rebased")
7262 .and_then(Value::as_bool)
7263 .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
7264 let (refreshed_files, refreshed_assets) = if rebased {
7265 (
7266 files_for_v2_view(
7267 &refreshed,
7268 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
7269 ),
7270 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
7271 )
7272 } else {
7273 let asset_changed = apply_generated_v2_operations(
7274 &operations,
7275 &local_assets,
7276 &mut remote,
7277 &mut remote_assets,
7278 )?;
7279 let assets = if asset_changed {
7280 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
7283 } else {
7284 remote_assets
7285 };
7286 (remote, assets)
7287 };
7288 let mut final_local = v2_local_files(store)?;
7289 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
7290 let final_assets = v2_local_asset_records(store)?;
7291 let local_dirty = final_local.riding != local_view.riding
7292 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
7293 final_local.policy.keeps_home(path)
7294 })
7295 || final_assets != local_assets
7296 || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
7297 let next = v2_baseline_from_head(
7298 cfg,
7299 &refreshed,
7300 refreshed_files,
7301 refreshed_assets,
7302 Some(&final_local),
7303 Some(&checkout_pseudonym),
7304 )?;
7305 let split_count = next.remote_copy_remains.len();
7306 accept_v2_head(cfg, &refreshed)?;
7307 if !local_dirty {
7308 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
7309 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
7310 }
7311 if let Some(object) = result.as_object_mut() {
7312 object.insert(
7313 "local_policy".to_string(),
7314 json!({ "remote_copy_remains": split_count }),
7315 );
7316 object.insert(
7317 "sync_status".to_string(),
7318 Value::String(if local_dirty {
7319 "remote_committed_local_dirty".to_string()
7320 } else {
7321 "synced".to_string()
7322 }),
7323 );
7324 }
7325 Ok(result)
7326}
7327
7328pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
7331 sync_push_incremental_with_policy(cfg, brain, store, false)
7332}
7333
7334pub fn sync_push_incremental_with_policy(
7337 cfg: &HubConfig,
7338 brain: &str,
7339 store: &Store,
7340 resume_local_policy: bool,
7341) -> LinkResult<Value> {
7342 sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
7343}
7344
7345pub fn sync_push_incremental_with_options(
7348 cfg: &HubConfig,
7349 brain: &str,
7350 store: &Store,
7351 resume_local_policy: bool,
7352 bulk_confirmation: Option<&V2BulkConfirmation>,
7353) -> LinkResult<Value> {
7354 sync_push_incremental_with_controls(
7355 cfg,
7356 brain,
7357 store,
7358 resume_local_policy,
7359 bulk_confirmation,
7360 &[],
7361 None,
7362 )
7363}
7364
7365pub fn sync_push_incremental_with_controls(
7367 cfg: &HubConfig,
7368 brain: &str,
7369 store: &Store,
7370 resume_local_policy: bool,
7371 bulk_confirmation: Option<&V2BulkConfirmation>,
7372 withdrawal_paths: &[String],
7373 withdrawal_reason: Option<&str>,
7374) -> LinkResult<Value> {
7375 require_safe_ref(brain)?;
7376 if let Some(head) = v2_verified_head(cfg, brain)? {
7377 return v2_sync_push(
7378 cfg,
7379 brain,
7380 store,
7381 head,
7382 V2SyncPushOptions {
7383 resume_local_policy,
7384 bulk_confirmation,
7385 resolution: None,
7386 pulled: None,
7387 withdrawal_paths,
7388 withdrawal_reason,
7389 },
7390 );
7391 }
7392 if !withdrawal_paths.is_empty() {
7393 return Err(LinkError::InvalidPack {
7394 message: "explicit withdrawal requires a link.md v2 brain".to_string(),
7395 });
7396 }
7397 legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
7398}
7399
7400pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
7404 require_safe_ref(brain)?;
7405 Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
7406}
7407
7408#[cfg(windows)]
7409fn legacy_sync_push_incremental(
7410 _cfg: &HubConfig,
7411 _brain: &str,
7412 _store: &Store,
7413 _resume_local_policy: bool,
7414 _bulk_confirmation: Option<&V2BulkConfirmation>,
7415) -> LinkResult<Value> {
7416 Err(LinkError::UnsupportedPlatform {
7417 operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
7418 })
7419}
7420
7421#[cfg(not(windows))]
7422fn legacy_sync_push_incremental(
7423 cfg: &HubConfig,
7424 brain: &str,
7425 store: &Store,
7426 resume_local_policy: bool,
7427 bulk_confirmation: Option<&V2BulkConfirmation>,
7428) -> LinkResult<Value> {
7429 if resume_local_policy || bulk_confirmation.is_some() {
7430 return Err(LinkError::InvalidPack {
7431 message: "v2 sync options require a link.md v2 brain".to_string(),
7432 });
7433 }
7434 let files = collect_push_files(store)?;
7435 sync_push(cfg, brain, &files)
7436}
7437
7438#[derive(Debug, Clone)]
7440pub enum V2ConflictChoice {
7441 KeepLocal,
7442 TakeRemote,
7443 From(PathBuf),
7444}
7445
7446fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
7447 if !crate::ulid::is_ulid(bundle) {
7448 return Err(LinkError::InvalidPack {
7449 message: "conflict bundle must be a lowercase ULID".to_string(),
7450 });
7451 }
7452 let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
7453 let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
7454 .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
7455 if plan.v != 2
7456 || plan.class != "content_resolution_required"
7457 || plan.bundle != bundle
7458 || !crate::ulid::is_ulid(&plan.brain)
7459 || plan.files.is_empty()
7460 || plan.files.len() > 100
7461 || plan.files.iter().any(|file| {
7462 crate::linkmd_v2::normalize_path(&file.path).is_err()
7463 || [&file.base, &file.local, &file.remote]
7464 .into_iter()
7465 .any(|coordinate| {
7466 coordinate
7467 .sha256
7468 .as_deref()
7469 .is_some_and(|hash| !is_sha256(hash))
7470 || coordinate.file.as_deref().is_some_and(|name| {
7471 name.starts_with('/')
7472 || name
7473 .split('/')
7474 .any(|part| part.is_empty() || part == "." || part == "..")
7475 })
7476 })
7477 })
7478 {
7479 return Err(invalid_feed("private conflict plan failed validation"));
7480 }
7481 Ok(plan)
7482}
7483
7484pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
7489 require_hardened_filesystem("private conflict maintenance")?;
7490 if all && !prune {
7491 return Err(LinkError::InvalidPack {
7492 message: "discarding all conflict bundles requires prune=true".to_string(),
7493 });
7494 }
7495 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7496 message: format!("conflict checkout is not a valid db.md store: {error}"),
7497 })?;
7498 let _transaction = store.transaction()?;
7499 let root = Path::new(".dbmd/conflicts");
7500 let names = match store.directory_names(root) {
7501 Ok(names) => names,
7502 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
7503 Err(error) => return Err(error.into()),
7504 };
7505 let now = SystemTime::now()
7506 .duration_since(UNIX_EPOCH)
7507 .unwrap_or_default()
7508 .as_secs();
7509 let mut bundles = Vec::new();
7510 let mut pruned = 0_u64;
7511 for name in names {
7512 let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
7513 continue;
7514 };
7515 let plan_path = v2_conflict_relative(bundle, "plan.json");
7516 let plan_exists = store.regular_file_exists(&plan_path)?;
7517 let expired = if plan_exists {
7518 match load_v2_conflict_plan(&store, bundle) {
7519 Ok(plan) => plan.expires_unix < now,
7520 Err(error) if all => {
7521 let _ = error;
7522 true
7523 }
7524 Err(error) => return Err(error),
7525 }
7526 } else {
7527 true
7528 };
7529 if prune && (all || expired) {
7530 store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
7531 pruned += 1;
7532 continue;
7533 }
7534 bundles.push(json!({
7535 "bundle": bundle,
7536 "complete": plan_exists,
7537 "expired": expired,
7538 }));
7539 }
7540 Ok(json!({
7541 "v": 2,
7542 "class": "private_conflict_state",
7543 "bundles": bundles.len(),
7544 "pruned": pruned,
7545 "items": bundles,
7546 }))
7547}
7548
7549pub fn sync_resolve_conflict(
7553 cfg: &HubConfig,
7554 checkout: &Path,
7555 bundle: &str,
7556 choice: V2ConflictChoice,
7557 bulk_confirmation: Option<&V2BulkConfirmation>,
7558) -> LinkResult<Value> {
7559 require_hardened_filesystem("conflict resolution")?;
7560 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7561 message: format!("conflict checkout is not a valid db.md store: {error}"),
7562 })?;
7563 let plan = load_v2_conflict_plan(&store, bundle)?;
7564 if plan.origin != normalized_origin(&cfg.hub)? {
7565 return Err(invalid_feed(
7566 "conflict bundle belongs to another hub origin",
7567 ));
7568 }
7569 let now = SystemTime::now()
7570 .duration_since(UNIX_EPOCH)
7571 .unwrap_or_default()
7572 .as_secs();
7573 if now > plan.expires_unix {
7574 return Err(LinkError::InvalidPack {
7575 message: "conflict bundle expired; rerun sync to obtain current coordinates"
7576 .to_string(),
7577 });
7578 }
7579 let head = v2_verified_head(cfg, &plan.brain)?
7580 .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
7581 let pointer = head.pointer.as_ref();
7582 if pointer.map_or(0, |value| value.seq) != plan.remote_seq
7583 || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
7584 || pointer.and_then(|value| value.content_root.as_deref())
7585 != plan.remote_content_root.as_deref()
7586 || head.view_kind != plan.view_kind
7587 || head.view_revision != plan.view_revision
7588 {
7589 return Err(LinkError::RemoteAdvancedDuringSync);
7590 }
7591
7592 for file in &plan.files {
7594 let actual = match store.regular_file_exists(Path::new(&file.path))? {
7595 true => Some(content_sha256(&store.read_bounded(
7596 Path::new(&file.path),
7597 file.local.bytes.unwrap_or(MAX_STORE_BYTES),
7598 )?)),
7599 false => None,
7600 };
7601 if actual.as_deref() != file.local.sha256.as_deref() {
7602 return Err(LinkError::InvalidPack {
7603 message: format!(
7604 "local conflict path `{}` changed after the bundle was created",
7605 file.path
7606 ),
7607 });
7608 }
7609 }
7610
7611 let from_source = match &choice {
7612 V2ConflictChoice::From(source) => Some(source.clone()),
7613 _ => None,
7614 };
7615 let result = match choice {
7616 V2ConflictChoice::TakeRemote => {
7617 if bulk_confirmation.is_some() {
7618 return Err(LinkError::InvalidPack {
7619 message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
7620 });
7621 }
7622 let current_remote =
7626 files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
7627 let _ = v2_take_remote_selection(&plan.files, ¤t_remote)?;
7628 let selected = plan
7629 .files
7630 .iter()
7631 .map(|file| file.path.clone())
7632 .collect::<std::collections::BTreeSet<_>>();
7633 serde_json::to_value(
7634 v2_sync_pull_with_resolution(
7635 cfg,
7636 &plan.brain,
7637 head,
7638 Some(checkout),
7639 Some(&selected),
7640 )?
7641 .report,
7642 )
7643 .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
7644 }
7645 V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
7646 if let Some(source) = from_source.as_ref() {
7647 if plan.files.len() != 1 {
7648 return Err(LinkError::InvalidPack {
7649 message: "--from requires a bundle with exactly one conflict".to_string(),
7650 });
7651 }
7652 let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
7653 if std::str::from_utf8(&candidate).is_err() {
7654 return Err(LinkError::NotUtf8 {
7655 path: source.display().to_string(),
7656 });
7657 }
7658 store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
7659 }
7660 let refreshed_store =
7661 Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7662 message: format!("resolved checkout is not a valid db.md store: {error}"),
7663 })?;
7664 let mut overrides = std::collections::BTreeMap::new();
7665 for file in &plan.files {
7666 let selected_local = match refreshed_store
7667 .regular_file_exists(Path::new(&file.path))?
7668 {
7669 true => Some(content_sha256(
7670 &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
7671 )),
7672 false => None,
7673 };
7674 overrides.insert(
7675 file.path.clone(),
7676 V2ResolutionOverride {
7677 expected_remote: file.remote.sha256.clone(),
7678 selected_local,
7679 },
7680 );
7681 }
7682 v2_sync_push(
7683 cfg,
7684 &plan.brain,
7685 &refreshed_store,
7686 head,
7687 V2SyncPushOptions {
7688 resume_local_policy: true,
7689 bulk_confirmation,
7690 resolution: Some(&overrides),
7691 pulled: None,
7692 withdrawal_paths: &[],
7693 withdrawal_reason: None,
7694 },
7695 )?
7696 }
7697 };
7698
7699 if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
7700 let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7701 message: format!("resolved checkout is not a valid db.md store: {error}"),
7702 })?;
7703 installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
7704 }
7705 Ok(json!({
7706 "v": 2,
7707 "class": "auto_converged",
7708 "bundle": bundle,
7709 "receipt": result,
7710 }))
7711}
7712
7713pub fn sync_converge(
7724 cfg: &HubConfig,
7725 brain: &str,
7726 checkout: &Path,
7727 resume_local_policy: bool,
7728) -> LinkResult<Value> {
7729 sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
7730}
7731
7732pub fn sync_converge_with_options(
7734 cfg: &HubConfig,
7735 brain: &str,
7736 checkout: &Path,
7737 resume_local_policy: bool,
7738 bulk_confirmation: Option<&V2BulkConfirmation>,
7739) -> LinkResult<Value> {
7740 sync_converge_with_controls(
7741 cfg,
7742 brain,
7743 checkout,
7744 resume_local_policy,
7745 bulk_confirmation,
7746 &[],
7747 None,
7748 )
7749}
7750
7751pub fn sync_converge_with_controls(
7753 cfg: &HubConfig,
7754 brain: &str,
7755 checkout: &Path,
7756 resume_local_policy: bool,
7757 bulk_confirmation: Option<&V2BulkConfirmation>,
7758 withdrawal_paths: &[String],
7759 withdrawal_reason: Option<&str>,
7760) -> LinkResult<Value> {
7761 require_hardened_filesystem("bidirectional sync")?;
7762 require_safe_ref(brain)?;
7763 let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
7764 message:
7765 "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
7766 .to_string(),
7767 })?;
7768 let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
7769 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7770 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
7771 })?;
7772 let _transaction = store.transaction()?;
7773 let pulled_report = pulled.report.clone();
7774 let pulled_head = pulled.head.clone();
7775 let mut result = v2_sync_push(
7776 cfg,
7777 brain,
7778 &store,
7779 pulled_head,
7780 V2SyncPushOptions {
7781 resume_local_policy,
7782 bulk_confirmation,
7783 resolution: None,
7784 pulled: Some(pulled),
7785 withdrawal_paths,
7786 withdrawal_reason,
7787 },
7788 )?;
7789 if let Some(object) = result.as_object_mut() {
7790 object.insert("pulled_files".to_string(), json!(pulled_report.files));
7791 object.insert("checkout".to_string(), Value::String(pulled_report.dest));
7792 object.insert(
7793 "mode".to_string(),
7794 Value::String("bidirectional".to_string()),
7795 );
7796 }
7797 Ok(result)
7798}
7799
7800pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7806 require_hardened_filesystem("sync pull")?;
7807 require_safe_ref(brain)?;
7808 if let Some(head) = v2_verified_head(cfg, brain)? {
7809 return v2_sync_pull(cfg, brain, head, out);
7810 }
7811 legacy_sync_pull(cfg, brain, out)
7812}
7813
7814#[cfg(windows)]
7815fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
7816 Err(LinkError::UnsupportedPlatform {
7817 operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
7818 })
7819}
7820
7821#[cfg(not(windows))]
7822fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7823 let remote = verified_remote_head(cfg, brain, false)?;
7824 if !remote.head.verified {
7825 return Err(invalid_feed(
7826 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
7827 ));
7828 }
7829 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
7830 let path = format!(
7831 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
7832 remote.head.seq
7833 );
7834 let body = ensure_ok(
7835 request(cfg, "GET", &path, None, Auth::Required)?,
7836 "sync pull",
7837 )?;
7838 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
7839 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
7840 {
7841 return Err(invalid_feed(
7842 "export response is not bound to the verified snapshot token",
7843 ));
7844 }
7845
7846 let remote_slug = body
7847 .get("slug")
7848 .and_then(Value::as_str)
7849 .filter(|slug| is_safe_slug(slug));
7850 let slug = remote_slug
7851 .or_else(|| is_safe_slug(brain).then_some(brain))
7852 .unwrap_or("brain")
7853 .to_string();
7854 let brain_id = body
7855 .get("brain")
7856 .and_then(Value::as_str)
7857 .unwrap_or(&remote.head.brain)
7858 .to_string();
7859 if brain_id != remote.head.brain {
7860 return Err(invalid_feed(
7861 "export response names a different brain than the verified head",
7862 ));
7863 }
7864 let head_seq = remote.head.seq;
7865 let dest: PathBuf = match out {
7866 Some(p) => p.to_path_buf(),
7867 None => PathBuf::from(&slug),
7868 };
7869 let entries = if head_seq == 0 {
7870 let files = body
7871 .get("files")
7872 .and_then(Value::as_array)
7873 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
7874 if !files.is_empty() || body.get("url").is_some() {
7875 return Err(invalid_feed(
7876 "empty signed feed cannot authorize non-empty exported content",
7877 ));
7878 }
7879 Vec::new()
7880 } else {
7881 let signed_head = remote
7882 .head_entry
7883 .as_ref()
7884 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
7885 let expected = &signed_head.entry.pack_sha256;
7886 if !is_sha256(expected) {
7887 return Err(invalid_feed(
7888 "signed head carries an invalid snapshot pack digest",
7889 ));
7890 }
7891 if let Some(url) = body.get("url").and_then(Value::as_str) {
7892 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
7893 return Err(invalid_feed(
7894 "export pack digest does not match the signed head entry",
7895 ));
7896 }
7897 let bytes = get_presigned(cfg, url)?;
7898 let actual = format!("{:x}", Sha256::digest(&bytes));
7899 if actual != *expected {
7900 return Err(LinkError::InvalidPack {
7901 message: "downloaded pack does not match the signed snapshot digest"
7902 .to_string(),
7903 });
7904 }
7905 let entries = parse_store_pack(bytes)?;
7906 if signed_head.entry.kind == "push" {
7907 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7908 }
7909 entries
7910 } else {
7911 if signed_head.entry.kind != "push" {
7912 return Err(invalid_feed(
7913 "delta snapshots must export the exact signed pack",
7914 ));
7915 }
7916 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
7917 invalid_feed("verified snapshot export carried neither a pack nor files")
7918 })?;
7919 let mut entries = Vec::with_capacity(files.len());
7920 for file in files {
7921 let path = file
7922 .get("path")
7923 .and_then(Value::as_str)
7924 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
7925 let content = file
7926 .get("content")
7927 .and_then(Value::as_str)
7928 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
7929 entries.push((path.to_string(), content.as_bytes().to_vec()));
7930 }
7931 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7932 entries
7933 }
7934 };
7935
7936 let mut seen = std::collections::HashSet::new();
7938 for (path, _) in &entries {
7939 if !safe_store_rel_path(path) {
7940 return Err(LinkError::UnsafePath { path: path.clone() });
7941 }
7942 if !seen.insert(path) {
7943 return Err(LinkError::InvalidPack {
7944 message: format!("duplicate path `{path}`"),
7945 });
7946 }
7947 }
7948 let pulled: std::collections::BTreeSet<&str> =
7951 entries.iter().map(|(p, _)| p.as_str()).collect();
7952 let mut extra_local = Vec::new();
7953 if let Ok(store) = Store::open(&dest) {
7954 if let Ok(walked) = store.walk() {
7955 for rel in walked {
7956 let rel_str = rel.to_string_lossy().replace('\\', "/");
7957 if !pulled.contains(rel_str.as_str()) {
7958 extra_local.push(rel_str);
7959 }
7960 }
7961 }
7962 }
7963 #[cfg(unix)]
7964 install_pulled_snapshot(&dest, &entries)?;
7965
7966 Ok(PullReport {
7967 brain: brain_id,
7968 slug,
7969 head_seq,
7970 files: entries.len(),
7971 dest: dest.to_string_lossy().into_owned(),
7972 extra_local,
7973 sync_status: "synced".to_string(),
7974 })
7975}
7976
7977#[cfg(unix)]
7978fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
7979 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
7980 path: display.to_string(),
7981 })
7982}
7983
7984#[cfg(unix)]
7985fn open_dir_at(
7986 parent: std::os::fd::RawFd,
7987 name: &std::ffi::CStr,
7988 display: &str,
7989) -> LinkResult<std::fs::File> {
7990 use std::os::fd::FromRawFd as _;
7991 let fd = unsafe {
7992 libc::openat(
7993 parent,
7994 name.as_ptr(),
7995 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7996 )
7997 };
7998 if fd < 0 {
7999 return Err(LinkError::UnsafePath {
8000 path: display.to_string(),
8001 });
8002 }
8003 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
8004}
8005
8006#[cfg(unix)]
8010fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
8011 use std::os::fd::AsRawFd as _;
8012
8013 #[cfg(target_os = "macos")]
8017 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
8018 .into_iter()
8019 .find_map(|(alias, real)| {
8020 path.strip_prefix(alias)
8021 .ok()
8022 .map(|rest| Path::new(real).join(rest))
8023 })
8024 .unwrap_or_else(|| path.to_path_buf());
8025 #[cfg(not(target_os = "macos"))]
8026 let normalized = path.to_path_buf();
8027
8028 let start = if normalized.is_absolute() {
8029 std::fs::File::open("/")?
8030 } else {
8031 std::fs::File::open(".")?
8032 };
8033 let mut directory = start;
8034 for component in normalized.components() {
8035 use std::path::Component;
8036 let name = match component {
8037 Component::RootDir | Component::CurDir => continue,
8038 Component::Normal(name) => name,
8039 Component::ParentDir | Component::Prefix(_) => {
8040 return Err(LinkError::UnsafePath {
8041 path: path.display().to_string(),
8042 });
8043 }
8044 };
8045 use std::os::unix::ffi::OsStrExt as _;
8046 let name = c_name(name.as_bytes(), &path.display().to_string())?;
8047 if create {
8048 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8049 if made != 0 {
8050 let error = std::io::Error::last_os_error();
8051 if error.raw_os_error() != Some(libc::EEXIST) {
8052 return Err(error.into());
8053 }
8054 }
8055 }
8056 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
8057 }
8058 Ok(directory)
8059}
8060
8061#[cfg(unix)]
8062fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
8063 open_dir_path_nofollow(path, true)
8064}
8065
8066#[cfg(unix)]
8067fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
8068 open_dir_path_nofollow(path, false)
8069}
8070
8071#[cfg(unix)]
8072fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
8073 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
8074 let result =
8075 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
8076 if result == 0 {
8077 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
8078 }
8079 let error = std::io::Error::last_os_error();
8080 if error.kind() == std::io::ErrorKind::NotFound {
8081 Ok(None)
8082 } else {
8083 Err(error.into())
8084 }
8085}
8086
8087#[cfg(unix)]
8088fn create_dir_exclusive_at(
8089 parent: std::os::fd::RawFd,
8090 name: &std::ffi::CStr,
8091 display: &str,
8092) -> LinkResult<std::fs::File> {
8093 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
8094 if made != 0 {
8095 return Err(LinkError::UnsafePath {
8096 path: display.to_string(),
8097 });
8098 }
8099 open_dir_at(parent, name, display)
8100}
8101
8102#[cfg(unix)]
8103fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
8104 use std::os::fd::AsRawFd as _;
8105
8106 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
8107 if duplicate < 0 {
8108 return Err(std::io::Error::last_os_error().into());
8109 }
8110 let stream = unsafe { libc::fdopendir(duplicate) };
8111 if stream.is_null() {
8112 let error = std::io::Error::last_os_error();
8113 unsafe {
8114 libc::close(duplicate);
8115 }
8116 return Err(error.into());
8117 }
8118 let mut names = Vec::new();
8119 loop {
8120 let entry = unsafe { libc::readdir(stream) };
8121 if entry.is_null() {
8122 break;
8123 }
8124 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
8125 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
8126 names.push(raw.to_owned());
8127 }
8128 }
8129 if unsafe { libc::closedir(stream) } != 0 {
8130 return Err(std::io::Error::last_os_error().into());
8131 }
8132 Ok(names)
8133}
8134
8135#[cfg(unix)]
8138fn remove_tree_at(
8139 parent: std::os::fd::RawFd,
8140 name: &std::ffi::CStr,
8141 display: &str,
8142) -> LinkResult<()> {
8143 use std::os::fd::AsRawFd as _;
8144
8145 match entry_is_dir_at(parent, name)? {
8146 None => return Ok(()),
8147 Some(false) => {
8148 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
8149 return Err(std::io::Error::last_os_error().into());
8150 }
8151 }
8152 Some(true) => {
8153 let directory = open_dir_at(parent, name, display)?;
8154 for child in directory_entry_names(&directory)? {
8155 let child_display =
8156 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
8157 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
8158 }
8159 drop(directory);
8160 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
8161 return Err(std::io::Error::last_os_error().into());
8162 }
8163 }
8164 }
8165 Ok(())
8166}
8167
8168#[cfg(unix)]
8172fn clone_tree_contents(
8173 source: &std::fs::File,
8174 destination: &std::fs::File,
8175 display: &str,
8176) -> LinkResult<()> {
8177 use std::os::fd::{AsRawFd as _, FromRawFd as _};
8178
8179 for name in directory_entry_names(source)? {
8180 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
8181 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
8182 if unsafe {
8183 libc::fstatat(
8184 source.as_raw_fd(),
8185 name.as_ptr(),
8186 &mut stat,
8187 libc::AT_SYMLINK_NOFOLLOW,
8188 )
8189 } != 0
8190 {
8191 return Err(std::io::Error::last_os_error().into());
8192 }
8193 match stat.st_mode & libc::S_IFMT {
8194 libc::S_IFDIR => {
8195 if unsafe {
8196 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
8197 } != 0
8198 {
8199 return Err(std::io::Error::last_os_error().into());
8200 }
8201 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
8202 let destination_child =
8203 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
8204 clone_tree_contents(&source_child, &destination_child, &child_display)?;
8205 destination_child.sync_all()?;
8206 }
8207 libc::S_IFREG => {
8208 let source_fd = unsafe {
8209 libc::openat(
8210 source.as_raw_fd(),
8211 name.as_ptr(),
8212 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8213 )
8214 };
8215 if source_fd < 0 {
8216 return Err(std::io::Error::last_os_error().into());
8217 }
8218 let destination_fd = unsafe {
8219 libc::openat(
8220 destination.as_raw_fd(),
8221 name.as_ptr(),
8222 libc::O_WRONLY
8223 | libc::O_CREAT
8224 | libc::O_EXCL
8225 | libc::O_CLOEXEC
8226 | libc::O_NOFOLLOW,
8227 (stat.st_mode & 0o777) as libc::c_uint,
8228 )
8229 };
8230 if destination_fd < 0 {
8231 unsafe {
8232 libc::close(source_fd);
8233 }
8234 return Err(std::io::Error::last_os_error().into());
8235 }
8236 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
8237 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
8238 std::io::copy(&mut input, &mut output)?;
8239 output.sync_all()?;
8240 }
8241 libc::S_IFLNK => {
8242 let mut target = vec![0_u8; 4097];
8243 let length = unsafe {
8244 libc::readlinkat(
8245 source.as_raw_fd(),
8246 name.as_ptr(),
8247 target.as_mut_ptr().cast(),
8248 target.len(),
8249 )
8250 };
8251 if length < 0 || length as usize >= target.len() {
8252 return Err(LinkError::UnsafePath {
8253 path: child_display,
8254 });
8255 }
8256 target.truncate(length as usize);
8257 let target = c_name(&target, &child_display)?;
8258 if unsafe {
8259 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
8260 } != 0
8261 {
8262 return Err(std::io::Error::last_os_error().into());
8263 }
8264 }
8265 _ => {
8266 return Err(LinkError::UnsafePath {
8267 path: child_display,
8268 });
8269 }
8270 }
8271 }
8272 destination.sync_all()?;
8273 Ok(())
8274}
8275
8276#[cfg(target_os = "linux")]
8277fn install_stage_at(
8278 parent: std::os::fd::RawFd,
8279 stage: &std::ffi::CStr,
8280 dest: &std::ffi::CStr,
8281 dest_exists: bool,
8282) -> LinkResult<()> {
8283 let flags = if dest_exists {
8284 libc::RENAME_EXCHANGE
8285 } else {
8286 libc::RENAME_NOREPLACE
8287 };
8288 let result = unsafe {
8292 libc::syscall(
8293 libc::SYS_renameat2,
8294 parent,
8295 stage.as_ptr(),
8296 parent,
8297 dest.as_ptr(),
8298 flags,
8299 )
8300 };
8301 if result == 0 {
8302 Ok(())
8303 } else {
8304 Err(std::io::Error::last_os_error().into())
8305 }
8306}
8307
8308#[cfg(target_os = "macos")]
8309fn install_stage_at(
8310 parent: std::os::fd::RawFd,
8311 stage: &std::ffi::CStr,
8312 dest: &std::ffi::CStr,
8313 dest_exists: bool,
8314) -> LinkResult<()> {
8315 let flags = if dest_exists {
8316 libc::RENAME_SWAP
8317 } else {
8318 libc::RENAME_EXCL
8319 };
8320 let result =
8321 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
8322 if result == 0 {
8323 Ok(())
8324 } else {
8325 Err(std::io::Error::last_os_error().into())
8326 }
8327}
8328
8329#[cfg(unix)]
8330fn write_pull_entries_beneath_dir(
8331 root: &std::fs::File,
8332 entries: &[(String, Vec<u8>)],
8333) -> LinkResult<()> {
8334 use std::os::fd::{AsRawFd as _, FromRawFd as _};
8335
8336 for (path, content) in entries {
8337 let components: Vec<&str> = path.split('/').collect();
8338 let (leaf, parents) = components
8339 .split_last()
8340 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8341 let mut directory = root.try_clone()?;
8342 for component in parents {
8343 let name = c_name(component.as_bytes(), path)?;
8344 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8345 if made != 0 {
8346 let error = std::io::Error::last_os_error();
8347 if error.raw_os_error() != Some(libc::EEXIST) {
8348 return Err(error.into());
8349 }
8350 }
8351 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8352 }
8353
8354 let leaf_name = c_name(leaf.as_bytes(), path)?;
8355 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
8356 let inspected = unsafe {
8357 libc::fstatat(
8358 directory.as_raw_fd(),
8359 leaf_name.as_ptr(),
8360 &mut existing,
8361 libc::AT_SYMLINK_NOFOLLOW,
8362 )
8363 };
8364 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
8365 return Err(LinkError::UnsafePath { path: path.clone() });
8366 }
8367
8368 let nonce = std::time::SystemTime::now()
8369 .duration_since(std::time::UNIX_EPOCH)
8370 .unwrap_or_default()
8371 .as_nanos();
8372 let temp_name = format!(
8373 ".dbmd-pull-{}-{nonce}-{}",
8374 std::process::id(),
8375 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
8376 );
8377 let temp = c_name(temp_name.as_bytes(), path)?;
8378 let fd = unsafe {
8379 libc::openat(
8380 directory.as_raw_fd(),
8381 temp.as_ptr(),
8382 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8383 0o600,
8384 )
8385 };
8386 if fd < 0 {
8387 return Err(std::io::Error::last_os_error().into());
8388 }
8389 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
8390 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
8391 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8392 return Err(error.into());
8393 }
8394 drop(file);
8395 let renamed = unsafe {
8396 libc::renameat(
8397 directory.as_raw_fd(),
8398 temp.as_ptr(),
8399 directory.as_raw_fd(),
8400 leaf_name.as_ptr(),
8401 )
8402 };
8403 if renamed != 0 {
8404 let error = std::io::Error::last_os_error();
8405 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8406 return Err(error.into());
8407 }
8408 directory.sync_all()?;
8409 }
8410 root.sync_all()?;
8411 Ok(())
8412}
8413
8414#[cfg(unix)]
8415fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
8416 use std::os::fd::{AsRawFd as _, FromRawFd as _};
8417
8418 let path = &entry.path;
8419 let components: Vec<&str> = path.split('/').collect();
8420 let (leaf, parents) = components
8421 .split_last()
8422 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8423 let mut directory = root.try_clone()?;
8424 for component in parents {
8425 let name = c_name(component.as_bytes(), path)?;
8426 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8427 if made != 0 {
8428 let error = std::io::Error::last_os_error();
8429 if error.raw_os_error() != Some(libc::EEXIST) {
8430 return Err(error.into());
8431 }
8432 }
8433 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8434 }
8435 let leaf_name = c_name(leaf.as_bytes(), path)?;
8436 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
8437 if unsafe {
8438 libc::fstatat(
8439 directory.as_raw_fd(),
8440 leaf_name.as_ptr(),
8441 &mut existing,
8442 libc::AT_SYMLINK_NOFOLLOW,
8443 )
8444 } == 0
8445 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
8446 {
8447 return Err(LinkError::UnsafePath { path: path.clone() });
8448 }
8449 let nonce = SystemTime::now()
8450 .duration_since(UNIX_EPOCH)
8451 .unwrap_or_default()
8452 .as_nanos();
8453 let temp_name = format!(
8454 ".dbmd-pull-{}-{nonce}-{}",
8455 std::process::id(),
8456 content_sha256(path.as_bytes())
8457 );
8458 let temp = c_name(temp_name.as_bytes(), path)?;
8459 let fd = unsafe {
8460 libc::openat(
8461 directory.as_raw_fd(),
8462 temp.as_ptr(),
8463 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8464 0o600,
8465 )
8466 };
8467 if fd < 0 {
8468 return Err(std::io::Error::last_os_error().into());
8469 }
8470 let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
8471 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
8472 let mut digest = Sha256::new();
8473 let mut total = 0_u64;
8474 let mut buffer = [0_u8; 64 * 1024];
8475 let copied = (|| -> std::io::Result<()> {
8476 loop {
8477 let read = input.read(&mut buffer)?;
8478 if read == 0 {
8479 break;
8480 }
8481 total = total.saturating_add(read as u64);
8482 if total > entry.bytes {
8483 return Err(std::io::Error::new(
8484 std::io::ErrorKind::InvalidData,
8485 "staged sync source grew beyond its verified length",
8486 ));
8487 }
8488 digest.update(&buffer[..read]);
8489 output.write_all(&buffer[..read])?;
8490 }
8491 Ok(())
8492 })();
8493 if let Err(error) = copied {
8494 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8495 return Err(error.into());
8496 }
8497 drop(output);
8498 if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
8499 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8500 return Err(invalid_feed(
8501 "private staged sync source failed final integrity verification",
8502 ));
8503 }
8504 if unsafe {
8505 libc::renameat(
8506 directory.as_raw_fd(),
8507 temp.as_ptr(),
8508 directory.as_raw_fd(),
8509 leaf_name.as_ptr(),
8510 )
8511 } != 0
8512 {
8513 let error = std::io::Error::last_os_error();
8514 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8515 return Err(error.into());
8516 }
8517 Ok(())
8518}
8519
8520#[cfg(unix)]
8521fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
8522 use std::os::fd::{AsRawFd as _, FromRawFd as _};
8523
8524 let path = &entry.path;
8525 let components: Vec<&str> = path.split('/').collect();
8526 let (leaf, parents) = components
8527 .split_last()
8528 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8529 let mut directory = root.try_clone()?;
8530 for component in parents {
8531 directory = open_dir_at(
8532 directory.as_raw_fd(),
8533 &c_name(component.as_bytes(), path)?,
8534 path,
8535 )?;
8536 }
8537 let leaf = c_name(leaf.as_bytes(), path)?;
8538 let fd = unsafe {
8539 libc::openat(
8540 directory.as_raw_fd(),
8541 leaf.as_ptr(),
8542 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8543 )
8544 };
8545 if fd < 0 {
8546 return Err(std::io::Error::last_os_error().into());
8547 }
8548 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
8549 if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
8550 return Err(invalid_feed(
8551 "private pull stage changed before its durability barrier",
8552 ));
8553 }
8554 file.sync_all()?;
8555 Ok(())
8556}
8557
8558#[cfg(unix)]
8559fn run_pull_source_workers(
8560 root: &std::fs::File,
8561 entries: &[V2StagedFile],
8562 operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
8563) -> LinkResult<()> {
8564 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
8565
8566 let next = AtomicUsize::new(0);
8567 let failed = AtomicBool::new(false);
8568 let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
8569 let mut first_error = None;
8570 std::thread::scope(|scope| {
8571 let (sender, receiver) = std::sync::mpsc::channel();
8572 for _ in 0..worker_count {
8573 let sender = sender.clone();
8574 let next = &next;
8575 let failed = &failed;
8576 scope.spawn(move || {
8577 while !failed.load(Ordering::Acquire) {
8578 let index = next.fetch_add(1, Ordering::Relaxed);
8579 let Some(entry) = entries.get(index) else {
8580 break;
8581 };
8582 let result = operation(root, entry);
8583 if result.is_err() {
8584 failed.store(true, Ordering::Release);
8585 }
8586 if sender.send(result).is_err() {
8587 break;
8588 }
8589 }
8590 });
8591 }
8592 drop(sender);
8593 for result in receiver {
8594 if let Err(error) = result {
8595 if first_error.is_none() {
8596 first_error = Some(error);
8597 }
8598 }
8599 }
8600 });
8601 if let Some(error) = first_error {
8602 return Err(error);
8603 }
8604 if next.load(Ordering::Relaxed) < entries.len() {
8605 return Err(invalid_feed(
8606 "a bounded pull worker stopped before reporting every file",
8607 ));
8608 }
8609 Ok(())
8610}
8611
8612#[cfg(unix)]
8613fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
8614 use std::os::fd::AsRawFd as _;
8615
8616 for name in directory_entry_names(root)? {
8617 if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
8618 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
8619 let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
8620 sync_pull_directory_tree(&child, &child_display)?;
8621 }
8622 }
8623 root.sync_all()?;
8624 Ok(())
8625}
8626
8627#[cfg(unix)]
8628fn write_pull_sources_beneath_dir(
8629 root: &std::fs::File,
8630 entries: &[V2StagedFile],
8631) -> LinkResult<()> {
8632 run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
8639 run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
8640 sync_pull_directory_tree(root, "v2 pull stage")
8641}
8642
8643#[cfg(unix)]
8644fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
8645 use std::os::fd::AsRawFd as _;
8646 for path in paths {
8647 if !safe_store_rel_path(path) {
8648 return Err(LinkError::UnsafePath { path: path.clone() });
8649 }
8650 let components = path.split('/').collect::<Vec<_>>();
8651 let Some((leaf, parents)) = components.split_last() else {
8652 return Err(LinkError::UnsafePath { path: path.clone() });
8653 };
8654 let mut directory = root.try_clone()?;
8655 let mut missing = false;
8656 for component in parents {
8657 let name = c_name(component.as_bytes(), path)?;
8658 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
8659 None => {
8660 missing = true;
8661 break;
8662 }
8663 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
8664 Some(true) => {
8665 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8666 }
8667 }
8668 }
8669 if missing {
8670 continue;
8671 }
8672 let leaf = c_name(leaf.as_bytes(), path)?;
8673 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
8674 None => {}
8675 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
8676 Some(false) => {
8677 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
8678 return Err(std::io::Error::last_os_error().into());
8679 }
8680 directory.sync_all()?;
8681 }
8682 }
8683 }
8684 Ok(())
8685}
8686
8687#[cfg(unix)]
8688fn install_pulled_delta(
8689 dest: &Path,
8690 entries: &[(String, Vec<u8>)],
8691 deleted: &[String],
8692 rebuild_indexes: bool,
8693) -> LinkResult<()> {
8694 use ring::rand::SecureRandom as _;
8695 use std::os::fd::AsRawFd as _;
8696 use std::os::unix::ffi::OsStrExt as _;
8697
8698 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8699 let name = dest
8700 .file_name()
8701 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
8702 .ok_or_else(|| LinkError::UnsafePath {
8703 path: dest.display().to_string(),
8704 })?;
8705 let parent_dir = open_or_create_dir_nofollow(parent)?;
8706 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
8707 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
8708 None => false,
8709 Some(true) => true,
8710 Some(false) => {
8711 return Err(LinkError::UnsafePath {
8712 path: dest.display().to_string(),
8713 });
8714 }
8715 };
8716
8717 let mut nonce = [0_u8; 16];
8718 ring::rand::SystemRandom::new()
8719 .fill(&mut nonce)
8720 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
8721 let stage_label = format!(
8722 ".{}.dbmd-pull-stage-{}",
8723 name.to_string_lossy(),
8724 URL_SAFE_NO_PAD.encode(nonce)
8725 );
8726 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
8727 let stage_dir = create_dir_exclusive_at(
8728 parent_dir.as_raw_fd(),
8729 &stage_name,
8730 &dest.display().to_string(),
8731 )?;
8732
8733 let prepared = (|| -> LinkResult<()> {
8734 if dest_exists {
8735 let live = open_dir_at(
8736 parent_dir.as_raw_fd(),
8737 &dest_name,
8738 &dest.display().to_string(),
8739 )?;
8740 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
8741 }
8742 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
8743 write_pull_entries_beneath_dir(&stage_dir, entries)?;
8744 if rebuild_indexes {
8745 let stage_store =
8746 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
8747 .map_err(|error| LinkError::InvalidPack {
8748 message: format!("v2 staging tree is not a valid db.md store: {error}"),
8749 })?;
8750 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
8751 LinkError::InvalidPack {
8752 message: format!("could not materialize v2 local catalogs: {error}"),
8753 }
8754 })?;
8755 }
8756 stage_dir.sync_all()?;
8757 Ok(())
8758 })();
8759 if let Err(error) = prepared {
8760 let _ = remove_tree_at(
8761 parent_dir.as_raw_fd(),
8762 &stage_name,
8763 &dest.display().to_string(),
8764 );
8765 return Err(error);
8766 }
8767
8768 if let Err(error) =
8769 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
8770 {
8771 let _ = remove_tree_at(
8772 parent_dir.as_raw_fd(),
8773 &stage_name,
8774 &dest.display().to_string(),
8775 );
8776 return Err(error);
8777 }
8778 parent_dir.sync_all()?;
8779 if dest_exists {
8780 let _ = remove_tree_at(
8784 parent_dir.as_raw_fd(),
8785 &stage_name,
8786 &dest.display().to_string(),
8787 );
8788 let _ = parent_dir.sync_all();
8789 }
8790 Ok(())
8791}
8792
8793#[cfg(unix)]
8794fn install_pulled_delta_sources(
8795 dest: &Path,
8796 entries: &[V2StagedFile],
8797 deleted: &[String],
8798 rebuild_indexes: bool,
8799 _previous: Option<&V2SyncBaseline>,
8800 _next: &V2VerifiedHead,
8801) -> LinkResult<()> {
8802 use ring::rand::SecureRandom as _;
8803 use std::os::fd::AsRawFd as _;
8804 use std::os::unix::ffi::OsStrExt as _;
8805
8806 if let Ok(store) = Store::open_strict(dest) {
8810 return install_established_v2_delta(
8811 store,
8812 entries,
8813 deleted,
8814 rebuild_indexes,
8815 _previous,
8816 _next,
8817 );
8818 }
8819
8820 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8821 let name = dest
8822 .file_name()
8823 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
8824 .ok_or_else(|| LinkError::UnsafePath {
8825 path: dest.display().to_string(),
8826 })?;
8827 let parent_dir = open_or_create_dir_nofollow(parent)?;
8828 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
8829 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
8830 None => false,
8831 Some(true) => true,
8832 Some(false) => {
8833 return Err(LinkError::UnsafePath {
8834 path: dest.display().to_string(),
8835 })
8836 }
8837 };
8838 let mut nonce = [0_u8; 16];
8839 ring::rand::SystemRandom::new()
8840 .fill(&mut nonce)
8841 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
8842 let stage_label = format!(
8843 ".{}.dbmd-pull-stage-{}",
8844 name.to_string_lossy(),
8845 URL_SAFE_NO_PAD.encode(nonce)
8846 );
8847 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
8848 let stage_dir = create_dir_exclusive_at(
8849 parent_dir.as_raw_fd(),
8850 &stage_name,
8851 &dest.display().to_string(),
8852 )?;
8853 let prepared = (|| -> LinkResult<()> {
8854 if dest_exists {
8855 let live = open_dir_at(
8856 parent_dir.as_raw_fd(),
8857 &dest_name,
8858 &dest.display().to_string(),
8859 )?;
8860 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
8861 }
8862 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
8863 write_pull_sources_beneath_dir(&stage_dir, entries)?;
8864 if rebuild_indexes {
8865 let stage_store =
8866 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
8867 .map_err(|error| LinkError::InvalidPack {
8868 message: format!("v2 staging tree is not a valid db.md store: {error}"),
8869 })?;
8870 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
8871 LinkError::InvalidPack {
8872 message: format!("could not materialize v2 local catalogs: {error}"),
8873 }
8874 })?;
8875 }
8876 stage_dir.sync_all()?;
8877 Ok(())
8878 })();
8879 if let Err(error) = prepared {
8880 let _ = remove_tree_at(
8881 parent_dir.as_raw_fd(),
8882 &stage_name,
8883 &dest.display().to_string(),
8884 );
8885 return Err(error);
8886 }
8887 if let Err(error) =
8888 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
8889 {
8890 let _ = remove_tree_at(
8891 parent_dir.as_raw_fd(),
8892 &stage_name,
8893 &dest.display().to_string(),
8894 );
8895 return Err(error);
8896 }
8897 parent_dir.sync_all()?;
8898 if dest_exists {
8899 let _ = remove_tree_at(
8900 parent_dir.as_raw_fd(),
8901 &stage_name,
8902 &dest.display().to_string(),
8903 );
8904 let _ = parent_dir.sync_all();
8905 }
8906 Ok(())
8907}
8908
8909#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8910struct V2PullCoordinate {
8911 head_seq: Option<u64>,
8912 commit_hash: Option<String>,
8913 view_kind: Option<String>,
8914 view_revision: Option<String>,
8915}
8916
8917#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8918struct V2PullFileCoordinate {
8919 sha256: String,
8920 bytes: u64,
8921}
8922
8923#[derive(Debug, Clone, Deserialize, Serialize)]
8924struct V2PullJournalEntry {
8925 path: String,
8926 old: Option<V2PullFileCoordinate>,
8927 new: Option<V2PullFileCoordinate>,
8928 backup: Option<String>,
8929}
8930
8931#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8932#[serde(rename_all = "snake_case")]
8933enum V2PullPhase {
8934 Preparing,
8935 Ready,
8936}
8937
8938#[derive(Debug, Clone, Deserialize, Serialize)]
8939struct V2PullJournal {
8940 v: u8,
8941 phase: V2PullPhase,
8942 brain: String,
8943 previous: V2PullCoordinate,
8944 next: V2PullCoordinate,
8945 backup_dir: String,
8946 entries: Vec<V2PullJournalEntry>,
8947}
8948
8949const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
8950
8951fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
8952 V2PullCoordinate {
8953 head_seq: baseline.and_then(|value| value.head_seq),
8954 commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
8955 view_kind: baseline.and_then(|value| value.view_kind.clone()),
8956 view_revision: baseline.and_then(|value| value.view_revision.clone()),
8957 }
8958}
8959
8960fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
8961 V2PullCoordinate {
8962 head_seq: head.pointer.as_ref().map(|value| value.seq),
8963 commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
8964 view_kind: Some(head.view_kind.clone()),
8965 view_revision: Some(head.view_revision.clone()),
8966 }
8967}
8968
8969fn v2_pull_file_coordinate(
8970 store: &Store,
8971 path: &str,
8972 limit: u64,
8973) -> LinkResult<Option<V2PullFileCoordinate>> {
8974 let file = match store.open_regular(Path::new(path)) {
8975 Ok(file) => file,
8976 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8977 Err(error) => return Err(error.into()),
8978 };
8979 let bytes = file.metadata()?.len();
8980 if bytes > limit || bytes > MAX_STORE_BYTES {
8981 return Err(invalid_feed(
8982 "pull transaction file exceeds its declared bound",
8983 ));
8984 }
8985 Ok(Some(V2PullFileCoordinate {
8986 sha256: content_sha256_reader(file)?,
8987 bytes,
8988 }))
8989}
8990
8991fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
8992 let mut bytes = serde_json::to_vec_pretty(journal)
8993 .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
8994 bytes.push(b'\n');
8995 Ok(bytes)
8996}
8997
8998fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
8999 let backup_prefix = ".dbmd/pull-backup-";
9000 let suffix = journal
9001 .backup_dir
9002 .strip_prefix(backup_prefix)
9003 .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
9004 let mut paths = std::collections::BTreeSet::new();
9005 if journal.v != 1
9006 || !crate::ulid::is_ulid(&journal.brain)
9007 || !crate::ulid::is_ulid(suffix)
9008 || journal.entries.is_empty()
9009 || journal.entries.len() > MAX_PUSH_FILES + 4
9010 || journal.previous == journal.next
9011 {
9012 return Err(invalid_feed("v2 pull journal failed validation"));
9013 }
9014 for (index, entry) in journal.entries.iter().enumerate() {
9015 if !safe_store_rel_path(&entry.path)
9016 || entry.path == V2_PULL_JOURNAL
9017 || entry.path.starts_with(backup_prefix)
9018 || !paths.insert(entry.path.clone())
9019 || (entry.old.is_none() && entry.new.is_none())
9020 || entry
9021 .old
9022 .iter()
9023 .chain(entry.new.iter())
9024 .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
9025 || entry.backup.as_deref()
9026 != entry
9027 .old
9028 .as_ref()
9029 .map(|_| format!("{index:08x}"))
9030 .as_deref()
9031 {
9032 return Err(invalid_feed("v2 pull journal entry failed validation"));
9033 }
9034 }
9035 Ok(())
9036}
9037
9038fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
9039 #[cfg(unix)]
9040 {
9041 use std::os::unix::fs::PermissionsExt as _;
9042 match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
9043 Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
9044 return Err(invalid_feed(
9045 "v2 pull journal is accessible to group/other; set mode 0600",
9046 ));
9047 }
9048 Ok(_) => {}
9049 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9050 Err(error) => return Err(error.into()),
9051 }
9052 }
9053 let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
9054 Ok(bytes) => bytes,
9055 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9056 Err(error) => return Err(error.into()),
9057 };
9058 let journal: V2PullJournal =
9059 serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
9060 validate_v2_pull_journal(&journal)?;
9061 Ok(Some(journal))
9062}
9063
9064fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
9065 match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
9069 Ok(()) => {}
9070 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
9071 Err(error) => return Err(error.into()),
9072 }
9073 match store.remove_private_tree(Path::new(&journal.backup_dir)) {
9074 Ok(()) => Ok(()),
9075 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
9076 Err(error) => Err(error.into()),
9077 }
9078}
9079
9080fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
9081 let names = match store.directory_names(Path::new(".dbmd")) {
9082 Ok(names) => names,
9083 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
9084 Err(error) => return Err(error.into()),
9085 };
9086 for name in names {
9087 let Some(name) = name.to_str() else {
9088 continue;
9089 };
9090 let Some(suffix) = name.strip_prefix("pull-backup-") else {
9091 continue;
9092 };
9093 if crate::ulid::is_ulid(suffix) {
9094 store.remove_private_tree(&Path::new(".dbmd").join(name))?;
9095 }
9096 }
9097 Ok(())
9098}
9099
9100fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
9101 for entry in &journal.entries {
9103 let limit = entry
9104 .old
9105 .as_ref()
9106 .into_iter()
9107 .chain(entry.new.iter())
9108 .map(|value| value.bytes)
9109 .max()
9110 .unwrap_or(0);
9111 let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
9112 if current != entry.old && current != entry.new {
9113 return Err(LinkError::InvalidPack {
9114 message: format!(
9115 "cannot recover interrupted pull because `{}` changed afterward",
9116 entry.path
9117 ),
9118 });
9119 }
9120 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
9121 let path = Path::new(&journal.backup_dir).join(backup);
9122 let file = store.open_regular(&path)?;
9123 if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
9124 return Err(invalid_feed("v2 pull recovery backup failed verification"));
9125 }
9126 }
9127 }
9128 for entry in journal.entries.iter().rev() {
9129 match (&entry.old, &entry.backup) {
9130 (Some(old), Some(backup)) => {
9131 let bytes =
9132 store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
9133 store.write_atomic(Path::new(&entry.path), &bytes)?;
9134 }
9135 (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
9136 store.remove_file(Path::new(&entry.path))?;
9137 }
9138 (None, None) => {}
9139 _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
9140 }
9141 }
9142 crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
9143 message: format!("could not rebuild catalogs after pull recovery: {error}"),
9144 })?;
9145 cleanup_v2_pull_journal(store, journal)
9146}
9147
9148fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
9149 let Ok(store) = Store::open_strict(dest) else {
9150 return Ok(());
9151 };
9152 if let Some(journal) = load_v2_pull_journal(&store)? {
9153 if journal.brain != brain {
9154 return Err(invalid_feed("v2 pull journal belongs to another brain"));
9155 }
9156 if journal.phase == V2PullPhase::Preparing {
9157 cleanup_v2_pull_journal(&store, &journal)?;
9158 } else {
9159 let baseline = load_v2_baseline(cfg, brain, dest)?;
9160 let current = v2_pull_baseline_coordinate(baseline.as_ref());
9161 if current == journal.next {
9162 cleanup_v2_pull_journal(&store, &journal)?;
9163 } else {
9164 if current != journal.previous {
9165 return Err(invalid_feed(
9166 "cannot recover interrupted pull because its baseline changed afterward",
9167 ));
9168 }
9169 rollback_v2_pull(&store, &journal)?;
9170 }
9171 }
9172 }
9173 prune_orphan_v2_pull_backups(&store)
9178}
9179
9180fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
9181 let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
9182 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9183 })?;
9184 if let Some(journal) = load_v2_pull_journal(&store)? {
9185 cleanup_v2_pull_journal(&store, &journal)?;
9186 }
9187 Ok(())
9188}
9189
9190#[cfg(windows)]
9191fn install_windows_initial_sources(
9192 dest: &Path,
9193 entries: &[V2StagedFile],
9194 rebuild_indexes: bool,
9195) -> LinkResult<()> {
9196 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9197 let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
9198 path: dest.display().to_string(),
9199 })?;
9200 let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
9201 if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
9202 return Err(LinkError::UnsafePath {
9203 path: dest.display().to_string(),
9204 });
9205 }
9206 let stage_name = format!(
9207 ".{}.dbmd-pull-stage-{}",
9208 name.to_string_lossy(),
9209 crate::ulid::mint()
9210 );
9211 let stage_path = parent.join(&stage_name);
9212 let stage_capability =
9213 crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
9214 let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
9215 let prepared = (|| -> LinkResult<()> {
9216 for entry in entries {
9217 let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
9218 if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
9219 return Err(invalid_feed(
9220 "private staged sync source failed final integrity verification",
9221 ));
9222 }
9223 stage.write_atomic(Path::new(&entry.path), &bytes)?;
9224 }
9225 let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
9226 .map_err(|error| LinkError::InvalidPack {
9227 message: format!("v2 staging tree is not a valid db.md store: {error}"),
9228 })?;
9229 if rebuild_indexes {
9230 crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
9231 message: format!("could not materialize v2 local catalogs: {error}"),
9232 })?;
9233 }
9234 Ok(())
9235 })();
9236 if let Err(error) = prepared {
9237 let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
9238 return Err(error);
9239 }
9240 crate::fsx::rename_directory_beneath(
9241 &parent_capability,
9242 Path::new(&stage_name),
9243 Path::new(name),
9244 )?;
9245 Ok(())
9246}
9247
9248fn install_established_v2_delta(
9249 store: Store,
9250 entries: &[V2StagedFile],
9251 deleted: &[String],
9252 rebuild_indexes: bool,
9253 previous: Option<&V2SyncBaseline>,
9254 next: &V2VerifiedHead,
9255) -> LinkResult<()> {
9256 if load_v2_pull_journal(&store)?.is_some() {
9257 return Err(invalid_feed(
9258 "an interrupted pull must be recovered before installing",
9259 ));
9260 }
9261 let mut sources = std::collections::BTreeMap::new();
9262 for entry in entries {
9263 if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
9264 return Err(invalid_feed("pull mutation repeats a path"));
9265 }
9266 }
9267 let mut paths = sources.keys().cloned().collect::<Vec<_>>();
9268 paths.extend(deleted.iter().cloned());
9269 paths.sort();
9270 paths.dedup();
9271 if paths.is_empty() {
9272 return Ok(());
9273 }
9274 let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
9275 let mut journal = V2PullJournal {
9276 v: 1,
9277 phase: V2PullPhase::Preparing,
9278 brain: next.brain_id.clone(),
9279 previous: v2_pull_baseline_coordinate(previous),
9280 next: v2_pull_head_coordinate(next),
9281 backup_dir: backup_dir.clone(),
9282 entries: Vec::with_capacity(paths.len()),
9283 };
9284 for path in &paths {
9285 let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
9286 let new = sources.get(path).map(|entry| V2PullFileCoordinate {
9287 sha256: entry.sha256.clone(),
9288 bytes: entry.bytes,
9289 });
9290 if old == new {
9291 continue;
9292 }
9293 let index = journal.entries.len();
9294 journal.entries.push(V2PullJournalEntry {
9295 path: path.clone(),
9296 backup: old.as_ref().map(|_| format!("{index:08x}")),
9297 old,
9298 new,
9299 });
9300 }
9301 if journal.entries.is_empty() {
9302 return Ok(());
9303 }
9304 let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
9305 entry
9306 .old
9307 .as_ref()
9308 .map_or(Some(total), |old| total.checked_add(old.bytes))
9309 });
9310 if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
9311 return Err(LinkError::InvalidPack {
9312 message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
9313 });
9314 }
9315 validate_v2_pull_journal(&journal)?;
9316 store.write_private_atomic_new(
9317 Path::new(V2_PULL_JOURNAL),
9318 &v2_pull_journal_bytes(&journal)?,
9319 )?;
9320 let prepared = (|| -> LinkResult<()> {
9321 store.create_private_dir_all(Path::new(&backup_dir))?;
9322 for entry in &journal.entries {
9323 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
9324 let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
9325 if content_sha256(&bytes) != old.sha256 {
9326 return Err(invalid_feed("live pull source changed during backup"));
9327 }
9328 store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
9329 }
9330 }
9331 journal.phase = V2PullPhase::Ready;
9332 store.write_private_atomic(
9333 Path::new(V2_PULL_JOURNAL),
9334 &v2_pull_journal_bytes(&journal)?,
9335 )?;
9336 Ok(())
9337 })();
9338 if let Err(error) = prepared {
9339 let cleanup = cleanup_v2_pull_journal(&store, &journal);
9340 return match cleanup {
9341 Ok(()) => Err(error),
9342 Err(cleanup) => Err(LinkError::InvalidPack {
9343 message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
9344 }),
9345 };
9346 }
9347 let installed = (|| -> LinkResult<()> {
9348 for entry in &journal.entries {
9349 if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
9350 return Err(LinkError::InvalidPack {
9351 message: format!("local path `{}` changed during pull", entry.path),
9352 });
9353 }
9354 if let Some(source) = sources.get(&entry.path) {
9355 let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
9356 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
9357 return Err(invalid_feed(
9358 "private staged sync source failed final integrity verification",
9359 ));
9360 }
9361 store.write_atomic(Path::new(&entry.path), &bytes)?;
9362 } else if entry.old.is_some() {
9363 store.remove_file(Path::new(&entry.path))?;
9364 }
9365 }
9366 if rebuild_indexes {
9367 crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
9368 message: format!("could not materialize v2 local catalogs: {error}"),
9369 })?;
9370 }
9371 Ok(())
9372 })();
9373 if let Err(error) = installed {
9374 return match rollback_v2_pull(&store, &journal) {
9375 Ok(()) => Err(error),
9376 Err(rollback) => Err(LinkError::InvalidPack {
9377 message: format!("{error}; durable pull rollback also failed: {rollback}"),
9378 }),
9379 };
9380 }
9381 Ok(())
9382}
9383
9384#[cfg(windows)]
9385fn install_pulled_delta_sources(
9386 dest: &Path,
9387 entries: &[V2StagedFile],
9388 deleted: &[String],
9389 rebuild_indexes: bool,
9390 previous: Option<&V2SyncBaseline>,
9391 next: &V2VerifiedHead,
9392) -> LinkResult<()> {
9393 match Store::open_strict(dest) {
9394 Ok(store) => {
9395 install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
9396 }
9397 Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
9398 }
9399}
9400
9401#[cfg(not(any(unix, windows)))]
9402fn install_pulled_delta_sources(
9403 _dest: &Path,
9404 _entries: &[V2StagedFile],
9405 _deleted: &[String],
9406 _rebuild_indexes: bool,
9407 _previous: Option<&V2SyncBaseline>,
9408 _next: &V2VerifiedHead,
9409) -> LinkResult<()> {
9410 Err(LinkError::UnsupportedPlatform {
9411 operation: "atomic v2 pull install",
9412 })
9413}
9414
9415#[cfg(unix)]
9416fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
9417 install_pulled_delta(dest, entries, &[], false)
9418}
9419
9420#[cfg(not(windows))]
9421fn is_safe_slug(slug: &str) -> bool {
9422 !slug.is_empty()
9423 && slug.len() <= 63
9424 && !slug.starts_with('-')
9425 && !slug.ends_with('-')
9426 && slug
9427 .bytes()
9428 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
9429}
9430
9431fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
9432 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
9433}
9434
9435fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
9436 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
9437}
9438
9439fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
9440 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
9441}
9442
9443fn preflight_zip_central_directory(
9444 bytes: &[u8],
9445 offset: usize,
9446 size: usize,
9447 count: u64,
9448) -> LinkResult<()> {
9449 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
9450 let end = offset
9451 .checked_add(size)
9452 .filter(|end| *end <= bytes.len())
9453 .ok_or_else(|| LinkError::InvalidPack {
9454 message: "ZIP central directory is out of bounds".to_string(),
9455 })?;
9456 let mut cursor = offset;
9457 for _ in 0..count {
9458 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
9459 return Err(LinkError::InvalidPack {
9460 message: "ZIP central directory entry count is inconsistent".to_string(),
9461 });
9462 }
9463 if le_u16(bytes, cursor + 34) != Some(0) {
9464 return Err(LinkError::InvalidPack {
9465 message: "multi-disk ZIP archives are not supported".to_string(),
9466 });
9467 }
9468 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
9469 total.checked_add(le_u16(bytes, cursor + at)? as usize)
9470 });
9471 cursor = cursor
9472 .checked_add(46)
9473 .and_then(|fixed| fixed.checked_add(variable?))
9474 .filter(|cursor| *cursor <= end)
9475 .ok_or_else(|| LinkError::InvalidPack {
9476 message: "ZIP central directory entry is truncated".to_string(),
9477 })?;
9478 }
9479 if cursor != end {
9480 return Err(LinkError::InvalidPack {
9481 message: "ZIP central directory size is inconsistent".to_string(),
9482 });
9483 }
9484 Ok(())
9485}
9486
9487fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
9491 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
9492 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
9493 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
9494 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
9495 let eocd = bytes[search_start..]
9496 .windows(4)
9497 .rposition(|window| window == EOCD_SIG)
9498 .map(|offset| search_start + offset)
9499 .ok_or_else(|| LinkError::InvalidPack {
9500 message: "ZIP has no end-of-central-directory record".to_string(),
9501 })?;
9502 let invalid_end = || LinkError::InvalidPack {
9503 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
9504 };
9505 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
9506 if eocd
9507 .checked_add(22)
9508 .and_then(|end| end.checked_add(comment_len))
9509 != Some(bytes.len())
9510 {
9511 return Err(invalid_end());
9515 }
9516 let disk = le_u16(bytes, eocd + 4);
9517 let central_disk = le_u16(bytes, eocd + 6);
9518 if disk != Some(0) || central_disk != Some(0) {
9519 return Err(LinkError::InvalidPack {
9520 message: "multi-disk ZIP archives are not supported".to_string(),
9521 });
9522 }
9523 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
9524 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
9525 if entries_on_disk != ordinary {
9526 return Err(LinkError::InvalidPack {
9527 message: "multi-disk ZIP archives are not supported".to_string(),
9528 });
9529 }
9530 let zip64_locator = eocd
9531 .checked_sub(20)
9532 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
9533 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
9534 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
9535 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
9536 if central_offset
9537 .checked_add(central_size)
9538 .filter(|end| *end == eocd)
9539 .is_none()
9540 {
9541 return Err(invalid_end());
9542 }
9543 (ordinary as u64, central_offset, central_size)
9544 } else {
9545 let Some(locator) = zip64_locator else {
9546 return Err(invalid_end());
9547 };
9548 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
9549 return Err(LinkError::InvalidPack {
9550 message: "multi-disk ZIP64 archives are not supported".to_string(),
9551 });
9552 }
9553 let record = le_u64(bytes, locator + 8)
9554 .and_then(|offset| usize::try_from(offset).ok())
9555 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
9556 .ok_or_else(|| LinkError::InvalidPack {
9557 message: "ZIP64 archive has an invalid end record".to_string(),
9558 })?;
9559 let record_size = le_u64(bytes, record + 4)
9560 .and_then(|size| usize::try_from(size).ok())
9561 .filter(|size| *size >= 44)
9562 .ok_or_else(invalid_end)?;
9563 if record
9564 .checked_add(12)
9565 .and_then(|end| end.checked_add(record_size))
9566 != Some(locator)
9567 || le_u32(bytes, record + 16) != Some(0)
9568 || le_u32(bytes, record + 20) != Some(0)
9569 {
9570 return Err(invalid_end());
9571 }
9572 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
9573 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
9574 let central_size = le_u64(bytes, record + 40)
9575 .and_then(|size| usize::try_from(size).ok())
9576 .ok_or_else(invalid_end)?;
9577 let central_offset = le_u64(bytes, record + 48)
9578 .and_then(|offset| usize::try_from(offset).ok())
9579 .ok_or_else(invalid_end)?;
9580 if zip64_on_disk != zip64_total
9581 || central_offset
9582 .checked_add(central_size)
9583 .filter(|end| *end == record)
9584 .is_none()
9585 {
9586 return Err(invalid_end());
9587 }
9588 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
9589 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
9590 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
9591 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
9592 {
9593 return Err(invalid_end());
9594 }
9595 (zip64_total, central_offset, central_size)
9596 };
9597 if count == 0 || count > max_entries as u64 {
9598 return Err(LinkError::InvalidPack {
9599 message: format!("invalid file count {count}"),
9600 });
9601 }
9602 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
9603 Ok(())
9604}
9605
9606fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
9607 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
9608 let mut archive =
9609 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
9610 message: format!("ZIP parse failed: {err}"),
9611 })?;
9612 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
9613 return Err(LinkError::InvalidPack {
9614 message: format!("invalid file count {}", archive.len()),
9615 });
9616 }
9617 let mut total = 0u64;
9618 let mut seen = std::collections::HashSet::new();
9619 let mut entries = Vec::with_capacity(archive.len());
9620 for index in 0..archive.len() {
9621 let mut file = archive
9622 .by_index(index)
9623 .map_err(|err| LinkError::InvalidPack {
9624 message: format!("ZIP entry failed: {err}"),
9625 })?;
9626 if file.is_dir() {
9627 continue;
9628 }
9629 let path = file.name().to_string();
9630 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
9631 return Err(LinkError::UnsafePath { path });
9632 }
9633 if file
9634 .unix_mode()
9635 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
9636 {
9637 return Err(LinkError::InvalidPack {
9638 message: format!("non-file entry `{path}`"),
9639 });
9640 }
9641 if !seen.insert(path.clone()) {
9642 return Err(LinkError::InvalidPack {
9643 message: format!("duplicate path `{path}`"),
9644 });
9645 }
9646 let remaining = MAX_STORE_BYTES.saturating_sub(total);
9647 if file.size() > remaining {
9648 return Err(LinkError::InvalidPack {
9649 message: "expanded content exceeds the 512 MB limit".to_string(),
9650 });
9651 }
9652 let mut content = Vec::new();
9653 (&mut file)
9654 .take(remaining + 1)
9655 .read_to_end(&mut content)
9656 .map_err(|err| LinkError::InvalidPack {
9657 message: format!("could not decompress `{path}`: {err}"),
9658 })?;
9659 if content.len() as u64 > remaining {
9660 return Err(LinkError::InvalidPack {
9661 message: "expanded content exceeds the 512 MB limit".to_string(),
9662 });
9663 }
9664 if content.len() as u64 != file.size() {
9665 return Err(LinkError::InvalidPack {
9666 message: format!("length mismatch for `{path}`"),
9667 });
9668 }
9669 total += content.len() as u64;
9670 entries.push((path, content));
9671 }
9672 if entries.is_empty() {
9673 return Err(LinkError::InvalidPack {
9674 message: "pack contains no files".to_string(),
9675 });
9676 }
9677 Ok(entries)
9678}
9679
9680fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
9681 let mut expected = std::collections::BTreeMap::new();
9682 for file in signed {
9683 if !safe_store_rel_path(&file.path) {
9684 return Err(LinkError::UnsafePath {
9685 path: file.path.clone(),
9686 });
9687 }
9688 if !is_sha256(&file.sha256)
9689 || expected
9690 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
9691 .is_some()
9692 {
9693 return Err(invalid_feed(
9694 "signed snapshot manifest contains an invalid or duplicate file",
9695 ));
9696 }
9697 }
9698 if expected.len() != entries.len() {
9699 return Err(invalid_feed(
9700 "downloaded pack file set differs from the signed snapshot manifest",
9701 ));
9702 }
9703 for (path, bytes) in entries {
9704 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
9705 return Err(invalid_feed(format!(
9706 "downloaded pack contains unsigned path `{path}`"
9707 )));
9708 };
9709 if *declared_bytes != bytes.len() as u64
9710 || *sha256 != format!("{:x}", Sha256::digest(bytes))
9711 {
9712 return Err(invalid_feed(format!(
9713 "downloaded file `{path}` differs from its signed manifest"
9714 )));
9715 }
9716 }
9717 Ok(())
9718}
9719
9720pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
9727 require_hardened_filesystem("sync push")?;
9728 preflight_push_ownership(store)?;
9729 let mut out: Vec<(String, String)> = Vec::new();
9730 let mut total = 0u64;
9731
9732 let mut read_text = |rel: &str| -> LinkResult<String> {
9733 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
9734 total = total
9735 .checked_add(bytes.len() as u64)
9736 .ok_or_else(|| LinkError::PushTooLarge {
9737 detail: "uncompressed byte count overflow".to_string(),
9738 })?;
9739 if total > MAX_STORE_BYTES {
9740 return Err(LinkError::PushTooLarge {
9741 detail: format!("{total} uncompressed bytes"),
9742 });
9743 }
9744 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
9745 path: rel.to_string(),
9746 })
9747 };
9748
9749 out.push(("DB.md".to_string(), read_text("DB.md")?));
9750 if store
9751 .regular_file_exists(Path::new("assets.jsonl"))
9752 .unwrap_or(false)
9753 {
9754 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
9755 }
9756
9757 for rel in store.walk()? {
9758 let rel_str = rel.to_string_lossy().replace('\\', "/");
9759 if !safe_store_rel_path(&rel_str) {
9760 return Err(LinkError::UnsafePath { path: rel_str });
9763 }
9764 let content = read_text(&rel_str)?;
9765 out.push((rel_str, content));
9766 }
9767
9768 out.sort_by(|a, b| a.0.cmp(&b.0));
9769 Ok(out)
9770}
9771
9772fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
9776 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
9777 return Err(LinkError::from(std::io::Error::new(
9778 std::io::ErrorKind::PermissionDenied,
9779 format!("cannot push: nested db.md store at {}", nested.display()),
9780 )));
9781 }
9782
9783 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
9784 return Err(LinkError::from(std::io::Error::new(
9785 std::io::ErrorKind::PermissionDenied,
9786 format!(
9787 "cannot push: {} is a symlink outside the store ownership model",
9788 symlink.display()
9789 ),
9790 )));
9791 }
9792 Ok(())
9793}
9794
9795pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
9801 require_safe_ref(brain)?;
9802 let remote = verified_remote_head(cfg, brain, false)?;
9803 if files.len() > MAX_PUSH_FILES {
9804 return Err(LinkError::PushTooLarge {
9805 detail: format!("{} files", files.len()),
9806 });
9807 }
9808 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
9809 if raw_total > MAX_STORE_BYTES {
9810 return Err(LinkError::PushTooLarge {
9811 detail: format!("{raw_total} uncompressed bytes"),
9812 });
9813 }
9814
9815 if cfg.brain_key.is_none() {
9819 let body = json!({
9820 "files": files
9821 .iter()
9822 .map(|(p, c)| json!({ "path": p, "content": c }))
9823 .collect::<Vec<_>>(),
9824 });
9825 if body.to_string().len() <= MAX_PUSH_BYTES {
9826 let path = format!("/api/hub/brains/{brain}/push");
9827 let pushed = ensure_ok(
9828 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9829 "sync push",
9830 )?;
9831 return Ok(pushed);
9832 }
9833 }
9834
9835 let pack = build_store_pack(files)?;
9836 if pack.len() as u64 > MAX_PACK_BYTES {
9837 return Err(LinkError::PushTooLarge {
9838 detail: format!("{} pack bytes", pack.len()),
9839 });
9840 }
9841 let sha256 = format!("{:x}", Sha256::digest(&pack));
9842 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
9843 if let Some(key) = &cfg.brain_key {
9844 if !remote.head.verified {
9845 return Err(invalid_feed(
9846 "self-custody push requires a fully verified, unscoped feed head",
9847 ));
9848 }
9849 let identity = remote
9850 .identity
9851 .as_ref()
9852 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
9853 let current_multikey = format!("ed25519:{}", identity.fingerprint);
9854 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
9855 return Err(invalid_feed(
9856 "configured brain key is not the verified current brain identity",
9857 ));
9858 }
9859 let next_seq = remote
9862 .head
9863 .seq
9864 .checked_add(1)
9865 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
9866 let mut manifest: Vec<WireFeedFile> = files
9867 .iter()
9868 .map(|(path, content)| WireFeedFile {
9869 path: path.clone(),
9870 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
9871 bytes: content.len() as u64,
9872 })
9873 .collect();
9874 manifest.sort_by(|a, b| a.path.cmp(&b.path));
9875 let ts = crate::now()
9876 .with_timezone(&chrono::Utc)
9877 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
9878 .to_string();
9879 let entry = self_custody_entry(
9880 key,
9881 next_seq,
9882 ts,
9883 &sha256,
9884 &manifest,
9885 remote.head.feed_hash.as_deref(),
9886 )?;
9887 meta["entry"] = Value::String(entry);
9888 }
9889 let presigned = ensure_ok(
9890 request(
9891 cfg,
9892 "POST",
9893 &format!("/api/hub/brains/{brain}/packs/presign"),
9894 Some(&meta),
9895 Auth::Required,
9896 )?,
9897 "prepare pack upload",
9898 )?;
9899 let url = presigned
9900 .get("url")
9901 .and_then(Value::as_str)
9902 .ok_or_else(|| LinkError::InvalidPack {
9903 message: "the hub returned no upload URL".to_string(),
9904 })?;
9905 put_presigned(
9906 cfg,
9907 url,
9908 presigned.get("headers").unwrap_or(&Value::Null),
9909 &pack,
9910 )?;
9911 let committed = ensure_ok(
9912 request(
9913 cfg,
9914 "POST",
9915 &format!("/api/hub/brains/{brain}/packs/commit"),
9916 Some(&meta),
9917 Auth::Required,
9918 )?,
9919 "commit pack",
9920 )?;
9921 Ok(committed)
9922}
9923
9924fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
9925 const LOCAL_HEADER: u32 = 0x0403_4b50;
9926 const CENTRAL_HEADER: u32 = 0x0201_4b50;
9927 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
9928 const VERSION_20: u16 = 20;
9929 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
9930 const UTF8_FLAG: u16 = 1 << 11;
9931 const STORED: u16 = 0;
9932 const DOS_TIME_MIDNIGHT: u16 = 0;
9933 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
9934 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
9935
9936 struct CentralEntry<'a> {
9937 name: &'a [u8],
9938 crc32: u32,
9939 size: u32,
9940 local_offset: u32,
9941 }
9942
9943 fn push_u16(out: &mut Vec<u8>, value: u16) {
9944 out.extend_from_slice(&value.to_le_bytes());
9945 }
9946
9947 fn push_u32(out: &mut Vec<u8>, value: u32) {
9948 out.extend_from_slice(&value.to_le_bytes());
9949 }
9950
9951 if files.is_empty() {
9952 return Err(LinkError::InvalidPack {
9953 message: "cannot create an empty snapshot pack".to_string(),
9954 });
9955 }
9956 if files.len() > u16::MAX as usize {
9957 return Err(LinkError::PushTooLarge {
9958 detail: format!(
9959 "{} files (canonical ZIP32 packs cap at {})",
9960 files.len(),
9961 u16::MAX
9962 ),
9963 });
9964 }
9965
9966 let mut sorted: Vec<_> = files.iter().collect();
9967 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
9968 let mut previous: Option<&str> = None;
9969 for (path, content) in &sorted {
9970 if !safe_store_rel_path(path) {
9971 return Err(LinkError::UnsafePath {
9972 path: (*path).clone(),
9973 });
9974 }
9975 if previous == Some(path.as_str()) {
9976 return Err(LinkError::InvalidPack {
9977 message: format!("duplicate path `{path}`"),
9978 });
9979 }
9980 previous = Some(path.as_str());
9981 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
9982 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9983 })?;
9984 }
9985
9986 let mut out = Vec::new();
9987 let mut central = Vec::with_capacity(sorted.len());
9988 for (path, content) in sorted {
9989 let name = path.as_bytes();
9990 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
9991 message: format!("ZIP entry name is too long: `{path}`"),
9992 })?;
9993 let bytes = content.as_bytes();
9994 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
9995 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9996 })?;
9997 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
9998 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
9999 })?;
10000 let crc32 = crc32fast::hash(bytes);
10001
10002 push_u32(&mut out, LOCAL_HEADER);
10005 push_u16(&mut out, VERSION_20);
10006 push_u16(&mut out, UTF8_FLAG);
10007 push_u16(&mut out, STORED);
10008 push_u16(&mut out, DOS_TIME_MIDNIGHT);
10009 push_u16(&mut out, DOS_DATE_1980_01_01);
10010 push_u32(&mut out, crc32);
10011 push_u32(&mut out, size);
10012 push_u32(&mut out, size);
10013 push_u16(&mut out, name_len);
10014 push_u16(&mut out, 0); out.extend_from_slice(name);
10016 out.extend_from_slice(bytes);
10017
10018 central.push(CentralEntry {
10019 name,
10020 crc32,
10021 size,
10022 local_offset,
10023 });
10024 }
10025
10026 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
10027 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
10028 })?;
10029 for entry in ¢ral {
10030 push_u32(&mut out, CENTRAL_HEADER);
10031 push_u16(&mut out, MADE_BY_UNIX_20);
10032 push_u16(&mut out, VERSION_20);
10033 push_u16(&mut out, UTF8_FLAG);
10034 push_u16(&mut out, STORED);
10035 push_u16(&mut out, DOS_TIME_MIDNIGHT);
10036 push_u16(&mut out, DOS_DATE_1980_01_01);
10037 push_u32(&mut out, entry.crc32);
10038 push_u32(&mut out, entry.size);
10039 push_u32(&mut out, entry.size);
10040 push_u16(&mut out, entry.name.len() as u16);
10041 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);
10046 push_u32(&mut out, entry.local_offset);
10047 out.extend_from_slice(entry.name);
10048 }
10049 let central_size = u32::try_from(out.len())
10050 .ok()
10051 .and_then(|end| end.checked_sub(central_offset))
10052 .ok_or_else(|| LinkError::PushTooLarge {
10053 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
10054 })?;
10055 let entry_count = central.len() as u16;
10056
10057 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
10058 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
10061 push_u16(&mut out, entry_count);
10062 push_u32(&mut out, central_size);
10063 push_u32(&mut out, central_offset);
10064 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
10067 return Err(LinkError::PushTooLarge {
10068 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
10069 });
10070 }
10071 Ok(out)
10072}
10073
10074#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10080pub enum Capability {
10081 Read,
10083 Write,
10085}
10086
10087impl Capability {
10088 pub fn as_str(self) -> &'static str {
10090 match self {
10091 Capability::Read => "read",
10092 Capability::Write => "write",
10093 }
10094 }
10095}
10096
10097pub fn grant_issue(
10103 cfg: &HubConfig,
10104 brain: &str,
10105 grantee: &str,
10106 can: Capability,
10107 scope: Option<&str>,
10108 until: Option<&str>,
10109) -> LinkResult<Value> {
10110 require_safe_ref(brain)?;
10111 let _ = verified_remote_head(cfg, brain, false)?;
10112 let is_key_grantee = URL_SAFE_NO_PAD
10117 .decode(grantee)
10118 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
10119 .unwrap_or(false);
10120 let mut body = if is_key_grantee {
10121 json!({ "keySpki": grantee, "capability": can.as_str() })
10122 } else {
10123 json!({ "email": grantee, "capability": can.as_str() })
10124 };
10125 if let Some(s) = scope {
10126 body["scopePrefix"] = json!(s);
10127 }
10128 if let Some(u) = until {
10129 body["expiresAt"] = json!(u);
10130 }
10131 let path = format!("/api/hub/brains/{brain}/grants");
10132 ensure_ok(
10133 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10134 "grant issue",
10135 )
10136}
10137
10138pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
10140 require_safe_ref(brain)?;
10141 let _ = verified_remote_head(cfg, brain, false)?;
10142 let path = format!("/api/hub/brains/{brain}/grants");
10143 ensure_ok(
10144 request(cfg, "GET", &path, None, Auth::Required)?,
10145 "grant list",
10146 )
10147}
10148
10149pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
10152 require_safe_ref(brain)?;
10153 require_safe_grant_id(grant_id)?;
10154 let _ = verified_remote_head(cfg, brain, false)?;
10155 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
10156 ensure_ok(
10157 request(cfg, "DELETE", &path, None, Auth::Required)?,
10158 "grant revoke",
10159 )
10160}
10161
10162#[derive(Debug)]
10167struct VerifiedV2Proposal {
10168 value: Value,
10169 changes: Value,
10170 blobs: Vec<(String, u64, String)>,
10171}
10172
10173fn require_proposal_id(id: &str) -> LinkResult<()> {
10174 if crate::ulid::is_ulid(id) {
10175 Ok(())
10176 } else {
10177 Err(invalid_feed("proposal id is not a lowercase ULID"))
10178 }
10179}
10180
10181fn verified_v2_proposal(
10182 cfg: &HubConfig,
10183 head: &V2VerifiedHead,
10184 proposal_id: &str,
10185) -> LinkResult<VerifiedV2Proposal> {
10186 require_proposal_id(proposal_id)?;
10187 if head.view_kind != "full" {
10188 return Err(invalid_feed(
10189 "proposal review requires a full readable view",
10190 ));
10191 }
10192 let path = format!(
10193 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
10194 head.brain_id
10195 );
10196 let value = ensure_ok(
10197 request_capped(
10198 cfg,
10199 "GET",
10200 &path,
10201 None,
10202 Auth::Required,
10203 MAX_FEED_RESPONSE_BYTES,
10204 )?,
10205 "v2 proposal",
10206 )?;
10207 verify_v2_proposal_value(head, proposal_id, value)
10208}
10209
10210fn verify_v2_proposal_value(
10211 head: &V2VerifiedHead,
10212 proposal_id: &str,
10213 value: Value,
10214) -> LinkResult<VerifiedV2Proposal> {
10215 if value.get("v").and_then(Value::as_u64) != Some(2) {
10216 return Err(invalid_feed("proposal response has an invalid version"));
10217 }
10218 let proposal = value
10219 .get("proposal")
10220 .and_then(Value::as_object)
10221 .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
10222 if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
10223 return Err(invalid_feed("proposal response changed its id"));
10224 }
10225 let payload_hash = proposal
10226 .get("payload_sha256")
10227 .and_then(Value::as_str)
10228 .filter(|hash| is_sha256(hash))
10229 .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
10230 let clear_hash = proposal
10231 .get("clear_sha256")
10232 .and_then(Value::as_str)
10233 .filter(|hash| is_sha256(hash))
10234 .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
10235 let submission_hash = proposal
10236 .get("submission_claim_sha256")
10237 .and_then(Value::as_str)
10238 .filter(|hash| is_sha256(hash))
10239 .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
10240 let submission = STANDARD
10241 .decode(
10242 proposal
10243 .get("submission_claim_base64")
10244 .and_then(Value::as_str)
10245 .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
10246 )
10247 .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
10248 let submission_value: Value = serde_json::from_slice(&submission)
10249 .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
10250 if crate::linkmd_v2::canonical_bytes(&submission_value)
10251 .map_err(|error| invalid_feed(error.to_string()))?
10252 != submission
10253 || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
10254 .map_err(|error| invalid_feed(error.to_string()))?
10255 != submission_hash
10256 {
10257 return Err(invalid_feed(
10258 "proposal submission claim is not canonical or addressed",
10259 ));
10260 }
10261 let envelope = submission_value
10262 .as_object()
10263 .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
10264 let claim = envelope
10265 .get("claim")
10266 .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
10267 let claim_object = claim
10268 .as_object()
10269 .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
10270 let actor_root = claim_object
10271 .get("actor_root")
10272 .and_then(Value::as_object)
10273 .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
10274 let public_key = envelope
10275 .get("public_key")
10276 .and_then(Value::as_str)
10277 .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
10278 let fingerprint = envelope
10279 .get("fingerprint")
10280 .and_then(Value::as_str)
10281 .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
10282 let signature = envelope
10283 .get("sig")
10284 .and_then(Value::as_str)
10285 .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
10286 let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
10287 .map_err(|error| invalid_feed(error.to_string()))?;
10288 let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
10289 let signer = format!("{fingerprint}:{public_key}");
10290 let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
10291 let grants = actor_root.get("grants").and_then(Value::as_array);
10292 let grants_are_canonical = grants.is_some_and(|items| {
10293 let mut prior: Option<&str> = None;
10294 items.iter().all(|item| {
10295 let Some(grant) = item.as_str() else {
10296 return false;
10297 };
10298 if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
10299 return false;
10300 }
10301 prior = Some(grant);
10302 true
10303 })
10304 });
10305 let optional_actor_field = |name: &str| {
10306 actor_root.get(name).is_some_and(|value| {
10307 value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
10308 })
10309 };
10310 let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
10311 if claim_object.get("v").and_then(Value::as_u64) != Some(2)
10312 || format!("{:x}", Sha256::digest(&der)) != fingerprint
10313 || head
10314 .trust
10315 .hub_signer
10316 .as_ref()
10317 .is_some_and(|known| known != &signer)
10318 || !matches!(
10319 actor_class,
10320 Some(
10321 "user"
10322 | "owned_agent"
10323 | "foreign_key"
10324 | "curation"
10325 | "inbox"
10326 | "restore"
10327 | "migration"
10328 | "operator_recovery"
10329 )
10330 )
10331 || actor_root
10332 .get("principal")
10333 .and_then(Value::as_str)
10334 .is_none_or(|value| value.is_empty())
10335 || actor_root
10336 .get("credential")
10337 .and_then(Value::as_str)
10338 .is_none_or(|value| value.is_empty())
10339 || !optional_actor_field("organization")
10340 || !optional_actor_field("role")
10341 || !grants_are_canonical
10342 || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
10343 || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
10344 || !claim_object
10345 .get("mutation_id")
10346 .and_then(Value::as_str)
10347 .is_some_and(|value| {
10348 !value.is_empty()
10349 && value.len() <= 128
10350 && value.chars().enumerate().all(|(index, char)| {
10351 char.is_ascii_alphanumeric()
10352 || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
10353 })
10354 })
10355 || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
10356 || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
10357 || !claim_object
10358 .get("control_revision")
10359 .and_then(Value::as_str)
10360 .is_some_and(is_sha256)
10361 || submitted_at.is_none_or(|value| {
10362 chrono::DateTime::parse_from_rfc3339(value).is_err()
10363 || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
10364 })
10365 || !proposal
10366 .get("state")
10367 .and_then(Value::as_str)
10368 .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
10369 || proposal
10370 .get("expires_at")
10371 .and_then(Value::as_str)
10372 .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
10373 || proposal
10374 .get("proposer")
10375 .and_then(Value::as_object)
10376 .and_then(|value| value.get("class"))
10377 .and_then(Value::as_str)
10378 != actor_class
10379 {
10380 return Err(invalid_feed(
10381 "proposal submission claim does not bind the verified proposal",
10382 ));
10383 }
10384 let changes_b64 = proposal
10385 .get("changes_base64")
10386 .and_then(Value::as_str)
10387 .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
10388 let changes_bytes = STANDARD
10389 .decode(changes_b64)
10390 .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
10391 let changes: Value = serde_json::from_slice(&changes_bytes)
10392 .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
10393 if crate::linkmd_v2::canonical_bytes(&changes)
10394 .map_err(|error| invalid_feed(error.to_string()))?
10395 != changes_bytes
10396 || changes.get("v").and_then(Value::as_u64) != Some(2)
10397 || !changes.get("operations").is_some_and(Value::is_array)
10398 {
10399 return Err(invalid_feed("proposal changeset is not canonical v2"));
10400 }
10401 let blob_values = proposal
10402 .get("blobs")
10403 .and_then(Value::as_array)
10404 .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
10405 let mut blobs = Vec::with_capacity(blob_values.len());
10406 let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
10407 let mut prior_hash: Option<String> = None;
10408 for item in blob_values {
10409 let hash = item
10410 .get("sha256")
10411 .and_then(Value::as_str)
10412 .filter(|hash| is_sha256(hash))
10413 .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
10414 let bytes = item
10415 .get("bytes")
10416 .and_then(Value::as_u64)
10417 .filter(|bytes| *bytes <= MAX_STORE_BYTES)
10418 .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
10419 if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
10420 return Err(invalid_feed(
10421 "proposal blob declarations are not unique and sorted",
10422 ));
10423 }
10424 prior_hash = Some(hash.to_string());
10425 let endpoint = item
10426 .get("endpoint")
10427 .and_then(Value::as_str)
10428 .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
10429 let expected_endpoint = format!(
10430 "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
10431 head.brain_id
10432 );
10433 if endpoint != expected_endpoint {
10434 return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
10435 }
10436 descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
10437 blobs.push((hash.to_string(), bytes, endpoint.to_string()));
10438 }
10439 let descriptor = json!({
10440 "base": proposal.get("base").cloned().unwrap_or(Value::Null),
10441 "blobs": descriptor_blobs,
10442 "changes_base64": changes_b64,
10443 "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
10444 "v": 2,
10445 });
10446 let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
10447 .map_err(|error| invalid_feed(error.to_string()))?;
10448 if content_sha256(&descriptor_bytes) != clear_hash {
10449 return Err(invalid_feed(
10450 "proposal clear payload differs from its signed submission claim",
10451 ));
10452 }
10453 Ok(VerifiedV2Proposal {
10454 value,
10455 changes,
10456 blobs,
10457 })
10458}
10459
10460pub fn proposal_list(
10461 cfg: &HubConfig,
10462 brain: &str,
10463 state: &str,
10464 after: Option<&str>,
10465 limit: usize,
10466) -> LinkResult<Value> {
10467 require_safe_ref(brain)?;
10468 if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
10469 return Err(invalid_feed("proposal state is invalid"));
10470 }
10471 if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
10472 return Err(invalid_feed("proposal cursor is invalid"));
10473 }
10474 let head = v2_verified_head(cfg, brain)?
10475 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10476 let path = format!(
10477 "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
10478 head.brain_id,
10479 limit.clamp(1, 100),
10480 after.map_or_else(String::new, |value| format!("&after={value}"))
10481 );
10482 ensure_ok(
10483 request_capped(
10484 cfg,
10485 "GET",
10486 &path,
10487 None,
10488 Auth::Required,
10489 MAX_FEED_RESPONSE_BYTES,
10490 )?,
10491 "v2 proposal list",
10492 )
10493}
10494
10495pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
10496 require_safe_ref(brain)?;
10497 let head = v2_verified_head(cfg, brain)?
10498 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10499 Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
10500}
10501
10502pub fn proposal_reject(
10503 cfg: &HubConfig,
10504 brain: &str,
10505 proposal_id: &str,
10506 mutation_id: &str,
10507 reason: &str,
10508) -> LinkResult<Value> {
10509 require_safe_ref(brain)?;
10510 require_proposal_id(proposal_id)?;
10511 let head = v2_verified_head(cfg, brain)?
10512 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10513 let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
10514 let body = json!({
10515 "mutation_id": mutation_id,
10516 "control_revision": head.control_revision,
10517 "reason": reason,
10518 });
10519 let path = format!(
10520 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
10521 head.brain_id
10522 );
10523 ensure_ok(
10524 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
10525 "v2 proposal rejection",
10526 )
10527}
10528
10529pub fn proposal_accept_exact(
10530 cfg: &HubConfig,
10531 brain: &str,
10532 proposal_id: &str,
10533 mutation_id: &str,
10534 reason: &str,
10535) -> LinkResult<Value> {
10536 require_safe_ref(brain)?;
10537 require_proposal_id(proposal_id)?;
10538 let head = v2_verified_head(cfg, brain)?
10539 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10540 let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
10541 let operations = proposal
10542 .changes
10543 .get("operations")
10544 .and_then(Value::as_array)
10545 .cloned()
10546 .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
10547 if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
10548 return Err(invalid_feed("proposal operation count is invalid"));
10549 }
10550 let mut downloaded = std::collections::BTreeMap::new();
10551 for (hash, bytes, endpoint) in &proposal.blobs {
10552 let body = ensure_raw_ok(
10553 request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
10554 "v2 proposal blob",
10555 )?;
10556 if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
10557 return Err(invalid_feed("proposal blob does not match its declaration"));
10558 }
10559 downloaded.insert(hash.clone(), body);
10560 }
10561 let remote = files_for_v2_view(
10562 &head,
10563 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
10564 );
10565 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
10566 let mut expected_candidate = remote.clone();
10567 let mut expected_candidate_assets = remote_assets;
10568 for operation in &operations {
10569 let op = operation
10570 .get("op")
10571 .and_then(Value::as_str)
10572 .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
10573 match op {
10574 "put" | "restore" => {
10575 let path = operation
10576 .get("path")
10577 .and_then(Value::as_str)
10578 .ok_or_else(|| invalid_feed("proposal write has no path"))?;
10579 crate::linkmd_v2::normalize_path(path)
10580 .map_err(|error| invalid_feed(error.to_string()))?;
10581 let hash = operation
10582 .get("blob")
10583 .and_then(Value::as_str)
10584 .filter(|hash| is_sha256(hash))
10585 .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
10586 let bytes = operation
10587 .get("bytes")
10588 .and_then(Value::as_u64)
10589 .ok_or_else(|| invalid_feed("proposal write has no size"))?;
10590 expected_candidate.insert(
10591 path.to_string(),
10592 V2BaselineFile {
10593 sha256: hash.to_string(),
10594 bytes,
10595 proof: None,
10596 },
10597 );
10598 }
10599 "delete" | "withdraw_from_hosting" => {
10600 let path = operation
10601 .get("path")
10602 .and_then(Value::as_str)
10603 .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
10604 crate::linkmd_v2::normalize_path(path)
10605 .map_err(|error| invalid_feed(error.to_string()))?;
10606 expected_candidate.remove(path);
10607 }
10608 "rename" => {
10609 let from = operation
10610 .get("from")
10611 .and_then(Value::as_str)
10612 .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
10613 let to = operation
10614 .get("to")
10615 .and_then(Value::as_str)
10616 .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
10617 crate::linkmd_v2::normalize_path(from)
10618 .and_then(|_| crate::linkmd_v2::normalize_path(to))
10619 .map_err(|error| invalid_feed(error.to_string()))?;
10620 let hash = operation
10621 .get("blob")
10622 .and_then(Value::as_str)
10623 .filter(|hash| is_sha256(hash))
10624 .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
10625 let bytes = operation
10626 .get("bytes")
10627 .and_then(Value::as_u64)
10628 .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
10629 expected_candidate.remove(from);
10630 expected_candidate.insert(
10631 to.to_string(),
10632 V2BaselineFile {
10633 sha256: hash.to_string(),
10634 bytes,
10635 proof: None,
10636 },
10637 );
10638 }
10639 "asset_delete" => {
10640 let path = operation
10641 .get("path")
10642 .and_then(Value::as_str)
10643 .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
10644 expected_candidate_assets.remove(path);
10645 }
10646 "asset_withdraw" => {
10647 let path = operation
10648 .get("path")
10649 .and_then(Value::as_str)
10650 .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
10651 let asset = expected_candidate_assets
10652 .get_mut(path)
10653 .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
10654 asset.disposition = "withheld".to_string();
10655 asset.leaf_hash.clear();
10656 }
10657 "asset_put" | "asset_resume" => {
10658 let path = operation
10659 .get("path")
10660 .and_then(Value::as_str)
10661 .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
10662 let asset = operation
10663 .get("asset")
10664 .and_then(Value::as_object)
10665 .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
10666 let blob_sha256 = asset
10667 .get("blob_sha256")
10668 .and_then(Value::as_str)
10669 .filter(|hash| is_sha256(hash))
10670 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
10671 let bytes = asset
10672 .get("bytes")
10673 .and_then(Value::as_u64)
10674 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
10675 let media_type = asset
10676 .get("media_type")
10677 .and_then(Value::as_str)
10678 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
10679 let wrappers = asset
10680 .get("wrappers")
10681 .and_then(Value::as_array)
10682 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
10683 .iter()
10684 .map(|wrapper| {
10685 wrapper
10686 .as_str()
10687 .map(str::to_string)
10688 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
10689 })
10690 .collect::<LinkResult<Vec<_>>>()?;
10691 let required = asset
10692 .get("required")
10693 .and_then(Value::as_bool)
10694 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
10695 let disposition = asset
10696 .get("disposition")
10697 .and_then(Value::as_str)
10698 .filter(|value| matches!(*value, "hosted" | "withheld"))
10699 .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
10700 expected_candidate_assets.insert(
10701 path.to_string(),
10702 V2BaselineAsset {
10703 blob_sha256: blob_sha256.to_string(),
10704 bytes,
10705 media_type: media_type.to_string(),
10706 wrappers,
10707 required,
10708 disposition: disposition.to_string(),
10709 leaf_hash: String::new(),
10710 },
10711 );
10712 }
10713 _ => return Err(invalid_feed("proposal operation kind is unsupported")),
10714 }
10715 }
10716 let base = head.pointer.as_ref().map(|pointer| {
10717 json!({
10718 "seq": pointer.seq,
10719 "commit_hash": pointer.commit_hash,
10720 "content_root": pointer.content_root,
10721 "asset_root": pointer.asset_root,
10722 })
10723 });
10724 let mut body = json!({
10725 "mutation_id": mutation_id,
10726 "base": base,
10727 "rebase": "strict",
10728 "reason": reason,
10729 "operations": operations,
10730 "blobs": downloaded
10731 .iter()
10732 .map(|(sha256, bytes)| json!({
10733 "sha256": sha256,
10734 "bytes": bytes.len(),
10735 "content_base64": STANDARD.encode(bytes),
10736 }))
10737 .collect::<Vec<_>>(),
10738 "proposal_id": proposal_id,
10739 "proposal_mode": "exact",
10740 });
10741 let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
10742 total
10743 .checked_add(bytes.len())
10744 .ok_or_else(|| LinkError::PushTooLarge {
10745 detail: "proposal changed-byte total overflow".to_string(),
10746 })
10747 })?;
10748 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
10749 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10750 for operation in &operations {
10751 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
10752 return Err(invalid_feed("proposal upload operation has no kind"));
10753 };
10754 let hash = match kind {
10755 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
10756 "asset_put" | "asset_resume" => operation
10757 .get("asset")
10758 .and_then(|asset| asset.get("blob_sha256"))
10759 .and_then(Value::as_str),
10760 _ => None,
10761 };
10762 let Some(hash) = hash else { continue };
10763 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
10764 if kind == "rename" {
10765 for field in ["from", "to"] {
10766 coordinates.insert(
10767 operation
10768 .get(field)
10769 .and_then(Value::as_str)
10770 .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
10771 .to_string(),
10772 );
10773 }
10774 } else {
10775 let path = operation
10776 .get("path")
10777 .and_then(Value::as_str)
10778 .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
10779 coordinates.insert(if kind.starts_with("asset_") {
10780 format!("assets/{path}")
10781 } else {
10782 path.to_string()
10783 });
10784 }
10785 }
10786 let declarations = downloaded
10787 .iter()
10788 .map(|(sha256, bytes)| {
10789 json!({
10790 "sha256": sha256,
10791 "bytes": bytes.len(),
10792 "coordinates": coordinates_by_hash
10793 .get(sha256)
10794 .into_iter()
10795 .flatten()
10796 .collect::<Vec<_>>(),
10797 })
10798 })
10799 .collect::<Vec<_>>();
10800 let reserved = ensure_ok(
10801 request(
10802 cfg,
10803 "POST",
10804 &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
10805 Some(&json!({ "blobs": declarations })),
10806 Auth::Required,
10807 )?,
10808 "prepare proposal blob transport",
10809 )?;
10810 let items = reserved
10811 .get("uploads")
10812 .and_then(Value::as_array)
10813 .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
10814 if items.len() != downloaded.len() {
10815 return Err(invalid_feed("proposal upload reservation changed the set"));
10816 }
10817 let mut references = Vec::with_capacity(items.len());
10818 for item in items {
10819 let hash = item
10820 .get("sha256")
10821 .and_then(Value::as_str)
10822 .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
10823 let bytes = downloaded
10824 .get(hash)
10825 .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
10826 let reservation_id = item
10827 .get("reservation_id")
10828 .and_then(Value::as_str)
10829 .filter(|id| crate::ulid::is_ulid(id))
10830 .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
10831 let expected_coordinates = coordinates_by_hash
10832 .get(hash)
10833 .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
10834 let returned_coordinates = item
10835 .get("coordinates")
10836 .and_then(Value::as_array)
10837 .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
10838 if returned_coordinates.len() != expected_coordinates.len()
10839 || returned_coordinates
10840 .iter()
10841 .zip(expected_coordinates)
10842 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
10843 {
10844 return Err(invalid_feed(
10845 "proposal upload reservation changed its coordinates",
10846 ));
10847 }
10848 match item.get("status").and_then(Value::as_str) {
10849 Some("upload") => put_presigned(
10850 cfg,
10851 item.get("url")
10852 .and_then(Value::as_str)
10853 .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
10854 item.get("headers").unwrap_or(&Value::Null),
10855 bytes,
10856 )?,
10857 Some("already_present") => {}
10858 _ => return Err(invalid_feed("proposal upload status is invalid")),
10859 }
10860 references.push(json!({
10861 "sha256": hash,
10862 "bytes": bytes.len(),
10863 "reservation_id": reservation_id,
10864 }));
10865 }
10866 body["blobs"] = Value::Array(references);
10867 }
10868 if body.to_string().len() > MAX_PUSH_BYTES {
10869 return Err(LinkError::PushTooLarge {
10870 detail: "proposal operation metadata exceeds the commit request cap".to_string(),
10871 });
10872 }
10873 let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
10874 let mut result = ensure_ok(
10875 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10876 "exact proposal acceptance",
10877 )?;
10878 let mut candidate_hub_signer = None;
10879 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
10880 let challenge = result
10881 .get("signing_challenge")
10882 .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
10883 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
10884 cfg,
10885 &head,
10886 &expected_candidate,
10887 &expected_candidate_assets,
10888 mutation_id,
10889 &body,
10890 challenge,
10891 )?;
10892 body["signing_challenge_id"] = Value::String(challenge_id);
10893 body["signature_base64url"] = Value::String(signature);
10894 candidate_hub_signer = Some(actor_signer);
10895 result = ensure_ok(
10896 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10897 "signed exact proposal acceptance",
10898 )?;
10899 }
10900 let refreshed = v2_verified_head(cfg, brain)?
10901 .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
10902 if candidate_hub_signer
10903 .as_ref()
10904 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
10905 || refreshed
10906 .pointer
10907 .as_ref()
10908 .map(|pointer| pointer.commit_hash.as_str())
10909 != result.get("commit_hash").and_then(Value::as_str)
10910 || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
10911 || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
10912 {
10913 return Err(LinkError::RemoteAdvancedDuringSync);
10914 }
10915 accept_v2_head(cfg, &refreshed)?;
10916 Ok(result)
10917}
10918
10919pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
10930 require_valid_handle(handle)?;
10931 if body.len() as u64 > MAX_PROPOSE_BYTES {
10932 return Err(LinkError::ProposeTooLarge {
10933 bytes: body.len() as u64,
10934 });
10935 }
10936 let payload = json!({ "app": app, "body": body });
10937 let (path, auth) = if crate::ulid::is_ulid(handle) {
10942 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
10943 } else {
10944 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
10945 };
10946 ensure_ok(
10947 request(cfg, "POST", &path, Some(&payload), auth)?,
10948 "propose",
10949 )
10950}
10951
10952#[derive(Debug, serde::Serialize)]
10958pub struct Head {
10959 pub brain: String,
10961 pub seq: u64,
10963 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
10965 pub updated_at: Option<String>,
10966 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
10968 pub feed_hash: Option<String>,
10969 pub verified: bool,
10972}
10973
10974struct BoundedVecVisitor<T, const MAX: usize> {
10975 label: &'static str,
10976 marker: std::marker::PhantomData<T>,
10977}
10978
10979impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
10980where
10981 T: Deserialize<'de>,
10982{
10983 type Value = Vec<T>;
10984
10985 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10986 write!(formatter, "at most {MAX} {}", self.label)
10987 }
10988
10989 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
10990 where
10991 A: serde::de::SeqAccess<'de>,
10992 {
10993 if sequence.size_hint().is_some_and(|size| size > MAX) {
10994 return Err(serde::de::Error::custom(format!(
10995 "{} exceeds the {MAX}-item limit",
10996 self.label
10997 )));
10998 }
10999 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
11000 while let Some(value) = sequence.next_element()? {
11001 if values.len() == MAX {
11002 return Err(serde::de::Error::custom(format!(
11003 "{} exceeds the {MAX}-item limit",
11004 self.label
11005 )));
11006 }
11007 values.push(value);
11008 }
11009 Ok(values)
11010 }
11011}
11012
11013fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
11014 deserializer: D,
11015 label: &'static str,
11016) -> Result<Vec<T>, D::Error>
11017where
11018 D: serde::Deserializer<'de>,
11019 T: Deserialize<'de>,
11020{
11021 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
11022 label,
11023 marker: std::marker::PhantomData,
11024 })
11025}
11026
11027fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
11028where
11029 D: serde::Deserializer<'de>,
11030{
11031 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
11032}
11033
11034fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
11035where
11036 D: serde::Deserializer<'de>,
11037{
11038 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
11039}
11040
11041fn deserialize_previous_identities<'de, D>(
11042 deserializer: D,
11043) -> Result<Vec<PreviousIdentity>, D::Error>
11044where
11045 D: serde::Deserializer<'de>,
11046{
11047 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
11048 deserializer,
11049 "previous identities",
11050 )
11051}
11052
11053fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
11054where
11055 D: serde::Deserializer<'de>,
11056{
11057 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
11058 deserializer,
11059 "rotation statements",
11060 )
11061}
11062
11063fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
11064where
11065 D: serde::Deserializer<'de>,
11066{
11067 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
11068}
11069
11070#[derive(Debug, Clone, Deserialize, Serialize)]
11071struct FeedFile {
11072 path: String,
11073 sha256: String,
11074 bytes: u64,
11075}
11076
11077#[cfg(test)]
11078#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11079enum V1DisclosureError {
11080 DuplicateFile,
11081 DuplicateRemoved,
11082 PushManifestMismatch,
11083 EditMissingChange,
11084 EditFalseFile,
11085 RemovedMismatch,
11086}
11087
11088#[cfg(test)]
11092fn verify_v1_manifest_disclosure(
11093 kind: &str,
11094 previous: &[FeedFile],
11095 resulting: &[FeedFile],
11096 files: &[FeedFile],
11097 removed: &[String],
11098) -> Result<(), V1DisclosureError> {
11099 fn as_map(
11100 files: &[FeedFile],
11101 ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
11102 let mut result = std::collections::BTreeMap::new();
11103 for file in files {
11104 if result
11105 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11106 .is_some()
11107 {
11108 return Err(V1DisclosureError::DuplicateFile);
11109 }
11110 }
11111 Ok(result)
11112 }
11113 let previous = as_map(previous)?;
11114 let resulting = as_map(resulting)?;
11115 let disclosed = as_map(files)?;
11116 let removed_set: std::collections::BTreeSet<&str> =
11117 removed.iter().map(String::as_str).collect();
11118 if removed_set.len() != removed.len() {
11119 return Err(V1DisclosureError::DuplicateRemoved);
11120 }
11121 let expected_removed: std::collections::BTreeSet<&str> = previous
11122 .keys()
11123 .copied()
11124 .filter(|path| !resulting.contains_key(path))
11125 .collect();
11126 if removed_set != expected_removed {
11127 return Err(V1DisclosureError::RemovedMismatch);
11128 }
11129 if kind == "push" {
11130 return if disclosed == resulting {
11131 Ok(())
11132 } else {
11133 Err(V1DisclosureError::PushManifestMismatch)
11134 };
11135 }
11136 if kind != "edit" {
11137 return Err(V1DisclosureError::EditFalseFile);
11138 }
11139 if disclosed
11140 .iter()
11141 .any(|(path, value)| resulting.get(path) != Some(value))
11142 {
11143 return Err(V1DisclosureError::EditFalseFile);
11144 }
11145 for (path, value) in &resulting {
11146 if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
11147 return Err(V1DisclosureError::EditMissingChange);
11148 }
11149 }
11150 Ok(())
11151}
11152
11153#[derive(Debug, Clone, Deserialize, Serialize)]
11154struct FeedEntry {
11155 v: u8,
11156 seq: u64,
11157 ts: String,
11158 brain: String,
11159 public_key: String,
11160 kind: String,
11161 op: String,
11162 pack_sha256: String,
11163 #[serde(deserialize_with = "deserialize_feed_files")]
11164 files: Vec<FeedFile>,
11165 #[serde(deserialize_with = "deserialize_removed_paths")]
11166 removed: Vec<String>,
11167 prev_entry_hash: Option<String>,
11168 sig: String,
11169}
11170
11171#[derive(Serialize)]
11172struct UnsignedFeedEntry<'a> {
11173 v: u8,
11174 seq: u64,
11175 ts: &'a str,
11176 brain: &'a str,
11177 public_key: &'a str,
11178 kind: &'a str,
11179 op: &'a str,
11180 pack_sha256: &'a str,
11181 files: &'a [FeedFile],
11182 removed: &'a [String],
11183 prev_entry_hash: &'a Option<String>,
11184}
11185
11186#[derive(Debug, Clone, Deserialize, Serialize)]
11187struct FeedItem {
11188 hash: String,
11189 entry: FeedEntry,
11190}
11191
11192#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
11193struct FeedIdentity {
11194 fingerprint: String,
11195 #[serde(rename = "publicKeySpki")]
11196 public_key_spki: String,
11197 #[serde(default, deserialize_with = "deserialize_previous_identities")]
11201 previous: Vec<PreviousIdentity>,
11202 #[serde(default, deserialize_with = "deserialize_rotations")]
11205 rotations: Vec<String>,
11206}
11207
11208#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
11209struct PreviousIdentity {
11210 fingerprint: String,
11211 #[serde(rename = "publicKeySpki")]
11212 public_key_spki: String,
11213}
11214
11215#[derive(Debug, Deserialize)]
11216struct FeedResponse {
11217 #[serde(rename = "headSeq")]
11218 head_seq: u64,
11219 #[serde(rename = "feedHash")]
11220 feed_hash: Option<String>,
11221 identity: Option<FeedIdentity>,
11222 #[serde(deserialize_with = "deserialize_feed_items")]
11223 entries: Vec<FeedItem>,
11224 #[serde(rename = "scopeLimited")]
11225 scope_limited: bool,
11226}
11227
11228#[derive(Debug, Deserialize, Serialize)]
11229#[serde(deny_unknown_fields)]
11230struct RotationStatement {
11231 v: u8,
11232 op: String,
11233 brain: String,
11234 public_key: String,
11235 new_brain: String,
11236 new_public_key: String,
11237 prior_head_seq: u64,
11238 prior_feed_hash: Option<String>,
11239 ts: String,
11240 sig: String,
11241}
11242
11243#[derive(Debug, Clone, Deserialize, Serialize)]
11244struct TrustState {
11245 v: u8,
11246 origin: String,
11247 #[serde(default)]
11251 requested: String,
11252 brain: String,
11254 #[serde(default, skip_serializing_if = "Option::is_none")]
11257 home: Option<String>,
11258 anchor: String,
11259 current: String,
11260 #[serde(rename = "headSeq")]
11261 head_seq: u64,
11262 #[serde(rename = "feedHash")]
11263 feed_hash: Option<String>,
11264 #[serde(default)]
11268 rotations: Vec<String>,
11269 #[serde(default, skip_serializing_if = "Option::is_none")]
11272 hub_signer: Option<String>,
11273 #[serde(default, skip_serializing_if = "Option::is_none")]
11276 protocol_profile: Option<String>,
11277}
11278
11279fn accepted_as_v2(state: &TrustState) -> bool {
11280 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
11281}
11282
11283fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
11284 let directory = open_trust_dir(cfg)?;
11285 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
11286 return Ok(true);
11287 }
11288 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
11289 return Ok(false);
11290 };
11291 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
11292}
11293
11294#[derive(Debug, Clone, Deserialize, Serialize)]
11295struct AliasBinding {
11296 v: u8,
11297 origin: String,
11298 requested: String,
11299 brain: String,
11300 #[serde(default, skip_serializing_if = "Option::is_none")]
11301 home: Option<String>,
11302}
11303
11304struct VerifiedRemote {
11305 head: Head,
11306 identity: Option<FeedIdentity>,
11307 head_entry: Option<FeedItem>,
11308 entries: Vec<FeedItem>,
11310 anchor: Option<String>,
11311}
11312
11313fn invalid_feed(message: impl Into<String>) -> LinkError {
11314 LinkError::InvalidFeed {
11315 message: message.into(),
11316 }
11317}
11318
11319fn is_sha256(value: &str) -> bool {
11320 value.len() == 64
11321 && value
11322 .bytes()
11323 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
11324}
11325
11326fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
11327 let der = URL_SAFE_NO_PAD
11328 .decode(public_key_spki)
11329 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
11330 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
11331 return Err(invalid_feed(
11332 "identity public key is not a valid Ed25519 SPKI",
11333 ));
11334 }
11335 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
11336}
11337
11338fn verify_identity_chain(
11342 identity: &FeedIdentity,
11343 pinned: Option<&TrustState>,
11344) -> LinkResult<String> {
11345 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
11346 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
11347 {
11348 return Err(invalid_feed(
11349 "identity rotation history exceeds the client cap",
11350 ));
11351 }
11352 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
11353 return Err(invalid_feed(
11354 "current identity fingerprint does not match its public key",
11355 ));
11356 }
11357 for previous in &identity.previous {
11358 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
11359 return Err(invalid_feed(
11360 "previous identity fingerprint does not match its public key",
11361 ));
11362 }
11363 }
11364 if identity.rotations.len() != identity.previous.len() {
11365 return Err(invalid_feed(
11366 "identity history is missing an old-key-signed rotation statement",
11367 ));
11368 }
11369
11370 let mut chain: Vec<(&str, &str)> = identity
11374 .previous
11375 .iter()
11376 .rev()
11377 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
11378 .collect();
11379 chain.push((&identity.fingerprint, &identity.public_key_spki));
11380
11381 for (index, raw) in identity.rotations.iter().enumerate() {
11382 let statement: RotationStatement = serde_json::from_str(raw)
11383 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
11384 let (old_fingerprint, old_spki) = chain[index];
11385 let (new_fingerprint, new_spki) = chain[index + 1];
11386 if statement.v != 1
11387 || statement.op != "rotate"
11388 || statement.brain != format!("ed25519:{old_fingerprint}")
11389 || statement.public_key != old_spki
11390 || statement.new_brain != format!("ed25519:{new_fingerprint}")
11391 || statement.new_public_key != new_spki
11392 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
11393 || (statement.prior_head_seq > 0
11394 && statement
11395 .prior_feed_hash
11396 .as_deref()
11397 .is_none_or(|hash| !is_sha256(hash)))
11398 {
11399 return Err(invalid_feed(
11400 "rotation statement does not connect adjacent identities",
11401 ));
11402 }
11403 let unsigned = serde_json::to_string(&UnsignedRotation {
11404 v: statement.v,
11405 op: &statement.op,
11406 brain: &statement.brain,
11407 public_key: &statement.public_key,
11408 new_brain: &statement.new_brain,
11409 new_public_key: &statement.new_public_key,
11410 prior_head_seq: statement.prior_head_seq,
11411 prior_feed_hash: statement.prior_feed_hash.as_deref(),
11412 ts: statement.ts.clone(),
11413 })
11414 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
11415 let exact = format!(
11416 "{},\"sig\":\"{}\"}}",
11417 &unsigned[..unsigned.len() - 1],
11418 statement.sig
11419 );
11420 if exact != *raw {
11421 return Err(invalid_feed(
11422 "rotation statement is not in normative serialization",
11423 ));
11424 }
11425 let der = URL_SAFE_NO_PAD
11426 .decode(old_spki)
11427 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
11428 let signature = URL_SAFE_NO_PAD
11429 .decode(&statement.sig)
11430 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
11431 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
11432 .verify(unsigned.as_bytes(), &signature)
11433 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
11434 if index > 0 {
11435 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
11436 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
11437 if statement.prior_head_seq < prior.prior_head_seq {
11438 return Err(invalid_feed("rotation feed boundaries move backward"));
11439 }
11440 }
11441 }
11442
11443 let anchor = format!("ed25519:{}", chain[0].0);
11444 let current = format!("ed25519:{}", identity.fingerprint);
11445 if let Some(pin) = pinned {
11446 if pin.anchor != anchor {
11447 return Err(invalid_feed(
11448 "served identity chain does not descend from the pinned anchor",
11449 ));
11450 }
11451 if !chain
11452 .iter()
11453 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
11454 {
11455 return Err(invalid_feed(
11456 "served identity chain forked away from the last pinned identity",
11457 ));
11458 }
11459 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
11460 return Err(invalid_feed("served identity discarded its rotation chain"));
11461 }
11462 if pin.v >= 2
11463 && (identity.rotations.len() < pin.rotations.len()
11464 || identity.rotations[..pin.rotations.len()] != pin.rotations)
11465 {
11466 return Err(invalid_feed(
11467 "served identity rewrote the locally accepted rotation history",
11468 ));
11469 }
11470 }
11471 Ok(anchor)
11472}
11473
11474fn verify_rotation_feed_boundaries(
11475 identity: &FeedIdentity,
11476 pinned: Option<&TrustState>,
11477 observed: &[FeedItem],
11478 advertised_seq: u64,
11479) -> LinkResult<()> {
11480 let mut chain: Vec<String> = identity
11481 .previous
11482 .iter()
11483 .rev()
11484 .map(|previous| format!("ed25519:{}", previous.fingerprint))
11485 .collect();
11486 chain.push(format!("ed25519:{}", identity.fingerprint));
11487 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
11488
11489 for (index, raw) in identity.rotations.iter().enumerate() {
11490 let rotation: RotationStatement = serde_json::from_str(raw)
11491 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
11492 if rotation.prior_head_seq > advertised_seq {
11493 return Err(invalid_feed(
11494 "rotation claims a feed boundary beyond the advertised head",
11495 ));
11496 }
11497 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
11498 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
11499 return Err(invalid_feed(
11500 "newly disclosed rotation predates the local feed checkpoint",
11501 ));
11502 }
11503 }
11504 let actual = if rotation.prior_head_seq == 0 {
11505 None
11506 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
11507 pinned.and_then(|pin| pin.feed_hash.as_deref())
11508 } else {
11509 observed
11510 .iter()
11511 .find(|item| item.entry.seq == rotation.prior_head_seq)
11512 .map(|item| item.hash.as_str())
11513 };
11514 if let Some(actual) = actual {
11515 if rotation.prior_feed_hash.as_deref() != Some(actual) {
11516 return Err(invalid_feed(
11517 "rotation statement does not commit the verified feed boundary",
11518 ));
11519 }
11520 } else if rotation.prior_head_seq == 0 {
11521 } else if pinned.is_some_and(|pin| {
11524 pinned_index.is_some_and(|pin_index| index >= pin_index)
11525 || rotation.prior_head_seq >= pin.head_seq
11526 }) {
11527 return Err(invalid_feed(
11528 "rotation feed boundary was not present in the verified chain",
11529 ));
11530 }
11531 }
11532 Ok(())
11533}
11534
11535fn reject_retired_signer_after_checkpoint(
11540 identity: &FeedIdentity,
11541 pinned: Option<&TrustState>,
11542 item: &FeedItem,
11543) -> LinkResult<()> {
11544 let Some(pin) = pinned else {
11545 return Ok(());
11546 };
11547 if item.entry.seq <= pin.head_seq {
11548 return Ok(());
11549 }
11550 let mut chain: Vec<String> = identity
11551 .previous
11552 .iter()
11553 .rev()
11554 .map(|previous| format!("ed25519:{}", previous.fingerprint))
11555 .collect();
11556 chain.push(format!("ed25519:{}", identity.fingerprint));
11557 let pinned_index = chain
11558 .iter()
11559 .position(|key| key == &pin.current)
11560 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
11561 let signer_index = chain
11562 .iter()
11563 .position(|key| key == &item.entry.brain)
11564 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
11565 if signer_index < pinned_index {
11566 return Err(invalid_feed(
11567 "a retired identity attempted to sign after the local checkpoint",
11568 ));
11569 }
11570 Ok(())
11571}
11572
11573fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
11574 let origin = normalized_origin(&cfg.hub)?;
11575 let key = format!(
11576 "{:x}",
11577 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
11578 );
11579 Ok(format!("{key}.json"))
11580}
11581
11582fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
11583 let origin = normalized_origin(&cfg.hub)?;
11584 let key = format!(
11585 "{:x}",
11586 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
11587 );
11588 Ok(format!("alias-{key}.json"))
11589}
11590
11591#[cfg(any(unix, windows))]
11592struct TrustLock {
11593 _file: std::fs::File,
11594}
11595
11596#[cfg(unix)]
11597fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
11598 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11599
11600 let lock_string = format!(".{state_name}.lock");
11601 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
11602 let fd = unsafe {
11603 libc::openat(
11604 directory.as_raw_fd(),
11605 lock_name.as_ptr(),
11606 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11607 0o600,
11608 )
11609 };
11610 if fd < 0 {
11611 return Err(std::io::Error::last_os_error().into());
11612 }
11613 let file = unsafe { std::fs::File::from_raw_fd(fd) };
11614 if !file.metadata()?.is_file() {
11615 return Err(LinkError::UnsafePath { path: lock_string });
11616 }
11617 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
11618 return Err(std::io::Error::last_os_error().into());
11619 }
11620 Ok(TrustLock { _file: file })
11621}
11622
11623#[cfg(windows)]
11624fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
11625 let lock_name = format!(".{state_name}.lock");
11626 let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
11627 Ok(TrustLock { _file: file })
11628}
11629
11630#[cfg(any(unix, windows))]
11631fn lock_trust_many(
11632 cfg: &HubConfig,
11633 directory: &std::fs::File,
11634 refs: &[&str],
11635) -> LinkResult<Vec<TrustLock>> {
11636 let mut names = refs
11637 .iter()
11638 .map(|reference| trust_file_name(cfg, reference))
11639 .collect::<LinkResult<Vec<_>>>()?;
11640 names.sort();
11641 names.dedup();
11642 names
11643 .iter()
11644 .map(|name| lock_trust_name(directory, name))
11645 .collect()
11646}
11647
11648#[cfg(not(any(unix, windows)))]
11649fn lock_trust_many(
11650 _cfg: &HubConfig,
11651 _directory: &TrustDirectory,
11652 _refs: &[&str],
11653) -> LinkResult<Vec<()>> {
11654 Err(LinkError::UnsupportedPlatform {
11655 operation: "verified link.md state",
11656 })
11657}
11658
11659#[cfg(any(unix, windows))]
11660type TrustDirectory = std::fs::File;
11661
11662#[cfg(not(any(unix, windows)))]
11663struct TrustDirectory;
11664
11665#[cfg(unix)]
11666fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11667 use std::os::fd::AsRawFd as _;
11668
11669 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
11670 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
11671 return Err(std::io::Error::last_os_error().into());
11672 }
11673 directory.sync_all()?;
11674 Ok(directory)
11675}
11676
11677#[cfg(windows)]
11678fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11679 let marker = cfg.state_dir.join("trust").join(".directory");
11680 crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
11681 Ok(crate::fsx::open_directory_nofollow(
11682 marker.parent().expect("trust marker has a parent"),
11683 )?)
11684}
11685
11686#[cfg(not(any(unix, windows)))]
11687fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11688 Err(LinkError::UnsupportedPlatform {
11689 operation: "verified link.md state",
11690 })
11691}
11692
11693#[cfg(unix)]
11694fn load_trust_in(
11695 cfg: &HubConfig,
11696 directory: &TrustDirectory,
11697 requested: &str,
11698) -> LinkResult<Option<TrustState>> {
11699 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11700
11701 let name_string = trust_file_name(cfg, requested)?;
11702 let name = c_name(name_string.as_bytes(), &name_string)?;
11703 let fd = unsafe {
11704 libc::openat(
11705 directory.as_raw_fd(),
11706 name.as_ptr(),
11707 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11708 )
11709 };
11710 if fd < 0 {
11711 let error = std::io::Error::last_os_error();
11712 if error.kind() == std::io::ErrorKind::NotFound {
11713 return Ok(None);
11714 }
11715 return Err(LinkError::UnsafePath { path: name_string });
11716 }
11717 let file = unsafe { std::fs::File::from_raw_fd(fd) };
11718 if !file.metadata()?.is_file() {
11719 return Err(LinkError::UnsafePath { path: name_string });
11720 }
11721 let mut bytes = Vec::new();
11722 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
11723 if bytes.len() > 1024 * 1024 {
11724 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
11725 }
11726 let mut state: TrustState = serde_json::from_slice(&bytes)
11727 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
11728 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
11729 return Err(invalid_feed(
11730 "local identity/feed checkpoint does not match this hub and brain",
11731 ));
11732 }
11733 if state.v == 1 {
11734 if state.brain != requested {
11738 return Err(invalid_feed(
11739 "legacy checkpoint is not bound to the requested brain id",
11740 ));
11741 }
11742 state.requested = requested.to_string();
11743 } else if state.requested != requested {
11744 return Err(invalid_feed(
11745 "local identity/feed checkpoint is bound to a different requested ref",
11746 ));
11747 }
11748 Ok(Some(state))
11749}
11750
11751#[cfg(windows)]
11752fn load_trust_in(
11753 cfg: &HubConfig,
11754 directory: &TrustDirectory,
11755 requested: &str,
11756) -> LinkResult<Option<TrustState>> {
11757 let name = trust_file_name(cfg, requested)?;
11758 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
11759 let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
11760 Ok(bytes) => bytes,
11761 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11762 Err(_) => return Err(LinkError::UnsafePath { path: name }),
11763 };
11764 let mut state: TrustState = serde_json::from_slice(&bytes)
11765 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
11766 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
11767 return Err(invalid_feed(
11768 "local identity/feed checkpoint does not match this hub and brain",
11769 ));
11770 }
11771 if state.v == 1 {
11772 if state.brain != requested {
11773 return Err(invalid_feed(
11774 "legacy checkpoint is not bound to the requested brain id",
11775 ));
11776 }
11777 state.requested = requested.to_string();
11778 } else if state.requested != requested {
11779 return Err(invalid_feed(
11780 "local identity/feed checkpoint is bound to a different requested ref",
11781 ));
11782 }
11783 Ok(Some(state))
11784}
11785
11786#[cfg(not(any(unix, windows)))]
11787fn load_trust_in(
11788 _cfg: &HubConfig,
11789 _directory: &TrustDirectory,
11790 _brain: &str,
11791) -> LinkResult<Option<TrustState>> {
11792 Err(LinkError::UnsupportedPlatform {
11793 operation: "verified link.md state",
11794 })
11795}
11796
11797#[cfg(all(test, any(unix, windows)))]
11798fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
11799 let directory = open_trust_dir(cfg)?;
11800 load_trust_in(cfg, &directory, requested)
11801}
11802
11803#[cfg(unix)]
11804fn save_trust_in(
11805 cfg: &HubConfig,
11806 directory: &TrustDirectory,
11807 state: &TrustState,
11808) -> LinkResult<()> {
11809 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11810
11811 let name_string = trust_file_name(cfg, &state.requested)?;
11812 let name = c_name(name_string.as_bytes(), &name_string)?;
11813 let mut bytes = serde_json::to_vec(state)
11814 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
11815 bytes.push(b'\n');
11816
11817 let nonce = std::time::SystemTime::now()
11818 .duration_since(std::time::UNIX_EPOCH)
11819 .unwrap_or_default()
11820 .as_nanos();
11821 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
11822 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
11823 let fd = unsafe {
11824 libc::openat(
11825 directory.as_raw_fd(),
11826 temp.as_ptr(),
11827 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11828 0o600,
11829 )
11830 };
11831 if fd < 0 {
11832 return Err(std::io::Error::last_os_error().into());
11833 }
11834 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
11835 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
11836 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11837 return Err(error.into());
11838 }
11839 drop(file);
11840 if unsafe {
11841 libc::renameat(
11842 directory.as_raw_fd(),
11843 temp.as_ptr(),
11844 directory.as_raw_fd(),
11845 name.as_ptr(),
11846 )
11847 } != 0
11848 {
11849 let error = std::io::Error::last_os_error();
11850 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11851 return Err(error.into());
11852 }
11853 directory.sync_all()?;
11854 Ok(())
11855}
11856
11857#[cfg(windows)]
11858fn save_trust_in(
11859 cfg: &HubConfig,
11860 directory: &TrustDirectory,
11861 state: &TrustState,
11862) -> LinkResult<()> {
11863 let name = trust_file_name(cfg, &state.requested)?;
11864 let mut bytes = serde_json::to_vec(state)
11865 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
11866 bytes.push(b'\n');
11867 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
11868 Ok(())
11869}
11870
11871#[cfg(not(any(unix, windows)))]
11872fn save_trust_in(
11873 _cfg: &HubConfig,
11874 _directory: &TrustDirectory,
11875 _state: &TrustState,
11876) -> LinkResult<()> {
11877 Err(LinkError::UnsupportedPlatform {
11878 operation: "verified link.md state",
11879 })
11880}
11881
11882#[cfg(unix)]
11883fn load_alias_in(
11884 cfg: &HubConfig,
11885 directory: &TrustDirectory,
11886 requested: &str,
11887) -> LinkResult<Option<AliasBinding>> {
11888 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11889
11890 let name_string = alias_file_name(cfg, requested)?;
11891 let name = c_name(name_string.as_bytes(), &name_string)?;
11892 let fd = unsafe {
11893 libc::openat(
11894 directory.as_raw_fd(),
11895 name.as_ptr(),
11896 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11897 )
11898 };
11899 if fd < 0 {
11900 let error = std::io::Error::last_os_error();
11901 if error.kind() == std::io::ErrorKind::NotFound {
11902 return Ok(None);
11903 }
11904 return Err(LinkError::UnsafePath { path: name_string });
11905 }
11906 let file = unsafe { std::fs::File::from_raw_fd(fd) };
11907 if !file.metadata()?.is_file() {
11908 return Err(LinkError::UnsafePath { path: name_string });
11909 }
11910 let mut bytes = Vec::new();
11911 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
11912 if bytes.len() > 64 * 1024 {
11913 return Err(invalid_feed("local alias binding is oversized"));
11914 }
11915 let alias: AliasBinding = serde_json::from_slice(&bytes)
11916 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
11917 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
11918 {
11919 return Err(invalid_feed(
11920 "local alias binding does not match this hub and requested ref",
11921 ));
11922 }
11923 Ok(Some(alias))
11924}
11925
11926#[cfg(windows)]
11927fn load_alias_in(
11928 cfg: &HubConfig,
11929 directory: &TrustDirectory,
11930 requested: &str,
11931) -> LinkResult<Option<AliasBinding>> {
11932 let name = alias_file_name(cfg, requested)?;
11933 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
11934 let bytes = match reader.read(Path::new(&name), 64 * 1024) {
11935 Ok(bytes) => bytes,
11936 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11937 Err(_) => return Err(LinkError::UnsafePath { path: name }),
11938 };
11939 let alias: AliasBinding = serde_json::from_slice(&bytes)
11940 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
11941 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
11942 {
11943 return Err(invalid_feed(
11944 "local alias binding does not match this hub and requested ref",
11945 ));
11946 }
11947 Ok(Some(alias))
11948}
11949
11950#[cfg(not(any(unix, windows)))]
11951fn load_alias_in(
11952 _cfg: &HubConfig,
11953 _directory: &TrustDirectory,
11954 _requested: &str,
11955) -> LinkResult<Option<AliasBinding>> {
11956 Err(LinkError::UnsupportedPlatform {
11957 operation: "verified link.md state",
11958 })
11959}
11960
11961#[cfg(unix)]
11962fn save_alias_in(
11963 cfg: &HubConfig,
11964 directory: &TrustDirectory,
11965 alias: &AliasBinding,
11966) -> LinkResult<()> {
11967 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11968
11969 let name_string = alias_file_name(cfg, &alias.requested)?;
11970 let name = c_name(name_string.as_bytes(), &name_string)?;
11971 let mut bytes = serde_json::to_vec(alias)
11972 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
11973 bytes.push(b'\n');
11974 let nonce = std::time::SystemTime::now()
11975 .duration_since(std::time::UNIX_EPOCH)
11976 .unwrap_or_default()
11977 .as_nanos();
11978 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
11979 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
11980 let fd = unsafe {
11981 libc::openat(
11982 directory.as_raw_fd(),
11983 temp.as_ptr(),
11984 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11985 0o600,
11986 )
11987 };
11988 if fd < 0 {
11989 return Err(std::io::Error::last_os_error().into());
11990 }
11991 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
11992 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
11993 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11994 return Err(error.into());
11995 }
11996 drop(file);
11997 if unsafe {
11998 libc::renameat(
11999 directory.as_raw_fd(),
12000 temp.as_ptr(),
12001 directory.as_raw_fd(),
12002 name.as_ptr(),
12003 )
12004 } != 0
12005 {
12006 let error = std::io::Error::last_os_error();
12007 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12008 return Err(error.into());
12009 }
12010 directory.sync_all()?;
12011 Ok(())
12012}
12013
12014#[cfg(windows)]
12015fn save_alias_in(
12016 cfg: &HubConfig,
12017 directory: &TrustDirectory,
12018 alias: &AliasBinding,
12019) -> LinkResult<()> {
12020 let name = alias_file_name(cfg, &alias.requested)?;
12021 let mut bytes = serde_json::to_vec(alias)
12022 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
12023 bytes.push(b'\n');
12024 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
12025 Ok(())
12026}
12027
12028#[cfg(not(any(unix, windows)))]
12029fn save_alias_in(
12030 _cfg: &HubConfig,
12031 _directory: &TrustDirectory,
12032 _alias: &AliasBinding,
12033) -> LinkResult<()> {
12034 Err(LinkError::UnsupportedPlatform {
12035 operation: "verified link.md state",
12036 })
12037}
12038
12039fn load_canonical_pin(
12044 cfg: &HubConfig,
12045 directory: &TrustDirectory,
12046 requested: &str,
12047 resolved_brain: &str,
12048) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
12049 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
12050 if requested == resolved_brain {
12051 return Ok((canonical, None));
12052 }
12053
12054 let mut alias = load_alias_in(cfg, directory, requested)?;
12055 if let Some(binding) = &alias {
12056 if binding.brain != resolved_brain {
12057 return Err(LinkError::AliasRebindRequired {
12058 alias: requested.to_string(),
12059 from: binding.brain.clone(),
12060 to: resolved_brain.to_string(),
12061 });
12062 }
12063 return Ok((canonical, alias));
12064 }
12065
12066 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
12070 if legacy.brain != resolved_brain {
12071 return Err(invalid_feed(
12072 "legacy alias checkpoint names a different canonical brain",
12073 ));
12074 }
12075 if let Some(existing) = &canonical {
12076 if existing.brain != legacy.brain
12077 || existing.anchor != legacy.anchor
12078 || existing.current != legacy.current
12079 || existing.head_seq != legacy.head_seq
12080 || existing.feed_hash != legacy.feed_hash
12081 || existing.rotations != legacy.rotations
12082 {
12083 return Err(invalid_feed(
12084 "legacy alias checkpoint conflicts with the canonical checkpoint",
12085 ));
12086 }
12087 } else {
12088 let mut promoted = legacy.clone();
12089 promoted.requested = resolved_brain.to_string();
12090 promoted.home = None;
12091 save_trust_in(cfg, directory, &promoted)?;
12092 canonical = Some(promoted);
12093 }
12094 alias = Some(AliasBinding {
12095 v: 1,
12096 origin: normalized_origin(&cfg.hub)?,
12097 requested: requested.to_string(),
12098 brain: resolved_brain.to_string(),
12099 home: legacy.home,
12100 });
12101 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
12102 }
12103 Ok((canonical, alias))
12104}
12105
12106pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
12111 require_hardened_filesystem("verified alias rebind")?;
12112 require_safe_ref(alias)?;
12113 require_safe_ref(from)?;
12114 require_safe_ref(to)?;
12115 if crate::ulid::is_ulid(alias)
12116 || !crate::ulid::is_ulid(from)
12117 || !crate::ulid::is_ulid(to)
12118 || from == to
12119 {
12120 return Err(LinkError::InvalidPack {
12121 message:
12122 "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
12123 .to_string(),
12124 });
12125 }
12126
12127 let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
12128 message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
12129 })?;
12130 accept_v2_head(cfg, &verified)?;
12131
12132 let alias_response = ensure_ok(
12133 request(
12134 cfg,
12135 "GET",
12136 &format!("/api/hub/brains/{alias}/v2/head"),
12137 None,
12138 Auth::Required,
12139 )?,
12140 "resolve alias for explicit rebind",
12141 )?;
12142 let resolved: V2HeadResponse = serde_json::from_value(alias_response)
12143 .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
12144 if resolved.v != 2 || resolved.brain_id != to {
12145 return Err(LinkError::RemoteAdvancedDuringSync);
12146 }
12147
12148 let directory = open_trust_dir(cfg)?;
12149 let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
12150 let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
12151 message: "the requested alias has no existing local binding to replace".to_string(),
12152 })?;
12153 if binding.brain != from {
12154 return Err(LinkError::AliasRebindRequired {
12155 alias: alias.to_string(),
12156 from: binding.brain,
12157 to: to.to_string(),
12158 });
12159 }
12160 save_alias_in(
12161 cfg,
12162 &directory,
12163 &AliasBinding {
12164 v: 1,
12165 origin: normalized_origin(&cfg.hub)?,
12166 requested: alias.to_string(),
12167 brain: to.to_string(),
12168 home: binding.home,
12169 },
12170 )?;
12171 Ok(json!({
12172 "v": 2,
12173 "alias": alias,
12174 "from": from,
12175 "to": to,
12176 "outcome": "alias_rebound",
12177 }))
12178}
12179
12180fn save_canonical_pin_and_alias(
12181 cfg: &HubConfig,
12182 directory: &TrustDirectory,
12183 requested: &str,
12184 resolved_brain: &str,
12185 mut state: TrustState,
12186 existing_alias: Option<&AliasBinding>,
12187) -> LinkResult<()> {
12188 state.requested = resolved_brain.to_string();
12189 state.brain = resolved_brain.to_string();
12190 state.home = None;
12191 save_trust_in(cfg, directory, &state)?;
12192 if requested != resolved_brain {
12193 save_alias_in(
12194 cfg,
12195 directory,
12196 &AliasBinding {
12197 v: 1,
12198 origin: normalized_origin(&cfg.hub)?,
12199 requested: requested.to_string(),
12200 brain: resolved_brain.to_string(),
12201 home: existing_alias.and_then(|alias| alias.home.clone()),
12202 },
12203 )?;
12204 }
12205 Ok(())
12206}
12207
12208fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
12209 const ED25519_SPKI_PREFIX: &[u8] = &[
12210 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
12211 ];
12212 let entry = &item.entry;
12213 let public_der = URL_SAFE_NO_PAD
12214 .decode(&entry.public_key)
12215 .map_err(|_| invalid_feed("public key is not base64url"))?;
12216 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
12217 || !public_der.starts_with(ED25519_SPKI_PREFIX)
12218 {
12219 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
12220 }
12221 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
12222 if entry.brain != format!("ed25519:{fingerprint}") {
12223 return Err(invalid_feed(
12224 "brain fingerprint does not match its public key",
12225 ));
12226 }
12227 let _ = verify_identity_chain(identity, None)?;
12229 let mut chain: Vec<(&str, &str)> = identity
12230 .previous
12231 .iter()
12232 .rev()
12233 .map(|previous| {
12234 (
12235 previous.fingerprint.as_str(),
12236 previous.public_key_spki.as_str(),
12237 )
12238 })
12239 .collect();
12240 chain.push((&identity.fingerprint, &identity.public_key_spki));
12241 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
12242 *known_fingerprint == fingerprint && *spki == entry.public_key
12243 });
12244 let Some(signer_index) = signer_index else {
12245 return Err(invalid_feed(
12246 "entry signer is not this brain's identity (current or rotated-from)",
12247 ));
12248 };
12249 let lower_boundary = if signer_index == 0 {
12250 None
12251 } else {
12252 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
12253 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12254 Some(prior.prior_head_seq)
12255 };
12256 let upper_boundary = if signer_index == identity.rotations.len() {
12257 None
12258 } else {
12259 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
12260 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12261 Some(next.prior_head_seq)
12262 };
12263 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
12264 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
12265 {
12266 return Err(invalid_feed(
12267 "entry signer is outside its authenticated rotation epoch",
12268 ));
12269 }
12270 let unsigned = UnsignedFeedEntry {
12271 v: entry.v,
12272 seq: entry.seq,
12273 ts: &entry.ts,
12274 brain: &entry.brain,
12275 public_key: &entry.public_key,
12276 kind: &entry.kind,
12277 op: &entry.op,
12278 pack_sha256: &entry.pack_sha256,
12279 files: &entry.files,
12280 removed: &entry.removed,
12281 prev_entry_hash: &entry.prev_entry_hash,
12282 };
12283 let message =
12284 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
12285 let signature = URL_SAFE_NO_PAD
12286 .decode(&entry.sig)
12287 .map_err(|_| invalid_feed("signature is not base64url"))?;
12288 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
12289 .verify(&message, &signature)
12290 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
12291
12292 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
12293 exact.push(b'\n');
12294 let actual_hash = format!("{:x}", Sha256::digest(&exact));
12295 if actual_hash != item.hash {
12296 return Err(invalid_feed("entry SHA-256 does not match"));
12297 }
12298 Ok(())
12299}
12300
12301#[derive(Serialize)]
12307struct UnsignedRotation<'a> {
12308 v: u8,
12309 op: &'a str,
12310 brain: &'a str,
12311 public_key: &'a str,
12312 new_brain: &'a str,
12313 new_public_key: &'a str,
12314 prior_head_seq: u64,
12315 prior_feed_hash: Option<&'a str>,
12316 ts: String,
12317}
12318
12319#[derive(Debug, Deserialize, Serialize)]
12324#[serde(deny_unknown_fields)]
12325struct RotationJournal {
12326 v: u8,
12327 origin: String,
12328 brain: String,
12329 old_brain: String,
12330 new_brain: String,
12331 prior_head_seq: u64,
12332 prior_feed_hash: Option<String>,
12333 statement: String,
12334}
12335
12336fn rotation_journal_path(key_path: &Path) -> PathBuf {
12337 let mut path = key_path.as_os_str().to_os_string();
12338 path.push(".rotation.json");
12339 PathBuf::from(path)
12340}
12341
12342fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
12343 #[cfg(unix)]
12344 let file = {
12345 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12346 use std::os::unix::ffi::OsStrExt as _;
12347 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
12348 .map_err(|error| {
12349 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
12350 })?;
12351 let leaf_name = path
12352 .file_name()
12353 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
12354 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
12355 let fd = unsafe {
12356 libc::openat(
12357 parent.as_raw_fd(),
12358 leaf.as_ptr(),
12359 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12360 )
12361 };
12362 if fd < 0 {
12363 return Err(bad_agent_key(
12364 "the rotation journal must be an existing regular file without symlink ancestors",
12365 ));
12366 }
12367 unsafe { std::fs::File::from_raw_fd(fd) }
12368 };
12369 #[cfg(not(unix))]
12370 let file = std::fs::File::open(path)
12371 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
12372 let metadata = file
12373 .metadata()
12374 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
12375 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
12376 return Err(bad_agent_key(
12377 "the rotation journal must be a bounded regular file",
12378 ));
12379 }
12380 #[cfg(unix)]
12381 {
12382 use std::os::unix::fs::PermissionsExt as _;
12383 if metadata.permissions().mode() & 0o077 != 0 {
12384 return Err(bad_agent_key(
12385 "the rotation journal is accessible to group/other; set mode 0600",
12386 ));
12387 }
12388 }
12389 serde_json::from_reader(file)
12390 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
12391}
12392
12393fn remove_rotation_journal(path: &Path) {
12394 #[cfg(unix)]
12395 {
12396 use std::os::fd::AsRawFd as _;
12397 use std::os::unix::ffi::OsStrExt as _;
12398 let Ok(parent) =
12399 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
12400 else {
12401 return;
12402 };
12403 let Some(leaf_name) = path.file_name() else {
12404 return;
12405 };
12406 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
12407 return;
12408 };
12409 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
12410 let _ = parent.sync_all();
12411 }
12412 }
12413 #[cfg(not(unix))]
12414 {
12415 let _ = std::fs::remove_file(path);
12416 }
12417}
12418
12419fn validate_rotation_journal(
12420 journal: &RotationJournal,
12421 cfg: &HubConfig,
12422 canonical_brain: &str,
12423 old_key: &AgentSigningKey,
12424 new_key: &AgentSigningKey,
12425 head: &Head,
12426) -> LinkResult<()> {
12427 if journal.v != 1
12428 || journal.origin != normalized_origin(&cfg.hub)?
12429 || journal.brain != canonical_brain
12430 || journal.old_brain != old_key.multikey
12431 || journal.new_brain != new_key.multikey
12432 || journal.prior_head_seq != head.seq
12433 || journal.prior_feed_hash != head.feed_hash
12434 {
12435 return Err(invalid_feed(
12436 "rotation journal does not match the verified key and feed boundary",
12437 ));
12438 }
12439 let statement: RotationStatement = serde_json::from_str(&journal.statement)
12440 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
12441 if statement.prior_head_seq != journal.prior_head_seq
12442 || statement.prior_feed_hash != journal.prior_feed_hash
12443 || statement.brain != old_key.multikey
12444 || statement.public_key != old_key.public_key_spki
12445 || statement.new_brain != new_key.multikey
12446 || statement.new_public_key != new_key.public_key_spki
12447 {
12448 return Err(invalid_feed(
12449 "rotation journal statement does not match its durable intent",
12450 ));
12451 }
12452 let identity = FeedIdentity {
12453 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
12454 public_key_spki: new_key.public_key_spki.clone(),
12455 previous: vec![PreviousIdentity {
12456 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
12457 public_key_spki: old_key.public_key_spki.clone(),
12458 }],
12459 rotations: vec![journal.statement.clone()],
12460 };
12461 verify_identity_chain(&identity, None)?;
12462 Ok(())
12463}
12464
12465#[derive(Debug, Serialize)]
12467pub struct RotationReport {
12468 pub brain: String,
12470 pub multikey: String,
12472 #[serde(rename = "keyFile")]
12474 pub key_file: String,
12475 pub previous: Vec<String>,
12477}
12478
12479pub fn rotate_brain_key(
12485 cfg: &HubConfig,
12486 brain: &str,
12487 old_key: &AgentSigningKey,
12488 out: &Path,
12489) -> LinkResult<RotationReport> {
12490 require_hardened_filesystem("key rotation")?;
12491 require_safe_ref(brain)?;
12492 let new_key = if out.exists() {
12496 load_signing_key(out)?
12497 } else {
12498 let rng = ring::rand::SystemRandom::new();
12499 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
12500 .map_err(|_| bad_agent_key("key generation failed"))?;
12501 let pair = agent_keypair(pkcs8.as_ref())?;
12502 let (public_key_spki, multikey) = public_identity_for(&pair);
12503 write_secret_new(
12504 out,
12505 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
12506 )?;
12507 AgentSigningKey {
12508 pkcs8: pkcs8.as_ref().to_vec(),
12509 multikey,
12510 public_key_spki,
12511 }
12512 };
12513 let new_spki = new_key.public_key_spki.clone();
12514 let new_multikey = new_key.multikey.clone();
12515 let journal_path = rotation_journal_path(out);
12516 let before = verified_remote_head(cfg, brain, false)?;
12517 let served_identity = before
12518 .identity
12519 .as_ref()
12520 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
12521 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
12522 if served_multikey == new_multikey {
12523 remove_rotation_journal(&journal_path);
12524 return Ok(RotationReport {
12525 brain: brain.to_string(),
12526 multikey: new_multikey,
12527 key_file: out.display().to_string(),
12528 previous: served_identity
12529 .previous
12530 .iter()
12531 .map(|identity| format!("ed25519:{}", identity.fingerprint))
12532 .collect(),
12533 });
12534 }
12535 if served_multikey != old_key.multikey {
12536 return Err(invalid_feed(
12537 "the supplied old key is not the brain's verified current identity",
12538 ));
12539 }
12540
12541 let journal = if journal_path.exists() {
12542 read_rotation_journal(&journal_path)?
12543 } else {
12544 let ts = crate::now()
12545 .with_timezone(&chrono::Utc)
12546 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
12547 .to_string();
12548 let unsigned = serde_json::to_string(&UnsignedRotation {
12549 v: 1,
12550 op: "rotate",
12551 brain: &old_key.multikey,
12552 public_key: &old_key.public_key_spki,
12553 new_brain: &new_multikey,
12554 new_public_key: &new_spki,
12555 prior_head_seq: before.head.seq,
12556 prior_feed_hash: before.head.feed_hash.as_deref(),
12557 ts,
12558 })
12559 .expect("serialize rotation");
12560 let old_pair = agent_keypair(&old_key.pkcs8)?;
12561 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
12562 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
12563 let journal = RotationJournal {
12564 v: 1,
12565 origin: normalized_origin(&cfg.hub)?,
12566 brain: before.head.brain.clone(),
12567 old_brain: old_key.multikey.clone(),
12568 new_brain: new_multikey.clone(),
12569 prior_head_seq: before.head.seq,
12570 prior_feed_hash: before.head.feed_hash.clone(),
12571 statement,
12572 };
12573 let mut exact = serde_json::to_vec(&journal)
12574 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
12575 exact.push(b'\n');
12576 if write_secret_new(&journal_path, &exact).is_err() {
12577 read_rotation_journal(&journal_path)?
12580 } else {
12581 journal
12582 }
12583 };
12584 validate_rotation_journal(
12585 &journal,
12586 cfg,
12587 &before.head.brain,
12588 old_key,
12589 &new_key,
12590 &before.head,
12591 )?;
12592
12593 let body = json!({ "statement": journal.statement });
12594 let path = format!("/api/hub/brains/{brain}/rotate");
12595 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
12596 let attempted_failure = match attempted {
12597 Ok(response) if (200..300).contains(&response.status) => None,
12598 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
12599 Err(error) => Some(error),
12600 };
12601
12602 let after = match verified_remote_head(cfg, brain, false) {
12606 Ok(after) => after,
12607 Err(error) => return Err(attempted_failure.unwrap_or(error)),
12608 };
12609 let identity = after
12610 .identity
12611 .as_ref()
12612 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?;
12613 if format!("ed25519:{}", identity.fingerprint) != new_multikey
12614 || identity.public_key_spki != new_spki
12615 {
12616 return Err(attempted_failure.unwrap_or_else(|| {
12617 invalid_feed("hub acknowledged rotation without committing the verified new identity")
12618 }));
12619 }
12620 let previous = identity
12621 .previous
12622 .iter()
12623 .map(|prior| format!("ed25519:{}", prior.fingerprint))
12624 .collect();
12625 remove_rotation_journal(&journal_path);
12626
12627 Ok(RotationReport {
12628 brain: brain.to_string(),
12629 multikey: new_multikey,
12630 key_file: out.display().to_string(),
12631 previous,
12632 })
12633}
12634
12635#[derive(Debug, Serialize)]
12641pub struct MirrorReport {
12642 pub brain: String,
12644 #[serde(rename = "headSeq")]
12646 pub head_seq: u64,
12647 #[serde(rename = "feedHash")]
12649 pub feed_hash: Option<String>,
12650 pub entries: u64,
12652 pub pinned: String,
12654 pub files: usize,
12656}
12657
12658pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
12660
12661#[derive(Debug)]
12663pub struct VerifiedMirrorMaterial {
12664 pub brain: String,
12665 pub head_seq: u64,
12666 pub feed_hash: Option<String>,
12667 pub identity: serde_json::Value,
12668 pub entries: Vec<(u64, String, String)>,
12670 pub pack_sha256: Option<String>,
12671}
12672
12673#[derive(Deserialize)]
12674#[serde(deny_unknown_fields)]
12675struct StoredMirrorHead {
12676 brain: String,
12677 #[serde(rename = "headSeq")]
12678 head_seq: u64,
12679 #[serde(rename = "feedHash")]
12680 feed_hash: Option<String>,
12681}
12682
12683pub fn verify_mirror_material(
12686 head_bytes: &[u8],
12687 identity_bytes: &[u8],
12688 feed_bytes: &[Vec<u8>],
12689 snapshot_pack: Option<&[u8]>,
12690 expected_anchor: &str,
12691) -> LinkResult<VerifiedMirrorMaterial> {
12692 let snapshot_hash = snapshot_pack
12693 .filter(|pack| !pack.is_empty())
12694 .map(content_sha256);
12695 verify_mirror_material_with_pack_hash(
12696 head_bytes,
12697 identity_bytes,
12698 feed_bytes,
12699 snapshot_hash.as_deref(),
12700 expected_anchor,
12701 )
12702}
12703
12704pub fn verify_mirror_material_with_pack_hash(
12708 head_bytes: &[u8],
12709 identity_bytes: &[u8],
12710 feed_bytes: &[Vec<u8>],
12711 snapshot_pack_sha256: Option<&str>,
12712 expected_anchor: &str,
12713) -> LinkResult<VerifiedMirrorMaterial> {
12714 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
12715 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
12716 require_safe_ref(&head.brain)?;
12717 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
12718 return Err(invalid_feed(
12719 "stored mirror feed count does not match its bounded head sequence",
12720 ));
12721 }
12722 let aggregate = feed_bytes
12723 .iter()
12724 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
12725 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
12726 if aggregate > MAX_FEED_REPLAY_BYTES {
12727 return Err(invalid_feed(
12728 "stored mirror feed metadata exceeds the aggregate limit",
12729 ));
12730 }
12731 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
12732 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
12733 let anchor = verify_identity_chain(&identity, None)?;
12734 if anchor != expected_anchor {
12735 return Err(invalid_feed(
12736 "stored mirror identity does not descend from the explicitly trusted anchor",
12737 ));
12738 }
12739
12740 let mut entries = Vec::with_capacity(feed_bytes.len());
12741 let mut items = Vec::with_capacity(feed_bytes.len());
12742 let mut previous_hash = None;
12743 let mut pack_sha256 = None;
12744 for (index, bytes) in feed_bytes.iter().enumerate() {
12745 let exact = bytes
12746 .strip_suffix(b"\n")
12747 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
12748 if exact.ends_with(b"\n") {
12749 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
12750 }
12751 let entry: FeedEntry = serde_json::from_slice(exact)
12752 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
12753 let expected_seq = index as u64 + 1;
12754 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
12755 return Err(invalid_feed(
12756 "stored mirror feed is not contiguous and hash-chained",
12757 ));
12758 }
12759 let canonical = serde_json::to_vec(&entry)
12760 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
12761 if canonical != exact {
12762 return Err(invalid_feed(
12763 "stored feed entry is not in normative serialization",
12764 ));
12765 }
12766 let hash = content_sha256(bytes);
12767 let item = FeedItem {
12768 hash: hash.clone(),
12769 entry,
12770 };
12771 verify_feed_item(&item, &identity)?;
12772 previous_hash = Some(hash.clone());
12773 if expected_seq == head.head_seq {
12774 pack_sha256 = Some(item.entry.pack_sha256.clone());
12775 }
12776 entries.push((
12777 expected_seq,
12778 std::str::from_utf8(exact)
12779 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
12780 .to_string(),
12781 hash,
12782 ));
12783 items.push(item);
12784 }
12785 if previous_hash != head.feed_hash {
12786 return Err(invalid_feed(
12787 "stored mirror feed does not converge on its advertised head",
12788 ));
12789 }
12790 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
12791 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
12792 (0, None, None) => {}
12793 (_, Some(actual), Some(expected)) if actual == expected => {}
12794 _ => {
12795 return Err(LinkError::InvalidPack {
12796 message: "stored snapshot pack does not match the signed head digest".to_string(),
12797 });
12798 }
12799 }
12800 let identity_value = serde_json::to_value(&identity)
12801 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
12802 Ok(VerifiedMirrorMaterial {
12803 brain: head.brain,
12804 head_seq: head.head_seq,
12805 feed_hash: head.feed_hash,
12806 identity: identity_value,
12807 entries,
12808 pack_sha256,
12809 })
12810}
12811
12812pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
12815 format!(
12816 "{:x}",
12817 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
12818 )
12819}
12820
12821pub fn content_sha256(bytes: &[u8]) -> String {
12824 format!("{:x}", Sha256::digest(bytes))
12825}
12826
12827pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
12829 let mut digest = Sha256::new();
12830 let mut buffer = [0u8; 64 * 1024];
12831 loop {
12832 let read = reader.read(&mut buffer)?;
12833 if read == 0 {
12834 break;
12835 }
12836 digest.update(&buffer[..read]);
12837 }
12838 Ok(format!("{:x}", digest.finalize()))
12839}
12840
12841#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
12849pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
12850 require_hardened_filesystem("mirror")?;
12851 require_safe_ref(brain)?;
12852 #[cfg(windows)]
12853 {
12854 let _ = (cfg, dest);
12855 return Err(LinkError::UnsupportedPlatform {
12856 operation: "atomic whole-mirror replacement on Windows",
12857 });
12858 }
12859 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
12860 let name = dest
12861 .file_name()
12862 .and_then(|name| name.to_str())
12863 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
12864 .ok_or_else(|| LinkError::UnsafePath {
12865 path: dest.display().to_string(),
12866 })?;
12867 #[cfg(unix)]
12868 let parent_dir = open_or_create_dir_nofollow(parent)?;
12869 #[cfg(unix)]
12870 use std::os::fd::AsRawFd as _;
12871 #[cfg(unix)]
12872 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
12873 #[cfg(unix)]
12874 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
12875 None => false,
12876 Some(true) => true,
12877 Some(false) => {
12878 return Err(LinkError::UnsafePath {
12879 path: dest.display().to_string(),
12880 });
12881 }
12882 };
12883
12884 #[cfg(unix)]
12887 let legacy_backup_name = c_name(
12888 format!(".{name}.dbmd-backup").as_bytes(),
12889 &dest.display().to_string(),
12890 )?;
12891 #[cfg(unix)]
12892 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
12893 return Err(LinkError::UnsafePath {
12894 path: parent
12895 .join(format!(".{name}.dbmd-backup"))
12896 .display()
12897 .to_string(),
12898 });
12899 }
12900
12901 let nonce = std::time::SystemTime::now()
12902 .duration_since(std::time::UNIX_EPOCH)
12903 .unwrap_or_default()
12904 .as_nanos();
12905 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
12906 #[cfg(unix)]
12907 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
12908 #[cfg(unix)]
12909 let stage_dir = create_dir_exclusive_at(
12910 parent_dir.as_raw_fd(),
12911 &stage_name,
12912 &dest.display().to_string(),
12913 )?;
12914
12915 let assembled = (|| -> LinkResult<MirrorReport> {
12916 let remote = verified_remote_head(cfg, brain, true)?;
12917 let brain_id = remote.head.brain.clone();
12918 let identity = remote
12919 .identity
12920 .as_ref()
12921 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
12922 let anchor = remote
12923 .anchor
12924 .clone()
12925 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
12926 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
12927 let snapshot_entries = parse_store_pack(pack.clone())?;
12928 let snapshot_count = snapshot_entries.len();
12929 let mut staged_entries = snapshot_entries;
12930 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
12931 for item in &remote.entries {
12932 let mut exact = serde_json::to_vec(&item.entry)
12933 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
12934 exact.push(b'\n');
12935 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
12936 return Err(invalid_feed(
12937 "serialized mirror entry differs from its verified hash",
12938 ));
12939 }
12940 staged_entries.push((
12941 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
12942 exact,
12943 ));
12944 }
12945 let mut identity_bytes = serde_json::to_vec(identity)
12946 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
12947 identity_bytes.push(b'\n');
12948 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
12949 let mut head_bytes = serde_json::to_vec(&json!({
12950 "brain": brain_id,
12951 "headSeq": remote.head.seq,
12952 "feedHash": remote.head.feed_hash,
12953 }))
12954 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
12955 head_bytes.push(b'\n');
12956 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
12957 staged_entries.push((
12958 CONFIG_REL_PATH.to_string(),
12959 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
12960 ));
12961 #[cfg(unix)]
12962 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
12963
12964 Ok(MirrorReport {
12965 brain: brain_id,
12966 head_seq: remote.head.seq,
12967 feed_hash: remote.head.feed_hash,
12968 entries: remote.entries.len() as u64,
12969 pinned: anchor,
12970 files: snapshot_count,
12971 })
12972 })();
12973
12974 let report = match assembled {
12975 Ok(report) => report,
12976 Err(error) => {
12977 #[cfg(unix)]
12978 let _ = remove_tree_at(
12979 parent_dir.as_raw_fd(),
12980 &stage_name,
12981 &dest.display().to_string(),
12982 );
12983 return Err(error);
12984 }
12985 };
12986
12987 #[cfg(unix)]
12988 if let Err(error) =
12989 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
12990 {
12991 let _ = remove_tree_at(
12992 parent_dir.as_raw_fd(),
12993 &stage_name,
12994 &dest.display().to_string(),
12995 );
12996 return Err(error);
12997 }
12998 #[cfg(unix)]
13001 if dest_exists {
13002 remove_tree_at(
13003 parent_dir.as_raw_fd(),
13004 &stage_name,
13005 &dest.display().to_string(),
13006 )?;
13007 }
13008 #[cfg(unix)]
13009 parent_dir.sync_all()?;
13010 Ok(report)
13011}
13012
13013fn verified_remote_head(
13014 cfg: &HubConfig,
13015 brain: &str,
13016 require_full_chain: bool,
13017) -> LinkResult<VerifiedRemote> {
13018 require_hardened_filesystem("verified link.md state")?;
13019 require_safe_ref(brain)?;
13020 let trust_directory = open_trust_dir(cfg)?;
13024 let path = format!("/api/hub/brains/{brain}");
13025 let body = ensure_ok(
13026 request(cfg, "GET", &path, None, Auth::Required)?,
13027 "subscribe",
13028 )?;
13029 let resolved_brain = body
13030 .get("id")
13031 .and_then(Value::as_str)
13032 .filter(|id| crate::ulid::is_ulid(id))
13033 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
13034 .to_string();
13035 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
13036 return Err(invalid_feed(
13037 "brain card id differs from the explicitly requested brain id",
13038 ));
13039 }
13040 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
13045 let (pinned, alias_binding) =
13046 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
13047 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
13048 let advertised_hash = body
13049 .get("feedHash")
13050 .and_then(Value::as_str)
13051 .map(str::to_string);
13052 let updated_at = body
13053 .get("updatedAt")
13054 .and_then(Value::as_str)
13055 .map(str::to_string);
13056 if let Some(pin) = &pinned {
13057 if seq < pin.head_seq {
13058 return Err(invalid_feed(format!(
13059 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
13060 pin.head_seq
13061 )));
13062 }
13063 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
13064 return Err(invalid_feed(
13065 "feed equivocation: the checkpoint sequence now has a different hash",
13066 ));
13067 }
13068 }
13069 if seq == 0 {
13070 if advertised_hash.is_some() {
13071 return Err(invalid_feed("an empty feed advertised a head hash"));
13072 }
13073 let identity: FeedIdentity = serde_json::from_value(
13074 body.get("identity")
13075 .cloned()
13076 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
13077 )
13078 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
13079 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
13080 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
13085 save_canonical_pin_and_alias(
13086 cfg,
13087 &trust_directory,
13088 brain,
13089 &resolved_brain,
13090 TrustState {
13091 v: 2,
13092 origin: normalized_origin(&cfg.hub)?,
13093 requested: resolved_brain.clone(),
13094 brain: resolved_brain.clone(),
13095 home: None,
13096 anchor: anchor.clone(),
13097 current: format!("ed25519:{}", identity.fingerprint),
13098 head_seq: 0,
13099 feed_hash: None,
13100 rotations: identity.rotations.clone(),
13101 hub_signer: None,
13102 protocol_profile: None,
13103 },
13104 alias_binding.as_ref(),
13105 )?;
13106 return Ok(VerifiedRemote {
13107 head: Head {
13108 brain: resolved_brain,
13109 seq,
13110 updated_at,
13111 feed_hash: None,
13112 verified: true,
13113 },
13114 identity: Some(identity),
13115 head_entry: None,
13116 entries: Vec::new(),
13117 anchor: Some(anchor),
13118 });
13119 }
13120 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
13121 return Err(invalid_feed(
13122 "non-empty feed did not advertise a valid SHA-256 head",
13123 ));
13124 }
13125
13126 let replay_head_only = !require_full_chain
13130 && pinned
13131 .as_ref()
13132 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
13133 let mut after = if replay_head_only {
13134 seq - 1
13135 } else if require_full_chain || pinned.is_none() {
13136 0
13137 } else {
13138 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
13139 };
13140 let mut expected_seq = after + 1;
13141 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
13142 None
13143 } else {
13144 pinned
13145 .as_ref()
13146 .and_then(|checkpoint| checkpoint.feed_hash.clone())
13147 };
13148 let mut identity: Option<FeedIdentity> = None;
13149 let mut anchor: Option<String> = None;
13150 let mut head_entry: Option<FeedItem> = None;
13151 let mut all_entries = Vec::new();
13152 let mut observed_entries = Vec::new();
13153 let replay_count = seq
13154 .checked_sub(after)
13155 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
13156 if replay_count > MAX_FEED_REPLAY_ENTRIES {
13157 return Err(invalid_feed(format!(
13158 "feed replay requires {replay_count} entries, over the client cap"
13159 )));
13160 }
13161 let mut replay_bytes = 0u64;
13162
13163 loop {
13164 let feed_bytes = ensure_raw_ok(
13165 request_raw(
13166 cfg,
13167 "GET",
13168 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
13169 None,
13170 Auth::Required,
13171 MAX_FEED_RESPONSE_BYTES,
13172 )?,
13173 "subscribe feed",
13174 )?;
13175 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
13176 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
13177 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
13178 return Err(invalid_feed("brain card and feed head disagree"));
13179 }
13180 if feed.entries.len() > FEED_PAGE_LIMIT {
13181 return Err(invalid_feed("feed page exceeds the requested entry limit"));
13182 }
13183 if feed.scope_limited {
13184 if require_full_chain {
13185 return Err(invalid_feed(
13186 "path-scoped grants cannot verify a full snapshot chain",
13187 ));
13188 }
13189 return Ok(VerifiedRemote {
13190 head: Head {
13191 brain: resolved_brain,
13192 seq,
13193 updated_at,
13194 feed_hash: advertised_hash,
13195 verified: false,
13196 },
13197 identity: None,
13198 head_entry: None,
13199 entries: Vec::new(),
13200 anchor: None,
13201 });
13202 }
13203 let page_identity = feed
13204 .identity
13205 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
13206 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
13207 if identity
13208 .as_ref()
13209 .is_some_and(|existing| existing != &page_identity)
13210 {
13211 return Err(invalid_feed("identity changed while reading the feed"));
13212 }
13213 if anchor
13214 .as_ref()
13215 .is_some_and(|existing| existing != &page_anchor)
13216 {
13217 return Err(invalid_feed(
13218 "identity anchor changed while reading the feed",
13219 ));
13220 }
13221 identity = Some(page_identity.clone());
13222 if anchor.is_none() {
13223 anchor = Some(page_anchor);
13224 }
13225 if feed.entries.is_empty() {
13226 return Err(invalid_feed("feed page was empty before the signed head"));
13227 }
13228
13229 for item in feed.entries {
13230 if item.entry.seq != expected_seq {
13231 return Err(invalid_feed(format!(
13232 "expected entry {expected_seq}, feed served {}",
13233 item.entry.seq
13234 )));
13235 }
13236 if item.entry.seq > seq {
13237 return Err(invalid_feed("feed advanced past the card snapshot"));
13238 }
13239 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
13240 return Err(invalid_feed(format!(
13241 "entry {} does not chain to the local checkpoint",
13242 item.entry.seq
13243 )));
13244 }
13245 verify_feed_item(&item, &page_identity)?;
13246 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
13247 replay_bytes = replay_bytes.saturating_add(
13248 serde_json::to_vec(&item)
13249 .map_err(|_| invalid_feed("could not size feed entry"))?
13250 .len() as u64,
13251 );
13252 if replay_bytes > MAX_FEED_REPLAY_BYTES {
13253 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
13254 }
13255 previous_hash = Some(item.hash.clone());
13256 after = item.entry.seq;
13257 expected_seq = expected_seq
13258 .checked_add(1)
13259 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
13260 if require_full_chain {
13261 all_entries.push(item.clone());
13262 }
13263 observed_entries.push(item.clone());
13264 head_entry = Some(item);
13265 }
13266 if after == seq {
13267 break;
13268 }
13269 }
13270
13271 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
13272 return Err(invalid_feed(
13273 "verified chain does not converge on the advertised head",
13274 ));
13275 }
13276 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
13277 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
13278 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
13279 save_canonical_pin_and_alias(
13280 cfg,
13281 &trust_directory,
13282 brain,
13283 &resolved_brain,
13284 TrustState {
13285 v: 2,
13286 origin: normalized_origin(&cfg.hub)?,
13287 requested: resolved_brain.clone(),
13288 brain: resolved_brain.clone(),
13289 home: None,
13290 anchor: anchor.clone(),
13291 current: format!("ed25519:{}", identity.fingerprint),
13292 head_seq: seq,
13293 feed_hash: advertised_hash.clone(),
13294 rotations: identity.rotations.clone(),
13295 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
13296 protocol_profile: pinned
13297 .as_ref()
13298 .and_then(|state| state.protocol_profile.clone()),
13299 },
13300 alias_binding.as_ref(),
13301 )?;
13302 Ok(VerifiedRemote {
13303 head: Head {
13304 brain: resolved_brain,
13305 seq,
13306 updated_at,
13307 feed_hash: advertised_hash,
13308 verified: true,
13309 },
13310 identity: Some(identity),
13311 head_entry,
13312 entries: all_entries,
13313 anchor: Some(anchor),
13314 })
13315}
13316
13317pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
13322 Ok(verified_remote_head(cfg, brain, false)?.head)
13323}
13324
13325#[cfg(test)]
13326mod tests {
13327 use super::*;
13328
13329 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
13330
13331 #[test]
13332 fn exact_source_move_becomes_one_provenance_preserving_rename() {
13333 let hash = "a".repeat(64);
13334 let operations = vec![
13335 json!({
13336 "op": "put",
13337 "path": "sources/curated/item.md",
13338 "expected": { "kind": "absent" },
13339 "blob": hash,
13340 "bytes": 19,
13341 }),
13342 json!({
13343 "op": "delete",
13344 "path": "sources/inbox/item.md",
13345 "expected": { "kind": "blob", "hash": hash },
13346 }),
13347 ];
13348
13349 assert_eq!(
13350 infer_exact_source_promotions(operations),
13351 vec![json!({
13352 "op": "rename",
13353 "from": "sources/inbox/item.md",
13354 "to": "sources/curated/item.md",
13355 "expected_from": { "kind": "blob", "hash": hash },
13356 "expected_to": { "kind": "absent" },
13357 "blob": hash,
13358 "bytes": 19,
13359 })]
13360 );
13361 }
13362
13363 #[test]
13364 fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
13365 let hash = "b".repeat(64);
13366 let operations = vec![
13367 json!({
13368 "op": "delete",
13369 "path": "sources/inbox/a.md",
13370 "expected": { "kind": "blob", "hash": hash },
13371 }),
13372 json!({
13373 "op": "delete",
13374 "path": "sources/inbox/b.md",
13375 "expected": { "kind": "blob", "hash": hash },
13376 }),
13377 json!({
13378 "op": "put",
13379 "path": "sources/curated/item.md",
13380 "expected": { "kind": "absent" },
13381 "blob": hash,
13382 "bytes": 19,
13383 }),
13384 ];
13385
13386 assert_eq!(
13387 infer_exact_source_promotions(operations.clone()),
13388 operations,
13389 "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
13390 );
13391 }
13392
13393 #[test]
13394 fn accepted_source_promotion_advances_the_local_baseline_exactly() {
13395 let hash = "c".repeat(64);
13396 let mut candidate = std::collections::BTreeMap::from([(
13397 "sources/inbox/item.md".to_string(),
13398 V2BaselineFile {
13399 sha256: hash.clone(),
13400 bytes: 19,
13401 proof: None,
13402 },
13403 )]);
13404 let mut candidate_assets = std::collections::BTreeMap::new();
13405 let operations = vec![
13406 json!({
13407 "op": "rename",
13408 "from": "sources/inbox/item.md",
13409 "to": "sources/curated/item.md",
13410 "expected_from": { "kind": "blob", "hash": hash },
13411 "expected_to": { "kind": "absent" },
13412 "blob": hash,
13413 "bytes": 19,
13414 }),
13415 json!({
13416 "op": "put",
13417 "path": "records/rsvps/item.md",
13418 "expected": { "kind": "absent" },
13419 "blob": "d".repeat(64),
13420 "bytes": 23,
13421 }),
13422 ];
13423
13424 assert!(!apply_generated_v2_operations(
13425 &operations,
13426 &std::collections::BTreeMap::new(),
13427 &mut candidate,
13428 &mut candidate_assets,
13429 )
13430 .unwrap());
13431 assert!(!candidate.contains_key("sources/inbox/item.md"));
13432 assert_eq!(
13433 candidate
13434 .get("sources/curated/item.md")
13435 .map(|file| (&file.sha256, file.bytes)),
13436 Some((&hash, 19))
13437 );
13438 assert_eq!(
13439 candidate
13440 .get("records/rsvps/item.md")
13441 .map(|file| (file.sha256.as_str(), file.bytes)),
13442 Some((
13443 "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
13444 23
13445 ))
13446 );
13447 }
13448
13449 fn merge_fixture(
13450 base: Option<&str>,
13451 remote: Option<&str>,
13452 local: Option<&str>,
13453 keep_local: bool,
13454 ) -> V2PulledMerge<String> {
13455 let map = |value: Option<&str>| {
13456 value
13457 .map(|value| [("records/a.md".to_string(), value.to_string())])
13458 .into_iter()
13459 .flatten()
13460 .collect::<std::collections::BTreeMap<_, _>>()
13461 };
13462 merge_v2_pulled_records(
13463 &map(base),
13464 &map(remote),
13465 &map(local),
13466 |value, _| value.clone(),
13467 |value, _| value.clone(),
13468 |_| keep_local,
13469 )
13470 }
13471
13472 #[test]
13473 fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
13474 let path = "records/a.md".to_string();
13475
13476 let local_add = merge_fixture(None, None, Some("local"), false);
13477 assert_eq!(
13478 local_add.records.get(&path).map(String::as_str),
13479 Some("local")
13480 );
13481 assert!(local_add.accept_remote.is_empty());
13482 assert!(local_add.conflicts.is_empty());
13483
13484 let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
13485 assert_eq!(
13486 local_edit.records.get(&path).map(String::as_str),
13487 Some("local")
13488 );
13489 assert!(local_edit.accept_remote.is_empty());
13490 assert!(local_edit.conflicts.is_empty());
13491
13492 let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
13493 assert!(!local_delete.records.contains_key(&path));
13494 assert!(local_delete.accept_remote.is_empty());
13495 assert!(local_delete.conflicts.is_empty());
13496
13497 let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
13498 assert_eq!(
13499 remote_edit.records.get(&path).map(String::as_str),
13500 Some("remote")
13501 );
13502 assert!(remote_edit.accept_remote.contains(&path));
13503 assert!(remote_edit.conflicts.is_empty());
13504
13505 let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
13506 assert!(!remote_delete.records.contains_key(&path));
13507 assert!(remote_delete.accept_remote.contains(&path));
13508 assert!(remote_delete.conflicts.is_empty());
13509
13510 let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
13511 assert_eq!(
13512 same_edit.records.get(&path).map(String::as_str),
13513 Some("same")
13514 );
13515 assert!(same_edit.accept_remote.contains(&path));
13516 assert!(same_edit.conflicts.is_empty());
13517
13518 let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
13519 assert_eq!(conflict.conflicts, vec![path.clone()]);
13520 assert_eq!(
13521 conflict.records.get(&path).map(String::as_str),
13522 Some("local")
13523 );
13524 assert!(conflict.accept_remote.is_empty());
13525
13526 let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
13527 assert_eq!(
13528 kept_home.records.get(&path).map(String::as_str),
13529 Some("local")
13530 );
13531 assert!(kept_home.accept_remote.is_empty());
13532 assert!(kept_home.conflicts.is_empty());
13533 }
13534
13535 #[test]
13536 fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
13537 let path = "sources/report.pdf";
13538 let record = crate::AssetRecord {
13539 path: path.to_string(),
13540 sha256: "a".repeat(64),
13541 bytes: 42,
13542 media_type: "application/pdf".to_string(),
13543 wrappers: vec!["gzip".to_string()],
13544 required: true,
13545 };
13546 let mut remote = V2BaselineAsset {
13547 blob_sha256: record.sha256.clone(),
13548 bytes: record.bytes,
13549 media_type: record.media_type.clone(),
13550 wrappers: record.wrappers.clone(),
13551 required: record.required,
13552 disposition: "withheld".to_string(),
13553 leaf_hash: "b".repeat(64),
13554 };
13555
13556 assert!(v2_asset_resumes_hosting(
13557 Some(&remote),
13558 path,
13559 &record,
13560 "hosted"
13561 ));
13562 assert!(!v2_asset_resumes_hosting(
13563 Some(&remote),
13564 path,
13565 &record,
13566 "withheld"
13567 ));
13568
13569 remote.disposition = "hosted".to_string();
13570 assert!(!v2_asset_resumes_hosting(
13571 Some(&remote),
13572 path,
13573 &record,
13574 "hosted"
13575 ));
13576
13577 remote.disposition = "withheld".to_string();
13578 remote.blob_sha256 = "c".repeat(64);
13579 assert!(!v2_asset_resumes_hosting(
13580 Some(&remote),
13581 path,
13582 &record,
13583 "hosted"
13584 ));
13585 assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
13586 }
13587
13588 #[test]
13589 fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
13590 let path = "records/team/alpha.md".to_string();
13591 let deleted_path = "records/team/deleted.md".to_string();
13592 let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
13593 sha256,
13594 bytes,
13595 file: None,
13596 };
13597 let files = vec![
13598 V2ConflictFile {
13599 path: path.clone(),
13600 base: coordinate(None, None),
13601 local: coordinate(Some("b".repeat(64)), Some(7)),
13602 remote: coordinate(Some("a".repeat(64)), Some(5)),
13603 },
13604 V2ConflictFile {
13605 path: deleted_path.clone(),
13606 base: coordinate(Some("c".repeat(64)), Some(9)),
13607 local: coordinate(Some("d".repeat(64)), Some(11)),
13608 remote: coordinate(None, None),
13609 },
13610 ];
13611 let proven = V2BaselineFile {
13612 sha256: "a".repeat(64),
13613 bytes: 5,
13614 proof: None,
13615 };
13616 let current = [(path.clone(), proven.clone())]
13617 .into_iter()
13618 .collect::<std::collections::BTreeMap<_, _>>();
13619
13620 let (selected, deleted) = v2_take_remote_selection(&files, ¤t).unwrap();
13621 assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
13622 assert_eq!(deleted, vec![deleted_path.clone()]);
13623
13624 let changed = [(
13625 path.clone(),
13626 V2BaselineFile {
13627 sha256: "e".repeat(64),
13628 bytes: 5,
13629 proof: None,
13630 },
13631 )]
13632 .into_iter()
13633 .collect::<std::collections::BTreeMap<_, _>>();
13634 assert!(v2_take_remote_selection(&files, &changed).is_err());
13635
13636 let resurrected = [
13637 (path, proven),
13638 (
13639 deleted_path,
13640 V2BaselineFile {
13641 sha256: "f".repeat(64),
13642 bytes: 13,
13643 proof: None,
13644 },
13645 ),
13646 ]
13647 .into_iter()
13648 .collect::<std::collections::BTreeMap<_, _>>();
13649 assert!(v2_take_remote_selection(&files, &resurrected).is_err());
13650 }
13651
13652 #[cfg(target_os = "linux")]
13653 #[test]
13654 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
13655 use std::os::fd::AsRawFd as _;
13656
13657 let sandbox = tempfile::TempDir::new().unwrap();
13658 let parent = std::fs::File::open(sandbox.path()).unwrap();
13659 let stage = std::ffi::CString::new("stage").unwrap();
13660 let destination = std::ffi::CString::new("brain").unwrap();
13661
13662 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
13663 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
13664 install_stage_at(
13665 parent.as_raw_fd(),
13666 stage.as_c_str(),
13667 destination.as_c_str(),
13668 false,
13669 )
13670 .unwrap();
13671 assert!(!sandbox.path().join("stage").exists());
13672 assert_eq!(
13673 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
13674 b"created"
13675 );
13676
13677 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
13678 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
13679 install_stage_at(
13680 parent.as_raw_fd(),
13681 stage.as_c_str(),
13682 destination.as_c_str(),
13683 true,
13684 )
13685 .unwrap();
13686 assert_eq!(
13687 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
13688 b"replacement"
13689 );
13690 assert_eq!(
13691 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
13692 b"created",
13693 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
13694 );
13695 }
13696
13697 struct SignedRemoteFixture {
13698 card: String,
13699 feed: String,
13700 key: AgentSigningKey,
13701 identity: FeedIdentity,
13702 }
13703
13704 fn signed_remote_fixture() -> SignedRemoteFixture {
13705 let rng = ring::rand::SystemRandom::new();
13706 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13707 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13708 let (public_key, multikey) = public_identity_for(&pair);
13709 let identity = FeedIdentity {
13710 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
13711 public_key_spki: public_key.clone(),
13712 previous: Vec::new(),
13713 rotations: Vec::new(),
13714 };
13715 let mut entry = FeedEntry {
13716 v: 1,
13717 seq: 1,
13718 ts: "2026-07-30T12:00:00.000Z".to_string(),
13719 brain: multikey.clone(),
13720 public_key: public_key.clone(),
13721 kind: "push".to_string(),
13722 op: "snapshot".to_string(),
13723 pack_sha256: "a".repeat(64),
13724 files: Vec::new(),
13725 removed: Vec::new(),
13726 prev_entry_hash: None,
13727 sig: String::new(),
13728 };
13729 let unsigned = UnsignedFeedEntry {
13730 v: entry.v,
13731 seq: entry.seq,
13732 ts: &entry.ts,
13733 brain: &entry.brain,
13734 public_key: &entry.public_key,
13735 kind: &entry.kind,
13736 op: &entry.op,
13737 pack_sha256: &entry.pack_sha256,
13738 files: &entry.files,
13739 removed: &entry.removed,
13740 prev_entry_hash: &entry.prev_entry_hash,
13741 };
13742 entry.sig =
13743 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
13744 let mut exact = serde_json::to_vec(&entry).unwrap();
13745 exact.push(b'\n');
13746 let hash = content_sha256(&exact);
13747 let card = json!({
13748 "id": TEST_BRAIN_ID,
13749 "headSeq": 1,
13750 "feedHash": hash,
13751 "identity": identity.clone(),
13752 })
13753 .to_string();
13754 let feed = json!({
13755 "headSeq": 1,
13756 "feedHash": hash,
13757 "identity": identity.clone(),
13758 "entries": [{"hash": hash, "entry": entry}],
13759 "scopeLimited": false,
13760 })
13761 .to_string();
13762 SignedRemoteFixture {
13763 card,
13764 feed,
13765 key: AgentSigningKey {
13766 pkcs8: pkcs8.as_ref().to_vec(),
13767 multikey,
13768 public_key_spki: public_key,
13769 },
13770 identity,
13771 }
13772 }
13773
13774 #[test]
13775 fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
13776 let file = |path: &str, byte: char| FeedFile {
13777 path: path.to_string(),
13778 sha256: byte.to_string().repeat(64),
13779 bytes: 1,
13780 };
13781 let a0 = file("records/a.md", 'a');
13782 let a1 = file("records/a.md", 'b');
13783 let stable = file("records/stable.md", 'c');
13784 let added = file("records/added.md", 'd');
13785 let removed_file = file("records/removed.md", 'e');
13786 let previous = vec![a0, stable.clone(), removed_file.clone()];
13787 let resulting = vec![a1.clone(), stable.clone(), added.clone()];
13788 let removed = vec![removed_file.path.clone()];
13789
13790 assert_eq!(
13791 verify_v1_manifest_disclosure(
13792 "edit",
13793 &previous,
13794 &resulting,
13795 &[a1.clone(), added.clone()],
13796 &removed,
13797 ),
13798 Ok(())
13799 );
13800 assert_eq!(
13801 verify_v1_manifest_disclosure(
13802 "edit",
13803 &previous,
13804 &resulting,
13805 &[stable.clone(), added.clone(), a1.clone()],
13806 &removed,
13807 ),
13808 Ok(())
13809 );
13810 assert_eq!(
13811 verify_v1_manifest_disclosure(
13812 "edit",
13813 &previous,
13814 &resulting,
13815 std::slice::from_ref(&added),
13816 &removed,
13817 ),
13818 Err(V1DisclosureError::EditMissingChange)
13819 );
13820 assert_eq!(
13821 verify_v1_manifest_disclosure(
13822 "edit",
13823 &previous,
13824 &resulting,
13825 &[file("records/a.md", 'f'), added.clone()],
13826 &removed,
13827 ),
13828 Err(V1DisclosureError::EditFalseFile)
13829 );
13830 assert_eq!(
13831 verify_v1_manifest_disclosure(
13832 "edit",
13833 &previous,
13834 &resulting,
13835 &[a1.clone(), added.clone()],
13836 &[],
13837 ),
13838 Err(V1DisclosureError::RemovedMismatch)
13839 );
13840 assert_eq!(
13841 verify_v1_manifest_disclosure(
13842 "push",
13843 &previous,
13844 &resulting,
13845 &[added.clone(), stable, a1],
13846 &removed,
13847 ),
13848 Ok(())
13849 );
13850 assert_eq!(
13851 verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
13852 Err(V1DisclosureError::PushManifestMismatch)
13853 );
13854 }
13855
13856 #[test]
13857 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
13858 let fixture = signed_remote_fixture();
13859 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
13860 let item = feed["entries"][0].to_string();
13861 let oversized_page = format!(
13862 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
13863 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
13864 .collect::<Vec<_>>()
13865 .join(",")
13866 );
13867 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
13868
13869 let oversized_identity = format!(
13870 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
13871 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
13872 .collect::<Vec<_>>()
13873 .join(",")
13874 );
13875 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
13876
13877 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
13878 let oversized_entry = format!(
13879 "{{\"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\"}}",
13880 "a".repeat(64),
13881 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
13882 .collect::<Vec<_>>()
13883 .join(",")
13884 );
13885 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
13886 }
13887
13888 #[test]
13889 fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
13890 let id = "01arz3ndektsv4rrffq69g5fav";
13891 let digest = "a".repeat(64);
13892 assert_eq!(
13893 V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
13894 V2BulkConfirmation {
13895 id: id.to_string(),
13896 digest,
13897 }
13898 );
13899 for invalid in [
13900 "",
13901 "01arz3ndektsv4rrffq69g5fav",
13902 "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
13903 "01arz3ndektsv4rrffq69g5fav:ABCDEF",
13904 "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
13905 ] {
13906 assert!(matches!(
13907 V2BulkConfirmation::parse(invalid),
13908 Err(LinkError::InvalidPack { .. })
13909 ));
13910 }
13911 }
13912
13913 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
13914 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
13915 use std::net::TcpListener;
13916
13917 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
13918 let url = format!("http://{}", listener.local_addr().unwrap());
13919 let handle = std::thread::spawn(move || {
13920 for (status, body) in responses {
13921 let (stream, _) = listener.accept().unwrap();
13922 let mut reader = BufReader::new(stream);
13923 let mut line = String::new();
13924 reader.read_line(&mut line).unwrap();
13925 let mut content_length = 0usize;
13926 loop {
13927 line.clear();
13928 reader.read_line(&mut line).unwrap();
13929 if line == "\r\n" || line == "\n" || line.is_empty() {
13930 break;
13931 }
13932 if let Some((name, value)) = line.split_once(':') {
13933 if name.eq_ignore_ascii_case("content-length") {
13934 content_length = value.trim().parse().unwrap();
13935 }
13936 }
13937 }
13938 let mut request_body = vec![0_u8; content_length];
13939 reader.read_exact(&mut request_body).unwrap();
13940 let response = format!(
13941 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
13942 body.len()
13943 );
13944 reader.get_mut().write_all(response.as_bytes()).unwrap();
13945 }
13946 });
13947 (url, handle)
13948 }
13949
13950 fn routed_json_hub(
13951 requests: usize,
13952 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
13953 ) -> (String, std::thread::JoinHandle<()>) {
13954 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
13955 use std::net::TcpListener;
13956
13957 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
13958 let url = format!("http://{}", listener.local_addr().unwrap());
13959 let handle = std::thread::spawn(move || {
13960 for _ in 0..requests {
13961 let (stream, _) = listener.accept().unwrap();
13962 let mut reader = BufReader::new(stream);
13963 let mut line = String::new();
13964 reader.read_line(&mut line).unwrap();
13965 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
13966 let mut content_length = 0usize;
13967 loop {
13968 line.clear();
13969 reader.read_line(&mut line).unwrap();
13970 if line == "\r\n" || line == "\n" || line.is_empty() {
13971 break;
13972 }
13973 if let Some((name, value)) = line.split_once(':') {
13974 if name.eq_ignore_ascii_case("content-length") {
13975 content_length = value.trim().parse().unwrap();
13976 }
13977 }
13978 }
13979 let mut request_body = vec![0_u8; content_length];
13980 reader.read_exact(&mut request_body).unwrap();
13981 let (status, body) = respond(&path);
13982 let response = format!(
13983 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
13984 body.len()
13985 );
13986 reader.get_mut().write_all(response.as_bytes()).unwrap();
13987 }
13988 });
13989 (url, handle)
13990 }
13991
13992 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
13993 HubConfig {
13994 hub,
13995 key: Some("test-key".to_string()),
13996 agent_key: None,
13997 brain_key: None,
13998 state_dir,
13999 store_selected: false,
14000 }
14001 }
14002
14003 #[test]
14004 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
14005 use ring::signature::KeyPair as _;
14006
14007 let rng = ring::rand::SystemRandom::new();
14008 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14009 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14010 let (spki, multikey) = public_identity_for(&pair);
14011 let key = AgentSigningKey {
14012 pkcs8: pkcs8.as_ref().to_vec(),
14013 multikey,
14014 public_key_spki: spki,
14015 };
14016 let header = linkmd_sig_header(
14017 &key,
14018 "https://hub-a.example",
14019 "post",
14020 "/api/hub/brains/brain/push?mode=exact",
14021 Some("{\"ok\":true}"),
14022 )
14023 .unwrap();
14024 assert!(header.starts_with("LinkMD-Sig v2,"));
14025 let ts = header
14026 .split(",ts=")
14027 .nth(1)
14028 .unwrap()
14029 .split(',')
14030 .next()
14031 .unwrap();
14032 let signature = URL_SAFE_NO_PAD
14033 .decode(header.rsplit(",sig=").next().unwrap())
14034 .unwrap();
14035 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
14036 let accepted = format!(
14037 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
14038 );
14039 let replayed = format!(
14040 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
14041 );
14042 let public = pair.public_key().as_ref();
14043 assert!(UnparsedPublicKey::new(&ED25519, public)
14044 .verify(accepted.as_bytes(), &signature)
14045 .is_ok());
14046 assert!(
14047 UnparsedPublicKey::new(&ED25519, public)
14048 .verify(replayed.as_bytes(), &signature)
14049 .is_err(),
14050 "a proof captured at hub A must not authenticate at hub B"
14051 );
14052 }
14053
14054 #[test]
14055 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
14056 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
14057 let card = json!({
14058 "id": other,
14059 "headSeq": 0,
14060 "identity": signed_remote_fixture().identity,
14061 })
14062 .to_string();
14063 let (hub, server) = scripted_json_hub(vec![(200, card)]);
14064 let state = tempfile::tempdir().unwrap();
14065 let cfg = test_hub_config(hub, state.path().to_path_buf());
14066 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14067 assert!(
14068 error.contains("differs from the explicitly requested"),
14069 "{error}"
14070 );
14071 server.join().unwrap();
14072 }
14073
14074 #[test]
14075 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
14076 let first = signed_remote_fixture().identity;
14077 let second = signed_remote_fixture().identity;
14078 let card = |identity: FeedIdentity| {
14079 json!({
14080 "id": TEST_BRAIN_ID,
14081 "headSeq": 0,
14082 "identity": identity,
14083 })
14084 .to_string()
14085 };
14086 let (hub, server) = scripted_json_hub(vec![(200, card(first)), (200, card(second))]);
14087 let state = tempfile::tempdir().unwrap();
14088 let cfg = test_hub_config(hub, state.path().to_path_buf());
14089 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
14090 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14091 assert!(
14092 error.contains("pinned anchor") || error.contains("forked away"),
14093 "{error}"
14094 );
14095 server.join().unwrap();
14096 }
14097
14098 #[test]
14099 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
14100 let old = signed_remote_fixture();
14101 let new = signed_remote_fixture();
14102 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
14103 let unsigned = serde_json::to_string(&UnsignedRotation {
14104 v: 1,
14105 op: "rotate",
14106 brain: &old.key.multikey,
14107 public_key: &old.key.public_key_spki,
14108 new_brain: &new.key.multikey,
14109 new_public_key: &new.key.public_key_spki,
14110 prior_head_seq: 1,
14111 prior_feed_hash: Some(&"a".repeat(64)),
14112 ts: "2026-07-30T12:00:00.000Z".to_string(),
14113 })
14114 .unwrap();
14115 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14116 let rotation = format!(
14117 "{},\"sig\":\"{}\"}}",
14118 &unsigned[..unsigned.len() - 1],
14119 signature
14120 );
14121 let identity = FeedIdentity {
14122 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
14123 public_key_spki: new.key.public_key_spki,
14124 previous: vec![PreviousIdentity {
14125 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
14126 public_key_spki: old.key.public_key_spki,
14127 }],
14128 rotations: vec![rotation],
14129 };
14130 let card = json!({
14131 "id": TEST_BRAIN_ID,
14132 "headSeq": 0,
14133 "feedHash": null,
14134 "identity": identity,
14135 })
14136 .to_string();
14137 let (hub, server) = scripted_json_hub(vec![(200, card)]);
14138 let state = tempfile::tempdir().unwrap();
14139 let cfg = test_hub_config(hub, state.path().to_path_buf());
14140 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14141 assert!(
14142 error.contains("rotation claims a feed boundary beyond the advertised head"),
14143 "{error}"
14144 );
14145 assert!(
14146 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
14147 "an inconsistent empty-head identity must not become the TOFU checkpoint"
14148 );
14149 server.join().unwrap();
14150 }
14151
14152 #[test]
14153 fn trust_checkpoint_rejects_a_later_fork() {
14154 let fixture = signed_remote_fixture();
14155 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
14156 fork["feedHash"] = Value::String("b".repeat(64));
14157 let (hub, server) = scripted_json_hub(vec![
14158 (200, fixture.card),
14159 (200, fixture.feed),
14160 (200, fork.to_string()),
14161 ]);
14162 let state = tempfile::tempdir().unwrap();
14163 let cfg = test_hub_config(hub, state.path().to_path_buf());
14164 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
14165 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
14166 server.join().unwrap();
14167 }
14168
14169 #[test]
14170 fn alias_and_canonical_id_share_one_identity_checkpoint() {
14171 let trusted = signed_remote_fixture();
14172 let attacker = signed_remote_fixture();
14173 let (hub, server) = scripted_json_hub(vec![
14174 (200, trusted.card),
14175 (200, trusted.feed),
14176 (200, attacker.card),
14177 ]);
14178 let state = tempfile::tempdir().unwrap();
14179 let cfg = test_hub_config(hub, state.path().to_path_buf());
14180 assert!(head(&cfg, "trusted-slug").unwrap().verified);
14181 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14182 assert!(
14183 error.contains("equivocation")
14184 || error.contains("pinned")
14185 || error.contains("identity"),
14186 "{error}"
14187 );
14188 server.join().unwrap();
14189 }
14190
14191 #[test]
14192 fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
14193 let state = tempfile::tempdir().unwrap();
14194 let cfg = test_hub_config(
14195 "https://hub.example".to_string(),
14196 state.path().to_path_buf(),
14197 );
14198 let directory = open_trust_dir(&cfg).unwrap();
14199 let old = TEST_BRAIN_ID;
14200 let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
14201 save_alias_in(
14202 &cfg,
14203 &directory,
14204 &AliasBinding {
14205 v: 1,
14206 origin: normalized_origin(&cfg.hub).unwrap(),
14207 requested: "company-brain".to_string(),
14208 brain: old.to_string(),
14209 home: Some("company-brain".to_string()),
14210 },
14211 )
14212 .unwrap();
14213
14214 let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
14215 assert!(matches!(
14216 error,
14217 LinkError::AliasRebindRequired {
14218 alias,
14219 from,
14220 to
14221 } if alias == "company-brain" && from == old && to == new
14222 ));
14223 let unchanged = load_alias_in(&cfg, &directory, "company-brain")
14224 .unwrap()
14225 .unwrap();
14226 assert_eq!(unchanged.brain, old);
14227 assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
14228 }
14229
14230 #[test]
14231 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
14232 let alpha = signed_remote_fixture();
14233 let beta = signed_remote_fixture();
14234 let alpha_card = alpha.card.clone();
14235 let alpha_feed = alpha.feed.clone();
14236 let beta_card = beta.card.clone();
14237 let beta_feed = beta.feed.clone();
14238 let (hub, server) = routed_json_hub(3, move |path| {
14239 if path.contains("/alpha/feed?") {
14240 (200, alpha_feed.clone())
14241 } else if path.contains("/beta/feed?") {
14242 (200, beta_feed.clone())
14243 } else if path.ends_with("/alpha") {
14244 (200, alpha_card.clone())
14245 } else if path.ends_with("/beta") {
14246 (200, beta_card.clone())
14247 } else {
14248 (500, r#"{"error":"unexpected path"}"#.to_string())
14249 }
14250 });
14251 let state = tempfile::tempdir().unwrap();
14252 let cfg = test_hub_config(hub, state.path().to_path_buf());
14253 let alpha_cfg = cfg.clone();
14254 let beta_cfg = cfg;
14255 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
14256 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
14257 let results = [first.join().unwrap(), second.join().unwrap()];
14258 assert_eq!(
14259 results.iter().filter(|result| result.is_ok()).count(),
14260 1,
14261 "only one alias identity may establish canonical TOFU: {results:?}"
14262 );
14263 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
14264 server.join().unwrap();
14265 }
14266
14267 #[cfg(unix)]
14268 #[test]
14269 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
14270 use std::os::unix::fs::symlink;
14271
14272 let fixture = signed_remote_fixture();
14273 let card = json!({
14274 "id": TEST_BRAIN_ID,
14275 "headSeq": 0,
14276 "feedHash": Value::Null,
14277 "identity": fixture.identity,
14278 })
14279 .to_string();
14280 let work = tempfile::tempdir().unwrap();
14281 let outside = tempfile::tempdir().unwrap();
14282 let state = work.path().join("state");
14283 let moved = work.path().join("state-held");
14284 let swap_state = state.clone();
14285 let swap_moved = moved.clone();
14286 let outside_path = outside.path().to_path_buf();
14287 let (hub, server) = routed_json_hub(1, move |_| {
14288 std::fs::rename(&swap_state, &swap_moved).unwrap();
14290 symlink(&outside_path, &swap_state).unwrap();
14291 (200, card.clone())
14292 });
14293 let cfg = test_hub_config(hub, state);
14294
14295 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
14296 assert_eq!(verified.head.seq, 0);
14297 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
14298 assert!(std::fs::read_dir(moved.join("trust"))
14299 .unwrap()
14300 .flatten()
14301 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
14302 server.join().unwrap();
14303 }
14304
14305 #[test]
14306 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
14307 let remote = signed_remote_fixture();
14308 let unrelated = signed_remote_fixture().key;
14309 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
14310 let state = tempfile::tempdir().unwrap();
14311 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
14312 cfg.brain_key = Some(unrelated);
14313 let error = sync_push(
14314 &cfg,
14315 TEST_BRAIN_ID,
14316 &[("DB.md".to_string(), "signed local content".to_string())],
14317 )
14318 .unwrap_err()
14319 .to_string();
14320 assert!(
14321 error.contains("not the verified current brain identity"),
14322 "{error}"
14323 );
14324 server.join().unwrap();
14325 }
14326
14327 #[test]
14328 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
14329 let remote = signed_remote_fixture();
14330 let new = signed_remote_fixture().key;
14331 let state = tempfile::tempdir().unwrap();
14332 let new_file = state.path().join("new.key");
14333 std::fs::write(
14334 &new_file,
14335 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
14336 )
14337 .unwrap();
14338 #[cfg(unix)]
14339 {
14340 use std::os::unix::fs::PermissionsExt as _;
14341 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
14342 }
14343 let forged = json!({
14344 "brain": TEST_BRAIN_ID,
14345 "identity": {
14346 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
14347 "publicKeySpki": new.public_key_spki,
14348 }
14349 })
14350 .to_string();
14351 let (hub, server) = scripted_json_hub(vec![
14352 (200, remote.card.clone()),
14353 (200, remote.feed.clone()),
14354 (200, forged),
14355 (200, remote.card),
14356 (200, remote.feed),
14357 ]);
14358 let cfg = test_hub_config(hub, state.path().to_path_buf());
14359 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
14360 .unwrap_err()
14361 .to_string();
14362 assert!(
14363 error.contains("without committing the verified new identity"),
14364 "{error}"
14365 );
14366 server.join().unwrap();
14367 }
14368
14369 #[test]
14370 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
14371 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
14372 let raw = format!(
14373 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
14374 );
14375 let pack = build_store_pack(&[
14376 (
14377 "DB.md".to_string(),
14378 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
14379 ),
14380 ("records/clients/truth.md".to_string(), raw.clone()),
14381 ])
14382 .unwrap();
14383 let by_id = resolve_from_verified_pack(
14384 "01j5qc3v9k4ym8rwbn2tqe6f7d",
14385 &AddressTarget::Id(record_id.to_string()),
14386 pack.clone(),
14387 )
14388 .unwrap();
14389 assert_eq!(by_id["document"]["summary"], "Signed truth");
14390 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
14391 assert_eq!(
14392 by_id["document"]["contentSha"],
14393 content_sha256(raw.as_bytes())
14394 );
14395
14396 let by_path = resolve_from_verified_pack(
14397 "01j5qc3v9k4ym8rwbn2tqe6f7d",
14398 &AddressTarget::Path("records/clients/truth.md".to_string()),
14399 pack,
14400 )
14401 .unwrap();
14402 assert_eq!(by_path["document"]["id"], record_id);
14403 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
14404 }
14405
14406 #[test]
14407 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
14408 let unsorted = vec![
14409 ("records/a.md".to_string(), "alpha\n".to_string()),
14410 ("DB.md".to_string(), "# db\n".to_string()),
14411 ];
14412 let sorted = vec![
14413 ("DB.md".to_string(), "# db\n".to_string()),
14414 ("records/a.md".to_string(), "alpha\n".to_string()),
14415 ];
14416 let pack = build_store_pack(&unsorted).unwrap();
14417
14418 assert_eq!(pack.len(), 219);
14423 assert_eq!(
14424 content_sha256(&pack),
14425 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
14426 );
14427 assert_eq!(pack, build_store_pack(&sorted).unwrap());
14428 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
14429 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
14430 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
14431
14432 assert_eq!(
14433 parse_store_pack(pack).unwrap(),
14434 vec![
14435 ("DB.md".to_string(), b"# db\n".to_vec()),
14436 ("records/a.md".to_string(), b"alpha\n".to_vec()),
14437 ]
14438 );
14439 }
14440
14441 #[test]
14442 fn canonical_store_pack_validates_every_path_before_writing() {
14443 let duplicate = vec![
14444 ("DB.md".to_string(), "first".to_string()),
14445 ("DB.md".to_string(), "second".to_string()),
14446 ];
14447 assert!(build_store_pack(&duplicate)
14448 .unwrap_err()
14449 .to_string()
14450 .contains("duplicate path"));
14451 assert!(matches!(
14452 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
14453 Err(LinkError::UnsafePath { .. })
14454 ));
14455 }
14456
14457 #[test]
14458 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
14459 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
14460 let mut bytes = vec![0_u8];
14463 let zip64_offset = bytes.len() as u64;
14464 bytes.extend_from_slice(b"PK\x06\x06");
14465 bytes.extend_from_slice(&44_u64.to_le_bytes());
14466 bytes.extend_from_slice(&[0_u8; 12]);
14467 bytes.extend_from_slice(&COUNT.to_le_bytes());
14468 bytes.extend_from_slice(&COUNT.to_le_bytes());
14469 bytes.extend_from_slice(&1_u64.to_le_bytes());
14470 bytes.extend_from_slice(&0_u64.to_le_bytes());
14471 bytes.extend_from_slice(b"PK\x06\x07");
14472 bytes.extend_from_slice(&0_u32.to_le_bytes());
14473 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
14474 bytes.extend_from_slice(&1_u32.to_le_bytes());
14475 bytes.extend_from_slice(b"PK\x05\x06");
14476 bytes.extend_from_slice(&0_u16.to_le_bytes());
14477 bytes.extend_from_slice(&0_u16.to_le_bytes());
14478 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14479 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14480 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14481 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14482 bytes.extend_from_slice(&0_u16.to_le_bytes());
14483
14484 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
14485 .unwrap_err()
14486 .to_string();
14487 assert!(error.contains("invalid file count"), "{error}");
14488 }
14489
14490 #[test]
14491 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
14492 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
14493 let mut bytes = vec![0_u8];
14494 let zip64_offset = bytes.len() as u64;
14495 bytes.extend_from_slice(b"PK\x06\x06");
14496 bytes.extend_from_slice(&44_u64.to_le_bytes());
14497 bytes.extend_from_slice(&[0_u8; 12]);
14498 bytes.extend_from_slice(&COUNT.to_le_bytes());
14499 bytes.extend_from_slice(&COUNT.to_le_bytes());
14500 bytes.extend_from_slice(&1_u64.to_le_bytes());
14501 bytes.extend_from_slice(&0_u64.to_le_bytes());
14502 bytes.extend_from_slice(b"PK\x06\x07");
14503 bytes.extend_from_slice(&0_u32.to_le_bytes());
14504 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
14505 bytes.extend_from_slice(&1_u32.to_le_bytes());
14506 bytes.extend_from_slice(b"PK\x05\x06");
14507 bytes.extend_from_slice(&0_u16.to_le_bytes());
14508 bytes.extend_from_slice(&0_u16.to_le_bytes());
14509 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14510 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
14511 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14512 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
14513 bytes.extend_from_slice(&0_u16.to_le_bytes());
14514 let fake_eocd = bytes.len() as u32;
14518 bytes.extend_from_slice(b"PK\x05\x06");
14519 bytes.extend_from_slice(&0_u16.to_le_bytes());
14520 bytes.extend_from_slice(&0_u16.to_le_bytes());
14521 bytes.extend_from_slice(&1_u16.to_le_bytes());
14522 bytes.extend_from_slice(&1_u16.to_le_bytes());
14523 bytes.extend_from_slice(&0_u32.to_le_bytes());
14524 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
14525 bytes.extend_from_slice(&0_u16.to_le_bytes());
14526
14527 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
14528 .unwrap_err()
14529 .to_string();
14530 assert!(error.contains("central directory"), "{error}");
14531 }
14532
14533 #[test]
14534 fn strict_http_status_handling_rejects_redirects_without_panicking() {
14535 let error = ensure_ok(
14536 HubResponse {
14537 status: 302,
14538 body: Some(json!({"redirect": "/elsewhere"})),
14539 },
14540 "mutation",
14541 )
14542 .unwrap_err();
14543 assert!(matches!(error, LinkError::Http { status: 302, .. }));
14544
14545 let error = ensure_raw_ok(
14546 RawHubResponse {
14547 status: 302,
14548 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
14549 },
14550 "feed",
14551 )
14552 .unwrap_err();
14553 assert!(matches!(error, LinkError::Http { status: 302, .. }));
14554 }
14555
14556 #[cfg(unix)]
14557 #[test]
14558 fn collect_push_files_refuses_external_symlink_and_nested_store() {
14559 use std::os::unix::fs::symlink;
14560
14561 let root = tempfile::tempdir().unwrap();
14562 std::fs::write(
14563 root.path().join("DB.md"),
14564 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
14565 )
14566 .unwrap();
14567 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
14568
14569 let external = tempfile::tempdir().unwrap();
14570 let secret = external.path().join("secret.md");
14571 std::fs::write(&secret, "TOP SECRET").unwrap();
14572 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
14573
14574 let store = Store::open_strict(root.path()).unwrap();
14575 let err = collect_push_files(&store).unwrap_err().to_string();
14576 assert!(err.contains("cannot push"), "{err}");
14577 assert!(
14578 !err.contains("TOP SECRET"),
14579 "external bytes must never leak"
14580 );
14581
14582 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
14583 let nested = root.path().join("records/nested");
14584 std::fs::create_dir_all(&nested).unwrap();
14585 std::fs::write(
14586 nested.join("DB.md"),
14587 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
14588 )
14589 .unwrap();
14590 let err = collect_push_files(&store).unwrap_err().to_string();
14591 assert!(err.contains("nested db.md store"), "{err}");
14592 }
14593
14594 #[cfg(unix)]
14595 #[test]
14596 fn remote_push_uses_opened_root_after_path_replacement() {
14597 use std::os::unix::fs::symlink;
14598
14599 let sandbox = tempfile::tempdir().unwrap();
14600 let root = sandbox.path().join("store");
14601 std::fs::create_dir_all(root.join("records/notes")).unwrap();
14602 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
14603 std::fs::write(
14604 root.join("records/notes/owned.md"),
14605 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
14606 )
14607 .unwrap();
14608 let store = Store::open_strict(&root).unwrap();
14609 let detached = sandbox.path().join("detached");
14610 std::fs::rename(&root, &detached).unwrap();
14611
14612 let replacement = sandbox.path().join("replacement");
14613 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
14614 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
14615 std::fs::write(
14616 replacement.join("records/notes/secret.md"),
14617 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
14618 )
14619 .unwrap();
14620 symlink(&replacement, &root).unwrap();
14621
14622 let files = collect_push_files(&store).unwrap();
14623 let wire_text = files
14624 .iter()
14625 .map(|(path, content)| format!("{path}\n{content}"))
14626 .collect::<Vec<_>>()
14627 .join("\n");
14628 assert!(wire_text.contains("owned upload"));
14629 assert!(!wire_text.contains("replacement sentinel"));
14630 assert!(!wire_text.contains("records/notes/secret.md"));
14631
14632 let remote = signed_remote_fixture();
14633 let (hub, server) = scripted_json_hub(vec![
14634 (200, remote.card),
14635 (200, remote.feed),
14636 (200, json!({"ok": true}).to_string()),
14637 ]);
14638 let state = tempfile::tempdir().unwrap();
14639 let cfg = test_hub_config(hub, state.path().to_path_buf());
14640 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
14641 assert_eq!(pushed, json!({"ok": true}));
14642 server.join().unwrap();
14643 }
14644
14645 #[test]
14646 fn signed_feed_item_verifies_identity_hash_and_signature() {
14647 use ring::rand::SystemRandom;
14648 use ring::signature::{Ed25519KeyPair, KeyPair};
14649
14650 const PREFIX: &[u8] = &[
14651 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
14652 ];
14653 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
14654 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14655 let mut spki = PREFIX.to_vec();
14656 spki.extend_from_slice(pair.public_key().as_ref());
14657 let public_key = URL_SAFE_NO_PAD.encode(&spki);
14658 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
14659 let mut entry = FeedEntry {
14660 v: 1,
14661 seq: 1,
14662 ts: "2026-07-14T00:00:00.000Z".to_string(),
14663 brain: format!("ed25519:{fingerprint}"),
14664 public_key: public_key.clone(),
14665 kind: "push".to_string(),
14666 op: "snapshot".to_string(),
14667 pack_sha256: "a".repeat(64),
14668 files: vec![FeedFile {
14669 path: "DB.md".to_string(),
14670 sha256: "b".repeat(64),
14671 bytes: 3,
14672 }],
14673 removed: vec![],
14674 prev_entry_hash: None,
14675 sig: String::new(),
14676 };
14677 let unsigned = UnsignedFeedEntry {
14678 v: entry.v,
14679 seq: entry.seq,
14680 ts: &entry.ts,
14681 brain: &entry.brain,
14682 public_key: &entry.public_key,
14683 kind: &entry.kind,
14684 op: &entry.op,
14685 pack_sha256: &entry.pack_sha256,
14686 files: &entry.files,
14687 removed: &entry.removed,
14688 prev_entry_hash: &entry.prev_entry_hash,
14689 };
14690 entry.sig =
14691 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
14692 let mut exact = serde_json::to_vec(&entry).unwrap();
14693 exact.push(b'\n');
14694 let item = FeedItem {
14695 hash: format!("{:x}", Sha256::digest(&exact)),
14696 entry,
14697 };
14698 let identity = FeedIdentity {
14699 fingerprint,
14700 public_key_spki: public_key,
14701 previous: Vec::new(),
14702 rotations: Vec::new(),
14703 };
14704 assert!(verify_feed_item(&item, &identity).is_ok());
14705 let mut tampered = item;
14706 tampered.entry.pack_sha256 = "c".repeat(64);
14707 assert!(verify_feed_item(&tampered, &identity).is_err());
14708 }
14709
14710 #[test]
14711 fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
14712 let rng = ring::rand::SystemRandom::new();
14713 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14714 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14715 let (spki, multikey) = public_identity_for(&pair);
14716 let identity = V2HeadIdentity {
14717 custody: "self".to_string(),
14718 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
14719 public_key_spki: spki.clone(),
14720 previous: Vec::new(),
14721 rotations: Vec::new(),
14722 };
14723 let unsigned = json!({
14724 "actor_ref": "a".repeat(64),
14725 "asset_root": Value::Null,
14726 "brain": multikey,
14727 "changes_sha256": "b".repeat(64),
14728 "control_revision": "c".repeat(64),
14729 "materializer": "dbmd-projection-v1",
14730 "op": "changeset",
14731 "parent_asset_root": Value::Null,
14732 "parent_commit": Value::Null,
14733 "parent_root": Value::Null,
14734 "prev_entry_hash": Value::Null,
14735 "public_key": spki,
14736 "seq": 1,
14737 "signer_epoch": 1,
14738 "state_root": "d".repeat(64),
14739 "ts": "2026-08-19T12:00:00.000Z",
14740 "v": 2,
14741 "v1_bridge": {
14742 "feed_hash": "e".repeat(64),
14743 "head_seq": 7,
14744 "pack_sha256": "f".repeat(64),
14745 },
14746 });
14747 let sign_value = |value: Value| {
14748 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
14749 let mut object = value.as_object().unwrap().clone();
14750 object.insert(
14751 "sig".to_string(),
14752 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14753 );
14754 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
14755 };
14756 assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
14757
14758 let mut extra = unsigned.clone();
14759 extra
14760 .as_object_mut()
14761 .unwrap()
14762 .insert("future".to_string(), Value::Bool(true));
14763 assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
14764
14765 let mut missing = unsigned.clone();
14766 missing.as_object_mut().unwrap().remove("v1_bridge");
14767 assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
14768
14769 let mut invalid_bridge = unsigned;
14770 invalid_bridge.as_object_mut().unwrap().insert(
14771 "v1_bridge".to_string(),
14772 json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
14773 );
14774 assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
14775 }
14776
14777 #[test]
14778 fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
14779 let vector: Value = serde_json::from_str(include_str!(
14780 "../tests/vectors/linkmd-v2-commit-bridge.json"
14781 ))
14782 .unwrap();
14783 let identity_value = vector.get("identity").unwrap();
14784 let identity = V2HeadIdentity {
14785 custody: "self".to_string(),
14786 fingerprint: identity_value
14787 .get("fingerprint")
14788 .and_then(Value::as_str)
14789 .unwrap()
14790 .to_string(),
14791 public_key_spki: identity_value
14792 .get("public_key_spki")
14793 .and_then(Value::as_str)
14794 .unwrap()
14795 .to_string(),
14796 previous: Vec::new(),
14797 rotations: Vec::new(),
14798 };
14799 let private = URL_SAFE_NO_PAD
14800 .decode(
14801 identity_value
14802 .get("private_key_pkcs8")
14803 .and_then(Value::as_str)
14804 .unwrap(),
14805 )
14806 .unwrap();
14807 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
14808 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
14809 .unwrap();
14810 let base = vector.get("body").unwrap().as_object().unwrap();
14811
14812 for item in vector.get("valid").unwrap().as_array().unwrap() {
14813 let mut body = base.clone();
14814 body.insert(
14815 "v1_bridge".to_string(),
14816 item.get("v1_bridge").unwrap().clone(),
14817 );
14818 body.insert(
14819 "sig".to_string(),
14820 item.get("signature_base64url").unwrap().clone(),
14821 );
14822 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
14823 assert!(verified_v2_commit_object(&signed, &identity).is_ok());
14824 assert_eq!(
14825 crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
14826 item.get("commit_hash").and_then(Value::as_str).unwrap()
14827 );
14828 assert_eq!(
14829 format!("{:x}", Sha256::digest(&signed)),
14830 item.get("feed_hash").and_then(Value::as_str).unwrap()
14831 );
14832 }
14833
14834 for item in vector.get("invalid").unwrap().as_array().unwrap() {
14835 let mut body = base.clone();
14836 if let Some(remove) = item.get("remove").and_then(Value::as_array) {
14837 for field in remove {
14838 body.remove(field.as_str().unwrap());
14839 }
14840 }
14841 if let Some(set) = item.get("set").and_then(Value::as_object) {
14842 for (field, value) in set {
14843 body.insert(field.clone(), value.clone());
14844 }
14845 }
14846 let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
14847 body.insert(
14848 "sig".to_string(),
14849 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14850 );
14851 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
14852 assert!(
14853 verified_v2_commit_object(&signed, &identity).is_err(),
14854 "accepted invalid shared vector {}",
14855 item.get("reason").and_then(Value::as_str).unwrap()
14856 );
14857 }
14858 }
14859
14860 #[test]
14861 fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
14862 let vector: Value = serde_json::from_str(include_str!(
14863 "../tests/vectors/linkmd-v2-changeset-withheld.json"
14864 ))
14865 .unwrap();
14866 assert_eq!(
14867 vector.get("profile").and_then(Value::as_str),
14868 Some("link.md-v2-changeset-withheld")
14869 );
14870 let canonical =
14871 crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
14872 let expected = STANDARD
14873 .decode(
14874 vector
14875 .get("canonical_base64")
14876 .and_then(Value::as_str)
14877 .unwrap(),
14878 )
14879 .unwrap();
14880 assert_eq!(canonical, expected);
14881 assert_eq!(
14882 crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
14883 vector.get("domain_hash").and_then(Value::as_str).unwrap()
14884 );
14885 }
14886
14887 #[test]
14888 fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
14889 let remote = signed_remote_fixture();
14890 let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
14891 let legacy_item = legacy.entries.first().unwrap();
14892 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
14893 let body = json!({
14894 "actor_ref": "a".repeat(64),
14895 "asset_root": Value::Null,
14896 "brain": remote.key.multikey,
14897 "changes_sha256": "b".repeat(64),
14898 "control_revision": "c".repeat(64),
14899 "materializer": "dbmd-projection-v1",
14900 "op": "changeset",
14901 "parent_asset_root": Value::Null,
14902 "parent_commit": Value::Null,
14903 "parent_root": Value::Null,
14904 "prev_entry_hash": Value::Null,
14905 "public_key": remote.key.public_key_spki,
14906 "seq": 1,
14907 "signer_epoch": 1,
14908 "state_root": "d".repeat(64),
14909 "ts": "2026-08-19T12:00:00.000Z",
14910 "v": 2,
14911 "v1_bridge": {
14912 "feed_hash": legacy_item.hash,
14913 "head_seq": legacy_item.entry.seq,
14914 "pack_sha256": legacy_item.entry.pack_sha256,
14915 },
14916 });
14917 let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
14918 let mut signed = body.as_object().unwrap().clone();
14919 signed.insert(
14920 "sig".to_string(),
14921 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14922 );
14923 let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
14924 let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
14925 let feed_hash = content_sha256(&raw);
14926 let pointer = V2PointerBody {
14927 v: 2,
14928 brain: TEST_BRAIN_ID.to_string(),
14929 seq: 1,
14930 commit_hash: commit_hash.clone(),
14931 feed_hash: feed_hash.clone(),
14932 content_root: Some("d".repeat(64)),
14933 asset_root: None,
14934 materializer: "dbmd-projection-v1".to_string(),
14935 signer_epoch: 1,
14936 control_revision: "c".repeat(64),
14937 backup_preparation: "e".repeat(64),
14938 prior_pointer_hash: None,
14939 signed_at: "2026-08-19T12:00:00.000Z".to_string(),
14940 };
14941 let v2_page = json!({
14942 "v": 2,
14943 "head_seq": 1,
14944 "head_commit_hash": commit_hash,
14945 "head_feed_hash": feed_hash,
14946 "entries": [{
14947 "seq": 1,
14948 "commit_hash": pointer.commit_hash,
14949 "feed_hash": pointer.feed_hash,
14950 "bytes_base64": STANDARD.encode(&raw),
14951 }],
14952 "next_after": 1,
14953 "complete": true,
14954 })
14955 .to_string();
14956 let identity = V2HeadIdentity {
14957 custody: "self".to_string(),
14958 fingerprint: remote.identity.fingerprint.clone(),
14959 public_key_spki: remote.identity.public_key_spki.clone(),
14960 previous: Vec::new(),
14961 rotations: Vec::new(),
14962 };
14963 let checkpoint = TrustState {
14964 v: 2,
14965 origin: "unused".to_string(),
14966 requested: TEST_BRAIN_ID.to_string(),
14967 brain: TEST_BRAIN_ID.to_string(),
14968 home: None,
14969 anchor: remote.key.multikey.clone(),
14970 current: remote.key.multikey,
14971 head_seq: legacy_item.entry.seq,
14972 feed_hash: Some(legacy_item.hash.clone()),
14973 rotations: Vec::new(),
14974 hub_signer: None,
14975 protocol_profile: None,
14976 };
14977 let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
14978 let state = tempfile::tempdir().unwrap();
14979 let cfg = test_hub_config(hub, state.path().to_path_buf());
14980 verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
14981 server.join().unwrap();
14982
14983 let mut wrong = checkpoint;
14984 wrong.feed_hash = Some("0".repeat(64));
14985 let (hub, server) = scripted_json_hub(vec![(
14986 200,
14987 json!({
14988 "v": 2,
14989 "head_seq": 1,
14990 "head_commit_hash": pointer.commit_hash,
14991 "head_feed_hash": pointer.feed_hash,
14992 "entries": [{
14993 "seq": 1,
14994 "commit_hash": pointer.commit_hash,
14995 "feed_hash": pointer.feed_hash,
14996 "bytes_base64": STANDARD.encode(&raw),
14997 }],
14998 "next_after": 1,
14999 "complete": true,
15000 })
15001 .to_string(),
15002 )]);
15003 let state = tempfile::tempdir().unwrap();
15004 let cfg = test_hub_config(hub, state.path().to_path_buf());
15005 assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
15006 server.join().unwrap();
15007 }
15008
15009 #[test]
15010 fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
15011 let rng = ring::rand::SystemRandom::new();
15012 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15013 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
15014 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15015 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
15016 let (old_spki, old_multikey) = public_identity_for(&old);
15017 let (new_spki, new_multikey) = public_identity_for(&new);
15018 let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
15019 v: 1,
15020 op: "rotate",
15021 brain: &old_multikey,
15022 public_key: &old_spki,
15023 new_brain: &new_multikey,
15024 new_public_key: &new_spki,
15025 prior_head_seq: 1,
15026 prior_feed_hash: Some(&"9".repeat(64)),
15027 ts: "2026-08-19T12:01:00.000Z".to_string(),
15028 })
15029 .unwrap();
15030 let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
15031 let rotation = format!(
15032 "{},\"sig\":\"{}\"}}",
15033 &rotation_unsigned[..rotation_unsigned.len() - 1],
15034 rotation_sig
15035 );
15036 let identity = V2HeadIdentity {
15037 custody: "self".to_string(),
15038 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
15039 public_key_spki: new_spki.clone(),
15040 previous: vec![V2PreviousIdentity {
15041 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
15042 public_key_spki: old_spki.clone(),
15043 }],
15044 rotations: vec![rotation],
15045 };
15046 let commit = |seq: u64,
15047 epoch: u64,
15048 multikey: &str,
15049 spki: &str,
15050 pair: &ring::signature::Ed25519KeyPair| {
15051 let value = json!({
15052 "actor_ref": "a".repeat(64),
15053 "asset_root": Value::Null,
15054 "brain": multikey,
15055 "changes_sha256": "b".repeat(64),
15056 "control_revision": "c".repeat(64),
15057 "materializer": "dbmd-projection-v1",
15058 "op": "changeset",
15059 "parent_asset_root": Value::Null,
15060 "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
15061 "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
15062 "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
15063 "public_key": spki,
15064 "seq": seq,
15065 "signer_epoch": epoch,
15066 "state_root": "1".repeat(64),
15067 "ts": "2026-08-19T12:00:00.000Z",
15068 "v": 2,
15069 "v1_bridge": Value::Null,
15070 });
15071 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
15072 let mut object = value.as_object().unwrap().clone();
15073 object.insert(
15074 "sig".to_string(),
15075 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
15076 );
15077 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
15078 };
15079
15080 assert!(verified_v2_commit_object(
15081 &commit(1, 1, &old_multikey, &old_spki, &old),
15082 &identity,
15083 )
15084 .is_ok());
15085 assert!(verified_v2_commit_object(
15086 &commit(2, 2, &new_multikey, &new_spki, &new),
15087 &identity,
15088 )
15089 .is_ok());
15090 assert!(verified_v2_commit_object(
15091 &commit(2, 1, &old_multikey, &old_spki, &old),
15092 &identity,
15093 )
15094 .is_err());
15095 assert!(verified_v2_commit_object(
15096 &commit(1, 2, &new_multikey, &new_spki, &new),
15097 &identity,
15098 )
15099 .is_err());
15100 }
15101
15102 #[test]
15103 fn a_self_custody_entry_verifies_like_any_hub_entry() {
15104 let rng = ring::rand::SystemRandom::new();
15105 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15106 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15107 let (spki, multikey) = public_identity_for(&pair);
15108 let key = AgentSigningKey {
15109 pkcs8: pkcs8.as_ref().to_vec(),
15110 multikey: multikey.clone(),
15111 public_key_spki: spki.clone(),
15112 };
15113 let files = vec![WireFeedFile {
15114 path: "DB.md".to_string(),
15115 sha256: "a".repeat(64),
15116 bytes: 3,
15117 }];
15118 let raw = self_custody_entry(
15119 &key,
15120 1,
15121 "2026-07-23T12:00:00.000Z".to_string(),
15122 &"c".repeat(64),
15123 &files,
15124 None,
15125 )
15126 .unwrap();
15127 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
15131 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
15132 let item = FeedItem { hash, entry };
15133 let identity = FeedIdentity {
15134 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15135 public_key_spki: spki,
15136 previous: Vec::new(),
15137 rotations: Vec::new(),
15138 };
15139 assert!(verify_feed_item(&item, &identity).is_ok());
15140 }
15141
15142 #[test]
15143 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
15144 let rng = ring::rand::SystemRandom::new();
15145 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15146 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
15147 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15148 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
15149 let (old_spki, old_multikey) = public_identity_for(&old);
15150 let (new_spki, new_multikey) = public_identity_for(&new);
15151 let unsigned = serde_json::to_string(&UnsignedRotation {
15152 v: 1,
15153 op: "rotate",
15154 brain: &old_multikey,
15155 public_key: &old_spki,
15156 new_brain: &new_multikey,
15157 new_public_key: &new_spki,
15158 prior_head_seq: 1,
15159 prior_feed_hash: Some(&"a".repeat(64)),
15160 ts: "2026-07-30T12:00:00.000Z".to_string(),
15161 })
15162 .unwrap();
15163 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
15164 let rotation = format!(
15165 "{},\"sig\":\"{}\"}}",
15166 &unsigned[..unsigned.len() - 1],
15167 signature
15168 );
15169 let identity = FeedIdentity {
15170 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
15171 public_key_spki: new_spki,
15172 previous: vec![PreviousIdentity {
15173 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
15174 public_key_spki: old_spki,
15175 }],
15176 rotations: vec![rotation],
15177 };
15178 let pin = TrustState {
15179 v: 2,
15180 origin: "https://hub.example".to_string(),
15181 requested: "brain".to_string(),
15182 brain: "brain".to_string(),
15183 home: None,
15184 anchor: old_multikey.clone(),
15185 current: old_multikey.clone(),
15186 head_seq: 1,
15187 feed_hash: Some("a".repeat(64)),
15188 rotations: Vec::new(),
15189 hub_signer: None,
15190 protocol_profile: None,
15191 };
15192 assert_eq!(
15193 verify_identity_chain(&identity, Some(&pin)).unwrap(),
15194 old_multikey
15195 );
15196 let mut accepted = pin.clone();
15197 accepted.current = new_multikey.clone();
15198 accepted.rotations = identity.rotations.clone();
15199 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
15200 v: 1,
15201 op: "rotate",
15202 brain: &old_multikey,
15203 public_key: &identity.previous[0].public_key_spki,
15204 new_brain: &new_multikey,
15205 new_public_key: &identity.public_key_spki,
15206 prior_head_seq: 1,
15207 prior_feed_hash: Some(&"a".repeat(64)),
15208 ts: "2026-07-30T12:00:01.000Z".to_string(),
15209 })
15210 .unwrap();
15211 let alternate_signature =
15212 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
15213 let mut rewritten = identity.clone();
15214 rewritten.rotations[0] = format!(
15215 "{},\"sig\":\"{}\"}}",
15216 &alternate_unsigned[..alternate_unsigned.len() - 1],
15217 alternate_signature
15218 );
15219 assert!(
15220 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
15221 "an alternate valid statement must not rewrite accepted history"
15222 );
15223
15224 let mut stale_entry = FeedEntry {
15225 v: 1,
15226 seq: 2,
15227 ts: "2026-07-30T12:01:00.000Z".to_string(),
15228 brain: pin.current.clone(),
15229 public_key: identity.previous[0].public_key_spki.clone(),
15230 kind: "push".to_string(),
15231 op: "snapshot".to_string(),
15232 pack_sha256: "b".repeat(64),
15233 files: Vec::new(),
15234 removed: Vec::new(),
15235 prev_entry_hash: pin.feed_hash.clone(),
15236 sig: String::new(),
15237 };
15238 let stale_unsigned = UnsignedFeedEntry {
15239 v: stale_entry.v,
15240 seq: stale_entry.seq,
15241 ts: &stale_entry.ts,
15242 brain: &stale_entry.brain,
15243 public_key: &stale_entry.public_key,
15244 kind: &stale_entry.kind,
15245 op: &stale_entry.op,
15246 pack_sha256: &stale_entry.pack_sha256,
15247 files: &stale_entry.files,
15248 removed: &stale_entry.removed,
15249 prev_entry_hash: &stale_entry.prev_entry_hash,
15250 };
15251 stale_entry.sig = URL_SAFE_NO_PAD.encode(
15252 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
15253 .as_ref(),
15254 );
15255 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
15256 stale_exact.push(b'\n');
15257 let stale_item = FeedItem {
15258 hash: content_sha256(&stale_exact),
15259 entry: stale_entry,
15260 };
15261 assert!(
15262 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
15263 .is_err(),
15264 "a key retired before the checkpoint must never append after it"
15265 );
15266 assert!(
15267 verify_feed_item(&stale_item, &identity).is_err(),
15268 "an old key must never append after its signed rotation boundary"
15269 );
15270
15271 let mut missing = identity.clone();
15272 missing.rotations.clear();
15273 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
15274
15275 let mut tampered = identity;
15276 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
15277 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
15278 }
15279
15280 #[cfg(unix)]
15281 #[test]
15282 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
15283 use std::os::unix::fs::symlink;
15284
15285 let dir = tempfile::tempdir().unwrap();
15286 let target = dir.path().join("valuable.txt");
15287 let planted = dir.path().join("agent.key");
15288 std::fs::write(&target, "do not overwrite").unwrap();
15289 symlink(&target, &planted).unwrap();
15290
15291 assert!(matches!(
15292 generate_agent_key(&planted),
15293 Err(LinkError::BadAgentKey { .. })
15294 ));
15295 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
15296 }
15297
15298 #[cfg(unix)]
15299 #[test]
15300 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
15301 use std::os::unix::fs::symlink;
15302
15303 let root = tempfile::tempdir().unwrap();
15304 let outside = tempfile::tempdir().unwrap();
15305 symlink(outside.path(), root.path().join("redirect")).unwrap();
15306
15307 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
15308 assert!(!outside.path().join("agent.key").exists());
15309 }
15310
15311 #[test]
15314 fn address_bare_brain_with_and_without_sigil() {
15315 for raw in ["@acme-ops", "acme-ops"] {
15316 let a = Address::parse(raw).expect(raw);
15317 assert_eq!(a.brain, "acme-ops");
15318 assert_eq!(a.target, None);
15319 }
15320 }
15321
15322 #[test]
15323 fn address_ulid_target_parses_as_id() {
15324 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
15325 assert_eq!(a.brain, "acme");
15326 assert_eq!(
15327 a.target,
15328 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
15329 );
15330 }
15331
15332 #[test]
15333 fn address_md_path_target_parses_as_path() {
15334 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
15335 assert_eq!(
15336 a.target,
15337 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
15338 );
15339 }
15340
15341 #[test]
15342 fn address_rejects_malformed_forms() {
15343 for raw in [
15344 "",
15345 "@",
15346 "@/x",
15347 "@acme/",
15348 "@acme/../etc/passwd",
15349 "@acme/records/.hidden.md",
15350 "@ACME", "@acme/notes/x.txt", "@a b", ] {
15354 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
15355 }
15356 }
15357
15358 #[test]
15361 fn safe_paths_accept_store_shapes_and_reject_escapes() {
15362 for ok in [
15363 "DB.md",
15364 "assets.jsonl",
15365 "records/clients/lumio.md",
15366 "sources/emails/2026/07/x.md",
15367 ] {
15368 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
15369 }
15370 for bad in [
15371 "",
15372 "/etc/passwd",
15373 "../up.md",
15374 "records/../../up.md",
15375 "records//x.md",
15376 ".dbmd/config",
15377 "records/.hidden/x.md",
15378 "records/a b.md",
15379 "records\\win.md",
15380 ] {
15381 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
15382 }
15383 }
15384
15385 #[cfg(unix)]
15386 #[test]
15387 fn opened_destination_capability_survives_an_ancestor_path_swap() {
15388 use std::os::unix::fs::symlink;
15389
15390 let work = tempfile::tempdir().unwrap();
15391 let outside = tempfile::tempdir().unwrap();
15392 let original = work.path().join("destination");
15393 let moved = work.path().join("destination-moved");
15394 let directory = open_or_create_dir_nofollow(&original).unwrap();
15395
15396 std::fs::rename(&original, &moved).unwrap();
15397 symlink(outside.path(), &original).unwrap();
15398 write_pull_entries_beneath_dir(
15399 &directory,
15400 &[("records/note.md".to_string(), b"held inode".to_vec())],
15401 )
15402 .unwrap();
15403
15404 assert_eq!(
15405 std::fs::read(moved.join("records/note.md")).unwrap(),
15406 b"held inode"
15407 );
15408 assert!(!outside.path().join("records/note.md").exists());
15409 }
15410
15411 #[test]
15415 fn hub_config_flag_beats_file_and_requires_some_source() {
15416 let dir = tempfile::tempdir().unwrap();
15417 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
15418 std::fs::write(
15419 dir.path().join(CONFIG_REL_PATH),
15420 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
15421 )
15422 .unwrap();
15423
15424 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
15425 assert_eq!(from_flag.hub, "https://flag.example.com");
15426
15427 let from_file = hub_config(None, dir.path()).unwrap();
15428 assert_eq!(from_file.hub, "https://file.example.com");
15429
15430 let none = hub_config(None, tempfile::tempdir().unwrap().path());
15431 assert!(matches!(none, Err(LinkError::NoHub)));
15432 }
15433
15434 #[test]
15435 fn https_guard_allows_loopback_only_for_plain_http() {
15436 assert!(assert_safe_hub("https://hub.example.com").is_ok());
15437 assert!(assert_safe_hub("http://localhost:3000").is_ok());
15438 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
15439 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
15440 assert!(matches!(
15441 assert_safe_hub("http://hub.example.com"),
15442 Err(LinkError::UnsafeHub { .. })
15443 ));
15444 assert!(matches!(
15445 assert_safe_hub("hub.example.com"),
15446 Err(LinkError::UnsafeHub { .. })
15447 ));
15448 assert!(matches!(
15449 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
15450 Err(LinkError::UnsafeHub { .. })
15451 ));
15452 assert!(matches!(
15453 assert_safe_hub("https://hub.example.com@attacker.example"),
15454 Err(LinkError::UnsafeHub { .. })
15455 ));
15456 assert!(matches!(
15457 assert_safe_hub("https://hub.example.com/base"),
15458 Err(LinkError::UnsafeHub { .. })
15459 ));
15460 }
15461
15462 #[test]
15463 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
15464 for blocked in [
15465 "127.0.0.1",
15466 "10.0.0.1",
15467 "100.64.0.1",
15468 "169.254.169.254",
15469 "172.16.0.1",
15470 "192.168.0.1",
15471 "192.88.99.1",
15472 "198.18.0.1",
15473 "203.0.113.1",
15474 "::1",
15475 "fe80::1",
15476 "fd00::1",
15477 "2001:db8::1",
15478 "2001:1::1",
15479 "2002:7f00:1::",
15480 "3fff::1",
15481 ] {
15482 assert!(
15483 !is_public_registry_ip(blocked.parse().unwrap()),
15484 "must block {blocked}"
15485 );
15486 }
15487 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
15488 assert!(is_public_registry_ip(
15489 "2606:4700:4700::1111".parse().unwrap()
15490 ));
15491 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
15492 }
15493
15494 #[test]
15495 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
15496 use ureq::Resolver as _;
15497
15498 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
15499 let resolver = PinnedRegistryResolver {
15500 netloc: "home.example:443".to_string(),
15501 addresses: vec![pinned],
15502 };
15503 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
15504 assert!(resolver.resolve("127.0.0.1:443").is_err());
15505 assert_eq!(
15506 resolver.resolve("home.example:443").unwrap(),
15507 vec![pinned],
15508 "subsequent connects reuse the validated answer instead of DNS"
15509 );
15510 }
15511
15512 #[test]
15513 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
15514 let cfg = HubConfig {
15515 hub: "https://hub.example".to_string(),
15516 key: None,
15517 agent_key: None,
15518 brain_key: None,
15519 state_dir: tempfile::tempdir().unwrap().keep(),
15520 store_selected: false,
15521 };
15522 assert!(
15523 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
15524 "a production hub must not turn its presigned URL into an SSRF primitive"
15525 );
15526
15527 let store_selected = HubConfig {
15528 hub: "https://127.0.0.1".to_string(),
15529 store_selected: true,
15530 ..cfg
15531 };
15532 assert!(
15533 hub_agent(&store_selected).is_err(),
15534 "bytes in a cloned store must not select a private-network hub"
15535 );
15536 }
15537
15538 #[test]
15539 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
15540 assert_eq!(
15541 one_past_bounded_limit(MAX_PACK_BYTES),
15542 Some(MAX_PACK_BYTES + 1),
15543 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
15544 );
15545 assert_eq!(
15546 presigned_download_read_limit(),
15547 MAX_PACK_BYTES + 1,
15548 "the presigned reader is capped by the client constant, not a hub response"
15549 );
15550 assert_eq!(
15551 one_past_bounded_limit(u64::MAX),
15552 None,
15553 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
15554 );
15555 }
15556
15557 #[test]
15558 fn https_guard_matches_the_scheme_case_insensitively() {
15559 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
15562 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
15563 assert!(matches!(
15565 assert_safe_hub("HTTP://hub.example.com"),
15566 Err(LinkError::UnsafeHub { .. })
15567 ));
15568 }
15569
15570 #[test]
15571 fn clean_key_refuses_paste_artifacts_without_echoing() {
15572 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
15573 for bad in ["vc account", "vc\naccount", "ключ", ""] {
15574 let err = clean_key(bad).unwrap_err();
15575 assert!(matches!(err, LinkError::BadKey));
15576 assert!(
15577 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
15578 "error must not echo the key"
15579 );
15580 }
15581 }
15582
15583 fn dead_hub() -> HubConfig {
15589 HubConfig {
15590 hub: "http://127.0.0.1:9".to_string(),
15591 key: Some("k".to_string()),
15592 agent_key: None,
15593 brain_key: None,
15594 state_dir: PathBuf::from("."),
15595 store_selected: false,
15596 }
15597 }
15598
15599 #[test]
15600 fn request_retries_a_connection_failure_before_sending() {
15601 use std::io::{Read as _, Write as _};
15602 use std::net::TcpListener;
15603 use std::thread;
15604 use std::time::Duration;
15605
15606 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
15607 let address = probe.local_addr().unwrap();
15608 drop(probe);
15609 let server = thread::spawn(move || {
15610 thread::sleep(Duration::from_millis(40));
15611 let listener = TcpListener::bind(address).unwrap();
15612 let (mut stream, _) = listener.accept().unwrap();
15613 let mut request_bytes = [0_u8; 1024];
15614 let _ = stream.read(&mut request_bytes).unwrap();
15615 stream
15616 .write_all(
15617 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
15618 )
15619 .unwrap();
15620 });
15621 let cfg = HubConfig {
15622 hub: format!("http://{address}"),
15623 key: None,
15624 agent_key: None,
15625 brain_key: None,
15626 state_dir: tempfile::tempdir().unwrap().keep(),
15627 store_selected: false,
15628 };
15629
15630 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
15631 assert_eq!(response.status, 200);
15632 assert_eq!(response.body, Some(json!({ "ok": true })));
15633 server.join().unwrap();
15634 }
15635
15636 #[test]
15637 fn endpoint_cap_refuses_a_body_before_json_parsing() {
15638 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
15639 let cfg = HubConfig {
15640 hub,
15641 key: None,
15642 agent_key: None,
15643 brain_key: None,
15644 state_dir: tempfile::tempdir().unwrap().keep(),
15645 store_selected: false,
15646 };
15647
15648 assert!(matches!(
15649 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
15650 Err(LinkError::ResponseTooLarge { .. })
15651 ));
15652 server.join().unwrap();
15653 }
15654
15655 #[test]
15656 fn overall_deadline_stops_a_dribbled_response_body() {
15657 use std::io::{Read as _, Write as _};
15658 use std::net::TcpListener;
15659 use std::time::{Duration, Instant};
15660
15661 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15662 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
15663 let server = std::thread::spawn(move || {
15664 let (mut stream, _) = listener.accept().unwrap();
15665 let mut request = [0_u8; 1024];
15666 let _ = stream.read(&mut request);
15667 stream
15668 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
15669 .unwrap();
15670 for byte in [b'x'; 32] {
15671 if stream.write_all(&[byte]).is_err() {
15672 break;
15673 }
15674 std::thread::sleep(Duration::from_millis(40));
15675 }
15676 });
15677 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
15678 let started = Instant::now();
15679 let response = http.get(&url).call().unwrap();
15680 let mut body = Vec::new();
15681 let error = response
15682 .into_reader()
15683 .read_to_end(&mut body)
15684 .expect_err("per-read progress must not reset the overall deadline");
15685 assert!(
15686 started.elapsed() < Duration::from_millis(700),
15687 "dribbled body exceeded the wall-clock budget: {error}"
15688 );
15689 server.join().unwrap();
15690 }
15691
15692 #[test]
15693 fn overall_deadline_stops_a_stalled_upload() {
15694 use std::net::TcpListener;
15695 use std::time::{Duration, Instant};
15696
15697 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15698 let url = format!("http://{}/upload", listener.local_addr().unwrap());
15699 let server = std::thread::spawn(move || {
15700 let (_stream, _) = listener.accept().unwrap();
15701 std::thread::sleep(Duration::from_millis(600));
15704 });
15705 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
15706 let body = vec![0x5a; 32 * 1024 * 1024];
15707 let started = Instant::now();
15708 let error = http
15709 .put(&url)
15710 .send_bytes(&body)
15711 .expect_err("stalled request-body writes must time out");
15712 assert!(
15713 started.elapsed() < Duration::from_millis(700),
15714 "stalled upload exceeded the wall-clock budget: {error}"
15715 );
15716 server.join().unwrap();
15717 }
15718
15719 #[test]
15720 fn verb_entry_gates_accept_the_hub_ref_shapes() {
15721 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
15722 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
15723 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
15724 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
15725 }
15726 }
15727
15728 #[test]
15729 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
15730 let cfg = dead_hub();
15731 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
15732 assert!(
15733 matches!(
15734 sync_pull(&cfg, bad, None),
15735 Err(LinkError::BadAddress { .. })
15736 ),
15737 "sync_pull must refuse {bad:?}"
15738 );
15739 assert!(
15740 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
15741 "sync_push must refuse {bad:?}"
15742 );
15743 assert!(
15744 matches!(
15745 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
15746 Err(LinkError::BadAddress { .. })
15747 ),
15748 "grant_issue must refuse {bad:?}"
15749 );
15750 assert!(
15751 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
15752 "grant_list must refuse {bad:?}"
15753 );
15754 assert!(
15755 matches!(
15756 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
15757 Err(LinkError::BadAddress { .. })
15758 ),
15759 "grant_revoke must refuse brain {bad:?}"
15760 );
15761 assert!(
15762 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
15763 "head must refuse {bad:?}"
15764 );
15765 }
15766 }
15767
15768 #[test]
15769 fn grant_revoke_refuses_url_reshaping_grant_ids() {
15770 let cfg = dead_hub();
15771 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
15772 assert!(
15773 matches!(
15774 grant_revoke(&cfg, "acme", bad),
15775 Err(LinkError::BadGrantId { .. })
15776 ),
15777 "grant_revoke must refuse grant id {bad:?}"
15778 );
15779 }
15780 }
15781
15782 #[test]
15783 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
15784 let cfg = dead_hub();
15785 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
15786 assert!(
15787 matches!(
15788 propose(&cfg, bad, "intake", "hi"),
15789 Err(LinkError::BadAddress { .. })
15790 ),
15791 "propose must refuse handle {bad:?}"
15792 );
15793 }
15794 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
15795 assert!(matches!(
15796 propose(&cfg, "acme-site", "intake", &oversize),
15797 Err(LinkError::ProposeTooLarge { .. })
15798 ));
15799 assert!(matches!(
15802 propose(&cfg, "acme-site", "intake", "hi"),
15803 Err(LinkError::Transport { .. })
15804 ));
15805 }
15806
15807 #[test]
15808 fn resolve_refuses_a_hand_built_unsafe_address() {
15809 let cfg = dead_hub();
15810 for brain in ["../up", "a/b", "a?x", "a#f"] {
15811 let addr = Address {
15812 brain: brain.to_string(),
15813 target: None,
15814 };
15815 assert!(
15816 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
15817 "resolve must refuse brain {brain:?}"
15818 );
15819 }
15820 for target in [
15821 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
15822 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
15824 AddressTarget::Path("records/x.md#frag".to_string()),
15825 ] {
15826 let addr = Address {
15827 brain: "acme".to_string(),
15828 target: Some(target.clone()),
15829 };
15830 assert!(
15831 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
15832 "resolve must refuse target {target:?}"
15833 );
15834 }
15835 }
15836
15837 #[test]
15838 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
15839 let mut local = std::collections::BTreeMap::new();
15840 local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
15841 local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
15842 let mut remote = std::collections::BTreeMap::new();
15843 remote.insert(
15844 "records/a.md".to_string(),
15845 V2BaselineFile {
15846 sha256: "c".repeat(64),
15847 bytes: 1,
15848 proof: None,
15849 },
15850 );
15851 remote.insert(
15852 "records/b.md".to_string(),
15853 V2BaselineFile {
15854 sha256: "b".repeat(64),
15855 bytes: 1,
15856 proof: None,
15857 },
15858 );
15859 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
15860 }
15861
15862 #[test]
15863 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
15864 let local = std::collections::BTreeMap::new();
15865 let mut remote = std::collections::BTreeMap::new();
15866 remote.insert(
15867 "private/local.md".to_string(),
15868 V2BaselineFile {
15869 sha256: "d".repeat(64),
15870 bytes: 1,
15871 proof: None,
15872 },
15873 );
15874 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
15875 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
15876 }
15877
15878 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
15879 V2VerifiedHead {
15880 requested: TEST_BRAIN_ID.to_string(),
15881 brain_id: TEST_BRAIN_ID.to_string(),
15882 view_kind: "scoped".to_string(),
15883 view_revision: revision.to_string(),
15884 control_revision: revision.to_string(),
15885 identity: V2HeadIdentity {
15886 custody: "hub".to_string(),
15887 fingerprint: "test".to_string(),
15888 public_key_spki: "test".to_string(),
15889 previous: Vec::new(),
15890 rotations: Vec::new(),
15891 },
15892 pointer: None,
15893 trust: TrustState {
15894 v: 2,
15895 origin: "https://hub.example".to_string(),
15896 requested: TEST_BRAIN_ID.to_string(),
15897 brain: TEST_BRAIN_ID.to_string(),
15898 home: None,
15899 anchor: "ed25519:test".to_string(),
15900 current: "ed25519:test".to_string(),
15901 head_seq: 0,
15902 feed_hash: None,
15903 rotations: Vec::new(),
15904 hub_signer: None,
15905 protocol_profile: Some("link-v2".to_string()),
15906 },
15907 alias: None,
15908 }
15909 }
15910
15911 #[test]
15912 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
15913 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
15914 assert!(accepted_as_v2(&trust));
15915
15916 trust.protocol_profile = None;
15917 trust.hub_signer = Some("ed25519:hub".to_string());
15918 assert!(accepted_as_v2(&trust));
15919
15920 trust.hub_signer = None;
15921 assert!(!accepted_as_v2(&trust));
15922 }
15923
15924 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
15925 V2SyncBaseline {
15926 v: 2,
15927 origin: "https://hub.example".to_string(),
15928 brain: TEST_BRAIN_ID.to_string(),
15929 checkout_id: Some("c".repeat(64)),
15930 head_seq: Some(0),
15931 commit_hash: None,
15932 content_root: None,
15933 asset_root: None,
15934 assets: std::collections::BTreeMap::new(),
15935 view_kind: Some("scoped".to_string()),
15936 view_revision: Some(revision.to_string()),
15937 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
15938 files: std::collections::BTreeMap::new(),
15939 local_policy_digest: None,
15940 local_eligibility: std::collections::BTreeMap::new(),
15941 remote_copy_remains: std::collections::BTreeMap::new(),
15942 }
15943 }
15944
15945 #[test]
15946 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
15947 let directory = tempfile::tempdir().unwrap();
15948 std::fs::write(
15949 directory.path().join("DB.md"),
15950 scoped_projection_bytes(TEST_BRAIN_ID),
15951 )
15952 .unwrap();
15953 let store = Store::open_strict(directory.path()).unwrap();
15954 let head = scoped_test_head(&"a".repeat(64));
15955 let baseline = scoped_test_baseline(&"a".repeat(64));
15956 let mut view = v2_local_files(&store).unwrap();
15957 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
15958 assert!(!view.riding.contains_key("DB.md"));
15959 assert!(!view.eligibility.contains_key("DB.md"));
15960 }
15961
15962 #[test]
15963 fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
15964 let directory = tempfile::tempdir().unwrap();
15965 std::fs::write(
15966 directory.path().join("DB.md"),
15967 scoped_projection_bytes(TEST_BRAIN_ID),
15968 )
15969 .unwrap();
15970 let store = Store::open_strict(directory.path()).unwrap();
15971 let head = scoped_test_head(&"a".repeat(64));
15972 let baseline = scoped_test_baseline(&"a".repeat(64));
15973
15974 let mut carried = v2_local_files(&store).unwrap();
15975 remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
15976 let handed_off =
15977 local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
15978 assert!(!handed_off.riding.contains_key("DB.md"));
15979
15980 let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
15981 assert!(!freshly_scanned.riding.contains_key("DB.md"));
15982
15983 std::fs::write(
15984 directory.path().join("DB.md"),
15985 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
15986 )
15987 .unwrap();
15988 let tampered = Store::open_strict(directory.path()).unwrap();
15989 assert!(matches!(
15990 local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
15991 Err(LinkError::ScopedProjectionModified)
15992 ));
15993 }
15994
15995 #[test]
15996 fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
15997 let directory = tempfile::tempdir().unwrap();
15998 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
15999 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
16000 std::fs::write(
16001 directory.path().join("DB.md"),
16002 b"---\nname: Kept home test\n---\n",
16003 )
16004 .unwrap();
16005 std::fs::write(
16006 directory.path().join("records/notes/a.md"),
16007 b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
16008 )
16009 .unwrap();
16010 std::fs::write(
16011 directory.path().join("sources/private/secret.md"),
16012 b"---\ntype: note\n---\nlocal only\n",
16013 )
16014 .unwrap();
16015 std::fs::write(
16016 directory.path().join("sources/private/unlinked.md"),
16017 b"---\ntype: note\n---\nnot disclosed\n",
16018 )
16019 .unwrap();
16020 std::fs::write(
16021 directory.path().join(".sevralocal"),
16022 b"sources/private/**\n",
16023 )
16024 .unwrap();
16025
16026 let store = Store::open_strict(directory.path()).unwrap();
16027 let view = v2_local_files(&store).unwrap();
16028 assert!(!view.riding.contains_key("sources/private/secret.md"));
16029 assert_eq!(
16030 view.withheld_links,
16031 vec![V2WithheldLink {
16032 source: "records/notes/a.md".to_string(),
16033 target: "sources/private/secret.md".to_string(),
16034 }]
16035 );
16036 }
16037
16038 #[test]
16039 fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
16040 let directory = tempfile::tempdir().unwrap();
16041 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
16042 std::fs::write(
16043 directory.path().join("DB.md"),
16044 b"---\nname: Withdrawal test\n---\n",
16045 )
16046 .unwrap();
16047 let source = b"---\ntype: note\n---\nlocal evidence\n";
16048 std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
16049 std::fs::write(
16050 directory.path().join(".sevralocal"),
16051 b"sources/private/**\n",
16052 )
16053 .unwrap();
16054 let store = Store::open_strict(directory.path()).unwrap();
16055 let view = v2_local_files(&store).unwrap();
16056 let mut remote = std::collections::BTreeMap::new();
16057 remote.insert(
16058 "sources/private/evidence.md".to_string(),
16059 V2BaselineFile {
16060 sha256: content_sha256(source),
16061 bytes: source.len() as u64,
16062 proof: None,
16063 },
16064 );
16065 assert_eq!(
16066 v2_content_withdrawal_operation(
16067 &store,
16068 &view,
16069 &remote,
16070 "sources/private/evidence.md",
16071 "approved retention change",
16072 )
16073 .unwrap(),
16074 json!({
16075 "op": "withdraw_from_hosting",
16076 "path": "sources/private/evidence.md",
16077 "expected": { "kind": "blob", "hash": content_sha256(source) },
16078 "reason": "approved retention change",
16079 })
16080 );
16081
16082 std::fs::write(
16083 directory.path().join("sources/private/evidence.md"),
16084 b"changed after review",
16085 )
16086 .unwrap();
16087 assert!(matches!(
16088 v2_content_withdrawal_operation(
16089 &store,
16090 &view,
16091 &remote,
16092 "sources/private/evidence.md",
16093 "approved retention change",
16094 ),
16095 Err(LinkError::InvalidPack { .. })
16096 ));
16097 }
16098
16099 #[test]
16100 fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
16101 let directory = tempfile::tempdir().unwrap();
16102 std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
16103 std::fs::write(
16104 directory.path().join("DB.md"),
16105 b"---\nname: Asset withdrawal test\n---\n",
16106 )
16107 .unwrap();
16108 let bytes = b"private binary";
16109 std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
16110 std::fs::write(
16111 directory.path().join(".sevralocal"),
16112 b"sources/files/private.pdf\n",
16113 )
16114 .unwrap();
16115 let store = Store::open_strict(directory.path()).unwrap();
16116 let view = v2_local_files(&store).unwrap();
16117 let local = crate::AssetRecord {
16118 path: "sources/files/private.pdf".to_string(),
16119 sha256: content_sha256(bytes),
16120 bytes: bytes.len() as u64,
16121 media_type: "application/pdf".to_string(),
16122 wrappers: vec!["sources/files/private.md".to_string()],
16123 required: true,
16124 };
16125 let current = V2BaselineAsset {
16126 blob_sha256: local.sha256.clone(),
16127 bytes: local.bytes,
16128 media_type: local.media_type.clone(),
16129 wrappers: local.wrappers.clone(),
16130 required: local.required,
16131 disposition: "hosted".to_string(),
16132 leaf_hash: "d".repeat(64),
16133 };
16134 assert_eq!(
16135 v2_asset_withdrawal_operation(
16136 &store,
16137 &view,
16138 &local.path,
16139 &local,
16140 ¤t,
16141 "approved retention change",
16142 )
16143 .unwrap(),
16144 json!({
16145 "op": "asset_withdraw",
16146 "path": local.path,
16147 "expected": { "kind": "asset", "hash": "d".repeat(64) },
16148 "reason": "approved retention change",
16149 })
16150 );
16151
16152 let mut mismatched = current.clone();
16153 mismatched.required = false;
16154 assert!(matches!(
16155 v2_asset_withdrawal_operation(
16156 &store,
16157 &view,
16158 &local.path,
16159 &local,
16160 &mismatched,
16161 "approved retention change",
16162 ),
16163 Err(LinkError::InvalidPack { .. })
16164 ));
16165 }
16166
16167 #[test]
16168 fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
16169 let first = v2_checkout_id(None).unwrap();
16170 assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
16171 assert_ne!(first, v2_checkout_id(None).unwrap());
16172 assert!(is_sha256(&first));
16173 }
16174
16175 #[test]
16176 fn scoped_projection_edit_and_scope_transition_fail_closed() {
16177 let directory = tempfile::tempdir().unwrap();
16178 std::fs::write(
16179 directory.path().join("DB.md"),
16180 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
16181 )
16182 .unwrap();
16183 let store = Store::open_strict(directory.path()).unwrap();
16184 let head = scoped_test_head(&"a".repeat(64));
16185 let baseline = scoped_test_baseline(&"a".repeat(64));
16186 let mut view = v2_local_files(&store).unwrap();
16187 assert!(matches!(
16188 remove_scoped_projection(&head, Some(&baseline), &mut view),
16189 Err(LinkError::ScopedProjectionModified)
16190 ));
16191
16192 let changed = scoped_test_head(&"b".repeat(64));
16193 assert!(matches!(
16194 ensure_v2_view_compatible(&changed, Some(&baseline)),
16195 Err(LinkError::ScopedViewChanged)
16196 ));
16197
16198 let mut same_view_new_control = head.clone();
16199 same_view_new_control.control_revision = "c".repeat(64);
16200 assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
16201 assert!(!same_v2_head(&head, &same_view_new_control));
16202 }
16203
16204 #[test]
16205 fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
16206 let scoped = scoped_test_head(&"a".repeat(64));
16207 let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
16208 assert!(matches!(
16209 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
16210 Err(LinkError::ScopedProjectionModified)
16211 ));
16212
16213 let mut full = scoped.clone();
16214 full.view_kind = "full".to_string();
16215 let mut full_baseline = scoped_baseline.clone();
16216 full_baseline.view_kind = Some("full".to_string());
16217 full_baseline.projection_sha256 = None;
16218 assert!(matches!(
16219 ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
16220 Err(LinkError::InvalidPack { .. })
16221 ));
16222
16223 assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
16224 assert!(
16225 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
16226 );
16227 }
16228
16229 #[test]
16230 fn scoped_view_metadata_is_explicitly_non_authoritative() {
16231 let head = scoped_test_head(&"a".repeat(64));
16232 let value: Value =
16233 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
16234 assert_eq!(value["kind"], "link.md-scoped-view");
16235 assert_eq!(value["authoritative"], false);
16236 assert_eq!(value["visible_files"], 7);
16237 assert_eq!(value["brain"], TEST_BRAIN_ID);
16238 }
16239
16240 #[test]
16241 fn local_scoped_marker_requires_the_exact_generated_projection() {
16242 let directory = tempfile::tempdir().unwrap();
16243 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
16244 std::fs::write(
16245 directory.path().join("DB.md"),
16246 scoped_projection_bytes(TEST_BRAIN_ID),
16247 )
16248 .unwrap();
16249 let head = scoped_test_head(&"a".repeat(64));
16250 std::fs::write(
16251 directory.path().join(".dbmd/view.json"),
16252 scoped_view_metadata(&head, 0).unwrap(),
16253 )
16254 .unwrap();
16255 let store = Store::open_strict(directory.path()).unwrap();
16256 assert!(has_verified_local_scoped_view(&store));
16257
16258 std::fs::write(
16259 directory.path().join("DB.md"),
16260 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
16261 )
16262 .unwrap();
16263 let altered = Store::open_strict(directory.path()).unwrap();
16264 assert!(!has_verified_local_scoped_view(&altered));
16265 }
16266
16267 fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
16268 use ring::signature::KeyPair as _;
16269
16270 let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
16271 let rng = ring::rand::SystemRandom::new();
16272 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16273 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16274 let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
16275 let public_key = URL_SAFE_NO_PAD.encode(&public_der);
16276 let fingerprint = format!("{:x}", Sha256::digest(&public_der));
16277 let blob = b"new";
16278 let blob_hash = content_sha256(blob);
16279 let changes = json!({
16280 "mutation_id": "sync:proposal-fixture",
16281 "operations": [{
16282 "blob": blob_hash,
16283 "bytes": blob.len(),
16284 "expected": null,
16285 "op": "put",
16286 "path": "records/new.md",
16287 }],
16288 "reason": "fixture",
16289 "v": 2,
16290 });
16291 let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
16292 let changes_base64 = STANDARD.encode(&changes_bytes);
16293 let descriptor = json!({
16294 "base": null,
16295 "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
16296 "changes_base64": changes_base64,
16297 "rebase": "strict",
16298 "v": 2,
16299 });
16300 let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
16301 let payload_hash = "b".repeat(64);
16302 let submitted_at = "2026-08-19T12:00:00.000Z";
16303 let claim = json!({
16304 "actor_root": {
16305 "actor_class": "foreign_key",
16306 "credential": "ed25519:fixture",
16307 "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
16308 "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
16309 "principal": "key:fixture",
16310 "role": null,
16311 },
16312 "brain": TEST_BRAIN_ID,
16313 "clear_sha256": clear_hash,
16314 "control_revision": "c".repeat(64),
16315 "mutation_id": "sync:proposal-fixture",
16316 "payload_sha256": payload_hash,
16317 "proposal_id": proposal_id,
16318 "submitted_at": submitted_at,
16319 "v": 2,
16320 });
16321 let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
16322 let envelope = json!({
16323 "claim": claim,
16324 "fingerprint": fingerprint,
16325 "public_key": public_key,
16326 "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
16327 });
16328 let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
16329 let submission_hash =
16330 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
16331 let mut head = scoped_test_head(&"c".repeat(64));
16332 head.view_kind = "full".to_string();
16333 head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
16334 let value = json!({
16335 "proposal": {
16336 "base": null,
16337 "blobs": [{
16338 "bytes": blob.len(),
16339 "endpoint": format!(
16340 "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
16341 ),
16342 "sha256": blob_hash,
16343 }],
16344 "changes_base64": changes_base64,
16345 "clear_sha256": clear_hash,
16346 "expires_at": "2026-08-26T12:00:00.000Z",
16347 "id": proposal_id,
16348 "payload_sha256": payload_hash,
16349 "proposer": { "class": "foreign_key" },
16350 "rebase": "strict",
16351 "state": "pending",
16352 "submission_claim_base64": STANDARD.encode(envelope_bytes),
16353 "submission_claim_sha256": submission_hash,
16354 "submitted_at": submitted_at,
16355 },
16356 "v": 2,
16357 });
16358 (head, proposal_id, value)
16359 }
16360
16361 #[test]
16362 fn v2_proposal_verifier_accepts_exact_signed_payload() {
16363 let (head, proposal_id, value) = signed_proposal_fixture();
16364 let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
16365 assert_eq!(verified.blobs.len(), 1);
16366 assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
16367 }
16368
16369 #[test]
16370 fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
16371 let (head, proposal_id, value) = signed_proposal_fixture();
16372
16373 let mut changed = value.clone();
16374 changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
16375 assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
16376
16377 let mut redirected = value.clone();
16378 redirected["proposal"]["blobs"][0]["endpoint"] =
16379 Value::String("https://attacker.example/blob".to_string());
16380 assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
16381
16382 let mut forged = value;
16383 let encoded = forged["proposal"]["submission_claim_base64"]
16384 .as_str()
16385 .unwrap();
16386 let mut envelope: Value =
16387 serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
16388 envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
16389 let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
16390 forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
16391 forged["proposal"]["submission_claim_sha256"] = Value::String(
16392 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
16393 );
16394 assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
16395 }
16396
16397 #[cfg(unix)]
16398 #[test]
16399 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
16400 let sandbox = tempfile::tempdir().unwrap();
16401 let destination = sandbox.path().join("brain");
16402 let entries = vec![
16403 (
16404 "DB.md".to_string(),
16405 scoped_projection_bytes(TEST_BRAIN_ID),
16406 ),
16407 (
16408 "records/contacts/a.md".to_string(),
16409 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
16410 .to_vec(),
16411 ),
16412 ];
16413 install_pulled_delta(&destination, &entries, &[], true).unwrap();
16414 assert!(destination.join("index.md").is_file());
16415 assert!(destination.join("records/index.md").is_file());
16416 assert!(destination.join("records/contacts/index.md").is_file());
16417 assert!(destination.join("records/contacts/index.jsonl").is_file());
16418 }
16419
16420 #[cfg(unix)]
16421 #[test]
16422 fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
16423 let sandbox = tempfile::tempdir().unwrap();
16424 let destination = sandbox.path().join("brain");
16425 let cache = sandbox.path().join("cache");
16426 std::fs::create_dir(&cache).unwrap();
16427 let db = scoped_projection_bytes(TEST_BRAIN_ID);
16428 let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
16429 let db_source = cache.join("db");
16430 let shared_source = cache.join("shared");
16431 crate::fsx::write_atomic(&db_source, &db).unwrap();
16432 crate::fsx::write_atomic(&shared_source, shared).unwrap();
16433 let mut entries = vec![V2StagedFile {
16434 path: "DB.md".to_string(),
16435 source: db_source,
16436 sha256: content_sha256(&db),
16437 bytes: db.len() as u64,
16438 }];
16439 for index in 0..512 {
16440 entries.push(V2StagedFile {
16441 path: format!("records/items/{index:05}.md"),
16442 source: shared_source.clone(),
16443 sha256: content_sha256(shared),
16444 bytes: shared.len() as u64,
16445 });
16446 }
16447 install_pulled_delta_sources(
16448 &destination,
16449 &entries,
16450 &[],
16451 false,
16452 None,
16453 &scoped_test_head(&"c".repeat(64)),
16454 )
16455 .unwrap();
16456 assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
16457 for index in 0..512 {
16458 assert_eq!(
16459 std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
16460 shared
16461 );
16462 }
16463 assert!(
16464 std::fs::read_dir(sandbox.path())
16465 .unwrap()
16466 .all(|entry| !entry
16467 .unwrap()
16468 .file_name()
16469 .to_string_lossy()
16470 .contains("pull-stage")),
16471 "the private stage must be atomically installed or removed"
16472 );
16473 }
16474
16475 #[cfg(unix)]
16476 #[test]
16477 fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
16478 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
16479
16480 let sandbox = tempfile::tempdir().unwrap();
16481 let root = sandbox.path().join("brain");
16482 std::fs::create_dir_all(root.join("records/items")).unwrap();
16483 let db = scoped_projection_bytes(TEST_BRAIN_ID);
16484 let old = b"---\ntype: note\n---\n\nold\n";
16485 let new = b"---\ntype: note\n---\n\nnew\n";
16486 let removed = b"---\ntype: note\n---\n\nremove me\n";
16487 std::fs::write(root.join("DB.md"), &db).unwrap();
16488 std::fs::write(root.join("records/items/change.md"), old).unwrap();
16489 std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
16490 for index in 0..512 {
16491 std::fs::write(
16492 root.join(format!("records/items/untouched-{index:04}.md")),
16493 old,
16494 )
16495 .unwrap();
16496 }
16497 let untouched = root.join("records/items/untouched-0256.md");
16498 let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
16499 let source = sandbox.path().join("changed-source");
16500 crate::fsx::write_atomic(&source, new).unwrap();
16501 let same_source = sandbox.path().join("unchanged-source");
16502 crate::fsx::write_atomic(&same_source, old).unwrap();
16503 let same_entry = V2StagedFile {
16504 path: "records/items/change.md".to_string(),
16505 source: same_source,
16506 sha256: content_sha256(old),
16507 bytes: old.len() as u64,
16508 };
16509 let entry = V2StagedFile {
16510 path: "records/items/change.md".to_string(),
16511 source,
16512 sha256: content_sha256(new),
16513 bytes: new.len() as u64,
16514 };
16515 let head = scoped_test_head(&"c".repeat(64));
16516
16517 install_established_v2_delta(
16521 Store::open_strict(&root).unwrap(),
16522 &[same_entry],
16523 &["records/items/already-absent.md".to_string()],
16524 true,
16525 None,
16526 &head,
16527 )
16528 .unwrap();
16529 assert_eq!(
16530 std::fs::metadata(&untouched).unwrap().ino(),
16531 untouched_inode
16532 );
16533 assert!(!root.join(V2_PULL_JOURNAL).exists());
16534
16535 install_established_v2_delta(
16536 Store::open_strict(&root).unwrap(),
16537 &[entry],
16538 &["records/items/delete.md".to_string()],
16539 false,
16540 None,
16541 &head,
16542 )
16543 .unwrap();
16544 assert_eq!(
16545 std::fs::read(root.join("records/items/change.md")).unwrap(),
16546 new
16547 );
16548 assert!(!root.join("records/items/delete.md").exists());
16549 assert_eq!(
16550 std::fs::metadata(&untouched).unwrap().ino(),
16551 untouched_inode
16552 );
16553 assert!(root.join(V2_PULL_JOURNAL).is_file());
16554 assert_eq!(
16555 std::fs::metadata(root.join(V2_PULL_JOURNAL))
16556 .unwrap()
16557 .permissions()
16558 .mode()
16559 & 0o777,
16560 0o600
16561 );
16562 let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
16563 .unwrap()
16564 .unwrap();
16565 assert_eq!(
16566 std::fs::metadata(root.join(&journal.backup_dir))
16567 .unwrap()
16568 .permissions()
16569 .mode()
16570 & 0o777,
16571 0o700
16572 );
16573 for entry in &journal.entries {
16574 if let Some(backup) = &entry.backup {
16575 assert_eq!(
16576 std::fs::metadata(root.join(&journal.backup_dir).join(backup))
16577 .unwrap()
16578 .permissions()
16579 .mode()
16580 & 0o777,
16581 0o600
16582 );
16583 }
16584 }
16585
16586 let cfg = test_hub_config(
16587 "https://example.test".to_string(),
16588 sandbox.path().join("state"),
16589 );
16590 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16591 assert_eq!(
16592 std::fs::read(root.join("records/items/change.md")).unwrap(),
16593 old
16594 );
16595 assert_eq!(
16596 std::fs::read(root.join("records/items/delete.md")).unwrap(),
16597 removed
16598 );
16599 assert_eq!(
16600 std::fs::metadata(&untouched).unwrap().ino(),
16601 untouched_inode
16602 );
16603 assert!(!root.join(V2_PULL_JOURNAL).exists());
16604 }
16605
16606 #[test]
16607 fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
16608 let body = b"bounded bytes";
16609 let path = "records/example.md".to_string();
16610 let file = V2BaselineFile {
16611 sha256: content_sha256(body),
16612 bytes: body.len() as u64,
16613 proof: None,
16614 };
16615 let header = serde_json::to_vec(&json!({
16616 "bytes": body.len(),
16617 "path": path,
16618 "sha256": file.sha256,
16619 "v": 2,
16620 }))
16621 .unwrap();
16622 let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
16623 stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
16624 stream.extend_from_slice(&header);
16625 stream.extend_from_slice(body);
16626 stream.extend_from_slice(&0_u32.to_be_bytes());
16627 let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
16628 assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
16629
16630 let mut tampered = stream.clone();
16631 let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
16632 tampered[body_offset] ^= 1;
16633 assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
16634
16635 let mut trailing = stream;
16636 trailing.push(0);
16637 assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
16638 }
16639
16640 #[test]
16641 fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
16642 let sandbox = tempfile::TempDir::new().unwrap();
16643 let root = sandbox.path().join("brain");
16644 std::fs::create_dir_all(&root).unwrap();
16645 std::fs::write(
16646 root.join("DB.md"),
16647 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16648 )
16649 .unwrap();
16650 let store = Store::open_strict(&root).unwrap();
16651 let incomplete = crate::ulid::mint();
16652 store
16653 .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
16654 .unwrap();
16655 let expired = crate::ulid::mint();
16656 store
16657 .create_dir_all(&v2_conflict_relative(&expired, "files"))
16658 .unwrap();
16659 let plan = V2ConflictPlan {
16660 v: 2,
16661 class: "content_resolution_required".to_string(),
16662 bundle: expired.clone(),
16663 brain: TEST_BRAIN_ID.to_string(),
16664 origin: "https://example.test".to_string(),
16665 created_unix: 0,
16666 expires_unix: 0,
16667 base_seq: None,
16668 base_commit: None,
16669 remote_seq: 0,
16670 remote_commit: None,
16671 remote_content_root: None,
16672 view_kind: "full".to_string(),
16673 view_revision: "a".repeat(64),
16674 files: vec![V2ConflictFile {
16675 path: "records/value.md".to_string(),
16676 base: V2ConflictCoordinate {
16677 sha256: None,
16678 bytes: None,
16679 file: None,
16680 },
16681 local: V2ConflictCoordinate {
16682 sha256: None,
16683 bytes: None,
16684 file: None,
16685 },
16686 remote: V2ConflictCoordinate {
16687 sha256: None,
16688 bytes: None,
16689 file: None,
16690 },
16691 }],
16692 };
16693 let mut bytes = serde_json::to_vec(&plan).unwrap();
16694 bytes.push(b'\n');
16695 store
16696 .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
16697 .unwrap();
16698
16699 let listed = sync_conflicts(&root, false, false).unwrap();
16700 assert_eq!(listed["bundles"], 2);
16701 assert_eq!(listed["pruned"], 0);
16702 let pruned = sync_conflicts(&root, true, false).unwrap();
16703 assert_eq!(pruned["bundles"], 0);
16704 assert_eq!(pruned["pruned"], 2);
16705 assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
16706 assert!(!root.join(".dbmd/conflicts").join(expired).exists());
16707 }
16708
16709 #[test]
16710 fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
16711 let sandbox = tempfile::TempDir::new().unwrap();
16712 let root = sandbox.path().join("brain");
16713 std::fs::create_dir_all(&root).unwrap();
16714 std::fs::write(
16715 root.join("DB.md"),
16716 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16717 )
16718 .unwrap();
16719 let store = Store::open_strict(&root).unwrap();
16720 let bundle = crate::ulid::mint();
16721 store
16722 .create_dir_all(&v2_conflict_relative(&bundle, "files"))
16723 .unwrap();
16724 store
16725 .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
16726 .unwrap();
16727
16728 assert!(sync_conflicts(&root, true, false).is_err());
16729 assert!(sync_conflicts(&root, false, true).is_err());
16730 let pruned = sync_conflicts(&root, true, true).unwrap();
16731 assert_eq!(pruned["pruned"], 1);
16732 assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
16733 }
16734
16735 #[test]
16736 fn ready_pull_journal_rolls_back_exact_preimages() {
16737 let sandbox = tempfile::TempDir::new().unwrap();
16738 let root = sandbox.path().join("brain");
16739 std::fs::create_dir_all(root.join("records")).unwrap();
16740 std::fs::write(
16741 root.join("DB.md"),
16742 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16743 )
16744 .unwrap();
16745 let path = "records/value.md";
16746 let old = b"---\ntype: note\n---\n\nold\n";
16747 let new = b"---\ntype: note\n---\n\nnew\n";
16748 std::fs::write(root.join(path), old).unwrap();
16749 let store = Store::open_strict(&root).unwrap();
16750 let bundle = crate::ulid::mint();
16751 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
16752 store
16753 .create_private_dir_all(Path::new(&backup_dir))
16754 .unwrap();
16755 store
16756 .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
16757 .unwrap();
16758 let journal = V2PullJournal {
16759 v: 1,
16760 phase: V2PullPhase::Ready,
16761 brain: TEST_BRAIN_ID.to_string(),
16762 previous: V2PullCoordinate {
16763 head_seq: None,
16764 commit_hash: None,
16765 view_kind: None,
16766 view_revision: None,
16767 },
16768 next: V2PullCoordinate {
16769 head_seq: Some(2),
16770 commit_hash: Some("c".repeat(64)),
16771 view_kind: Some("full".to_string()),
16772 view_revision: Some("d".repeat(64)),
16773 },
16774 backup_dir: backup_dir.clone(),
16775 entries: vec![V2PullJournalEntry {
16776 path: path.to_string(),
16777 old: Some(V2PullFileCoordinate {
16778 sha256: content_sha256(old),
16779 bytes: old.len() as u64,
16780 }),
16781 new: Some(V2PullFileCoordinate {
16782 sha256: content_sha256(new),
16783 bytes: new.len() as u64,
16784 }),
16785 backup: Some("00000000".to_string()),
16786 }],
16787 };
16788 validate_v2_pull_journal(&journal).unwrap();
16789 store
16790 .write_private_atomic_new(
16791 Path::new(V2_PULL_JOURNAL),
16792 &v2_pull_journal_bytes(&journal).unwrap(),
16793 )
16794 .unwrap();
16795 store.write_atomic(Path::new(path), new).unwrap();
16796
16797 let cfg = test_hub_config(
16798 "https://example.test".to_string(),
16799 sandbox.path().join("state"),
16800 );
16801 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16802 assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
16803 assert!(!root.join(V2_PULL_JOURNAL).exists());
16804 assert!(!root.join(backup_dir).exists());
16805 }
16806
16807 #[test]
16808 fn preparing_pull_journal_discards_only_private_staging() {
16809 let sandbox = tempfile::TempDir::new().unwrap();
16810 let root = sandbox.path().join("brain");
16811 std::fs::create_dir_all(root.join(".dbmd")).unwrap();
16812 std::fs::write(
16813 root.join("DB.md"),
16814 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16815 )
16816 .unwrap();
16817 let store = Store::open_strict(&root).unwrap();
16818 let bundle = crate::ulid::mint();
16819 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
16820 store
16821 .create_private_dir_all(Path::new(&backup_dir))
16822 .unwrap();
16823 let journal = V2PullJournal {
16824 v: 1,
16825 phase: V2PullPhase::Preparing,
16826 brain: TEST_BRAIN_ID.to_string(),
16827 previous: V2PullCoordinate {
16828 head_seq: None,
16829 commit_hash: None,
16830 view_kind: None,
16831 view_revision: None,
16832 },
16833 next: V2PullCoordinate {
16834 head_seq: Some(1),
16835 commit_hash: Some("a".repeat(64)),
16836 view_kind: Some("full".to_string()),
16837 view_revision: Some("b".repeat(64)),
16838 },
16839 backup_dir: backup_dir.clone(),
16840 entries: vec![V2PullJournalEntry {
16841 path: "records/new.md".to_string(),
16842 old: None,
16843 new: Some(V2PullFileCoordinate {
16844 sha256: "c".repeat(64),
16845 bytes: 1,
16846 }),
16847 backup: None,
16848 }],
16849 };
16850 store
16851 .write_private_atomic_new(
16852 Path::new(V2_PULL_JOURNAL),
16853 &v2_pull_journal_bytes(&journal).unwrap(),
16854 )
16855 .unwrap();
16856 let cfg = test_hub_config(
16857 "https://example.test".to_string(),
16858 sandbox.path().join("state"),
16859 );
16860
16861 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16862
16863 assert!(root.join("DB.md").is_file());
16864 assert!(!root.join(V2_PULL_JOURNAL).exists());
16865 assert!(!root.join(backup_dir).exists());
16866 }
16867
16868 #[test]
16869 fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
16870 let sandbox = tempfile::TempDir::new().unwrap();
16871 let root = sandbox.path().join("brain");
16872 std::fs::create_dir_all(root.join("records")).unwrap();
16873 std::fs::write(
16874 root.join("DB.md"),
16875 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
16876 )
16877 .unwrap();
16878 let new = b"---\ntype: note\n---\n\nnew\n";
16879 std::fs::write(root.join("records/value.md"), new).unwrap();
16880 let store = Store::open_strict(&root).unwrap();
16881 let bundle = crate::ulid::mint();
16882 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
16883 store
16884 .create_private_dir_all(Path::new(&backup_dir))
16885 .unwrap();
16886 let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
16887 store.create_private_dir_all(Path::new(&orphan)).unwrap();
16888 let next = V2PullCoordinate {
16889 head_seq: Some(2),
16890 commit_hash: Some("c".repeat(64)),
16891 view_kind: Some("full".to_string()),
16892 view_revision: Some("d".repeat(64)),
16893 };
16894 let journal = V2PullJournal {
16895 v: 1,
16896 phase: V2PullPhase::Ready,
16897 brain: TEST_BRAIN_ID.to_string(),
16898 previous: V2PullCoordinate {
16899 head_seq: Some(1),
16900 commit_hash: Some("a".repeat(64)),
16901 view_kind: Some("full".to_string()),
16902 view_revision: Some("b".repeat(64)),
16903 },
16904 next: next.clone(),
16905 backup_dir: backup_dir.clone(),
16906 entries: vec![V2PullJournalEntry {
16907 path: "records/value.md".to_string(),
16908 old: Some(V2PullFileCoordinate {
16909 sha256: "e".repeat(64),
16910 bytes: new.len() as u64,
16911 }),
16912 new: Some(V2PullFileCoordinate {
16913 sha256: content_sha256(new),
16914 bytes: new.len() as u64,
16915 }),
16916 backup: Some("00000000".to_string()),
16917 }],
16918 };
16919 store
16920 .write_private_atomic_new(
16921 Path::new(V2_PULL_JOURNAL),
16922 &v2_pull_journal_bytes(&journal).unwrap(),
16923 )
16924 .unwrap();
16925 let cfg = test_hub_config(
16926 "https://example.test".to_string(),
16927 sandbox.path().join("state"),
16928 );
16929 save_v2_baseline(
16930 &cfg,
16931 TEST_BRAIN_ID,
16932 &root,
16933 &V2SyncBaseline {
16934 v: 2,
16935 origin: "https://example.test".to_string(),
16936 brain: TEST_BRAIN_ID.to_string(),
16937 checkout_id: Some("c".repeat(64)),
16938 head_seq: next.head_seq,
16939 commit_hash: next.commit_hash.clone(),
16940 content_root: Some("f".repeat(64)),
16941 asset_root: None,
16942 assets: Default::default(),
16943 view_kind: next.view_kind.clone(),
16944 view_revision: next.view_revision.clone(),
16945 projection_sha256: None,
16946 files: Default::default(),
16947 local_policy_digest: None,
16948 local_eligibility: Default::default(),
16949 remote_copy_remains: Default::default(),
16950 },
16951 )
16952 .unwrap();
16953
16954 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
16955
16956 assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
16957 assert!(!root.join(V2_PULL_JOURNAL).exists());
16958 assert!(!root.join(backup_dir).exists());
16959 assert!(!root.join(orphan).exists());
16960 }
16961}