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