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("sync conflict on {paths:?} — resolve the named files and retry")]
348 Conflict {
349 paths: Vec<String>,
351 },
352
353 #[error(
357 "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
358 )]
359 ConflictBundle {
360 bundle: String,
362 paths: Vec<String>,
364 },
365
366 #[error(
370 "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
371 )]
372 LocalPolicyTransition {
373 paths: Vec<String>,
375 },
376
377 #[error(
382 "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
383 )]
384 BulkPreviewRequired {
385 preview: Value,
387 },
388
389 #[error(
392 "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
393 )]
394 ScopedProjectionModified,
395
396 #[error(
400 "the checkout's permission scope changed — clone into a new directory to accept the new view"
401 )]
402 ScopedViewChanged,
403
404 #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
407 BrainUnavailable,
408
409 #[error(
412 "the remote brain advanced during sync — retry to converge from the new verified head"
413 )]
414 RemoteAdvancedDuringSync,
415
416 #[error(
419 "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
420 )]
421 UnsupportedPlatform {
422 operation: &'static str,
424 },
425
426 #[error(transparent)]
428 Io(#[from] std::io::Error),
429
430 #[error(transparent)]
432 Store(#[from] crate::StoreError),
433}
434
435pub type LinkResult<T> = std::result::Result<T, LinkError>;
437
438#[derive(Debug, Clone, PartialEq, Eq)]
440pub struct V2BulkConfirmation {
441 pub id: String,
443 pub digest: String,
446}
447
448impl V2BulkConfirmation {
449 pub fn parse(value: &str) -> LinkResult<Self> {
452 let (id, digest) = value
453 .split_once(':')
454 .ok_or_else(|| LinkError::InvalidPack {
455 message: "bulk confirmation must be <id>:<digest>".to_string(),
456 })?;
457 if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
458 return Err(LinkError::InvalidPack {
459 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
460 .to_string(),
461 });
462 }
463 Ok(Self {
464 id: id.to_string(),
465 digest: digest.to_string(),
466 })
467 }
468}
469
470fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
475 #[cfg(any(target_os = "linux", target_os = "macos", windows))]
476 {
477 let _ = operation;
478 Ok(())
479 }
480 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
481 {
482 Err(LinkError::UnsupportedPlatform { operation })
483 }
484}
485
486#[derive(Debug, Clone, PartialEq, Eq)]
492pub enum AddressTarget {
493 Id(String),
495 Path(String),
499}
500
501const BAD_BRAIN_REASON: &str =
504 "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
505
506const BAD_TARGET_REASON: &str =
509 "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
510
511#[derive(Debug, Clone, PartialEq, Eq)]
516pub struct Address {
517 pub brain: String,
519 pub target: Option<AddressTarget>,
521}
522
523impl Address {
524 pub fn parse(raw: &str) -> LinkResult<Address> {
528 let bad = |reason: &str| LinkError::BadAddress {
529 given: raw.to_string(),
530 reason: reason.to_string(),
531 };
532
533 let trimmed = raw.trim();
534 let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
535 if body.is_empty() {
536 return Err(bad("empty address"));
537 }
538
539 let (brain, rest) = match body.split_once('/') {
540 Some((b, r)) => (b, Some(r)),
541 None => (body, None),
542 };
543
544 if brain.is_empty() {
545 return Err(bad("missing brain reference before `/`"));
546 }
547 if !is_safe_ref(brain) {
548 return Err(bad(BAD_BRAIN_REASON));
549 }
550
551 let target = match rest {
552 None => None,
553 Some("") => return Err(bad("trailing `/` with no record id or path")),
554 Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
555 Some(r) => {
556 if !safe_store_rel_path(r) || !r.ends_with(".md") {
557 return Err(bad(BAD_TARGET_REASON));
558 }
559 Some(AddressTarget::Path(r.to_string()))
560 }
561 };
562
563 Ok(Address {
564 brain: brain.to_string(),
565 target,
566 })
567 }
568}
569
570fn is_safe_ref(s: &str) -> bool {
573 !s.is_empty()
574 && s.len() <= 64
575 && s.bytes()
576 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
577}
578
579pub fn is_valid_handle(s: &str) -> bool {
582 is_safe_ref(s)
583}
584
585pub fn safe_store_rel_path(p: &str) -> bool {
591 if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
592 return false;
593 }
594 if !p
595 .bytes()
596 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
597 {
598 return false;
599 }
600 p.split('/')
601 .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
602}
603
604fn require_safe_ref(brain: &str) -> LinkResult<()> {
612 if is_safe_ref(brain) {
613 Ok(())
614 } else {
615 Err(LinkError::BadAddress {
616 given: brain.to_string(),
617 reason: BAD_BRAIN_REASON.to_string(),
618 })
619 }
620}
621
622fn require_valid_handle(handle: &str) -> LinkResult<()> {
624 if is_valid_handle(handle) {
625 Ok(())
626 } else {
627 Err(LinkError::BadAddress {
628 given: handle.to_string(),
629 reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
630 })
631 }
632}
633
634fn require_safe_grant_id(id: &str) -> LinkResult<()> {
638 if is_safe_ref(id) {
639 Ok(())
640 } else {
641 Err(LinkError::BadGrantId {
642 given: id.to_string(),
643 })
644 }
645}
646
647#[derive(Debug, Clone)]
653pub struct HubConfig {
654 pub hub: String,
656 pub key: Option<String>,
658 pub agent_key: Option<AgentSigningKey>,
661 pub brain_key: Option<AgentSigningKey>,
664 pub state_dir: PathBuf,
667 store_selected: bool,
670}
671
672#[derive(Clone)]
675pub struct AgentSigningKey {
676 pkcs8: Vec<u8>,
677 pub multikey: String,
679 pub public_key_spki: String,
681}
682
683impl std::fmt::Debug for AgentSigningKey {
684 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
685 f.debug_struct("AgentSigningKey")
686 .field("multikey", &self.multikey)
687 .field("pkcs8", &"<redacted>")
688 .finish()
689 }
690}
691
692impl HubConfig {
693 pub fn require_key(&self) -> LinkResult<&str> {
696 self.key.as_deref().ok_or(LinkError::NoCredential)
697 }
698}
699
700pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
705 let explicit_hub = flag_hub
706 .map(str::to_string)
707 .or_else(|| env_nonempty(HUB_URL_ENV));
708 let selected_by_store = explicit_hub.is_none();
709 let hub = explicit_hub
710 .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
711 .ok_or(LinkError::NoHub)?;
712 let hub = hub.trim().trim_end_matches('/').to_string();
713 assert_safe_hub(&hub)?;
714 if selected_by_store {
715 let parsed =
716 url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
717 if !parsed.scheme().eq_ignore_ascii_case("https")
721 || (parsed.path() != "/" && !parsed.path().is_empty())
722 {
723 return Err(LinkError::UnsafeHub { hub });
724 }
725 }
726
727 let key = match env_nonempty(HUB_KEY_ENV) {
728 Some(raw) => Some(clean_key(&raw)?),
729 None => None,
730 };
731
732 let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
733 Some(path) => Some(load_agent_key(Path::new(&path))?),
734 None => None,
735 };
736
737 let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
738 Some(path) => Some(load_agent_key(Path::new(&path))?),
739 None => None,
740 };
741
742 if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
749 let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
750 .and_then(|value| normalized_origin(&value).ok());
751 let selected_origin = normalized_origin(&hub)?;
752 if bound.as_deref() != Some(selected_origin.as_str()) {
753 return Err(LinkError::UnboundCredential);
754 }
755 }
756
757 Ok(HubConfig {
758 hub,
759 key,
760 agent_key,
761 brain_key,
762 state_dir: toolkit_state_dir()?,
763 store_selected: selected_by_store,
764 })
765}
766
767fn toolkit_state_dir() -> LinkResult<PathBuf> {
768 if let Some(path) = env_nonempty(STATE_DIR_ENV) {
769 let path = PathBuf::from(path);
770 if !path.is_absolute() {
771 return Err(LinkError::UnsafePath {
772 path: path.display().to_string(),
773 });
774 }
775 return Ok(path);
776 }
777 #[cfg(windows)]
778 if let Some(base) = env_nonempty("LOCALAPPDATA") {
779 let base = PathBuf::from(base);
780 if base.is_absolute() {
781 return Ok(base.join("dbmd").join("state"));
782 }
783 }
784 #[cfg(windows)]
785 {
786 Err(LinkError::Io(std::io::Error::new(
787 std::io::ErrorKind::NotFound,
788 format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
789 )))
790 }
791 #[cfg(not(windows))]
792 if let Some(base) = env_nonempty("XDG_STATE_HOME") {
793 let base = PathBuf::from(base);
794 if base.is_absolute() {
795 return Ok(base.join("dbmd"));
796 }
797 }
798 #[cfg(not(windows))]
799 let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
800 LinkError::Io(std::io::Error::new(
801 std::io::ErrorKind::NotFound,
802 format!("cannot locate user state; set {STATE_DIR_ENV}"),
803 ))
804 })?);
805 #[cfg(not(windows))]
806 if !home.is_absolute() {
807 return Err(LinkError::UnsafePath {
808 path: home.display().to_string(),
809 });
810 }
811 #[cfg(target_os = "macos")]
812 {
813 Ok(home
814 .join("Library")
815 .join("Application Support")
816 .join("dbmd")
817 .join("state"))
818 }
819 #[cfg(all(not(target_os = "macos"), not(windows)))]
820 {
821 Ok(home.join(".local").join("state").join("dbmd"))
822 }
823}
824
825fn normalized_origin(value: &str) -> LinkResult<String> {
826 let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
827 hub: value.to_string(),
828 })?;
829 if !(parsed.scheme().eq_ignore_ascii_case("https")
830 || parsed.scheme().eq_ignore_ascii_case("http"))
831 || !parsed.username().is_empty()
832 || parsed.password().is_some()
833 || (parsed.path() != "/" && !parsed.path().is_empty())
834 || parsed.query().is_some()
835 || parsed.fragment().is_some()
836 {
837 return Err(LinkError::UnsafeHub {
838 hub: value.to_string(),
839 });
840 }
841 let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
842 hub: value.to_string(),
843 })?;
844 let host = if host.contains(':') {
845 format!("[{host}]")
846 } else {
847 host.to_ascii_lowercase()
848 };
849 let port = parsed
850 .port_or_known_default()
851 .ok_or_else(|| LinkError::UnsafeHub {
852 hub: value.to_string(),
853 })?;
854 let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
855 || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
856 Ok(format!(
857 "{}://{}{}",
858 parsed.scheme().to_ascii_lowercase(),
859 host,
860 if default {
861 String::new()
862 } else {
863 format!(":{port}")
864 }
865 ))
866}
867
868const ED25519_SPKI_PREFIX: [u8; 12] = [
875 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
876];
877
878fn bad_agent_key(message: &str) -> LinkError {
879 LinkError::BadAgentKey {
880 message: message.to_string(),
881 }
882}
883
884fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
885 ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
889 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
890 .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
891}
892
893fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
895 use ring::signature::KeyPair as _;
896 let mut spki = Vec::with_capacity(44);
897 spki.extend_from_slice(&ED25519_SPKI_PREFIX);
898 spki.extend_from_slice(pair.public_key().as_ref());
899 (
900 URL_SAFE_NO_PAD.encode(&spki),
901 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
902 )
903}
904
905pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
909 load_agent_key(path)
910}
911
912fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
914 #[cfg(unix)]
915 let file = {
916 use std::os::fd::{AsRawFd as _, FromRawFd as _};
917 use std::os::unix::ffi::OsStrExt as _;
918 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
919 .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
920 let leaf = path
921 .file_name()
922 .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
923 let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
924 let fd = unsafe {
925 libc::openat(
926 parent.as_raw_fd(),
927 leaf.as_ptr(),
928 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
929 )
930 };
931 if fd < 0 {
932 return Err(bad_agent_key(
933 "the key path must be an existing regular file without symlink ancestors",
934 ));
935 }
936 unsafe { std::fs::File::from_raw_fd(fd) }
937 };
938 #[cfg(not(unix))]
939 let file = std::fs::File::open(path)
940 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
941 let metadata = file
942 .metadata()
943 .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
944 if !metadata.is_file() {
945 return Err(bad_agent_key("the key path must be a regular file"));
946 }
947 #[cfg(unix)]
948 {
949 use std::os::unix::fs::PermissionsExt as _;
950 if metadata.permissions().mode() & 0o077 != 0 {
951 return Err(bad_agent_key(
952 "the key file is accessible to group/other; set mode 0600",
953 ));
954 }
955 }
956 let mut text = String::new();
957 file.take(1024 * 1024 + 1)
958 .read_to_string(&mut text)
959 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
960 if text.len() > 1024 * 1024 {
961 return Err(bad_agent_key("the key file exceeds the size limit"));
962 }
963 let pkcs8 = URL_SAFE_NO_PAD
964 .decode(text.trim())
965 .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
966 let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
967 Ok(AgentSigningKey {
968 pkcs8,
969 multikey,
970 public_key_spki,
971 })
972}
973
974fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
980 #[cfg(unix)]
981 let (mut file, parent, leaf) = {
982 use std::os::fd::{AsRawFd as _, FromRawFd as _};
983 use std::os::unix::ffi::OsStrExt as _;
984 let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
985 let leaf_name = path
986 .file_name()
987 .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
988 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
989 let fd = unsafe {
990 libc::openat(
991 parent.as_raw_fd(),
992 leaf.as_ptr(),
993 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
994 0o600,
995 )
996 };
997 if fd < 0 {
998 let error = std::io::Error::last_os_error();
999 if error.kind() == std::io::ErrorKind::AlreadyExists {
1000 return Err(bad_agent_key(
1001 "the output file already exists — refusing to overwrite a key",
1002 ));
1003 }
1004 return Err(error.into());
1005 }
1006 (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1007 };
1008 #[cfg(not(unix))]
1009 let mut file = std::fs::OpenOptions::new()
1010 .write(true)
1011 .create_new(true)
1012 .open(path)
1013 .map_err(|error| {
1014 if error.kind() == std::io::ErrorKind::AlreadyExists {
1015 bad_agent_key("the output file already exists — refusing to overwrite a key")
1016 } else {
1017 LinkError::Io(error)
1018 }
1019 })?;
1020 if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1021 drop(file);
1022 #[cfg(unix)]
1023 let _ =
1024 unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1025 #[cfg(not(unix))]
1026 let _ = std::fs::remove_file(path);
1027 return Err(LinkError::Io(error));
1028 }
1029 drop(file);
1030 #[cfg(unix)]
1031 parent.sync_all()?;
1032 Ok(())
1033}
1034
1035#[derive(Debug, Serialize)]
1038pub struct GeneratedAgentKey {
1039 pub multikey: String,
1041 #[serde(rename = "publicKeySpki")]
1043 pub public_key_spki: String,
1044 #[serde(rename = "keyFile")]
1046 pub key_file: String,
1047}
1048
1049pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1054 require_hardened_filesystem("key generation")?;
1055 let rng = ring::rand::SystemRandom::new();
1056 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1057 .map_err(|_| bad_agent_key("key generation failed"))?;
1058 let pair = agent_keypair(pkcs8.as_ref())?;
1059 let (spki_b64u, multikey) = public_identity_for(&pair);
1060
1061 write_secret_new(
1062 out,
1063 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1064 )?;
1065
1066 Ok(GeneratedAgentKey {
1067 multikey,
1068 public_key_spki: spki_b64u,
1069 key_file: out.display().to_string(),
1070 })
1071}
1072
1073fn linkmd_sig_header(
1082 key: &AgentSigningKey,
1083 origin: &str,
1084 method: &str,
1085 path: &str,
1086 body: Option<&str>,
1087) -> LinkResult<String> {
1088 let ts = std::time::SystemTime::now()
1089 .duration_since(std::time::UNIX_EPOCH)
1090 .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1091 .as_secs();
1092 let body_hash = match body {
1093 Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1094 None => "-".to_string(),
1095 };
1096 let canonical = format!(
1097 "v2\n{}\n{}\n{}\n{}\n{}",
1098 origin,
1099 method.to_uppercase(),
1100 path,
1101 ts,
1102 body_hash
1103 );
1104 let pair = agent_keypair(&key.pkcs8)?;
1105 let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1106 let fingerprint = key.multikey.trim_start_matches("ed25519:");
1107 Ok(format!(
1108 "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1109 ))
1110}
1111
1112#[derive(Serialize)]
1119struct WireFeedFile {
1120 path: String,
1121 sha256: String,
1122 bytes: u64,
1123}
1124
1125#[derive(Serialize)]
1128struct UnsignedWireEntry<'a> {
1129 v: u8,
1130 seq: u64,
1131 ts: String,
1132 brain: &'a str,
1133 public_key: &'a str,
1134 kind: &'a str,
1135 op: &'a str,
1136 pack_sha256: &'a str,
1137 files: &'a [WireFeedFile],
1138 removed: &'a [String],
1139 prev_entry_hash: Option<&'a str>,
1140}
1141
1142fn self_custody_entry(
1148 key: &AgentSigningKey,
1149 seq: u64,
1150 ts: String,
1151 pack_sha256: &str,
1152 files: &[WireFeedFile],
1153 prev_entry_hash: Option<&str>,
1154) -> LinkResult<String> {
1155 let removed: [String; 0] = [];
1156 let unsigned = serde_json::to_string(&UnsignedWireEntry {
1157 v: 1,
1158 seq,
1159 ts,
1160 brain: &key.multikey,
1161 public_key: &key.public_key_spki,
1162 kind: "push",
1163 op: "snapshot",
1164 pack_sha256,
1165 files,
1166 removed: &removed,
1167 prev_entry_hash,
1168 })
1169 .expect("serialize feed entry");
1170 let pair = agent_keypair(&key.pkcs8)?;
1171 let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1172 Ok(format!(
1173 "{},\"sig\":\"{}\"}}",
1174 &unsigned[..unsigned.len() - 1],
1175 sig
1176 ))
1177}
1178
1179fn env_nonempty(name: &str) -> Option<String> {
1182 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1183}
1184
1185fn config_file_hub(path: &Path) -> Option<String> {
1190 const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1191 #[cfg(unix)]
1192 let file = {
1193 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1194 use std::os::unix::ffi::OsStrExt as _;
1195 let parent =
1196 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1197 let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1198 let fd = unsafe {
1199 libc::openat(
1200 parent.as_raw_fd(),
1201 leaf.as_ptr(),
1202 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1203 )
1204 };
1205 if fd < 0 {
1206 return None;
1207 }
1208 unsafe { std::fs::File::from_raw_fd(fd) }
1209 };
1210 #[cfg(not(unix))]
1211 let file = std::fs::File::open(path).ok()?;
1212 let metadata = file.metadata().ok()?;
1213 if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1214 return None;
1215 }
1216 let mut bytes = Vec::with_capacity(metadata.len() as usize);
1217 file.take(MAX_CONFIG_BYTES + 1)
1218 .read_to_end(&mut bytes)
1219 .ok()?;
1220 if bytes.len() as u64 > MAX_CONFIG_BYTES {
1221 return None;
1222 }
1223 let text = String::from_utf8(bytes).ok()?;
1224 for line in text.lines() {
1225 let line = line.trim();
1226 if line.is_empty() || line.starts_with('#') {
1227 continue;
1228 }
1229 if let Some((k, v)) = line.split_once('=') {
1230 if k.trim() == "hub" {
1231 let v = v.trim();
1232 if !v.is_empty() {
1233 return Some(v.to_string());
1234 }
1235 }
1236 }
1237 }
1238 None
1239}
1240
1241fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1244 let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1245 hub: hub.to_string(),
1246 })?;
1247 if !(parsed.scheme().eq_ignore_ascii_case("https")
1248 || parsed.scheme().eq_ignore_ascii_case("http"))
1249 || !parsed.username().is_empty()
1250 || parsed.password().is_some()
1251 || (parsed.path() != "/" && !parsed.path().is_empty())
1252 || parsed.query().is_some()
1253 || parsed.fragment().is_some()
1254 {
1255 return Err(LinkError::UnsafeHub {
1256 hub: hub.to_string(),
1257 });
1258 }
1259 let loopback = match parsed.host() {
1260 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1261 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1262 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1263 None => false,
1264 };
1265 if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1266 Ok(())
1267 } else {
1268 Err(LinkError::UnsafeHub {
1269 hub: hub.to_string(),
1270 })
1271 }
1272}
1273
1274fn clean_key(raw: &str) -> LinkResult<String> {
1279 let k = raw.trim();
1280 if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1281 return Err(LinkError::BadKey);
1282 }
1283 Ok(k.to_string())
1284}
1285
1286#[derive(Debug)]
1292pub struct HubResponse {
1293 pub status: u16,
1295 pub body: Option<Value>,
1297}
1298
1299struct RawHubResponse {
1300 status: u16,
1301 body: Vec<u8>,
1302}
1303
1304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1306enum Auth {
1307 Required,
1309 None,
1311 Optional,
1315}
1316
1317fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1318 ureq::AgentBuilder::new()
1319 .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1320 .redirects(0)
1324 .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1325 .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1326 .timeout_write(overall)
1327 .timeout(overall)
1328}
1329
1330fn agent_builder() -> ureq::AgentBuilder {
1331 agent_builder_with_timeout(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS))
1332}
1333
1334fn agent() -> ureq::Agent {
1335 agent_builder().build()
1336}
1337
1338fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1339 if !cfg.store_selected {
1340 return Ok(agent());
1341 }
1342 let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1343 hub: cfg.hub.clone(),
1344 })?;
1345 pinned_public_agent(&parsed, false, "store-selected hub")
1346}
1347
1348fn request_raw(
1353 cfg: &HubConfig,
1354 method: &str,
1355 path: &str,
1356 body: Option<&Value>,
1357 auth: Auth,
1358 max_response_bytes: u64,
1359) -> LinkResult<RawHubResponse> {
1360 let http = hub_agent(cfg)?;
1361 request_raw_with_agent(cfg, &http, method, path, body, auth, max_response_bytes)
1362}
1363
1364fn request_raw_with_agent(
1365 cfg: &HubConfig,
1366 http: &ureq::Agent,
1367 method: &str,
1368 path: &str,
1369 body: Option<&Value>,
1370 auth: Auth,
1371 max_response_bytes: u64,
1372) -> LinkResult<RawHubResponse> {
1373 let url = format!("{}{}", cfg.hub, path);
1374 let encoded_body = body.map(Value::to_string);
1375 let origin = normalized_origin(&cfg.hub)?;
1376 let credential = match auth {
1379 Auth::Required => Some(match &cfg.agent_key {
1380 Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1381 None => format!("Bearer {}", cfg.require_key()?),
1382 }),
1383 Auth::Optional => match &cfg.agent_key {
1384 Some(key) => Some(linkmd_sig_header(
1385 key,
1386 &origin,
1387 method,
1388 path,
1389 encoded_body.as_deref(),
1390 )?),
1391 None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1392 },
1393 Auth::None => None,
1394 };
1395 let result = with_connect_retries(|| {
1396 let mut req = http.request(method, &url);
1397 if let Some(value) = &credential {
1398 req = req.set("authorization", value);
1399 }
1400 match &encoded_body {
1401 Some(value) => req
1402 .set("content-type", "application/json")
1403 .send_string(value)
1404 .map_err(Box::new),
1405 None => req.call().map_err(Box::new),
1406 }
1407 });
1408 let resp = match result {
1409 Ok(resp) => resp,
1410 Err(error) => match *error {
1411 ureq::Error::Status(_, resp) => resp,
1412 ureq::Error::Transport(error) => {
1413 return Err(LinkError::Transport {
1414 hub: cfg.hub.clone(),
1415 message: error.to_string(),
1416 });
1417 }
1418 },
1419 };
1420
1421 let status = resp.status();
1422 let mut buf = Vec::new();
1423 resp.into_reader()
1424 .take(max_response_bytes + 1)
1425 .read_to_end(&mut buf)?;
1426 if buf.len() as u64 > max_response_bytes {
1427 return Err(LinkError::ResponseTooLarge {
1428 limit_bytes: max_response_bytes,
1429 });
1430 }
1431 Ok(RawHubResponse { status, body: buf })
1432}
1433
1434fn request_capped(
1435 cfg: &HubConfig,
1436 method: &str,
1437 path: &str,
1438 body: Option<&Value>,
1439 auth: Auth,
1440 max_response_bytes: u64,
1441) -> LinkResult<HubResponse> {
1442 let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1443 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1444 Ok(HubResponse {
1445 status: raw.status,
1446 body: parsed,
1447 })
1448}
1449
1450fn request(
1451 cfg: &HubConfig,
1452 method: &str,
1453 path: &str,
1454 body: Option<&Value>,
1455 auth: Auth,
1456) -> LinkResult<HubResponse> {
1457 request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1458}
1459
1460fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1461 if (200..300).contains(&r.status) {
1462 return Ok(r.body);
1463 }
1464 ensure_ok(
1465 HubResponse {
1466 status: r.status,
1467 body: serde_json::from_slice(&r.body).ok(),
1468 },
1469 what,
1470 )
1471 .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1472}
1473
1474fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1479 matches!(
1480 kind,
1481 ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1482 )
1483}
1484
1485fn with_connect_retries(
1486 mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1487) -> Result<ureq::Response, Box<ureq::Error>> {
1488 let mut attempt = 0;
1489 loop {
1490 match send() {
1491 Err(error)
1492 if matches!(
1493 error.as_ref(),
1494 ureq::Error::Transport(transport)
1495 if is_pre_request_transport(transport.kind())
1496 ) && attempt + 1 < CONNECT_ATTEMPTS =>
1497 {
1498 std::thread::sleep(std::time::Duration::from_millis(
1499 CONNECT_RETRY_BACKOFF_MS[attempt],
1500 ));
1501 attempt += 1;
1502 }
1503 result => return result,
1504 }
1505 }
1506}
1507
1508fn hub_is_loopback(hub: &str) -> bool {
1509 url::Url::parse(hub).ok().is_some_and(|parsed| {
1510 parsed.host().is_some_and(|host| match host {
1511 url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1512 url::Host::Ipv4(ip) => ip.is_loopback(),
1513 url::Host::Ipv6(ip) => ip.is_loopback(),
1514 })
1515 })
1516}
1517
1518fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1519 let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1520 message: "the hub returned an invalid object-store URL".to_string(),
1521 })?;
1522 let allow_private = hub_is_loopback(&cfg.hub)
1523 || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1524 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1525 || !parsed.username().is_empty()
1526 || parsed.password().is_some()
1527 || parsed.fragment().is_some()
1528 {
1529 return Err(LinkError::InvalidPack {
1530 message: "the hub returned an unsafe object-store URL".to_string(),
1531 });
1532 }
1533 pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1534 LinkError::InvalidPack {
1535 message: "the hub returned an object-store URL with an unsafe network target"
1536 .to_string(),
1537 }
1538 })
1539}
1540
1541fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1542 let http = presigned_agent(cfg, raw)?;
1543 let result = with_connect_retries(|| {
1544 let mut req = http.put(raw);
1545 if let Some(map) = headers.as_object() {
1546 for (name, value) in map {
1547 if let Some(value) = value.as_str() {
1548 req = req.set(name, value);
1549 }
1550 }
1551 }
1552 req.send_bytes(bytes).map_err(Box::new)
1553 });
1554 match result {
1555 Ok(resp) if (200..300).contains(&resp.status()) => Ok(()),
1556 Ok(resp) => Err(LinkError::Http {
1557 what: "pack upload",
1558 status: resp.status(),
1559 message: "object store rejected the upload".to_string(),
1560 code: None,
1561 details: None,
1562 }),
1563 Err(error) => match *error {
1564 ureq::Error::Status(412, _) => Ok(()),
1569 ureq::Error::Status(_, resp) => Err(LinkError::Http {
1570 what: "pack upload",
1571 status: resp.status(),
1572 message: "object store rejected the upload".to_string(),
1573 code: None,
1574 details: None,
1575 }),
1576 ureq::Error::Transport(err) => Err(LinkError::Transport {
1577 hub: "the object store".to_string(),
1578 message: err.to_string(),
1579 }),
1580 },
1581 }
1582}
1583
1584fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1585 max_bytes.checked_add(1)
1586}
1587
1588fn presigned_download_read_limit() -> u64 {
1589 one_past_bounded_limit(MAX_PACK_BYTES)
1590 .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1591}
1592
1593fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1594 let http = presigned_agent(cfg, raw)?;
1595 let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1596 Ok(resp) => resp,
1597 Err(error) => match *error {
1598 ureq::Error::Status(_, resp) => {
1599 return Err(LinkError::Http {
1600 what: "pack download",
1601 status: resp.status(),
1602 message: "object store rejected the download".to_string(),
1603 code: None,
1604 details: None,
1605 });
1606 }
1607 ureq::Error::Transport(err) => {
1608 return Err(LinkError::Transport {
1609 hub: "the object store".to_string(),
1610 message: err.to_string(),
1611 });
1612 }
1613 },
1614 };
1615 if !(200..300).contains(&resp.status()) {
1616 return Err(LinkError::Http {
1617 what: "pack download",
1618 status: resp.status(),
1619 message: "object store rejected the download".to_string(),
1620 code: None,
1621 details: None,
1622 });
1623 }
1624 let mut bytes = Vec::new();
1625 resp.into_reader()
1626 .take(presigned_download_read_limit())
1627 .read_to_end(&mut bytes)?;
1628 if bytes.len() as u64 > MAX_PACK_BYTES {
1629 return Err(LinkError::InvalidPack {
1630 message: "download exceeds the compressed-size limit".to_string(),
1631 });
1632 }
1633 Ok(bytes)
1634}
1635
1636fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
1640 if !(200..300).contains(&r.status) {
1641 let message = r
1642 .body
1643 .as_ref()
1644 .and_then(|b| b.get("error"))
1645 .and_then(Value::as_str)
1646 .unwrap_or("unknown error")
1647 .to_string();
1648 let code = r
1649 .body
1650 .as_ref()
1651 .and_then(|b| b.get("code"))
1652 .and_then(Value::as_str)
1653 .map(str::to_string);
1654 let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
1655 return Err(LinkError::Http {
1656 what,
1657 status: r.status,
1658 message,
1659 code,
1660 details,
1661 });
1662 }
1663 r.body.ok_or(LinkError::NotJson {
1664 what,
1665 status: r.status,
1666 })
1667}
1668
1669fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
1678 match ip {
1679 std::net::IpAddr::V4(ip) => {
1680 let [a, b, c, _] = ip.octets();
1681 !(a == 0
1682 || a == 10
1683 || a == 127
1684 || (a == 100 && (64..=127).contains(&b))
1685 || (a == 169 && b == 254)
1686 || (a == 172 && (16..=31).contains(&b))
1687 || (a == 192 && b == 0 && c == 0)
1688 || (a == 192 && b == 0 && c == 2)
1689 || (a == 192 && b == 88 && c == 99)
1690 || (a == 192 && b == 168)
1691 || (a == 198 && (b == 18 || b == 19))
1692 || (a == 198 && b == 51 && c == 100)
1693 || (a == 203 && b == 0 && c == 113)
1694 || a >= 224)
1695 }
1696 std::net::IpAddr::V6(ip) => {
1697 let segments = ip.segments();
1698 (segments[0] & 0xe000) == 0x2000
1703 && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
1704 && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
1705 && segments[0] != 0x2002
1706 && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
1707 }
1708 }
1709}
1710
1711#[derive(Clone)]
1712struct PinnedRegistryResolver {
1713 netloc: String,
1714 addresses: Vec<std::net::SocketAddr>,
1715}
1716
1717impl ureq::Resolver for PinnedRegistryResolver {
1718 fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
1719 if requested == self.netloc {
1720 Ok(self.addresses.clone())
1721 } else {
1722 Err(std::io::Error::new(
1723 std::io::ErrorKind::PermissionDenied,
1724 "registry request attempted to resolve an unvalidated authority",
1725 ))
1726 }
1727 }
1728}
1729
1730fn pinned_public_agent(
1731 url: &url::Url,
1732 allow_private: bool,
1733 label: &str,
1734) -> LinkResult<ureq::Agent> {
1735 let host = url
1736 .host_str()
1737 .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
1738 let port = url
1739 .port_or_known_default()
1740 .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
1741 let addresses = resolve_addresses_with_deadline(
1742 host,
1743 port,
1744 std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
1745 )
1746 .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
1747 if addresses.is_empty() {
1748 return Err(invalid_feed(format!("{label} DNS returned no addresses")));
1749 }
1750 if !allow_private
1751 && addresses
1752 .iter()
1753 .any(|address| !is_public_registry_ip(address.ip()))
1754 {
1755 return Err(invalid_feed(format!(
1756 "{label} resolves to a non-public address"
1757 )));
1758 }
1759 let netloc = if host.contains(':') {
1760 format!("[{host}]:{port}")
1761 } else {
1762 format!("{host}:{port}")
1763 };
1764 Ok(agent_builder()
1765 .resolver(PinnedRegistryResolver { netloc, addresses })
1766 .build())
1767}
1768
1769fn resolve_addresses_with_deadline(
1774 host: &str,
1775 port: u16,
1776 timeout: std::time::Duration,
1777) -> std::io::Result<Vec<std::net::SocketAddr>> {
1778 use std::net::ToSocketAddrs as _;
1779
1780 let host = host.to_string();
1781 let (send, receive) = std::sync::mpsc::sync_channel(1);
1782 std::thread::Builder::new()
1783 .name("dbmd-dns".to_string())
1784 .spawn(move || {
1785 let result = (host.as_str(), port)
1786 .to_socket_addrs()
1787 .map(|addresses| addresses.collect());
1788 let _ = send.send(result);
1789 })
1790 .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
1791 match receive.recv_timeout(timeout) {
1792 Ok(result) => result,
1793 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
1794 std::io::ErrorKind::TimedOut,
1795 "resolution exceeded its deadline",
1796 )),
1797 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
1798 "resolver stopped without returning a result",
1799 )),
1800 }
1801}
1802
1803fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
1804 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1805 pinned_public_agent(url, allow_private, "registry home")
1806}
1807
1808fn get_json_absolute(url: &str) -> LinkResult<Value> {
1813 let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
1814 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1815 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1816 || !parsed.username().is_empty()
1817 || parsed.password().is_some()
1818 || parsed.query().is_some()
1819 || parsed.fragment().is_some()
1820 {
1821 return Err(invalid_feed("unsafe registry home URL"));
1822 }
1823 let http = registry_agent(&parsed)?;
1824 let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
1825 Ok(resp) => resp,
1826 Err(error) => match *error {
1827 ureq::Error::Status(status, resp) => {
1828 let _ = resp;
1829 return Err(LinkError::Http {
1830 what: "registry home fetch",
1831 status,
1832 message: "the home node rejected the card request".to_string(),
1833 code: None,
1834 details: None,
1835 });
1836 }
1837 ureq::Error::Transport(err) => {
1838 return Err(LinkError::Transport {
1839 hub: url.to_string(),
1840 message: err.to_string(),
1841 });
1842 }
1843 },
1844 };
1845 if !(200..300).contains(&resp.status()) {
1846 return Err(LinkError::Http {
1847 what: "registry home fetch",
1848 status: resp.status(),
1849 message: "the home node returned a redirect or error".to_string(),
1850 code: None,
1851 details: None,
1852 });
1853 }
1854 let mut buf = Vec::new();
1855 resp.into_reader()
1856 .take(MAX_REGISTRY_CARD_BYTES + 1)
1857 .read_to_end(&mut buf)?;
1858 if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
1859 return Err(LinkError::ResponseTooLarge {
1860 limit_bytes: MAX_REGISTRY_CARD_BYTES,
1861 });
1862 }
1863 serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
1864 message: "the home node returned invalid JSON".to_string(),
1865 })
1866}
1867
1868pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
1875 require_safe_ref(handle)?;
1876 let trust_directory = open_trust_dir(cfg)?;
1880 let reg = request_capped(
1881 cfg,
1882 "GET",
1883 &format!("/api/hub/registry/{handle}"),
1884 None,
1885 Auth::None,
1886 MAX_REGISTRY_CARD_BYTES,
1887 )?;
1888 if reg.status == 404 {
1889 return Ok(None);
1890 }
1891 let body = ensure_ok(reg, "registry resolve")?;
1892 let home = body
1893 .get("home")
1894 .and_then(Value::as_str)
1895 .ok_or_else(|| invalid_feed("registry entry has no home"))?;
1896 let brain = body
1897 .get("brain")
1898 .and_then(Value::as_str)
1899 .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
1900 if !crate::ulid::is_ulid(brain) {
1901 return Err(invalid_feed(
1902 "registry entry brain is not a canonical lowercase ULID",
1903 ));
1904 }
1905 let want_fp = body
1906 .get("identity")
1907 .and_then(|i| i.get("fingerprint"))
1908 .and_then(Value::as_str)
1909 .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
1910
1911 let home = home.trim_end_matches('/');
1912 let origin = normalized_origin(home)?;
1913 if origin != home {
1914 return Err(invalid_feed(
1915 "registry home must be an origin without a path, query, or fragment",
1916 ));
1917 }
1918 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
1919 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
1920 if let Some(binding) = &alias_binding {
1921 if binding
1922 .home
1923 .as_deref()
1924 .is_some_and(|pinned_home| pinned_home != home)
1925 {
1926 return Err(invalid_feed(
1927 "registry relocated a pinned handle to a different home",
1928 ));
1929 }
1930 }
1931 let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
1932 if card.get("id").and_then(Value::as_str) != Some(brain) {
1933 return Err(invalid_feed(
1934 "the home node served a card for a different brain",
1935 ));
1936 }
1937 let identity: FeedIdentity = serde_json::from_value(
1938 card.get("identity")
1939 .cloned()
1940 .ok_or_else(|| invalid_feed("the home node served no identity"))?,
1941 )
1942 .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
1943 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
1944 let got_fp = card
1945 .get("identity")
1946 .and_then(|i| i.get("fingerprint"))
1947 .and_then(Value::as_str)
1948 .unwrap_or_default();
1949 if got_fp != want_fp {
1950 return Err(invalid_feed(
1951 "the home node served an identity that does not match the registry — refusing",
1952 ));
1953 }
1954 let current = format!("ed25519:{}", identity.fingerprint);
1955 let advertised_seq = card
1956 .get("headSeq")
1957 .and_then(Value::as_u64)
1958 .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
1959 let advertised_hash = card.get("feedHash").and_then(Value::as_str);
1960 if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
1961 || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
1962 {
1963 return Err(invalid_feed(
1964 "the home node served an invalid feed head boundary",
1965 ));
1966 }
1967 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
1971 let registry_alias = AliasBinding {
1972 v: 1,
1973 origin: normalized_origin(&cfg.hub)?,
1974 requested: handle.to_string(),
1975 brain: brain.to_string(),
1976 home: Some(home.to_string()),
1977 };
1978 save_canonical_pin_and_alias(
1979 cfg,
1980 &trust_directory,
1981 handle,
1982 brain,
1983 TrustState {
1984 v: 2,
1985 origin: normalized_origin(&cfg.hub)?,
1986 requested: brain.to_string(),
1987 brain: brain.to_string(),
1988 home: None,
1989 anchor,
1990 current,
1991 head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
1992 feed_hash: pinned
1993 .as_ref()
1994 .and_then(|checkpoint| checkpoint.feed_hash.clone()),
1995 rotations: identity.rotations.clone(),
1996 hub_signer: None,
1997 protocol_profile: None,
1998 },
1999 Some(®istry_alias),
2000 )?;
2001 let mut out = card;
2002 if let Value::Object(map) = &mut out {
2003 map.insert("home".to_string(), Value::String(home.to_string()));
2004 map.insert(
2005 "resolvedVia".to_string(),
2006 Value::String("registry".to_string()),
2007 );
2008 }
2009 Ok(Some(out))
2010}
2011
2012pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2013 require_safe_ref(&addr.brain)?;
2017 if let Some(target) = &addr.target {
2018 let (given, ok) = match target {
2019 AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2020 AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2021 };
2022 if !ok {
2023 return Err(LinkError::BadAddress {
2024 given: given.clone(),
2025 reason: BAD_TARGET_REASON.to_string(),
2026 });
2027 }
2028 }
2029
2030 if let Some(target) = &addr.target {
2036 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2037 if !remote.head.verified {
2038 return Err(invalid_feed(
2039 "a path-scoped feed cannot prove a record against the full signed snapshot",
2040 ));
2041 }
2042 if remote.head.seq == 0 {
2043 return Err(LinkError::Http {
2044 what: "resolve",
2045 status: 404,
2046 message: "record not found".to_string(),
2047 code: Some("NOT_FOUND".to_string()),
2048 details: None,
2049 });
2050 }
2051 let brain = remote.head.brain.clone();
2052 let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2053 return resolve_from_verified_pack(&brain, target, pack);
2054 }
2055
2056 let path = format!("/api/hub/brains/{}", addr.brain);
2057 let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2062 if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2063 if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2064 return Ok(card);
2065 }
2066 }
2067 let resolved = ensure_ok(direct, "resolve")?;
2068 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2072 if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2073 || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2074 || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2075 {
2076 return Err(invalid_feed(
2077 "resolve card is not bound to the exact verified feed checkpoint",
2078 ));
2079 }
2080 let card_identity: FeedIdentity = serde_json::from_value(
2081 resolved
2082 .get("identity")
2083 .cloned()
2084 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2085 )
2086 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2087 if remote.identity.as_ref() != Some(&card_identity) {
2088 return Err(invalid_feed(
2089 "resolve card identity differs from the verified feed identity",
2090 ));
2091 }
2092 Ok(resolved)
2093}
2094
2095fn resolve_from_verified_pack(
2100 brain: &str,
2101 target: &AddressTarget,
2102 pack: Vec<u8>,
2103) -> LinkResult<Value> {
2104 let entries = parse_store_pack(pack)?;
2105 let mut matched: Option<(String, Vec<u8>)> = None;
2106
2107 for (path, bytes) in entries {
2108 let is_candidate = match target {
2109 AddressTarget::Path(want) => &path == want,
2110 AddressTarget::Id(_) => {
2111 path.ends_with(".md")
2112 && (path.starts_with("records/") || path.starts_with("sources/"))
2113 }
2114 };
2115 if !is_candidate {
2116 continue;
2117 }
2118 let text = std::str::from_utf8(&bytes)
2119 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2120 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2121 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2122 if let AddressTarget::Id(want) = target {
2123 let frontmatter =
2124 crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2125 .map_err(|_| {
2126 invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2127 })?;
2128 if frontmatter.id.as_deref() != Some(want) {
2129 continue;
2130 }
2131 }
2132 if matched.is_some() {
2133 return Err(invalid_feed(
2134 "signed snapshot contains more than one record for the requested target",
2135 ));
2136 }
2137 matched = Some((path, bytes));
2138 }
2139
2140 let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2141 what: "resolve",
2142 status: 404,
2143 message: "record not found".to_string(),
2144 code: Some("NOT_FOUND".to_string()),
2145 details: None,
2146 })?;
2147 let text = std::str::from_utf8(&bytes)
2148 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2149 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2150 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2151 let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2152 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2153 let Value::Object(fields) = frontmatter else {
2154 return Err(invalid_feed(format!(
2155 "signed snapshot record `{path}` frontmatter is not a mapping"
2156 )));
2157 };
2158 let mut document = serde_json::Map::new();
2159 document.insert("path".to_string(), Value::String(path));
2160 for (key, value) in fields {
2161 document.insert(key, value);
2162 }
2163 document.insert("body".to_string(), Value::String(parsed.body));
2164 document.insert(
2165 "contentSha".to_string(),
2166 Value::String(content_sha256(&bytes)),
2167 );
2168 Ok(json!({
2169 "brain": brain,
2170 "document": Value::Object(document),
2171 }))
2172}
2173
2174#[derive(Debug, Clone, serde::Serialize)]
2180pub struct PullReport {
2181 pub brain: String,
2183 pub slug: String,
2185 #[serde(rename = "headSeq")]
2187 pub head_seq: u64,
2188 pub files: usize,
2190 pub dest: String,
2192 #[serde(rename = "extraLocal")]
2195 pub extra_local: Vec<String>,
2196 #[serde(rename = "syncStatus")]
2198 pub sync_status: String,
2199}
2200
2201struct V2PulledSnapshot {
2202 report: PullReport,
2203 head: V2VerifiedHead,
2204 files: std::collections::BTreeMap<String, V2BaselineFile>,
2205 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2206 local: V2LocalView,
2207 local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2208}
2209
2210fn download_verified_snapshot_pack(
2211 cfg: &HubConfig,
2212 brain: &str,
2213 remote: &VerifiedRemote,
2214) -> LinkResult<Vec<u8>> {
2215 let feed_hash = remote
2216 .head
2217 .feed_hash
2218 .as_deref()
2219 .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2220 let signed_head = remote
2221 .head_entry
2222 .as_ref()
2223 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2224 let expected = &signed_head.entry.pack_sha256;
2225 if !is_sha256(expected) {
2226 return Err(invalid_feed(
2227 "signed head carries an invalid snapshot pack digest",
2228 ));
2229 }
2230 let path = format!(
2231 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2232 remote.head.seq
2233 );
2234 let body = ensure_ok(
2235 request(cfg, "GET", &path, None, Auth::Required)?,
2236 "sync pull",
2237 )?;
2238 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2239 || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2240 || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2241 || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2242 {
2243 return Err(invalid_feed(
2244 "export response is not bound to the exact verified snapshot",
2245 ));
2246 }
2247 let url = body
2248 .get("url")
2249 .and_then(Value::as_str)
2250 .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2251 let bytes = get_presigned(cfg, url)?;
2252 if content_sha256(&bytes) != *expected {
2253 return Err(LinkError::InvalidPack {
2254 message: "downloaded pack does not match the signed snapshot digest".to_string(),
2255 });
2256 }
2257 let entries = parse_store_pack(bytes.clone())?;
2258 if signed_head.entry.kind == "push" {
2259 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2260 }
2261 Ok(bytes)
2262}
2263
2264#[derive(Debug, Clone, Deserialize, Serialize)]
2265struct V2PointerBody {
2266 v: u8,
2267 brain: String,
2268 seq: u64,
2269 commit_hash: String,
2270 feed_hash: String,
2271 content_root: Option<String>,
2272 asset_root: Option<String>,
2273 materializer: String,
2274 signer_epoch: u64,
2275 control_revision: String,
2276 backup_preparation: String,
2277 prior_pointer_hash: Option<String>,
2278 signed_at: String,
2279}
2280
2281#[derive(Debug, Clone, Deserialize)]
2282struct V2SignedPointer {
2283 pointer: V2PointerBody,
2284 hub_public_key: String,
2285 hub_fingerprint: String,
2286 sig: String,
2287}
2288
2289#[derive(Debug, Clone, Deserialize)]
2290struct V2HeadIdentity {
2291 #[serde(default)]
2292 custody: String,
2293 fingerprint: String,
2294 public_key_spki: String,
2295 #[serde(default)]
2296 previous: Vec<V2PreviousIdentity>,
2297 #[serde(default)]
2298 rotations: Vec<String>,
2299}
2300
2301#[derive(Debug, Clone, Deserialize)]
2302struct V2PreviousIdentity {
2303 fingerprint: String,
2304 public_key_spki: String,
2305}
2306
2307#[derive(Debug, Deserialize)]
2308struct V2HeadResponse {
2309 v: u8,
2310 brain_id: String,
2311 profile: String,
2312 view: Option<V2HeadView>,
2313 pointer: Option<V2SignedPointer>,
2314 identity: Option<V2HeadIdentity>,
2315}
2316
2317#[derive(Debug, Clone, Deserialize)]
2318struct V2HeadView {
2319 kind: String,
2320 #[serde(default)]
2321 id: Option<String>,
2322 control_revision: String,
2323}
2324
2325#[derive(Debug, Clone)]
2326struct V2VerifiedHead {
2327 requested: String,
2328 brain_id: String,
2329 view_kind: String,
2330 view_revision: String,
2332 control_revision: String,
2334 identity: V2HeadIdentity,
2335 pointer: Option<V2PointerBody>,
2336 trust: TrustState,
2337 alias: Option<AliasBinding>,
2338}
2339
2340fn verify_v2_spki_signature(
2341 public_key: &str,
2342 message: &[u8],
2343 signature: &str,
2344) -> LinkResult<Vec<u8>> {
2345 let der = URL_SAFE_NO_PAD
2346 .decode(public_key)
2347 .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2348 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2349 return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2350 }
2351 let sig = URL_SAFE_NO_PAD
2352 .decode(signature)
2353 .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2354 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2355 .verify(message, &sig)
2356 .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2357 Ok(der)
2358}
2359
2360fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2361 if pointer.pointer.v != 2
2362 || pointer.pointer.brain != expected_brain
2363 || pointer.pointer.seq == 0
2364 || !is_sha256(&pointer.pointer.commit_hash)
2365 || !is_sha256(&pointer.pointer.feed_hash)
2366 || pointer
2367 .pointer
2368 .content_root
2369 .as_deref()
2370 .is_some_and(|hash| !is_sha256(hash))
2371 || !is_sha256(&pointer.pointer.backup_preparation)
2372 {
2373 return Err(invalid_feed("v2 pointer fields are invalid"));
2374 }
2375 let value = serde_json::to_value(&pointer.pointer)
2376 .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2377 let message = crate::linkmd_v2::canonical_bytes(&value)
2378 .map_err(|error| invalid_feed(error.to_string()))?;
2379 let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2380 let fingerprint = format!("{:x}", Sha256::digest(&der));
2381 if fingerprint != pointer.hub_fingerprint {
2382 return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2383 }
2384 Ok(format!(
2385 "{}:{}",
2386 pointer.hub_fingerprint, pointer.hub_public_key
2387 ))
2388}
2389
2390fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2391 FeedIdentity {
2392 fingerprint: identity.fingerprint.clone(),
2393 public_key_spki: identity.public_key_spki.clone(),
2394 previous: identity
2395 .previous
2396 .iter()
2397 .map(|previous| PreviousIdentity {
2398 fingerprint: previous.fingerprint.clone(),
2399 public_key_spki: previous.public_key_spki.clone(),
2400 })
2401 .collect(),
2402 rotations: identity.rotations.clone(),
2403 }
2404}
2405
2406fn verified_v2_commit_object(
2407 raw: &[u8],
2408 identity: &V2HeadIdentity,
2409) -> LinkResult<serde_json::Map<String, Value>> {
2410 let mut value: Value =
2411 serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2412 let canonical = crate::linkmd_v2::canonical_bytes(&value)
2413 .map_err(|error| invalid_feed(error.to_string()))?;
2414 if canonical != raw {
2415 return Err(invalid_feed("v2 commit is not canonical JSON"));
2416 }
2417 let object = value
2418 .as_object_mut()
2419 .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
2420 let sig = object
2421 .remove("sig")
2422 .and_then(|value| value.as_str().map(str::to_string))
2423 .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
2424 const FIELDS: [&str; 18] = [
2425 "actor_ref",
2426 "asset_root",
2427 "brain",
2428 "changes_sha256",
2429 "control_revision",
2430 "materializer",
2431 "op",
2432 "parent_asset_root",
2433 "parent_commit",
2434 "parent_root",
2435 "prev_entry_hash",
2436 "public_key",
2437 "seq",
2438 "signer_epoch",
2439 "state_root",
2440 "ts",
2441 "v",
2442 "v1_bridge",
2443 ];
2444 if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
2445 return Err(invalid_feed("v2 commit has a non-normative field set"));
2446 }
2447 let seq = object
2448 .get("seq")
2449 .and_then(Value::as_u64)
2450 .filter(|seq| *seq > 0)
2451 .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
2452 let signer_epoch = object
2453 .get("signer_epoch")
2454 .and_then(Value::as_u64)
2455 .filter(|epoch| *epoch > 0)
2456 .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
2457 let hash_or_null = |field: &str| {
2458 object
2459 .get(field)
2460 .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
2461 };
2462 if object.get("v").and_then(Value::as_u64) != Some(2)
2463 || object.get("op").and_then(Value::as_str) != Some("changeset")
2464 || !object
2465 .get("changes_sha256")
2466 .and_then(Value::as_str)
2467 .is_some_and(is_sha256)
2468 || !object
2469 .get("actor_ref")
2470 .and_then(Value::as_str)
2471 .is_some_and(is_sha256)
2472 || !object
2473 .get("control_revision")
2474 .and_then(Value::as_str)
2475 .is_some_and(is_sha256)
2476 || !object
2477 .get("state_root")
2478 .and_then(Value::as_str)
2479 .is_some_and(is_sha256)
2480 || !hash_or_null("parent_commit")
2481 || !hash_or_null("parent_root")
2482 || !hash_or_null("parent_asset_root")
2483 || !hash_or_null("asset_root")
2484 || !hash_or_null("prev_entry_hash")
2485 || !object
2486 .get("materializer")
2487 .and_then(Value::as_str)
2488 .is_some_and(|value| !value.is_empty() && value.len() <= 128)
2489 || !object
2490 .get("ts")
2491 .and_then(Value::as_str)
2492 .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
2493 {
2494 return Err(invalid_feed("v2 commit fields are invalid"));
2495 }
2496 if (seq == 1
2497 && [
2498 "parent_commit",
2499 "parent_root",
2500 "parent_asset_root",
2501 "prev_entry_hash",
2502 ]
2503 .iter()
2504 .any(|field| !object.get(*field).is_some_and(Value::is_null)))
2505 || (seq > 1
2506 && ["parent_commit", "parent_root", "prev_entry_hash"]
2507 .iter()
2508 .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
2509 {
2510 return Err(invalid_feed("v2 commit parent shape is invalid"));
2511 }
2512 match object.get("v1_bridge") {
2513 Some(Value::Null) => {}
2514 Some(Value::Object(bridge))
2515 if seq == 1
2516 && bridge.len() == 3
2517 && bridge
2518 .get("head_seq")
2519 .and_then(Value::as_u64)
2520 .is_some_and(|v| v > 0)
2521 && bridge
2522 .get("feed_hash")
2523 .and_then(Value::as_str)
2524 .is_some_and(is_sha256)
2525 && bridge
2526 .get("pack_sha256")
2527 .and_then(Value::as_str)
2528 .is_some_and(is_sha256) => {}
2529 _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
2530 }
2531 let public_key = object
2532 .get("public_key")
2533 .and_then(Value::as_str)
2534 .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
2535 let der = URL_SAFE_NO_PAD
2536 .decode(public_key)
2537 .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
2538 let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
2539 if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
2540 return Err(invalid_feed("v2 commit brain identity mismatch"));
2541 }
2542 verify_identity_chain(&v2_identity(identity), None)?;
2544 let mut chain: Vec<(&str, &str)> = identity
2547 .previous
2548 .iter()
2549 .rev()
2550 .map(|previous| {
2551 (
2552 previous.fingerprint.as_str(),
2553 previous.public_key_spki.as_str(),
2554 )
2555 })
2556 .collect();
2557 chain.push((&identity.fingerprint, &identity.public_key_spki));
2558 let signer_index = chain.iter().position(|(fingerprint, spki)| {
2559 *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
2560 });
2561 let Some(signer_index) = signer_index else {
2562 return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
2563 };
2564 if signer_epoch != signer_index as u64 + 1 {
2565 return Err(invalid_feed("v2 commit signer epoch differs from its key"));
2566 }
2567 let lower_boundary = if signer_index == 0 {
2568 None
2569 } else {
2570 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
2571 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
2572 Some(prior.prior_head_seq)
2573 };
2574 let upper_boundary = if signer_index == identity.rotations.len() {
2575 None
2576 } else {
2577 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
2578 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
2579 Some(next.prior_head_seq)
2580 };
2581 if lower_boundary.is_some_and(|boundary| seq <= boundary)
2582 || upper_boundary.is_some_and(|boundary| seq > boundary)
2583 {
2584 return Err(invalid_feed(
2585 "v2 commit signer is outside its authenticated rotation epoch",
2586 ));
2587 }
2588 let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
2589 .map_err(|error| invalid_feed(error.to_string()))?;
2590 verify_v2_spki_signature(public_key, &unsigned, &sig)?;
2591 Ok(object.clone())
2592}
2593
2594#[derive(Debug, Deserialize)]
2595struct V2FeedWireEntry {
2596 seq: u64,
2597 commit_hash: String,
2598 feed_hash: String,
2599 bytes_base64: String,
2600}
2601
2602#[derive(Debug, Deserialize)]
2603struct V2FeedPage {
2604 v: u8,
2605 head_seq: u64,
2606 head_commit_hash: String,
2607 head_feed_hash: String,
2608 entries: Vec<V2FeedWireEntry>,
2609 next_after: u64,
2610 complete: bool,
2611}
2612
2613fn replay_v2_feed(
2614 cfg: &HubConfig,
2615 brain: &str,
2616 pointer: &V2PointerBody,
2617 identity: &V2HeadIdentity,
2618 start_after: u64,
2619 start_feed: Option<String>,
2620) -> LinkResult<()> {
2621 let mut after = start_after;
2622 let mut prior_feed = start_feed;
2623 let mut final_object = None;
2624 let mut replayed_entries = 0_u64;
2625 let mut replayed_bytes = 0_u64;
2626 while after < pointer.seq {
2627 let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
2628 let value = ensure_ok(
2629 request_capped(
2630 cfg,
2631 "GET",
2632 &path,
2633 None,
2634 Auth::Required,
2635 MAX_FEED_REPLAY_BYTES,
2636 )?,
2637 "v2 feed replay",
2638 )?;
2639 let page: V2FeedPage = serde_json::from_value(value)
2640 .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
2641 if page.v != 2
2642 || page.head_seq != pointer.seq
2643 || page.head_commit_hash != pointer.commit_hash
2644 || page.head_feed_hash != pointer.feed_hash
2645 || page.entries.is_empty()
2646 || page.entries.len() > FEED_PAGE_LIMIT
2647 {
2648 return Err(invalid_feed("v2 feed page differs from the signed head"));
2649 }
2650 for entry in page.entries {
2651 if entry.seq != after + 1
2652 || !is_sha256(&entry.commit_hash)
2653 || !is_sha256(&entry.feed_hash)
2654 {
2655 return Err(invalid_feed("v2 feed sequence is not contiguous"));
2656 }
2657 let raw = base64::engine::general_purpose::STANDARD
2658 .decode(&entry.bytes_base64)
2659 .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
2660 replayed_entries = replayed_entries
2661 .checked_add(1)
2662 .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
2663 replayed_bytes = replayed_bytes
2664 .checked_add(raw.len() as u64)
2665 .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
2666 if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
2667 {
2668 return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
2669 }
2670 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2671 .map_err(|error| invalid_feed(error.to_string()))?
2672 != entry.commit_hash
2673 || content_sha256(&raw) != entry.feed_hash
2674 {
2675 return Err(invalid_feed("v2 feed entry address mismatch"));
2676 }
2677 let object = verified_v2_commit_object(&raw, identity)?;
2678 if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
2679 || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
2680 {
2681 return Err(invalid_feed(
2682 "v2 feed entry does not extend its predecessor",
2683 ));
2684 }
2685 after = entry.seq;
2686 prior_feed = Some(entry.feed_hash);
2687 final_object = Some((entry.commit_hash, object));
2688 }
2689 if page.next_after != after || (page.complete != (after == pointer.seq)) {
2690 return Err(invalid_feed("v2 feed page cursor is inconsistent"));
2691 }
2692 }
2693 let (final_hash, object) =
2694 final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
2695 if final_hash != pointer.commit_hash
2696 || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
2697 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2698 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2699 || object.get("control_revision").and_then(Value::as_str)
2700 != Some(pointer.control_revision.as_str())
2701 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2702 {
2703 return Err(invalid_feed(
2704 "v2 replay did not converge on the signed pointer",
2705 ));
2706 }
2707 Ok(())
2708}
2709
2710fn verify_v1_to_v2_bridge(
2711 cfg: &HubConfig,
2712 brain: &str,
2713 pointer: &V2PointerBody,
2714 identity: &V2HeadIdentity,
2715 checkpoint: &TrustState,
2716) -> LinkResult<()> {
2717 let value = ensure_ok(
2718 request_capped(
2719 cfg,
2720 "GET",
2721 &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
2722 None,
2723 Auth::Required,
2724 MAX_FEED_RESPONSE_BYTES,
2725 )?,
2726 "v2 genesis bridge",
2727 )?;
2728 let page: V2FeedPage = serde_json::from_value(value)
2729 .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
2730 if page.v != 2
2731 || page.head_seq != pointer.seq
2732 || page.head_commit_hash != pointer.commit_hash
2733 || page.head_feed_hash != pointer.feed_hash
2734 || page.entries.len() != 1
2735 || page.entries[0].seq != 1
2736 || !is_sha256(&page.entries[0].commit_hash)
2737 || !is_sha256(&page.entries[0].feed_hash)
2738 {
2739 return Err(invalid_feed(
2740 "v2 genesis bridge page differs from the signed head",
2741 ));
2742 }
2743 let first = &page.entries[0];
2744 let raw = STANDARD
2745 .decode(&first.bytes_base64)
2746 .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
2747 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2748 .map_err(|error| invalid_feed(error.to_string()))?
2749 != first.commit_hash
2750 || content_sha256(&raw) != first.feed_hash
2751 {
2752 return Err(invalid_feed("v2 genesis bridge address mismatch"));
2753 }
2754 let object = verified_v2_commit_object(&raw, identity)?;
2755 if checkpoint.head_seq == 0 {
2756 if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
2757 return Err(invalid_feed(
2758 "empty v1 checkpoint did not transition through an empty v2 genesis",
2759 ));
2760 }
2761 return Ok(());
2762 }
2763 let bridge = object
2764 .get("v1_bridge")
2765 .and_then(Value::as_object)
2766 .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
2767 let checkpoint_feed = checkpoint
2768 .feed_hash
2769 .as_deref()
2770 .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
2771 if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
2772 || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
2773 {
2774 return Err(invalid_feed(
2775 "v2 genesis bridge differs from the pinned v1 checkpoint",
2776 ));
2777 }
2778 let legacy_raw = ensure_raw_ok(
2779 request_raw(
2780 cfg,
2781 "GET",
2782 &format!(
2783 "/api/hub/brains/{brain}/feed?after={}&limit=1",
2784 checkpoint.head_seq - 1
2785 ),
2786 None,
2787 Auth::Required,
2788 MAX_FEED_RESPONSE_BYTES,
2789 )?,
2790 "v1 bridge boundary",
2791 )?;
2792 let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
2793 .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
2794 let legacy_identity = legacy
2795 .identity
2796 .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
2797 let item = legacy
2798 .entries
2799 .first()
2800 .filter(|_| legacy.entries.len() == 1)
2801 .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
2802 if legacy.scope_limited
2803 || legacy.head_seq != checkpoint.head_seq
2804 || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
2805 || item.entry.seq != checkpoint.head_seq
2806 || item.hash != checkpoint_feed
2807 || legacy_identity != v2_identity(identity)
2808 || bridge.get("pack_sha256").and_then(Value::as_str)
2809 != Some(item.entry.pack_sha256.as_str())
2810 {
2811 return Err(invalid_feed(
2812 "v1 bridge boundary differs from its signed legacy head",
2813 ));
2814 }
2815 let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
2816 if anchor != checkpoint.anchor {
2817 return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
2818 }
2819 verify_feed_item(item, &legacy_identity)?;
2820 verify_rotation_feed_boundaries(
2821 &legacy_identity,
2822 Some(checkpoint),
2823 std::slice::from_ref(item),
2824 checkpoint.head_seq,
2825 )?;
2826 Ok(())
2827}
2828
2829fn verify_v2_commit(
2830 cfg: &HubConfig,
2831 brain: &str,
2832 pointer: &V2PointerBody,
2833 identity: &V2HeadIdentity,
2834 pinned: Option<&TrustState>,
2835) -> LinkResult<()> {
2836 let path = format!(
2837 "/api/hub/brains/{brain}/v2/commit?commit={}",
2838 pointer.commit_hash
2839 );
2840 let raw = ensure_raw_ok(
2841 request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
2842 "v2 commit",
2843 )?;
2844 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2845 .map_err(|error| invalid_feed(error.to_string()))?
2846 != pointer.commit_hash
2847 || content_sha256(&raw) != pointer.feed_hash
2848 {
2849 return Err(invalid_feed("v2 commit address differs from the pointer"));
2850 }
2851 let object = verified_v2_commit_object(&raw, identity)?;
2852 if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
2853 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2854 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2855 || object.get("control_revision").and_then(Value::as_str)
2856 != Some(pointer.control_revision.as_str())
2857 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2858 {
2859 return Err(invalid_feed("v2 commit fields differ from the pointer"));
2860 }
2861 if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
2862 if pointer.seq == checkpoint.head_seq + 1
2863 && object.get("prev_entry_hash").and_then(Value::as_str)
2864 != checkpoint.feed_hash.as_deref()
2865 {
2866 return Err(invalid_feed(
2867 "v2 commit does not extend the pinned feed hash",
2868 ));
2869 }
2870 if pointer.seq > checkpoint.head_seq + 1 {
2871 return replay_v2_feed(
2872 cfg,
2873 brain,
2874 pointer,
2875 identity,
2876 checkpoint.head_seq,
2877 checkpoint.feed_hash.clone(),
2878 );
2879 }
2880 } else {
2881 if let Some(checkpoint) = pinned {
2882 verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
2883 }
2884 if pointer.seq > 1 {
2885 return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
2886 }
2887 }
2888 Ok(())
2889}
2890
2891fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
2892 require_hardened_filesystem("verified link.md v2 state")?;
2893 require_safe_ref(brain)?;
2894 let path = format!("/api/hub/brains/{brain}/v2/head");
2895 let response = request(cfg, "GET", &path, None, Auth::Required)?;
2896 if response.status == 404 {
2897 if has_accepted_v2_ref(cfg, brain)? {
2898 return Err(LinkError::BrainUnavailable);
2899 }
2900 return Ok(None);
2901 }
2902 let body = ensure_ok(response, "v2 head")?;
2903 let head: V2HeadResponse = serde_json::from_value(body)
2904 .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
2905 if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
2906 return Err(invalid_feed("v2 head has no canonical brain id"));
2907 }
2908 if crate::ulid::is_ulid(brain) && head.brain_id != brain {
2909 return Err(invalid_feed("v2 head resolved a different brain id"));
2910 }
2911 if head.profile == "v1" {
2912 return Ok(None);
2913 }
2914 if head.profile != "v2" && head.profile != "v2-empty" {
2915 return Err(invalid_feed("v2 head advertised an unknown profile"));
2916 }
2917 let view = head
2918 .view
2919 .as_ref()
2920 .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
2921 if !matches!(view.kind.as_str(), "full" | "scoped")
2922 || !is_sha256(&view.control_revision)
2923 || view.id.as_deref().is_some_and(|id| !is_sha256(id))
2924 {
2925 return Err(invalid_feed("v2 head has an invalid permission view"));
2926 }
2927 let view_kind = view.kind.clone();
2928 let view_revision = view
2931 .id
2932 .clone()
2933 .unwrap_or_else(|| view.control_revision.clone());
2934 let control_revision = view.control_revision.clone();
2935 let identity = head
2936 .identity
2937 .as_ref()
2938 .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
2939 let trust_directory = open_trust_dir(cfg)?;
2940 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
2941 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
2942 let feed_identity = v2_identity(identity);
2943 let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
2944 let (seq, feed_hash, hub_signer) = match &head.pointer {
2945 None => {
2946 if head.profile != "v2-empty" {
2947 return Err(invalid_feed("initialized v2 head has no pointer"));
2948 }
2949 (
2950 0,
2951 None,
2952 pinned.as_ref().and_then(|state| state.hub_signer.clone()),
2953 )
2954 }
2955 Some(signed) => {
2956 let signer = verify_v2_pointer(signed, &head.brain_id)?;
2957 if pinned
2958 .as_ref()
2959 .and_then(|state| state.hub_signer.as_ref())
2960 .is_some_and(|known| known != &signer)
2961 {
2962 return Err(invalid_feed(
2963 "v2 hub pointer signer changed without a trust transition",
2964 ));
2965 }
2966 if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
2967 if signed.pointer.seq < checkpoint.head_seq
2968 || (signed.pointer.seq == checkpoint.head_seq
2969 && checkpoint.feed_hash.as_deref()
2970 != Some(signed.pointer.feed_hash.as_str()))
2971 {
2972 return Err(invalid_feed("v2 pointer rolled back or equivocated"));
2973 }
2974 }
2975 verify_v2_commit(
2976 cfg,
2977 &head.brain_id,
2978 &signed.pointer,
2979 identity,
2980 pinned.as_ref(),
2981 )?;
2982 (
2983 signed.pointer.seq,
2984 Some(signed.pointer.feed_hash.clone()),
2985 Some(signer),
2986 )
2987 }
2988 };
2989 let trust = TrustState {
2990 v: 2,
2991 origin: normalized_origin(&cfg.hub)?,
2992 requested: head.brain_id.clone(),
2993 brain: head.brain_id.clone(),
2994 home: None,
2995 anchor,
2996 current: format!("ed25519:{}", identity.fingerprint),
2997 head_seq: seq,
2998 feed_hash,
2999 rotations: identity.rotations.clone(),
3000 hub_signer,
3001 protocol_profile: Some("link-v2".to_string()),
3002 };
3003 Ok(Some(V2VerifiedHead {
3004 requested: brain.to_string(),
3005 brain_id: head.brain_id,
3006 view_kind,
3007 view_revision,
3008 control_revision,
3009 identity: identity.clone(),
3010 pointer: head.pointer.map(|signed| signed.pointer),
3011 trust,
3012 alias: alias_binding,
3013 }))
3014}
3015
3016fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3017 let directory = open_trust_dir(cfg)?;
3018 let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3019 let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3020 if let Some(current) = current {
3021 let common_invalid = head.trust.anchor != current.anchor
3022 || !head.trust.rotations.starts_with(¤t.rotations);
3023 let profile_invalid = if accepted_as_v2(¤t) {
3024 head.trust.head_seq < current.head_seq
3025 || (head.trust.head_seq == current.head_seq
3026 && head.trust.feed_hash != current.feed_hash)
3027 || current
3028 .hub_signer
3029 .as_ref()
3030 .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3031 } else {
3032 head.trust.protocol_profile.as_deref() != Some("link-v2")
3033 || head.trust.hub_signer.is_none()
3034 };
3035 if common_invalid || profile_invalid {
3036 return Err(invalid_feed(
3037 "v2 head cannot advance the currently accepted trust checkpoint",
3038 ));
3039 }
3040 }
3041 save_canonical_pin_and_alias(
3042 cfg,
3043 &directory,
3044 &head.requested,
3045 &head.brain_id,
3046 head.trust.clone(),
3047 alias.as_ref().or(head.alias.as_ref()),
3048 )
3049}
3050
3051#[derive(Debug, Clone, Deserialize, Serialize)]
3052struct V2BaselineFile {
3053 sha256: String,
3054 bytes: u64,
3055 #[serde(skip)]
3056 proof: Option<Vec<V2ProofStep>>,
3057}
3058
3059#[derive(Debug, Clone, Deserialize, Serialize)]
3060struct V2SyncBaseline {
3061 v: u8,
3062 origin: String,
3063 brain: String,
3064 #[serde(default)]
3065 head_seq: Option<u64>,
3066 commit_hash: Option<String>,
3067 content_root: Option<String>,
3068 #[serde(default)]
3069 asset_root: Option<String>,
3070 #[serde(default)]
3071 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3072 #[serde(default)]
3073 view_kind: Option<String>,
3074 #[serde(default)]
3075 view_revision: Option<String>,
3076 #[serde(default)]
3077 projection_sha256: Option<String>,
3078 files: std::collections::BTreeMap<String, V2BaselineFile>,
3079 #[serde(default)]
3080 local_policy_digest: Option<String>,
3081 #[serde(default)]
3082 local_eligibility: std::collections::BTreeMap<String, bool>,
3083 #[serde(default)]
3084 remote_copy_remains: std::collections::BTreeMap<String, String>,
3085}
3086
3087struct V2LocalView {
3088 riding: std::collections::BTreeMap<String, (String, u64)>,
3089 eligibility: std::collections::BTreeMap<String, bool>,
3090 policy: crate::linkmd_sync_policy::SyncPolicy,
3091}
3092
3093#[derive(Debug, Clone, Deserialize, Serialize)]
3094struct V2ProofStep {
3095 directory_root: String,
3096 component: String,
3097 proof: crate::linkmd_v2::HamtProof,
3098}
3099
3100#[derive(Debug, Deserialize)]
3101struct V2ManifestFile {
3102 path: String,
3103 sha256: String,
3104 bytes: u64,
3105 proof: Vec<V2ProofStep>,
3106}
3107
3108#[derive(Debug, Deserialize)]
3109struct V2ManifestPage {
3110 v: u8,
3111 commit: String,
3112 content_root: Option<String>,
3113 files: Vec<V2ManifestFile>,
3114 next_cursor: Option<String>,
3115}
3116
3117#[derive(Debug, Clone, Deserialize, Serialize)]
3118struct V2BaselineAsset {
3119 blob_sha256: String,
3120 bytes: u64,
3121 media_type: String,
3122 wrappers: Vec<String>,
3123 required: bool,
3124 disposition: String,
3125 leaf_hash: String,
3126}
3127
3128#[derive(Debug, Deserialize)]
3129struct V2AssetManifestItem {
3130 path: String,
3131 blob_sha256: String,
3132 bytes: u64,
3133 media_type: String,
3134 wrappers: Vec<String>,
3135 required: bool,
3136 disposition: String,
3137 leaf_hash: String,
3138 proof: crate::linkmd_v2::HamtProof,
3139}
3140
3141#[derive(Debug, Deserialize)]
3142struct V2AssetManifestPage {
3143 v: u8,
3144 commit: String,
3145 asset_root: Option<String>,
3146 assets: Vec<V2AssetManifestItem>,
3147 next_cursor: Option<String>,
3148}
3149
3150#[derive(Debug, Deserialize)]
3151struct V2SigningCandidate {
3152 seq: u64,
3153 content_root: Option<String>,
3154 asset_root: Option<String>,
3155 signing_bytes_base64: String,
3156 changes_base64: String,
3157 actor_claim_base64: String,
3158}
3159
3160#[derive(Debug, Deserialize)]
3161struct V2SigningCandidatePage {
3162 v: u8,
3163 challenge_id: String,
3164 mutation_id: String,
3165 request_hash: String,
3166 parent: V2SigningParent,
3167 candidate: V2SigningCandidate,
3168 files: Vec<V2ManifestFile>,
3169 #[serde(default)]
3170 assets: Vec<V2AssetManifestItem>,
3171 next_cursor: Option<String>,
3172 expires_at: String,
3173}
3174
3175#[derive(Debug, Deserialize)]
3176struct V2SigningParent {
3177 seq: u64,
3178 commit_hash: Option<String>,
3179}
3180
3181fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3182 let normalized = crate::linkmd_v2::normalize_path(&file.path)
3183 .map_err(|error| invalid_feed(error.to_string()))?;
3184 let components = normalized.split('/').collect::<Vec<_>>();
3185 if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3186 return Err(invalid_feed("v2 file proof has the wrong shape"));
3187 }
3188 let mut directory_root = root.to_string();
3189 for (index, step) in file.proof.iter().enumerate() {
3190 if step.directory_root != directory_root || step.component != components[index] {
3191 return Err(invalid_feed(
3192 "v2 file proof path chain differs from its manifest",
3193 ));
3194 }
3195 if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3196 .map_err(|error| invalid_feed(error.to_string()))?
3197 {
3198 return Err(invalid_feed("v2 file proof failed verification"));
3199 }
3200 let entry = match &step.proof {
3201 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3202 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3203 return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3204 }
3205 };
3206 if index + 1 == components.len() {
3207 if entry.kind != crate::linkmd_v2::EntryKind::Blob
3208 || entry.child_hash != file.sha256
3209 || entry.bytes != Some(file.bytes)
3210 {
3211 return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3212 }
3213 } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3214 return Err(invalid_feed("v2 file proof traversed a non-directory"));
3215 } else {
3216 directory_root = entry.child_hash.clone();
3217 }
3218 }
3219 Ok(())
3220}
3221
3222fn v2_manifest(
3223 cfg: &HubConfig,
3224 brain: &str,
3225 pointer: Option<&V2PointerBody>,
3226) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3227 let Some(pointer) = pointer else {
3228 return Ok(std::collections::BTreeMap::new());
3229 };
3230 let Some(root) = pointer.content_root.as_deref() else {
3231 return Ok(std::collections::BTreeMap::new());
3232 };
3233 let mut files = std::collections::BTreeMap::new();
3234 let mut after = String::new();
3235 loop {
3236 let encoded_after: String =
3237 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3238 let path = format!(
3239 "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3240 pointer.commit_hash
3241 );
3242 let value = ensure_ok(
3243 request_capped(
3244 cfg,
3245 "GET",
3246 &path,
3247 None,
3248 Auth::Required,
3249 MAX_FEED_RESPONSE_BYTES,
3250 )?,
3251 "v2 file manifest",
3252 )?;
3253 let page: V2ManifestPage = serde_json::from_value(value)
3254 .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3255 if page.v != 2
3256 || page.commit != pointer.commit_hash
3257 || page.content_root.as_deref() != Some(root)
3258 || page.files.len() > 500
3259 {
3260 return Err(invalid_feed(
3261 "v2 file manifest is not bound to the verified head",
3262 ));
3263 }
3264 for file in page.files {
3265 verify_v2_file_proof(root, &file)?;
3266 if files
3267 .insert(
3268 file.path.clone(),
3269 V2BaselineFile {
3270 sha256: file.sha256,
3271 bytes: file.bytes,
3272 proof: Some(file.proof),
3273 },
3274 )
3275 .is_some()
3276 {
3277 return Err(invalid_feed("v2 file manifest repeats a path"));
3278 }
3279 if files.len() > MAX_PUSH_FILES {
3280 return Err(invalid_feed(
3281 "v2 file manifest exceeds the file-count bound",
3282 ));
3283 }
3284 }
3285 match page.next_cursor {
3286 None => break,
3287 Some(next) if next > after => after = next,
3288 Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3289 }
3290 }
3291 Ok(files)
3292}
3293
3294fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
3295 crate::linkmd_v2::normalize_path(&item.path)
3296 .map_err(|error| invalid_feed(error.to_string()))?;
3297 if !is_sha256(&item.blob_sha256)
3298 || !is_sha256(&item.leaf_hash)
3299 || item.wrappers.is_empty()
3300 || !matches!(item.disposition.as_str(), "hosted" | "withheld")
3301 || item
3302 .wrappers
3303 .iter()
3304 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
3305 {
3306 return Err(invalid_feed("v2 asset manifest item is invalid"));
3307 }
3308 let leaf = json!({
3309 "blob_sha256": item.blob_sha256,
3310 "bytes": item.bytes,
3311 "disposition": item.disposition,
3312 "media_type": item.media_type,
3313 "path": item.path,
3314 "required": item.required,
3315 "v": 2,
3316 "wrappers": item.wrappers,
3317 });
3318 if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
3319 .map_err(|error| invalid_feed(error.to_string()))?
3320 != item.leaf_hash
3321 || !crate::linkmd_v2::verify_proof_with_domain(
3322 root,
3323 &item.path,
3324 &item.proof,
3325 crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
3326 )
3327 .map_err(|error| invalid_feed(error.to_string()))?
3328 {
3329 return Err(invalid_feed("v2 asset inclusion proof failed"));
3330 }
3331 match &item.proof {
3332 crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
3333 if entry.name == item.path
3334 && entry.kind == crate::linkmd_v2::EntryKind::Blob
3335 && entry.child_hash == item.leaf_hash
3336 && entry.bytes == Some(item.bytes) =>
3337 {
3338 Ok(())
3339 }
3340 _ => Err(invalid_feed(
3341 "v2 asset proof leaf differs from its manifest",
3342 )),
3343 }
3344}
3345
3346fn v2_asset_manifest(
3347 cfg: &HubConfig,
3348 brain: &str,
3349 pointer: Option<&V2PointerBody>,
3350) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
3351 let Some(pointer) = pointer else {
3352 return Ok(std::collections::BTreeMap::new());
3353 };
3354 let Some(root) = pointer.asset_root.as_deref() else {
3355 return Ok(std::collections::BTreeMap::new());
3356 };
3357 let mut assets = std::collections::BTreeMap::new();
3358 let mut after = String::new();
3359 loop {
3360 let encoded_after: String =
3361 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3362 let path = format!(
3363 "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
3364 pointer.commit_hash
3365 );
3366 let value = ensure_ok(
3367 request_capped(
3368 cfg,
3369 "GET",
3370 &path,
3371 None,
3372 Auth::Required,
3373 MAX_FEED_RESPONSE_BYTES,
3374 )?,
3375 "v2 asset manifest",
3376 )?;
3377 let page: V2AssetManifestPage = serde_json::from_value(value)
3378 .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
3379 if page.v != 2
3380 || page.commit != pointer.commit_hash
3381 || page.asset_root.as_deref() != Some(root)
3382 || page.assets.len() > 500
3383 {
3384 return Err(invalid_feed(
3385 "v2 asset manifest is not bound to the verified head",
3386 ));
3387 }
3388 for item in page.assets {
3389 verify_v2_asset_proof(root, &item)?;
3390 let path = item.path.clone();
3391 if assets
3392 .insert(
3393 path,
3394 V2BaselineAsset {
3395 blob_sha256: item.blob_sha256,
3396 bytes: item.bytes,
3397 media_type: item.media_type,
3398 wrappers: item.wrappers,
3399 required: item.required,
3400 disposition: item.disposition,
3401 leaf_hash: item.leaf_hash,
3402 },
3403 )
3404 .is_some()
3405 {
3406 return Err(invalid_feed("v2 asset manifest repeats a path"));
3407 }
3408 if assets.len() > MAX_PUSH_FILES {
3409 return Err(invalid_feed(
3410 "v2 asset manifest exceeds the item-count bound",
3411 ));
3412 }
3413 }
3414 match page.next_cursor {
3415 None => break,
3416 Some(next) if next > after => after = next,
3417 Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
3418 }
3419 }
3420 Ok(assets)
3421}
3422
3423fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
3424 crate::AssetRecord {
3425 path: path.to_string(),
3426 sha256: asset.blob_sha256.clone(),
3427 bytes: asset.bytes,
3428 media_type: asset.media_type.clone(),
3429 wrappers: asset.wrappers.clone(),
3430 required: asset.required,
3431 }
3432}
3433
3434fn v2_asset_resumes_hosting(
3435 remote: Option<&V2BaselineAsset>,
3436 path: &str,
3437 record: &crate::AssetRecord,
3438 disposition: &str,
3439) -> bool {
3440 remote.is_some_and(|asset| {
3441 asset.disposition == "withheld"
3442 && disposition == "hosted"
3443 && v2_asset_record(asset, path) == *record
3444 })
3445}
3446
3447fn v2_asset_record_manifest_bytes(
3448 assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
3449) -> LinkResult<Vec<u8>> {
3450 let mut bytes = Vec::new();
3451 for (path, asset) in assets {
3452 if asset.path != *path {
3453 return Err(invalid_feed(
3454 "local asset manifest key differs from its record path",
3455 ));
3456 }
3457 serde_json::to_writer(&mut bytes, asset)
3458 .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
3459 bytes.push(b'\n');
3460 }
3461 Ok(bytes)
3462}
3463
3464fn v2_local_asset_records(
3465 store: &Store,
3466) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
3467 Ok(crate::assets::read_manifest(store)
3468 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
3469 .into_iter()
3470 .map(|asset| (asset.path.clone(), asset))
3471 .collect())
3472}
3473
3474fn v2_asset_records_match_remote(
3475 local: &std::collections::BTreeMap<String, crate::AssetRecord>,
3476 remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
3477) -> bool {
3478 local.len() == remote.len()
3479 && remote
3480 .iter()
3481 .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
3482}
3483
3484#[derive(Debug, Clone, PartialEq, Eq)]
3485struct V2PulledMerge<T> {
3486 records: std::collections::BTreeMap<String, T>,
3487 accept_remote: std::collections::BTreeSet<String>,
3488 conflicts: Vec<String>,
3489}
3490
3491fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
3497 base: &std::collections::BTreeMap<String, Base>,
3498 remote: &std::collections::BTreeMap<String, Remote>,
3499 local: &std::collections::BTreeMap<String, Record>,
3500 base_record: BaseRecord,
3501 remote_record: RemoteRecord,
3502 keep_local: KeepLocal,
3503) -> V2PulledMerge<Record>
3504where
3505 Record: Clone + Eq,
3506 BaseRecord: Fn(&Base, &str) -> Record,
3507 RemoteRecord: Fn(&Remote, &str) -> Record,
3508 KeepLocal: Fn(&str) -> bool,
3509{
3510 let paths = base
3511 .keys()
3512 .chain(remote.keys())
3513 .chain(local.keys())
3514 .cloned()
3515 .collect::<std::collections::BTreeSet<_>>();
3516 let mut records = local.clone();
3517 let mut accept_remote = std::collections::BTreeSet::new();
3518 let mut conflicts = Vec::new();
3519 for path in paths {
3520 if keep_local(&path) {
3521 continue;
3522 }
3523 let base_value = base.get(&path).map(|value| base_record(value, &path));
3524 let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
3525 let local_value = local.get(&path).cloned();
3526 if local_value != base_value && remote_value != base_value && local_value != remote_value {
3527 conflicts.push(path);
3528 continue;
3529 }
3530 if local_value == base_value || local_value == remote_value {
3531 accept_remote.insert(path.clone());
3532 match remote_value {
3533 Some(value) => {
3534 records.insert(path, value);
3535 }
3536 None => {
3537 records.remove(&path);
3538 }
3539 }
3540 }
3541 }
3542 V2PulledMerge {
3543 records,
3544 accept_remote,
3545 conflicts,
3546 }
3547}
3548
3549fn sign_verified_v2_candidate(
3550 cfg: &HubConfig,
3551 head: &V2VerifiedHead,
3552 expected: &std::collections::BTreeMap<String, V2BaselineFile>,
3553 expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
3554 mutation_id: &str,
3555 request_body: &Value,
3556 challenge_value: &Value,
3557) -> LinkResult<(String, String, String)> {
3558 if head.view_kind != "full" {
3559 return Err(invalid_feed(
3560 "a scoped self-custody writer must use the proposal workflow",
3561 ));
3562 }
3563 if head.identity.custody != "self" {
3564 return Err(invalid_feed(
3565 "a hub-custodied brain unexpectedly requested an external signature",
3566 ));
3567 }
3568 let key = cfg
3569 .brain_key
3570 .as_ref()
3571 .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
3572 if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
3573 || key.public_key_spki != head.identity.public_key_spki
3574 {
3575 return Err(bad_agent_key(
3576 "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
3577 ));
3578 }
3579 let challenge_id = challenge_value
3580 .get("id")
3581 .and_then(Value::as_str)
3582 .filter(|id| crate::ulid::is_ulid(id))
3583 .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
3584 let expected_endpoint = format!(
3585 "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
3586 head.brain_id
3587 );
3588 if challenge_value
3589 .get("candidate_endpoint")
3590 .and_then(Value::as_str)
3591 != Some(expected_endpoint.as_str())
3592 {
3593 return Err(invalid_feed(
3594 "self-custody challenge candidate endpoint is not origin-bound",
3595 ));
3596 }
3597
3598 let mut files = std::collections::BTreeMap::new();
3599 let mut after = String::new();
3600 type CandidateCoordinate = (
3601 String,
3602 String,
3603 String,
3604 String,
3605 Option<String>,
3606 Option<String>,
3607 u64,
3608 Option<String>,
3609 );
3610 let mut pinned: Option<CandidateCoordinate> = None;
3611 loop {
3612 let encoded_after: String =
3613 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3614 let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
3615 let value = ensure_ok(
3616 request_capped(
3617 cfg,
3618 "GET",
3619 &path,
3620 None,
3621 Auth::Required,
3622 MAX_FEED_RESPONSE_BYTES,
3623 )?,
3624 "v2 self-custody candidate",
3625 )?;
3626 let page: V2SigningCandidatePage = serde_json::from_value(value)
3627 .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
3628 if page.v != 2
3629 || page.challenge_id != challenge_id
3630 || page.mutation_id != mutation_id
3631 || page.candidate.seq != page.parent.seq + 1
3632 || page.files.len() > 500
3633 || page.expires_at.is_empty()
3634 {
3635 return Err(invalid_feed(
3636 "self-custody candidate is not bound to this mutation",
3637 ));
3638 }
3639 let coordinate = (
3640 page.request_hash.clone(),
3641 page.candidate.signing_bytes_base64.clone(),
3642 page.candidate.changes_base64.clone(),
3643 page.candidate.actor_claim_base64.clone(),
3644 page.candidate.content_root.clone(),
3645 page.candidate.asset_root.clone(),
3646 page.parent.seq,
3647 page.parent.commit_hash.clone(),
3648 );
3649 if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
3650 return Err(invalid_feed(
3651 "self-custody candidate changed between manifest pages",
3652 ));
3653 }
3654 pinned = Some(coordinate);
3655 let root = page
3656 .candidate
3657 .content_root
3658 .as_deref()
3659 .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
3660 for file in page.files {
3661 verify_v2_file_proof(root, &file)?;
3662 if files
3663 .insert(
3664 file.path.clone(),
3665 V2BaselineFile {
3666 sha256: file.sha256,
3667 bytes: file.bytes,
3668 proof: Some(file.proof),
3669 },
3670 )
3671 .is_some()
3672 {
3673 return Err(invalid_feed(
3674 "self-custody candidate repeats a manifest path",
3675 ));
3676 }
3677 if files.len() > MAX_PUSH_FILES {
3678 return Err(invalid_feed(
3679 "self-custody candidate exceeds the file-count bound",
3680 ));
3681 }
3682 }
3683 match page.next_cursor {
3684 None => break,
3685 Some(next) if next > after => after = next,
3686 Some(_) => {
3687 return Err(invalid_feed(
3688 "self-custody candidate cursor did not advance",
3689 ))
3690 }
3691 }
3692 }
3693 if files.len() != expected.len()
3694 || files.iter().any(|(path, file)| {
3695 expected.get(path).is_none_or(|expected| {
3696 expected.sha256 != file.sha256 || expected.bytes != file.bytes
3697 })
3698 })
3699 {
3700 return Err(invalid_feed(
3701 "self-custody candidate contains an unexpected file mutation",
3702 ));
3703 }
3704 let mut assets = std::collections::BTreeMap::new();
3705 after.clear();
3706 loop {
3707 let encoded_after: String =
3708 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3709 let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
3710 let value = ensure_ok(
3711 request_capped(
3712 cfg,
3713 "GET",
3714 &path,
3715 None,
3716 Auth::Required,
3717 MAX_FEED_RESPONSE_BYTES,
3718 )?,
3719 "v2 self-custody asset candidate",
3720 )?;
3721 let page: V2SigningCandidatePage = serde_json::from_value(value)
3722 .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
3723 let coordinate = (
3724 page.request_hash.clone(),
3725 page.candidate.signing_bytes_base64.clone(),
3726 page.candidate.changes_base64.clone(),
3727 page.candidate.actor_claim_base64.clone(),
3728 page.candidate.content_root.clone(),
3729 page.candidate.asset_root.clone(),
3730 page.parent.seq,
3731 page.parent.commit_hash.clone(),
3732 );
3733 if page.v != 2
3734 || page.challenge_id != challenge_id
3735 || page.mutation_id != mutation_id
3736 || page.assets.len() > 500
3737 || pinned.as_ref() != Some(&coordinate)
3738 {
3739 return Err(invalid_feed(
3740 "self-custody asset candidate changed or is not bound",
3741 ));
3742 }
3743 let root = page.candidate.asset_root.as_deref();
3744 if !page.assets.is_empty() && root.is_none() {
3745 return Err(invalid_feed("asset candidate has no asset root"));
3746 }
3747 for item in page.assets {
3748 verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
3749 if assets
3750 .insert(
3751 item.path.clone(),
3752 V2BaselineAsset {
3753 blob_sha256: item.blob_sha256,
3754 bytes: item.bytes,
3755 media_type: item.media_type,
3756 wrappers: item.wrappers,
3757 required: item.required,
3758 disposition: item.disposition,
3759 leaf_hash: item.leaf_hash,
3760 },
3761 )
3762 .is_some()
3763 {
3764 return Err(invalid_feed("self-custody candidate repeats an asset"));
3765 }
3766 }
3767 match page.next_cursor {
3768 None => break,
3769 Some(next) if next > after => after = next,
3770 Some(_) => {
3771 return Err(invalid_feed(
3772 "self-custody asset candidate cursor did not advance",
3773 ))
3774 }
3775 }
3776 }
3777 if assets.len() != expected_assets.len()
3778 || assets.iter().any(|(path, asset)| {
3779 expected_assets.get(path).is_none_or(|expected| {
3780 asset.blob_sha256 != expected.blob_sha256
3781 || asset.bytes != expected.bytes
3782 || asset.media_type != expected.media_type
3783 || asset.wrappers != expected.wrappers
3784 || asset.required != expected.required
3785 || asset.disposition != expected.disposition
3786 })
3787 })
3788 {
3789 return Err(invalid_feed(
3790 "self-custody candidate contains an unexpected asset mutation",
3791 ));
3792 }
3793 let Some((
3794 request_hash,
3795 signing_b64,
3796 changes_b64,
3797 actor_b64,
3798 root,
3799 asset_root,
3800 parent_seq,
3801 parent,
3802 )) = pinned
3803 else {
3804 return Err(invalid_feed("self-custody candidate has no manifest"));
3805 };
3806 let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
3807 let current_commit = head
3808 .pointer
3809 .as_ref()
3810 .map(|pointer| pointer.commit_hash.clone());
3811 if parent_seq != current_seq || parent != current_commit {
3812 return Err(LinkError::RemoteAdvancedDuringSync);
3813 }
3814 let changes = STANDARD
3815 .decode(changes_b64)
3816 .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
3817 let expected_changes = json!({
3818 "mutation_id": mutation_id,
3819 "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
3820 "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
3821 "v": 2,
3822 });
3823 let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
3824 .map_err(|error| invalid_feed(error.to_string()))?;
3825 if changes != expected_changes_bytes {
3826 return Err(invalid_feed(
3827 "self-custody changeset differs from the requested mutation",
3828 ));
3829 }
3830 let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
3831 .map_err(|error| invalid_feed(error.to_string()))?;
3832 let request_value = json!({
3833 "base": request_body.get("base").cloned().unwrap_or(Value::Null),
3834 "brain": head.brain_id,
3835 "changes_sha256": changes_hash,
3836 "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
3837 "v": 2,
3838 "v1_bridge": Value::Null,
3839 });
3840 let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
3841 .map_err(|error| invalid_feed(error.to_string()))?;
3842 if request_hash != expected_request_hash {
3843 return Err(invalid_feed(
3844 "self-custody request hash differs from the requested mutation",
3845 ));
3846 }
3847 let actor = STANDARD
3848 .decode(actor_b64)
3849 .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
3850 let actor_value: Value = serde_json::from_slice(&actor)
3851 .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
3852 if crate::linkmd_v2::canonical_bytes(&actor_value)
3853 .map_err(|error| invalid_feed(error.to_string()))?
3854 != actor
3855 {
3856 return Err(invalid_feed("self-custody actor claim is not canonical"));
3857 }
3858 let actor_object = actor_value
3859 .as_object()
3860 .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
3861 let actor_claim = actor_object
3862 .get("claim")
3863 .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
3864 let actor_public_key = actor_object
3865 .get("public_key")
3866 .and_then(Value::as_str)
3867 .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
3868 let actor_fingerprint = actor_object
3869 .get("fingerprint")
3870 .and_then(Value::as_str)
3871 .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
3872 let actor_signature = actor_object
3873 .get("sig")
3874 .and_then(Value::as_str)
3875 .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
3876 let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
3877 .map_err(|error| invalid_feed(error.to_string()))?;
3878 let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
3879 let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
3880 let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
3881 let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
3882 let impact = actor_claim
3883 .get("result")
3884 .and_then(|result| result.get("impact"))
3885 .and_then(Value::as_object);
3886 let impact_fields = [
3887 "creates",
3888 "updates",
3889 "deletes",
3890 "withdrawals",
3891 "renames",
3892 "restores",
3893 "asset_changes",
3894 "public_expansions",
3895 "executable_activations",
3896 ];
3897 let impact_is_valid = impact.is_some_and(|impact| {
3898 impact.len() == impact_fields.len() + 1
3899 && impact.get("v").and_then(Value::as_u64) == Some(1)
3900 && impact_fields
3901 .iter()
3902 .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
3903 });
3904 if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
3905 || head
3906 .trust
3907 .hub_signer
3908 .as_ref()
3909 .is_some_and(|known| known != &expected_actor_signer)
3910 || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
3911 || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
3912 || actor_claim
3913 .get("candidate")
3914 .and_then(|candidate| candidate.get("changes_sha256"))
3915 .and_then(Value::as_str)
3916 != Some(changes_hash.as_str())
3917 || actor_claim
3918 .get("candidate")
3919 .and_then(|candidate| candidate.get("state_root"))
3920 != Some(&expected_actor_root)
3921 || actor_claim
3922 .get("candidate")
3923 .and_then(|candidate| candidate.get("asset_root"))
3924 != Some(&expected_actor_asset_root)
3925 || actor_claim
3926 .get("candidate")
3927 .and_then(|candidate| candidate.get("control_revision"))
3928 .and_then(Value::as_str)
3929 != Some(head.control_revision.as_str())
3930 || !impact_is_valid
3931 {
3932 return Err(invalid_feed(
3933 "self-custody actor claim does not bind the verified authority",
3934 ));
3935 }
3936 let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
3937 .map_err(|error| invalid_feed(error.to_string()))?;
3938 let signing = STANDARD
3939 .decode(signing_b64)
3940 .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
3941 let signing_value: Value = serde_json::from_slice(&signing)
3942 .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
3943 if crate::linkmd_v2::canonical_bytes(&signing_value)
3944 .map_err(|error| invalid_feed(error.to_string()))?
3945 != signing
3946 {
3947 return Err(invalid_feed("self-custody signing bytes are not canonical"));
3948 }
3949 let pointer = head.pointer.as_ref();
3950 let expected_materializer = pointer
3951 .map(|value| value.materializer.as_str())
3952 .unwrap_or("dbmd-projection-v1");
3953 let expected_parent_commit = request_body
3954 .get("base")
3955 .and_then(|base| base.get("commit_hash"))
3956 .cloned()
3957 .unwrap_or(Value::Null);
3958 let expected_parent_root = request_body
3959 .get("base")
3960 .and_then(|base| base.get("content_root"))
3961 .cloned()
3962 .unwrap_or(Value::Null);
3963 let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
3964 let expected_parent_asset_root = request_body
3965 .get("base")
3966 .and_then(|base| base.get("asset_root"))
3967 .cloned()
3968 .unwrap_or(Value::Null);
3969 let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
3970 let expected_prev_entry = pointer
3971 .map(|value| Value::String(value.feed_hash.clone()))
3972 .unwrap_or(Value::Null);
3973 let expected_signer_epoch = u64::try_from(head.identity.previous.len())
3974 .map_err(|_| invalid_feed("brain identity history is too large"))?
3975 + 1;
3976 if signing_value.get("v").and_then(Value::as_u64) != Some(2)
3977 || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
3978 || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
3979 || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
3980 || signing_value.get("public_key").and_then(Value::as_str)
3981 != Some(key.public_key_spki.as_str())
3982 || signing_value.get("parent_commit") != Some(&expected_parent_commit)
3983 || signing_value.get("parent_root") != Some(&expected_parent_root)
3984 || signing_value.get("state_root") != Some(&expected_state_root)
3985 || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
3986 || signing_value.get("asset_root") != Some(&expected_asset_root)
3987 || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
3988 || signing_value.get("changes_sha256").and_then(Value::as_str)
3989 != Some(changes_hash.as_str())
3990 || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
3991 || signing_value
3992 .get("control_revision")
3993 .and_then(Value::as_str)
3994 != Some(head.control_revision.as_str())
3995 || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
3996 || signing_value.get("v1_bridge") != Some(&Value::Null)
3997 || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
3998 {
3999 return Err(invalid_feed(
4000 "self-custody signing bytes do not bind the verified candidate",
4001 ));
4002 }
4003 let pair = agent_keypair(&key.pkcs8)?;
4004 let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4005 Ok((challenge_id.to_string(), signature, expected_actor_signer))
4006}
4007
4008fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4009 let origin = normalized_origin(&cfg.hub)?;
4010 let absolute = if checkout.is_absolute() {
4011 checkout.to_path_buf()
4012 } else {
4013 std::env::current_dir()?.join(checkout)
4014 };
4015 Ok(format!(
4016 "sync-{}.json",
4017 content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4018 ))
4019}
4020
4021#[cfg(any(unix, windows))]
4022fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4023 let directory = open_trust_dir(cfg)?;
4024 let origin = normalized_origin(&cfg.hub)?;
4025 let name = format!(
4026 "operation-{}.lock",
4027 content_sha256(format!("{origin}\0{brain}").as_bytes())
4028 );
4029 lock_trust_name(&directory, &name)
4030}
4031
4032#[cfg(not(any(unix, windows)))]
4033fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4034 Err(LinkError::UnsupportedPlatform {
4035 operation: "serialized link.md v2 sync",
4036 })
4037}
4038
4039fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4040 left.brain_id == right.brain_id
4041 && left.view_kind == right.view_kind
4042 && left.view_revision == right.view_revision
4043 && left.control_revision == right.control_revision
4044 && match (&left.pointer, &right.pointer) {
4045 (None, None) => true,
4046 (Some(left), Some(right)) => {
4047 left.seq == right.seq
4048 && left.commit_hash == right.commit_hash
4049 && left.content_root == right.content_root
4050 && left.asset_root == right.asset_root
4051 && left.feed_hash == right.feed_hash
4052 }
4053 _ => false,
4054 }
4055}
4056
4057fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4058 format!(
4059 "---\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"
4060 )
4061 .into_bytes()
4062}
4063
4064fn scoped_projection_sha256(brain: &str) -> String {
4065 content_sha256(&scoped_projection_bytes(brain))
4066}
4067
4068#[derive(Deserialize)]
4069struct LocalScopedViewMarker {
4070 v: u8,
4071 kind: String,
4072 authoritative: bool,
4073 brain: String,
4074 projection_sha256: String,
4075}
4076
4077pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4081 let marker = store
4082 .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4083 .ok()
4084 .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4085 let Some(marker) = marker else {
4086 return false;
4087 };
4088 if marker.v != 1
4089 || marker.kind != "link.md-scoped-view"
4090 || marker.authoritative
4091 || !crate::ulid::is_ulid(&marker.brain)
4092 || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4093 {
4094 return false;
4095 }
4096 store
4097 .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4098 .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4099}
4100
4101fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4102 let mut bytes = serde_json::to_vec_pretty(&json!({
4103 "v": 1,
4104 "kind": "link.md-scoped-view",
4105 "authoritative": false,
4106 "brain": head.brain_id,
4107 "view_revision": head.view_revision,
4108 "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4109 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4110 "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4111 "visible_files": files,
4112 "projection_sha256": scoped_projection_sha256(&head.brain_id),
4113 }))
4114 .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4115 bytes.push(b'\n');
4116 Ok(bytes)
4117}
4118
4119fn refresh_scoped_view_marker(
4120 store: &Store,
4121 head: &V2VerifiedHead,
4122 files: usize,
4123) -> LinkResult<()> {
4124 if head.view_kind == "scoped" {
4125 store.write_atomic(
4126 Path::new(".dbmd/view.json"),
4127 &scoped_view_metadata(head, files)?,
4128 )?;
4129 }
4130 Ok(())
4131}
4132
4133fn ensure_v2_view_compatible(
4134 head: &V2VerifiedHead,
4135 baseline: Option<&V2SyncBaseline>,
4136) -> LinkResult<()> {
4137 let Some(baseline) = baseline else {
4138 return Ok(());
4139 };
4140 match (
4141 baseline.view_kind.as_deref(),
4142 baseline.view_revision.as_deref(),
4143 ) {
4144 (None, None) if head.view_kind == "full" => Ok(()),
4145 (Some(kind), Some(revision))
4146 if kind == head.view_kind && revision == head.view_revision =>
4147 {
4148 Ok(())
4149 }
4150 _ => Err(LinkError::ScopedViewChanged),
4151 }
4152}
4153
4154fn ensure_established_v2_checkout_opened(
4155 head: &V2VerifiedHead,
4156 baseline: Option<&V2SyncBaseline>,
4157 opened: bool,
4158) -> LinkResult<()> {
4159 if baseline.is_none() || opened {
4160 return Ok(());
4161 }
4162 if head.view_kind == "scoped" {
4163 return Err(LinkError::ScopedProjectionModified);
4164 }
4165 Err(LinkError::InvalidPack {
4166 message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4167 })
4168}
4169
4170fn remove_scoped_projection(
4171 head: &V2VerifiedHead,
4172 baseline: Option<&V2SyncBaseline>,
4173 view: &mut V2LocalView,
4174) -> LinkResult<()> {
4175 if head.view_kind != "scoped" {
4176 return Ok(());
4177 }
4178 let expected = scoped_projection_sha256(&head.brain_id);
4179 if baseline
4180 .and_then(|state| state.projection_sha256.as_deref())
4181 .is_some_and(|pinned| pinned != expected)
4182 {
4183 return Err(LinkError::ScopedViewChanged);
4184 }
4185 if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4186 return Err(LinkError::ScopedProjectionModified);
4187 }
4188 view.riding.remove("DB.md");
4189 view.eligibility.remove("DB.md");
4190 Ok(())
4191}
4192
4193fn files_for_v2_view(
4194 head: &V2VerifiedHead,
4195 mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4196) -> std::collections::BTreeMap<String, V2BaselineFile> {
4197 if head.view_kind == "scoped" {
4198 files.remove("DB.md");
4202 }
4203 files
4204}
4205
4206fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4207 let baseline: V2SyncBaseline =
4208 serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
4209 if baseline.v != 2
4210 || baseline.origin != normalized_origin(&cfg.hub)?
4211 || baseline.brain != brain
4212 || baseline
4213 .commit_hash
4214 .as_deref()
4215 .is_some_and(|hash| !is_sha256(hash))
4216 || baseline
4217 .content_root
4218 .as_deref()
4219 .is_some_and(|hash| !is_sha256(hash))
4220 || baseline
4221 .asset_root
4222 .as_deref()
4223 .is_some_and(|hash| !is_sha256(hash))
4224 || baseline
4225 .local_policy_digest
4226 .as_deref()
4227 .is_some_and(|hash| !is_sha256(hash))
4228 || baseline
4229 .view_kind
4230 .as_deref()
4231 .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
4232 || baseline
4233 .view_revision
4234 .as_deref()
4235 .is_some_and(|hash| !is_sha256(hash))
4236 || baseline
4237 .projection_sha256
4238 .as_deref()
4239 .is_some_and(|hash| !is_sha256(hash))
4240 || (baseline.view_kind.as_deref() == Some("scoped")
4241 && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
4242 || baseline.files.len() > MAX_PUSH_FILES
4243 || baseline.assets.len() > MAX_PUSH_FILES
4244 || baseline.local_eligibility.len() > MAX_PUSH_FILES
4245 || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
4246 || baseline.files.iter().any(|(path, file)| {
4247 crate::linkmd_v2::normalize_path(path).is_err()
4248 || !is_sha256(&file.sha256)
4249 || file.bytes > MAX_STORE_BYTES
4250 })
4251 || baseline.assets.iter().any(|(path, asset)| {
4252 crate::linkmd_v2::normalize_path(path).is_err()
4253 || !is_sha256(&asset.blob_sha256)
4254 || !is_sha256(&asset.leaf_hash)
4255 || asset.bytes > MAX_STORE_BYTES
4256 || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
4257 || asset.wrappers.is_empty()
4258 || asset
4259 .wrappers
4260 .iter()
4261 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4262 })
4263 || baseline
4264 .local_eligibility
4265 .keys()
4266 .chain(baseline.remote_copy_remains.keys())
4267 .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
4268 || baseline
4269 .remote_copy_remains
4270 .values()
4271 .any(|hash| !is_sha256(hash))
4272 {
4273 return Err(invalid_feed("v2 sync baseline failed validation"));
4274 }
4275 Ok(baseline)
4276}
4277
4278#[cfg(unix)]
4279fn load_v2_baseline(
4280 cfg: &HubConfig,
4281 brain: &str,
4282 checkout: &Path,
4283) -> LinkResult<Option<V2SyncBaseline>> {
4284 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4285 let directory = open_trust_dir(cfg)?;
4286 let name_string = v2_baseline_name(cfg, brain, checkout)?;
4287 let _lock = lock_trust_name(&directory, &name_string)?;
4288 let name = c_name(name_string.as_bytes(), &name_string)?;
4289 let fd = unsafe {
4290 libc::openat(
4291 directory.as_raw_fd(),
4292 name.as_ptr(),
4293 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4294 )
4295 };
4296 if fd < 0 {
4297 let error = std::io::Error::last_os_error();
4298 return if error.kind() == std::io::ErrorKind::NotFound {
4299 Ok(None)
4300 } else {
4301 Err(LinkError::UnsafePath { path: name_string })
4302 };
4303 }
4304 let file = unsafe { std::fs::File::from_raw_fd(fd) };
4305 let mut bytes = Vec::new();
4306 file.take(MAX_FEED_RESPONSE_BYTES + 1)
4307 .read_to_end(&mut bytes)?;
4308 if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
4309 return Err(invalid_feed("v2 sync baseline is oversized"));
4310 }
4311 Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
4312}
4313
4314#[cfg(windows)]
4315fn load_v2_baseline(
4316 cfg: &HubConfig,
4317 brain: &str,
4318 checkout: &Path,
4319) -> LinkResult<Option<V2SyncBaseline>> {
4320 let directory = open_trust_dir(cfg)?;
4321 let name = v2_baseline_name(cfg, brain, checkout)?;
4322 let _lock = lock_trust_name(&directory, &name)?;
4323 let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
4324 match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
4325 Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
4326 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
4327 Err(_) => Err(LinkError::UnsafePath { path: name }),
4328 }
4329}
4330
4331#[cfg(not(any(unix, windows)))]
4332fn load_v2_baseline(
4333 _cfg: &HubConfig,
4334 _brain: &str,
4335 _checkout: &Path,
4336) -> LinkResult<Option<V2SyncBaseline>> {
4337 Err(LinkError::UnsupportedPlatform {
4338 operation: "verified link.md v2 baseline",
4339 })
4340}
4341
4342#[cfg(unix)]
4343fn save_v2_baseline(
4344 cfg: &HubConfig,
4345 brain: &str,
4346 checkout: &Path,
4347 baseline: &V2SyncBaseline,
4348) -> LinkResult<()> {
4349 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4350 let directory = open_trust_dir(cfg)?;
4351 let name_string = v2_baseline_name(cfg, brain, checkout)?;
4352 let _lock = lock_trust_name(&directory, &name_string)?;
4353 let name = c_name(name_string.as_bytes(), &name_string)?;
4354 let mut bytes = serde_json::to_vec(baseline)
4355 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
4356 bytes.push(b'\n');
4357 let temp_string = format!(
4358 ".{name_string}.tmp.{}-{}",
4359 std::process::id(),
4360 std::time::SystemTime::now()
4361 .duration_since(std::time::UNIX_EPOCH)
4362 .unwrap_or_default()
4363 .as_nanos()
4364 );
4365 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
4366 let fd = unsafe {
4367 libc::openat(
4368 directory.as_raw_fd(),
4369 temp.as_ptr(),
4370 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4371 0o600,
4372 )
4373 };
4374 if fd < 0 {
4375 return Err(std::io::Error::last_os_error().into());
4376 }
4377 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
4378 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
4379 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4380 return Err(error.into());
4381 }
4382 drop(file);
4383 if unsafe {
4384 libc::renameat(
4385 directory.as_raw_fd(),
4386 temp.as_ptr(),
4387 directory.as_raw_fd(),
4388 name.as_ptr(),
4389 )
4390 } != 0
4391 {
4392 let error = std::io::Error::last_os_error();
4393 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4394 return Err(error.into());
4395 }
4396 directory.sync_all()?;
4397 Ok(())
4398}
4399
4400#[cfg(windows)]
4401fn save_v2_baseline(
4402 cfg: &HubConfig,
4403 brain: &str,
4404 checkout: &Path,
4405 baseline: &V2SyncBaseline,
4406) -> LinkResult<()> {
4407 let directory = open_trust_dir(cfg)?;
4408 let name = v2_baseline_name(cfg, brain, checkout)?;
4409 let _lock = lock_trust_name(&directory, &name)?;
4410 let mut bytes = serde_json::to_vec(baseline)
4411 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
4412 bytes.push(b'\n');
4413 crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
4414 Ok(())
4415}
4416
4417#[cfg(not(any(unix, windows)))]
4418fn save_v2_baseline(
4419 _cfg: &HubConfig,
4420 _brain: &str,
4421 _checkout: &Path,
4422 _baseline: &V2SyncBaseline,
4423) -> LinkResult<()> {
4424 Err(LinkError::UnsupportedPlatform {
4425 operation: "verified link.md v2 baseline",
4426 })
4427}
4428
4429fn v2_baseline_from_head(
4430 cfg: &HubConfig,
4431 head: &V2VerifiedHead,
4432 files: std::collections::BTreeMap<String, V2BaselineFile>,
4433 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
4434 local: Option<&V2LocalView>,
4435) -> LinkResult<V2SyncBaseline> {
4436 let mut local_eligibility = local
4437 .map(|view| view.eligibility.clone())
4438 .unwrap_or_default();
4439 if let Some(view) = local {
4440 for path in files.keys() {
4441 local_eligibility
4442 .entry(path.clone())
4443 .or_insert_with(|| !view.policy.keeps_home(path));
4444 }
4445 }
4446 let remote_copy_remains = local_eligibility
4447 .iter()
4448 .filter(|(_, riding)| !**riding)
4449 .filter_map(|(path, _)| {
4450 files
4451 .get(path)
4452 .map(|file| (path.clone(), file.sha256.clone()))
4453 })
4454 .collect();
4455 Ok(V2SyncBaseline {
4456 v: 2,
4457 origin: normalized_origin(&cfg.hub)?,
4458 brain: head.brain_id.clone(),
4459 head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
4460 commit_hash: head
4461 .pointer
4462 .as_ref()
4463 .map(|pointer| pointer.commit_hash.clone()),
4464 content_root: head
4465 .pointer
4466 .as_ref()
4467 .and_then(|pointer| pointer.content_root.clone()),
4468 asset_root: head
4469 .pointer
4470 .as_ref()
4471 .and_then(|pointer| pointer.asset_root.clone()),
4472 assets,
4473 view_kind: Some(head.view_kind.clone()),
4474 view_revision: Some(head.view_revision.clone()),
4475 projection_sha256: (head.view_kind == "scoped")
4476 .then(|| scoped_projection_sha256(&head.brain_id)),
4477 files,
4478 local_policy_digest: local.map(|view| view.policy.digest.clone()),
4479 local_eligibility,
4480 remote_copy_remains,
4481 })
4482}
4483
4484fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
4485 let policy = crate::linkmd_sync_policy::load(store)
4486 .map_err(|message| LinkError::InvalidPack { message })?;
4487 let asset_paths = crate::assets::read_manifest(store)
4488 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
4489 .into_iter()
4490 .map(|asset| asset.path)
4491 .collect::<std::collections::BTreeSet<_>>();
4492 let mut result = std::collections::BTreeMap::new();
4493 let mut eligibility = std::collections::BTreeMap::new();
4494 let mut total = 0_u64;
4495 let mut paths = vec![PathBuf::from("DB.md")];
4496 paths.extend(store.walk()?);
4497 for relative in paths {
4498 let path = relative.to_string_lossy().replace('\\', "/");
4499 if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
4501 continue;
4502 }
4503 if asset_paths.contains(&path) {
4504 continue;
4505 }
4506 crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
4507 path: error.to_string(),
4508 })?;
4509 let riding = !policy.keeps_home(&path);
4510 eligibility.insert(path.clone(), riding);
4511 if !riding {
4512 continue;
4513 }
4514 let remaining = MAX_STORE_BYTES.saturating_sub(total);
4515 let bytes = store.read_bounded(&relative, remaining)?;
4516 total = total
4517 .checked_add(bytes.len() as u64)
4518 .ok_or_else(|| LinkError::PushTooLarge {
4519 detail: "v2 local byte count overflow".to_string(),
4520 })?;
4521 if total > MAX_STORE_BYTES {
4522 return Err(LinkError::PushTooLarge {
4523 detail: format!("{total} uncompressed bytes"),
4524 });
4525 }
4526 if std::str::from_utf8(&bytes).is_err() {
4527 return Err(LinkError::NotUtf8 { path });
4528 }
4529 result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
4530 }
4531 Ok(V2LocalView {
4532 riding: result,
4533 eligibility,
4534 policy,
4535 })
4536}
4537
4538#[derive(Debug, Deserialize)]
4539struct V2DownloadItem {
4540 path: String,
4541 sha256: String,
4542 bytes: u64,
4543 url: String,
4544 method: String,
4545}
4546
4547#[derive(Debug, Deserialize)]
4548struct V2DownloadWindow {
4549 v: u8,
4550 commit: String,
4551 downloads: Vec<V2DownloadItem>,
4552}
4553
4554#[derive(Debug, Deserialize)]
4555struct V2BulkStreamHeader {
4556 v: u8,
4557 path: String,
4558 sha256: String,
4559 bytes: u64,
4560}
4561
4562fn parse_v2_bulk_stream(
4563 bytes: &[u8],
4564 expected: &[(&String, &V2BaselineFile)],
4565) -> LinkResult<Vec<(String, Vec<u8>)>> {
4566 if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
4567 return Err(invalid_feed("v2 bulk stream has an invalid magic"));
4568 }
4569 let mut cursor = V2_BULK_STREAM_MAGIC.len();
4570 let mut result = Vec::with_capacity(expected.len());
4571 for (expected_path, expected_file) in expected {
4572 let length_bytes = bytes
4573 .get(cursor..cursor + 4)
4574 .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
4575 cursor += 4;
4576 let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
4577 if header_len == 0 || header_len > 4 * 1024 {
4578 return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
4579 }
4580 let header_bytes = bytes
4581 .get(cursor..cursor + header_len)
4582 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
4583 cursor += header_len;
4584 let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
4585 .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
4586 if header.v != 2
4587 || &header.path != *expected_path
4588 || header.sha256 != expected_file.sha256
4589 || header.bytes != expected_file.bytes
4590 || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
4591 {
4592 return Err(invalid_feed(
4593 "v2 bulk stream frame differs from its proven manifest entry",
4594 ));
4595 }
4596 let body_len = usize::try_from(header.bytes)
4597 .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
4598 let body = bytes
4599 .get(cursor..cursor + body_len)
4600 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
4601 cursor += body_len;
4602 if content_sha256(body) != header.sha256 {
4603 return Err(invalid_feed(
4604 "v2 bulk stream file differs from its proven manifest entry",
4605 ));
4606 }
4607 result.push((header.path, body.to_vec()));
4608 }
4609 if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
4610 return Err(invalid_feed("v2 bulk stream has no exact end marker"));
4611 }
4612 cursor += 4;
4613 if cursor != bytes.len() {
4614 return Err(invalid_feed("v2 bulk stream carries trailing data"));
4615 }
4616 Ok(result)
4617}
4618
4619fn download_v2_bulk_stream(
4620 cfg: &HubConfig,
4621 brain: &str,
4622 pointer: &V2PointerBody,
4623 pending: &[(&String, &V2BaselineFile)],
4624) -> LinkResult<Vec<(String, Vec<u8>)>> {
4625 let claims = pending
4626 .iter()
4627 .map(|(path, file)| {
4628 Ok(json!({
4629 "path": path,
4630 "sha256": file.sha256,
4631 "bytes": file.bytes,
4632 "proof": file.proof.as_ref().ok_or_else(|| {
4633 invalid_feed("v2 manifest omitted a bulk-stream proof")
4634 })?,
4635 }))
4636 })
4637 .collect::<LinkResult<Vec<_>>>()?;
4638 let raw = request_raw(
4639 cfg,
4640 "POST",
4641 &format!("/api/hub/brains/{brain}/v2/stream"),
4642 Some(&json!({
4643 "commit": pointer.commit_hash,
4644 "files": claims,
4645 })),
4646 Auth::Required,
4647 V2_BULK_STREAM_RESPONSE_BYTES,
4648 )?;
4649 let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
4650 parse_v2_bulk_stream(&body, pending)
4651}
4652
4653fn prepare_v2_downloads(
4654 cfg: &HubConfig,
4655 brain: &str,
4656 pointer: &V2PointerBody,
4657 pending: &[(&String, &V2BaselineFile)],
4658) -> LinkResult<Vec<V2DownloadItem>> {
4659 let mut result = Vec::with_capacity(pending.len());
4660 for chunk in pending.chunks(128) {
4661 let claims = chunk
4662 .iter()
4663 .map(|(path, file)| {
4664 Ok(json!({
4665 "path": path,
4666 "sha256": file.sha256,
4667 "bytes": file.bytes,
4668 "proof": file.proof.as_ref().ok_or_else(|| {
4669 invalid_feed("v2 manifest omitted a download proof")
4670 })?,
4671 }))
4672 })
4673 .collect::<LinkResult<Vec<_>>>()?;
4674 let value = ensure_ok(
4675 request_capped(
4676 cfg,
4677 "POST",
4678 &format!("/api/hub/brains/{brain}/v2/downloads"),
4679 Some(&json!({
4680 "commit": pointer.commit_hash,
4681 "files": claims,
4682 })),
4683 Auth::Required,
4684 MAX_FEED_RESPONSE_BYTES,
4685 )?,
4686 "prepare v2 blob downloads",
4687 )?;
4688 let window: V2DownloadWindow = serde_json::from_value(value)
4689 .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
4690 if window.v != 2
4691 || window.commit != pointer.commit_hash
4692 || window.downloads.len() != chunk.len()
4693 {
4694 return Err(invalid_feed(
4695 "v2 download window is not bound to the requested files",
4696 ));
4697 }
4698 let mut by_path = window
4699 .downloads
4700 .into_iter()
4701 .map(|item| (item.path.clone(), item))
4702 .collect::<std::collections::BTreeMap<_, _>>();
4703 if by_path.len() != chunk.len() {
4704 return Err(invalid_feed("v2 download window repeats a path"));
4705 }
4706 for (path, file) in chunk {
4707 let item = by_path
4708 .remove(*path)
4709 .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
4710 if item.method != "GET"
4711 || item.sha256 != file.sha256
4712 || item.bytes != file.bytes
4713 || item.url.is_empty()
4714 {
4715 return Err(invalid_feed(
4716 "v2 download capability differs from its proven file",
4717 ));
4718 }
4719 result.push(item);
4720 }
4721 }
4722 Ok(result)
4723}
4724
4725fn prepare_v2_asset_downloads(
4726 cfg: &HubConfig,
4727 brain: &str,
4728 pointer: &V2PointerBody,
4729 pending: &[(&String, &V2BaselineAsset)],
4730) -> LinkResult<Vec<V2DownloadItem>> {
4731 let mut result = Vec::with_capacity(pending.len());
4732 for chunk in pending.chunks(128) {
4733 let claims = chunk
4734 .iter()
4735 .map(|(path, asset)| {
4736 json!({
4737 "path": path,
4738 "sha256": asset.blob_sha256,
4739 "bytes": asset.bytes,
4740 "leaf_hash": asset.leaf_hash,
4741 })
4742 })
4743 .collect::<Vec<_>>();
4744 let value = ensure_ok(
4745 request_capped(
4746 cfg,
4747 "POST",
4748 &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
4749 Some(&json!({
4750 "commit": pointer.commit_hash,
4751 "assets": claims,
4752 })),
4753 Auth::Required,
4754 MAX_FEED_RESPONSE_BYTES,
4755 )?,
4756 "prepare v2 asset downloads",
4757 )?;
4758 let window: V2DownloadWindow = serde_json::from_value(value)
4759 .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
4760 if window.v != 2
4761 || window.commit != pointer.commit_hash
4762 || window.downloads.len() != chunk.len()
4763 {
4764 return Err(invalid_feed(
4765 "v2 asset download window is not bound to the requested assets",
4766 ));
4767 }
4768 let mut by_path = window
4769 .downloads
4770 .into_iter()
4771 .map(|item| (item.path.clone(), item))
4772 .collect::<std::collections::BTreeMap<_, _>>();
4773 if by_path.len() != chunk.len() {
4774 return Err(invalid_feed("v2 asset download window repeats a path"));
4775 }
4776 for (path, asset) in chunk {
4777 let item = by_path
4778 .remove(*path)
4779 .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
4780 if item.method != "GET"
4781 || item.sha256 != asset.blob_sha256
4782 || item.bytes != asset.bytes
4783 || item.url.is_empty()
4784 {
4785 return Err(invalid_feed(
4786 "v2 asset download capability differs from its signed leaf",
4787 ));
4788 }
4789 result.push(item);
4790 }
4791 }
4792 Ok(result)
4793}
4794
4795fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
4796 let bytes = get_presigned(cfg, &item.url)?;
4797 if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
4798 return Err(invalid_feed("v2 blob differs from its proven path entry"));
4799 }
4800 Ok(bytes)
4801}
4802
4803#[derive(Debug, Clone)]
4804struct V2StagedFile {
4805 path: String,
4806 source: PathBuf,
4807 sha256: String,
4808 bytes: u64,
4809}
4810
4811#[cfg(unix)]
4812fn v2_download_cache_dir(
4813 cfg: &HubConfig,
4814 brain: &str,
4815 pointer: &V2PointerBody,
4816) -> LinkResult<PathBuf> {
4817 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
4818}
4819
4820#[cfg(unix)]
4821fn v2_download_cache_dir_for(
4822 cfg: &HubConfig,
4823 brain: &str,
4824 transaction: &str,
4825) -> LinkResult<PathBuf> {
4826 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4827 return Err(invalid_feed("v2 download cache address is invalid"));
4828 }
4829 let path = cfg
4830 .state_dir
4831 .join("downloads")
4832 .join(brain)
4833 .join(transaction);
4834 let directory = open_or_create_dir_nofollow(&path)?;
4835 use std::os::fd::AsRawFd as _;
4836 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
4837 return Err(std::io::Error::last_os_error().into());
4838 }
4839 directory.sync_all()?;
4840 Ok(path)
4841}
4842
4843#[cfg(windows)]
4844fn v2_download_cache_dir(
4845 cfg: &HubConfig,
4846 brain: &str,
4847 pointer: &V2PointerBody,
4848) -> LinkResult<PathBuf> {
4849 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
4850}
4851
4852#[cfg(windows)]
4853fn v2_download_cache_dir_for(
4854 cfg: &HubConfig,
4855 brain: &str,
4856 transaction: &str,
4857) -> LinkResult<PathBuf> {
4858 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4859 return Err(invalid_feed("v2 download cache address is invalid"));
4860 }
4861 let path = cfg
4862 .state_dir
4863 .join("downloads")
4864 .join(brain)
4865 .join(transaction);
4866 crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
4867 crate::fsx::open_directory_nofollow(&path)?;
4868 Ok(path)
4869}
4870
4871#[cfg(unix)]
4872fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
4873 use std::os::fd::AsRawFd as _;
4874 let parent = cfg.state_dir.join("downloads").join(brain);
4875 let Ok(directory) = open_existing_dir_nofollow(&parent) else {
4876 return;
4877 };
4878 let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
4879 return;
4880 };
4881 let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
4882 let _ = directory.sync_all();
4883}
4884
4885#[cfg(windows)]
4886fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
4887 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4888 return;
4889 }
4890 let parent = cfg.state_dir.join("downloads").join(brain);
4891 let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
4892 return;
4893 };
4894 let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
4895}
4896
4897#[cfg(not(any(unix, windows)))]
4898fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
4899
4900#[cfg(not(any(unix, windows)))]
4901fn v2_download_cache_dir_for(
4902 _cfg: &HubConfig,
4903 _brain: &str,
4904 _transaction: &str,
4905) -> LinkResult<PathBuf> {
4906 Err(LinkError::UnsupportedPlatform {
4907 operation: "resumable v2 download staging",
4908 })
4909}
4910
4911#[cfg(any(unix, windows))]
4912fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
4913 let file = match crate::fsx::open_regular_nofollow(path) {
4914 Ok(file) => file,
4915 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
4916 Err(error) => return Err(error.into()),
4917 };
4918 if file.metadata()?.len() != bytes {
4919 return Ok(false);
4920 }
4921 Ok(content_sha256_reader(file)? == sha256)
4922}
4923
4924#[cfg(any(unix, windows))]
4925fn cache_v2_blob_bytes(
4926 cache_dir: &Path,
4927 sha256: &str,
4928 expected_bytes: u64,
4929 bytes: &[u8],
4930) -> LinkResult<PathBuf> {
4931 if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
4932 return Err(invalid_feed("v2 cached blob differs from its declaration"));
4933 }
4934 let path = cache_dir.join(sha256);
4935 if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
4936 crate::fsx::write_atomic(&path, bytes)?;
4937 }
4938 Ok(path)
4939}
4940
4941#[cfg(not(any(unix, windows)))]
4942fn cache_v2_blob_bytes(
4943 _cache_dir: &Path,
4944 _sha256: &str,
4945 _expected_bytes: u64,
4946 _bytes: &[u8],
4947) -> LinkResult<PathBuf> {
4948 Err(LinkError::UnsupportedPlatform {
4949 operation: "resumable v2 download staging",
4950 })
4951}
4952
4953#[cfg(unix)]
4954fn download_presigned_to_cache(
4955 cfg: &HubConfig,
4956 url: &str,
4957 cache_dir: &Path,
4958 sha256: &str,
4959 expected_bytes: u64,
4960) -> LinkResult<PathBuf> {
4961 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4962
4963 let target = cache_dir.join(sha256);
4964 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
4965 return Ok(target);
4966 }
4967 let directory = open_existing_dir_nofollow(cache_dir)?;
4968 let mut nonce = [0_u8; 16];
4969 ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
4970 .map_err(|_| invalid_feed("could not mint a download cache name"))?;
4971 let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
4972 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
4973 let fd = unsafe {
4974 libc::openat(
4975 directory.as_raw_fd(),
4976 temp.as_ptr(),
4977 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4978 0o600,
4979 )
4980 };
4981 if fd < 0 {
4982 return Err(std::io::Error::last_os_error().into());
4983 }
4984 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
4985 let response = match presigned_agent(cfg, url)?.get(url).call() {
4986 Ok(response) => response,
4987 Err(ureq::Error::Status(_, response)) => {
4988 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4989 return Err(LinkError::Http {
4990 what: "v2 direct download",
4991 status: response.status(),
4992 message: "object store rejected the download".to_string(),
4993 code: None,
4994 details: None,
4995 });
4996 }
4997 Err(ureq::Error::Transport(error)) => {
4998 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4999 return Err(LinkError::Transport {
5000 hub: cfg.hub.clone(),
5001 message: error.to_string(),
5002 });
5003 }
5004 };
5005 let mut reader = response
5006 .into_reader()
5007 .take(expected_bytes.saturating_add(1));
5008 let mut digest = Sha256::new();
5009 let mut total = 0_u64;
5010 let mut buffer = [0_u8; 64 * 1024];
5011 let write_result = (|| -> std::io::Result<()> {
5012 loop {
5013 let read = reader.read(&mut buffer)?;
5014 if read == 0 {
5015 break;
5016 }
5017 total = total.saturating_add(read as u64);
5018 digest.update(&buffer[..read]);
5019 output.write_all(&buffer[..read])?;
5020 }
5021 output.sync_all()
5022 })();
5023 if let Err(error) = write_result {
5024 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5025 return Err(error.into());
5026 }
5027 drop(output);
5028 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5029 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5030 return Err(invalid_feed(
5031 "v2 direct download failed integrity verification",
5032 ));
5033 }
5034 let target_name = c_name(sha256.as_bytes(), sha256)?;
5035 if unsafe {
5038 libc::renameat(
5039 directory.as_raw_fd(),
5040 temp.as_ptr(),
5041 directory.as_raw_fd(),
5042 target_name.as_ptr(),
5043 )
5044 } != 0
5045 {
5046 let error = std::io::Error::last_os_error();
5047 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5048 return Err(error.into());
5049 }
5050 directory.sync_all()?;
5051 Ok(target)
5052}
5053
5054#[cfg(windows)]
5055fn download_presigned_to_cache(
5056 cfg: &HubConfig,
5057 url: &str,
5058 cache_dir: &Path,
5059 sha256: &str,
5060 expected_bytes: u64,
5061) -> LinkResult<PathBuf> {
5062 use std::fs::OpenOptions;
5063
5064 let target = cache_dir.join(sha256);
5065 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5066 return Ok(target);
5067 }
5068 let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
5072 let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
5073 let mut output = OpenOptions::new()
5074 .write(true)
5075 .create_new(true)
5076 .open(&temp)?;
5077 let response = match presigned_agent(cfg, url)?.get(url).call() {
5078 Ok(response) => response,
5079 Err(ureq::Error::Status(_, response)) => {
5080 let _ = std::fs::remove_file(&temp);
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 _ = std::fs::remove_file(&temp);
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 copied = (|| -> 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) = copied {
5116 let _ = std::fs::remove_file(&temp);
5117 return Err(error.into());
5118 }
5119 drop(output);
5120 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5121 let _ = std::fs::remove_file(&temp);
5122 return Err(invalid_feed(
5123 "v2 direct download failed integrity verification",
5124 ));
5125 }
5126 if target.exists() {
5127 std::fs::remove_file(&target)?;
5128 }
5129 if let Err(error) = std::fs::rename(&temp, &target) {
5130 let _ = std::fs::remove_file(&temp);
5131 return Err(error.into());
5132 }
5133 Ok(target)
5134}
5135
5136#[cfg(not(any(unix, windows)))]
5137fn download_presigned_to_cache(
5138 _cfg: &HubConfig,
5139 _url: &str,
5140 _cache_dir: &Path,
5141 _sha256: &str,
5142 _expected_bytes: u64,
5143) -> LinkResult<PathBuf> {
5144 Err(LinkError::UnsupportedPlatform {
5145 operation: "resumable v2 download staging",
5146 })
5147}
5148
5149fn download_v2_blobs(
5150 cfg: &HubConfig,
5151 brain: &str,
5152 pointer: &V2PointerBody,
5153 pending: Vec<(&String, &V2BaselineFile)>,
5154) -> LinkResult<Vec<(String, Vec<u8>)>> {
5155 if pending.is_empty() {
5156 return Ok(Vec::new());
5157 }
5158 let expected_order = pending
5159 .iter()
5160 .map(|(path, _)| (*path).clone())
5161 .collect::<Vec<_>>();
5162 let mut streamed = std::collections::BTreeMap::new();
5163 let mut direct = Vec::new();
5164 let mut window = Vec::new();
5165 let mut window_bytes = 0_u64;
5166 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5167 window_bytes: &mut u64,
5168 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
5169 -> LinkResult<()> {
5170 if window.is_empty() {
5171 return Ok(());
5172 }
5173 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
5174 if streamed.insert(path, bytes).is_some() {
5175 return Err(invalid_feed("v2 bulk streams repeated a path"));
5176 }
5177 }
5178 window.clear();
5179 *window_bytes = 0;
5180 Ok(())
5181 };
5182 for &(path, file) in &pending {
5183 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5184 flush(&mut window, &mut window_bytes, &mut streamed)?;
5185 direct.push((path, file));
5186 continue;
5187 }
5188 if window.len() == V2_BULK_STREAM_FILES
5189 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5190 {
5191 flush(&mut window, &mut window_bytes, &mut streamed)?;
5192 }
5193 window.push((path, file));
5194 window_bytes += file.bytes;
5195 }
5196 flush(&mut window, &mut window_bytes, &mut streamed)?;
5197
5198 let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
5199 let next = std::sync::atomic::AtomicUsize::new(0);
5200 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5201 let mut results = std::iter::repeat_with(|| None)
5202 .take(downloads.len())
5203 .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
5204 std::thread::scope(|scope| {
5205 let (sender, receiver) = std::sync::mpsc::channel();
5206 for _ in 0..worker_count {
5207 let sender = sender.clone();
5208 let downloads = &downloads;
5209 let next = &next;
5210 scope.spawn(move || loop {
5211 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5212 let Some(item) = downloads.get(index) else {
5213 break;
5214 };
5215 let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
5216 if sender.send((index, result)).is_err() {
5217 break;
5218 }
5219 });
5220 }
5221 drop(sender);
5222 for (index, result) in receiver {
5223 results[index] = Some(result);
5224 }
5225 });
5226 for result in results.into_iter().map(|result| {
5227 result.ok_or_else(|| LinkError::Transport {
5228 hub: cfg.hub.clone(),
5229 message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
5230 })?
5231 }) {
5232 let (path, bytes) = result?;
5233 if streamed.insert(path, bytes).is_some() {
5234 return Err(invalid_feed("v2 download lanes repeated a path"));
5235 }
5236 }
5237 expected_order
5238 .into_iter()
5239 .map(|path| {
5240 streamed
5241 .remove(&path)
5242 .map(|bytes| (path, bytes))
5243 .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
5244 })
5245 .collect()
5246}
5247
5248#[cfg(any(unix, windows))]
5252fn stage_v2_blobs(
5253 cfg: &HubConfig,
5254 brain: &str,
5255 pointer: &V2PointerBody,
5256 pending: Vec<(&String, &V2BaselineFile)>,
5257) -> LinkResult<Vec<V2StagedFile>> {
5258 let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
5259 let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
5260 let mut direct = Vec::new();
5261 let mut window = Vec::new();
5262 let mut window_bytes = 0_u64;
5263 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5264 window_bytes: &mut u64,
5265 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
5266 -> LinkResult<()> {
5267 if window.is_empty() {
5268 return Ok(());
5269 }
5270 let missing = window
5271 .iter()
5272 .filter_map(|(path, file)| {
5273 let target = cache_dir.join(&file.sha256);
5274 match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
5275 Ok(true) => {
5276 staged.insert(
5277 (*path).clone(),
5278 V2StagedFile {
5279 path: (*path).clone(),
5280 source: target,
5281 sha256: file.sha256.clone(),
5282 bytes: file.bytes,
5283 },
5284 );
5285 None
5286 }
5287 Ok(false) => Some(Ok((*path, *file))),
5288 Err(error) => Some(Err(error)),
5289 }
5290 })
5291 .collect::<LinkResult<Vec<_>>>()?;
5292 if !missing.is_empty() {
5293 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
5294 let file = missing
5295 .iter()
5296 .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
5297 .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
5298 let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
5299 staged.insert(
5300 path.clone(),
5301 V2StagedFile {
5302 path,
5303 source,
5304 sha256: file.sha256.clone(),
5305 bytes: file.bytes,
5306 },
5307 );
5308 }
5309 }
5310 window.clear();
5311 *window_bytes = 0;
5312 Ok(())
5313 };
5314 for &(path, file) in &pending {
5315 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5316 flush(&mut window, &mut window_bytes, &mut staged)?;
5317 direct.push((path, file));
5318 continue;
5319 }
5320 if window.len() == V2_BULK_STREAM_FILES
5321 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5322 {
5323 flush(&mut window, &mut window_bytes, &mut staged)?;
5324 }
5325 window.push((path, file));
5326 window_bytes += file.bytes;
5327 }
5328 flush(&mut window, &mut window_bytes, &mut staged)?;
5329 for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
5330 let source =
5331 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
5332 staged.insert(
5333 item.path.clone(),
5334 V2StagedFile {
5335 path: item.path,
5336 source,
5337 sha256: item.sha256,
5338 bytes: item.bytes,
5339 },
5340 );
5341 }
5342 pending
5343 .into_iter()
5344 .map(|(path, _)| {
5345 staged
5346 .remove(path)
5347 .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
5348 })
5349 .collect()
5350}
5351
5352#[cfg(not(any(unix, windows)))]
5353fn stage_v2_blobs(
5354 _cfg: &HubConfig,
5355 _brain: &str,
5356 _pointer: &V2PointerBody,
5357 _pending: Vec<(&String, &V2BaselineFile)>,
5358) -> LinkResult<Vec<V2StagedFile>> {
5359 Err(LinkError::UnsupportedPlatform {
5360 operation: "resumable v2 download staging",
5361 })
5362}
5363
5364const V2_CONFLICT_BUNDLE_MAX: usize = 32;
5365const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
5366const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
5367
5368#[derive(Debug, Clone, Deserialize, Serialize)]
5369struct V2ConflictCoordinate {
5370 sha256: Option<String>,
5371 bytes: Option<u64>,
5372 file: Option<String>,
5373}
5374
5375#[derive(Debug, Clone, Deserialize, Serialize)]
5376struct V2ConflictFile {
5377 path: String,
5378 base: V2ConflictCoordinate,
5379 local: V2ConflictCoordinate,
5380 remote: V2ConflictCoordinate,
5381}
5382
5383#[derive(Debug, Clone, Deserialize, Serialize)]
5384struct V2ConflictPlan {
5385 v: u8,
5386 class: String,
5387 bundle: String,
5388 brain: String,
5389 origin: String,
5390 created_unix: u64,
5391 expires_unix: u64,
5392 base_seq: Option<u64>,
5393 base_commit: Option<String>,
5394 remote_seq: u64,
5395 remote_commit: Option<String>,
5396 remote_content_root: Option<String>,
5397 view_kind: String,
5398 view_revision: String,
5399 files: Vec<V2ConflictFile>,
5400}
5401
5402fn v2_take_remote_selection(
5403 files: &[V2ConflictFile],
5404 current: &std::collections::BTreeMap<String, V2BaselineFile>,
5405) -> LinkResult<(
5406 std::collections::BTreeMap<String, V2BaselineFile>,
5407 Vec<String>,
5408)> {
5409 let mut selected = std::collections::BTreeMap::new();
5410 let mut deleted = Vec::new();
5411 for file in files {
5412 match (&file.remote.sha256, file.remote.bytes) {
5413 (Some(sha256), Some(bytes)) => {
5414 let proven = current.get(&file.path).ok_or_else(|| {
5415 invalid_feed("conflict remote coordinate disappeared from the exact head")
5416 })?;
5417 if proven.sha256 != *sha256 || proven.bytes != bytes {
5418 return Err(invalid_feed(
5419 "conflict remote coordinate differs from the exact head",
5420 ));
5421 }
5422 if selected.insert(file.path.clone(), proven.clone()).is_some() {
5423 return Err(invalid_feed("conflict plan repeats a remote coordinate"));
5424 }
5425 }
5426 (None, None) => {
5427 if current.contains_key(&file.path) {
5428 return Err(invalid_feed(
5429 "conflict remote deletion differs from the exact head",
5430 ));
5431 }
5432 deleted.push(file.path.clone());
5433 }
5434 _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
5435 }
5436 }
5437 Ok((selected, deleted))
5438}
5439
5440fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
5441 PathBuf::from(".dbmd")
5442 .join("conflicts")
5443 .join(bundle)
5444 .join(suffix)
5445}
5446
5447fn read_historical_conflict_blob(
5448 cfg: &HubConfig,
5449 brain: &str,
5450 baseline: &V2SyncBaseline,
5451 path: &str,
5452 file: &V2BaselineFile,
5453) -> LinkResult<Option<Vec<u8>>> {
5454 let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
5455 return Ok(None);
5456 };
5457 if seq == 0 {
5458 return Ok(None);
5459 }
5460 let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
5461 let endpoint = format!(
5462 "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
5463 file.sha256
5464 );
5465 let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
5466 if raw.status == 404 || raw.status == 403 {
5467 return Ok(None);
5468 }
5469 let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
5470 if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
5471 return Err(invalid_feed(
5472 "v2 conflict base failed integrity verification",
5473 ));
5474 }
5475 Ok(Some(bytes))
5476}
5477
5478fn create_v2_conflict_bundle(
5481 cfg: &HubConfig,
5482 store: &Store,
5483 head: &V2VerifiedHead,
5484 baseline: Option<&V2SyncBaseline>,
5485 local: &std::collections::BTreeMap<String, (String, u64)>,
5486 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
5487 paths: &[String],
5488) -> LinkResult<(String, Vec<String>)> {
5489 let conflicts_root = Path::new(".dbmd/conflicts");
5490 store.create_dir_all(conflicts_root)?;
5491 let completed = store
5492 .directory_names(conflicts_root)?
5493 .into_iter()
5494 .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
5495 .count();
5496 if completed >= V2_CONFLICT_BUNDLE_MAX {
5497 return Err(LinkError::InvalidPack {
5498 message: format!(
5499 "private conflict cache has {completed} bundles; resolve or prune one before syncing"
5500 ),
5501 });
5502 }
5503
5504 let mut selected_paths = Vec::new();
5508 let mut selected_remote_bytes = 0_u64;
5509 for path in paths {
5510 let bytes = remote.get(path).map_or(0, |file| file.bytes);
5511 if !selected_paths.is_empty()
5512 && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
5513 {
5514 break;
5515 }
5516 selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
5517 selected_paths.push(path.clone());
5518 if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
5519 break;
5520 }
5521 }
5522 if selected_paths.is_empty() {
5523 return Err(invalid_feed("content conflict set is empty"));
5524 }
5525 let bundle = crate::ulid::mint();
5526 let bundle_root = v2_conflict_relative(&bundle, "");
5527 store.create_dir_all(&bundle_root.join("files"))?;
5528 let pointer = head.pointer.as_ref();
5529 let remote_bytes = match pointer {
5530 Some(pointer) => download_v2_blobs(
5531 cfg,
5532 &head.brain_id,
5533 pointer,
5534 selected_paths
5535 .iter()
5536 .filter_map(|path| {
5537 remote
5538 .get(path)
5539 .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
5540 .map(|file| (path, file))
5541 })
5542 .collect(),
5543 )?
5544 .into_iter()
5545 .collect::<std::collections::BTreeMap<_, _>>(),
5546 None => std::collections::BTreeMap::new(),
5547 };
5548
5549 let mut files = Vec::with_capacity(selected_paths.len());
5550 for (index, path) in selected_paths.iter().enumerate() {
5551 let base_file = baseline.and_then(|state| state.files.get(path));
5552 let base_bytes = match (baseline, base_file) {
5553 (Some(state), Some(file)) => {
5554 read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
5555 }
5556 _ => None,
5557 };
5558 let local_file = local.get(path);
5559 let remote_file = remote.get(path);
5560 let remote_content = remote_bytes.get(path);
5561 let prefix = format!("files/{index:04}");
5562 let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
5563 let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
5564 let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
5565 if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
5566 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
5567 }
5568 if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
5569 let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
5570 if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
5571 return Err(LinkError::InvalidPack {
5572 message: format!("local conflict path `{path}` changed while bundling"),
5573 });
5574 }
5575 store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
5576 }
5577 if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
5578 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
5579 }
5580 files.push(V2ConflictFile {
5581 path: path.clone(),
5582 base: V2ConflictCoordinate {
5583 sha256: base_file.map(|file| file.sha256.clone()),
5584 bytes: base_file.map(|file| file.bytes),
5585 file: base_name,
5586 },
5587 local: V2ConflictCoordinate {
5588 sha256: local_file.map(|(sha256, _)| sha256.clone()),
5589 bytes: local_file.map(|(_, bytes)| *bytes),
5590 file: local_name,
5591 },
5592 remote: V2ConflictCoordinate {
5593 sha256: remote_file.map(|file| file.sha256.clone()),
5594 bytes: remote_file.map(|file| file.bytes),
5595 file: remote_name,
5596 },
5597 });
5598 }
5599 let now = SystemTime::now()
5600 .duration_since(UNIX_EPOCH)
5601 .unwrap_or_default()
5602 .as_secs();
5603 let plan = V2ConflictPlan {
5604 v: 2,
5605 class: "content_resolution_required".to_string(),
5606 bundle: bundle.clone(),
5607 brain: head.brain_id.clone(),
5608 origin: normalized_origin(&cfg.hub)?,
5609 created_unix: now,
5610 expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
5611 base_seq: baseline.and_then(|state| state.head_seq),
5612 base_commit: baseline.and_then(|state| state.commit_hash.clone()),
5613 remote_seq: pointer.map_or(0, |value| value.seq),
5614 remote_commit: pointer.map(|value| value.commit_hash.clone()),
5615 remote_content_root: pointer.and_then(|value| value.content_root.clone()),
5616 view_kind: head.view_kind.clone(),
5617 view_revision: head.view_revision.clone(),
5618 files,
5619 };
5620 let mut bytes = serde_json::to_vec_pretty(&plan)
5621 .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
5622 bytes.push(b'\n');
5623 store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
5624 Ok((bundle, selected_paths))
5625}
5626
5627fn v2_sync_pull_with_resolution(
5628 cfg: &HubConfig,
5629 requested_brain: &str,
5630 expected_head: V2VerifiedHead,
5631 out: Option<&Path>,
5632 take_remote: Option<&std::collections::BTreeSet<String>>,
5633) -> LinkResult<V2PulledSnapshot> {
5634 let dest = out
5635 .map(Path::to_path_buf)
5636 .unwrap_or_else(|| PathBuf::from(requested_brain));
5637 let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
5638 recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
5639 let head = v2_verified_head(cfg, requested_brain)?
5640 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
5641 if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
5642 return Err(LinkError::RemoteAdvancedDuringSync);
5643 }
5644 let remote = files_for_v2_view(
5645 &head,
5646 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
5647 );
5648 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
5649 let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
5650 ensure_v2_view_compatible(&head, baseline.as_ref())?;
5651 let local_store = Store::open_strict(&dest).ok();
5652 ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
5657 let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
5658 if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
5659 return Err(LinkError::ScopedViewChanged);
5660 }
5661 if let Some(view) = local_view.as_mut() {
5662 remove_scoped_projection(&head, baseline.as_ref(), view)?;
5663 }
5664 let empty_local = std::collections::BTreeMap::new();
5665 let local = local_view
5666 .as_ref()
5667 .map_or(&empty_local, |view| &view.riding);
5668 let kept_home = |path: &str| {
5669 local_view
5670 .as_ref()
5671 .is_some_and(|view| view.policy.keeps_home(path))
5672 };
5673 let empty_base = std::collections::BTreeMap::new();
5674 let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
5675 let empty_base_assets = std::collections::BTreeMap::new();
5676 let base_assets = baseline
5677 .as_ref()
5678 .map_or(&empty_base_assets, |state| &state.assets);
5679 let mut local_assets = local_store
5680 .as_ref()
5681 .map(v2_local_asset_records)
5682 .transpose()?
5683 .unwrap_or_default();
5684 let mut content_merge = merge_v2_pulled_records(
5685 base,
5686 &remote,
5687 local,
5688 |file, _| (file.sha256.clone(), file.bytes),
5689 |file, _| (file.sha256.clone(), file.bytes),
5690 kept_home,
5691 );
5692 if let Some(selected) = take_remote {
5693 for path in selected {
5694 if let Some(position) = content_merge
5695 .conflicts
5696 .iter()
5697 .position(|conflict| conflict == path)
5698 {
5699 content_merge.conflicts.remove(position);
5700 content_merge.accept_remote.insert(path.clone());
5701 match remote.get(path) {
5702 Some(file) => {
5703 content_merge
5704 .records
5705 .insert(path.clone(), (file.sha256.clone(), file.bytes));
5706 }
5707 None => {
5708 content_merge.records.remove(path);
5709 }
5710 }
5711 } else if !content_merge.accept_remote.contains(path) {
5712 return Err(LinkError::InvalidPack {
5713 message: format!(
5714 "take-remote path `{path}` is no longer at its conflict coordinate"
5715 ),
5716 });
5717 }
5718 }
5719 }
5720 if !content_merge.conflicts.is_empty() {
5721 let mut conflicts = content_merge.conflicts.clone();
5722 conflicts.truncate(100);
5723 if let Some(store) = local_store.as_ref() {
5724 let (bundle, paths) = create_v2_conflict_bundle(
5725 cfg,
5726 store,
5727 &head,
5728 baseline.as_ref(),
5729 local,
5730 &remote,
5731 &conflicts,
5732 )?;
5733 return Err(LinkError::ConflictBundle { bundle, paths });
5734 }
5735 return Err(LinkError::Conflict { paths: conflicts });
5736 }
5737 let asset_merge = merge_v2_pulled_records(
5738 base_assets,
5739 &remote_assets,
5740 &local_assets,
5741 v2_asset_record,
5742 v2_asset_record,
5743 |_| false,
5744 );
5745 if !asset_merge.conflicts.is_empty() {
5746 let mut conflicts = asset_merge.conflicts.clone();
5747 conflicts.truncate(100);
5748 return Err(LinkError::Conflict { paths: conflicts });
5749 }
5750 let pointer = head.pointer.as_ref();
5751 let cache_transaction = pointer.map_or_else(
5752 || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
5753 |value| value.commit_hash.clone(),
5754 );
5755 let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
5756 let mut changed = match pointer {
5757 Some(pointer) => stage_v2_blobs(
5758 cfg,
5759 &head.brain_id,
5760 pointer,
5761 remote
5762 .iter()
5763 .filter(|(path, file)| {
5764 content_merge.accept_remote.contains(*path)
5765 && local.get(*path).map(|value| value.0.as_str())
5766 != Some(file.sha256.as_str())
5767 })
5768 .collect(),
5769 )?,
5770 None => Vec::new(),
5771 };
5772 let mut deleted = content_merge
5773 .accept_remote
5774 .iter()
5775 .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
5776 .cloned()
5777 .collect::<Vec<_>>();
5778 if local_assets != asset_merge.records {
5779 if asset_merge.records.is_empty() {
5780 deleted.push("assets.jsonl".to_string());
5781 } else {
5782 let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
5783 let sha256 = content_sha256(&bytes);
5784 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
5785 changed.push(V2StagedFile {
5786 path: "assets.jsonl".to_string(),
5787 source,
5788 sha256,
5789 bytes: bytes.len() as u64,
5790 });
5791 }
5792 }
5793 if let Some(pointer) = pointer {
5794 let mut pending_assets = Vec::new();
5795 for (path, asset) in &remote_assets {
5796 if asset.disposition != "hosted"
5797 || kept_home(path)
5798 || !asset_merge.accept_remote.contains(path)
5799 {
5800 continue;
5801 }
5802 let already_current = local_store.as_ref().is_some_and(|store| {
5803 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
5804 && store
5805 .read_bounded(Path::new(path), asset.bytes)
5806 .ok()
5807 .is_some_and(|bytes| {
5808 bytes.len() as u64 == asset.bytes
5809 && content_sha256(&bytes) == asset.blob_sha256
5810 })
5811 });
5812 if !already_current {
5813 pending_assets.push((path, asset));
5814 }
5815 }
5816 for item in prepare_v2_asset_downloads(cfg, &head.brain_id, pointer, &pending_assets)? {
5817 let source =
5818 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
5819 changed.push(V2StagedFile {
5820 path: item.path,
5821 source,
5822 sha256: item.sha256,
5823 bytes: item.bytes,
5824 });
5825 }
5826 }
5827 for (path, prior) in base_assets {
5828 if remote_assets.contains_key(path)
5829 || kept_home(path)
5830 || !asset_merge.accept_remote.contains(path)
5831 {
5832 continue;
5833 }
5834 let unchanged = local_store.as_ref().is_some_and(|store| {
5835 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
5836 && store
5837 .read_bounded(Path::new(path), prior.bytes)
5838 .ok()
5839 .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
5840 });
5841 if unchanged {
5842 deleted.push(path.clone());
5843 }
5844 }
5845 let extra_local = content_merge
5846 .records
5847 .keys()
5848 .filter(|path| !remote.contains_key(*path))
5849 .cloned()
5850 .collect::<Vec<_>>();
5851 if head.view_kind == "scoped" {
5852 for (path, bytes) in [
5853 ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
5854 (
5855 ".dbmd/view.json".to_string(),
5856 scoped_view_metadata(&head, remote.len())?,
5857 ),
5858 ] {
5859 let sha256 = content_sha256(&bytes);
5860 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
5861 changed.push(V2StagedFile {
5862 path,
5863 source,
5864 sha256,
5865 bytes: bytes.len() as u64,
5866 });
5867 }
5868 }
5869 let install_changed = !changed.is_empty() || !deleted.is_empty();
5870 install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
5871 let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
5872 let installed_store =
5873 Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
5874 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
5875 })?;
5876 let installed_local = if install_changed {
5877 let mut scanned = v2_local_files(&installed_store)?;
5878 remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
5879 scanned
5880 } else {
5881 local_view
5882 .take()
5883 .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
5884 };
5885 if installed_local.riding != content_merge.records {
5886 return Err(LinkError::InvalidPack {
5887 message: "local content changed while installing the v2 pull".to_string(),
5888 });
5889 }
5890 let installed_assets = if install_changed {
5891 v2_local_asset_records(&installed_store)?
5892 } else {
5893 std::mem::take(&mut local_assets)
5894 };
5895 if installed_assets != asset_merge.records {
5896 return Err(LinkError::InvalidPack {
5897 message: "local assets changed while installing the v2 pull".to_string(),
5898 });
5899 }
5900 let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
5901 installed_local.policy.keeps_home(path)
5902 })
5903 || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
5904 let final_head = v2_verified_head(cfg, requested_brain)?
5905 .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
5906 if !same_v2_head(&head, &final_head) {
5907 return Err(LinkError::RemoteAdvancedDuringSync);
5908 }
5909 accept_v2_head(cfg, &final_head)?;
5910 save_v2_baseline(
5911 cfg,
5912 &head.brain_id,
5913 &dest,
5914 &v2_baseline_from_head(
5915 cfg,
5916 &head,
5917 remote.clone(),
5918 remote_assets.clone(),
5919 Some(&installed_local),
5920 )?,
5921 )?;
5922 complete_v2_pull(&dest)?;
5923 Ok((local_dirty, installed_local, installed_assets))
5924 })();
5925 let (local_dirty, installed_local, installed_assets) = match finalized {
5926 Ok(value) => value,
5927 Err(error) => {
5928 if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
5929 return Err(LinkError::InvalidPack {
5930 message: format!("{error}; durable pull recovery also failed: {recovery}"),
5931 });
5932 }
5933 return Err(error);
5934 }
5935 };
5936 cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
5937 let report = PullReport {
5938 brain: head.brain_id.clone(),
5939 slug: requested_brain.to_string(),
5940 head_seq: pointer.map_or(0, |value| value.seq),
5941 files: remote.len() + remote_assets.len(),
5942 dest: dest.to_string_lossy().into_owned(),
5943 extra_local,
5944 sync_status: if local_dirty {
5945 "local_dirty_after_install".to_string()
5946 } else {
5947 "synced".to_string()
5948 },
5949 };
5950 Ok(V2PulledSnapshot {
5951 report,
5952 head,
5953 files: remote,
5954 assets: remote_assets,
5955 local: installed_local,
5956 local_assets: installed_assets,
5957 })
5958}
5959
5960fn v2_sync_pull(
5961 cfg: &HubConfig,
5962 requested_brain: &str,
5963 head: V2VerifiedHead,
5964 out: Option<&Path>,
5965) -> LinkResult<PullReport> {
5966 Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
5967}
5968
5969fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
5970 match remote {
5971 Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
5972 None => json!({ "kind": "absent" }),
5973 }
5974}
5975
5976fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
5977 match remote {
5978 Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
5979 None => json!({ "kind": "absent" }),
5980 }
5981}
5982
5983fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
5984 json!({
5985 "blob_sha256": record.sha256,
5986 "bytes": record.bytes,
5987 "media_type": record.media_type,
5988 "wrappers": record.wrappers,
5989 "required": record.required,
5990 "disposition": disposition,
5991 })
5992}
5993
5994fn apply_generated_v2_operations(
5998 operations: &[Value],
5999 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
6000 candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
6001 candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
6002) -> LinkResult<bool> {
6003 let mut asset_changed = false;
6004 for operation in operations {
6005 match operation.get("op").and_then(Value::as_str) {
6006 Some("put") => {
6007 let path = operation
6008 .get("path")
6009 .and_then(Value::as_str)
6010 .ok_or_else(|| invalid_feed("v2 put has no path"))?;
6011 let sha256 = operation
6012 .get("blob")
6013 .and_then(Value::as_str)
6014 .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
6015 let bytes = operation
6016 .get("bytes")
6017 .and_then(Value::as_u64)
6018 .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
6019 candidate.insert(
6020 path.to_string(),
6021 V2BaselineFile {
6022 sha256: sha256.to_string(),
6023 bytes,
6024 proof: None,
6025 },
6026 );
6027 }
6028 Some("delete") => {
6029 let path = operation
6030 .get("path")
6031 .and_then(Value::as_str)
6032 .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
6033 candidate.remove(path);
6034 }
6035 Some("asset_delete") => {
6036 let path = operation
6037 .get("path")
6038 .and_then(Value::as_str)
6039 .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
6040 candidate_assets.remove(path);
6041 asset_changed = true;
6042 }
6043 Some("asset_put" | "asset_resume") => {
6044 let path = operation
6045 .get("path")
6046 .and_then(Value::as_str)
6047 .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
6048 let record = local_assets
6049 .get(path)
6050 .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
6051 let disposition = operation
6052 .get("asset")
6053 .and_then(|asset| asset.get("disposition"))
6054 .and_then(Value::as_str)
6055 .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?;
6056 candidate_assets.insert(
6057 path.to_string(),
6058 V2BaselineAsset {
6059 blob_sha256: record.sha256.clone(),
6060 bytes: record.bytes,
6061 media_type: record.media_type.clone(),
6062 wrappers: record.wrappers.clone(),
6063 required: record.required,
6064 disposition: disposition.to_string(),
6065 leaf_hash: String::new(),
6068 },
6069 );
6070 asset_changed = true;
6071 }
6072 _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
6073 }
6074 }
6075 Ok(asset_changed)
6076}
6077
6078fn v2_riding_matches_remote(
6079 local: &std::collections::BTreeMap<String, (String, u64)>,
6080 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6081 keeps_home: impl Fn(&str) -> bool,
6082) -> bool {
6083 remote.iter().all(|(path, file)| {
6084 keeps_home(path)
6085 || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
6086 }) && local.iter().all(|(path, (hash, _))| {
6087 remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
6088 })
6089}
6090
6091#[derive(Debug, Clone)]
6092struct V2ResolutionOverride {
6093 expected_remote: Option<String>,
6094 selected_local: Option<String>,
6095}
6096
6097#[derive(Debug, Clone)]
6098struct V2UploadSource {
6099 path: String,
6100 bytes: u64,
6101}
6102
6103struct V2SyncPushOptions<'a> {
6104 resume_local_policy: bool,
6105 bulk_confirmation: Option<&'a V2BulkConfirmation>,
6106 resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
6107 pulled: Option<V2PulledSnapshot>,
6108}
6109
6110fn verify_v2_upload_source(
6111 store: &Store,
6112 path: &str,
6113 sha256: &str,
6114 expected_bytes: u64,
6115) -> LinkResult<()> {
6116 let file = store.open_regular(Path::new(path))?;
6117 if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
6118 return Err(LinkError::InvalidPack {
6119 message: format!("local path `{path}` changed during sync planning"),
6120 });
6121 }
6122 Ok(())
6123}
6124
6125fn put_presigned_source(
6126 cfg: &HubConfig,
6127 raw: &str,
6128 headers: &Value,
6129 store: &Store,
6130 source: &V2UploadSource,
6131) -> LinkResult<()> {
6132 let http = presigned_agent(cfg, raw)?;
6133 let mut attempt = 0;
6134 let result = loop {
6135 let file = store.open_regular(Path::new(&source.path))?;
6136 if file.metadata()?.len() != source.bytes {
6137 return Err(LinkError::InvalidPack {
6138 message: format!("local path `{}` changed before upload", source.path),
6139 });
6140 }
6141 let mut req = http
6142 .put(raw)
6143 .set("Content-Length", &source.bytes.to_string());
6144 if let Some(map) = headers.as_object() {
6145 for (name, value) in map {
6146 if let Some(value) = value.as_str() {
6147 req = req.set(name, value);
6148 }
6149 }
6150 }
6151 match req.send(file) {
6152 Err(ureq::Error::Transport(error))
6153 if is_pre_request_transport(error.kind()) && attempt + 1 < CONNECT_ATTEMPTS =>
6154 {
6155 std::thread::sleep(std::time::Duration::from_millis(
6156 CONNECT_RETRY_BACKOFF_MS[attempt],
6157 ));
6158 attempt += 1;
6159 }
6160 result => break result,
6161 }
6162 };
6163 match result {
6164 Ok(response) if (200..300).contains(&response.status()) => Ok(()),
6165 Ok(response) => Err(LinkError::Http {
6166 what: "v2 changed-byte upload",
6167 status: response.status(),
6168 message: "object store rejected the upload".to_string(),
6169 code: None,
6170 details: None,
6171 }),
6172 Err(error) => match error {
6173 ureq::Error::Status(412, _) => Ok(()),
6174 ureq::Error::Status(_, response) => Err(LinkError::Http {
6175 what: "v2 changed-byte upload",
6176 status: response.status(),
6177 message: "object store rejected the upload".to_string(),
6178 code: None,
6179 details: None,
6180 }),
6181 ureq::Error::Transport(error) => Err(LinkError::Transport {
6182 hub: "the object store".to_string(),
6183 message: error.to_string(),
6184 }),
6185 },
6186 }
6187}
6188
6189fn v2_sync_push(
6190 cfg: &HubConfig,
6191 requested_brain: &str,
6192 store: &Store,
6193 head: V2VerifiedHead,
6194 options: V2SyncPushOptions<'_>,
6195) -> LinkResult<Value> {
6196 let V2SyncPushOptions {
6197 resume_local_policy,
6198 bulk_confirmation,
6199 resolution,
6200 pulled,
6201 } = options;
6202 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
6203 let head = v2_verified_head(cfg, requested_brain)?
6204 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6205 let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
6206 match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
6207 Some(snapshot) => (
6208 snapshot.files,
6209 snapshot.assets,
6210 Some(snapshot.local),
6211 Some(snapshot.local_assets),
6212 ),
6213 None => (
6214 files_for_v2_view(
6215 &head,
6216 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6217 ),
6218 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6219 None,
6220 None,
6221 ),
6222 };
6223 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
6224 ensure_v2_view_compatible(&head, baseline.as_ref())?;
6225 if head.view_kind == "scoped" && baseline.is_none() {
6226 return Err(LinkError::ScopedViewChanged);
6227 }
6228 let mut local_view = match carried_local {
6229 Some(view) => view,
6230 None => v2_local_files(store)?,
6231 };
6232 remove_scoped_projection(&head, baseline.as_ref(), &mut local_view)?;
6233 let local = &local_view.riding;
6234 let local_assets = match carried_local_assets {
6235 Some(assets) => assets,
6236 None => v2_local_asset_records(store)?,
6237 };
6238 if let Some(previous) = baseline.as_ref() {
6239 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
6240 && !resume_local_policy
6241 {
6242 let mut newly_eligible = previous
6243 .local_eligibility
6244 .iter()
6245 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
6246 .map(|(path, _)| path.clone())
6247 .collect::<Vec<_>>();
6248 if !newly_eligible.is_empty() {
6249 newly_eligible.truncate(100);
6250 return Err(LinkError::LocalPolicyTransition {
6251 paths: newly_eligible,
6252 });
6253 }
6254 }
6255 }
6256 let base = match baseline.as_ref() {
6257 Some(state) => &state.files,
6258 None if remote.is_empty() => &remote,
6259 None => {
6260 let mut conflicts = remote
6261 .iter()
6262 .filter(|(path, file)| {
6263 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
6264 })
6265 .map(|(path, _)| path.clone())
6266 .collect::<Vec<_>>();
6267 if !conflicts.is_empty() {
6268 conflicts.truncate(100);
6269 let (bundle, paths) =
6270 create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
6271 return Err(LinkError::ConflictBundle { bundle, paths });
6272 }
6273 &remote
6274 }
6275 };
6276 let all_paths = base
6277 .keys()
6278 .chain(remote.keys())
6279 .chain(local.keys())
6280 .cloned()
6281 .collect::<std::collections::BTreeSet<_>>();
6282 let mut conflicts = Vec::new();
6283 let mut operations = Vec::new();
6284 let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
6285 for path in all_paths {
6286 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
6287 let remote_file = remote.get(&path);
6288 let remote_hash = remote_file.map(|file| file.sha256.as_str());
6289 let local_file = local.get(&path);
6290 let local_hash = local_file.map(|file| file.0.as_str());
6291 if local_hash == base_hash {
6292 continue;
6293 }
6294 if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
6295 continue;
6296 }
6297 if local_view.policy.keeps_home(&path) {
6298 continue;
6301 }
6302 if remote_hash != base_hash && local_hash != remote_hash {
6303 let explicitly_resolved = resolution
6304 .and_then(|allowed| allowed.get(&path))
6305 .is_some_and(|selected| {
6306 selected.expected_remote.as_deref() == remote_hash
6307 && selected.selected_local.as_deref() == local_hash
6308 });
6309 if !explicitly_resolved {
6310 conflicts.push(path);
6311 continue;
6312 }
6313 }
6314 match local_file {
6315 Some((sha256, byte_count)) => {
6316 verify_v2_upload_source(store, &path, sha256, *byte_count)?;
6317 operations.push(json!({
6318 "op": "put",
6319 "path": path,
6320 "expected": v2_expected(remote_file),
6321 "blob": sha256,
6322 "bytes": byte_count,
6323 }));
6324 upload_sources
6325 .entry(sha256.clone())
6326 .or_insert_with(|| V2UploadSource {
6327 path: path.clone(),
6328 bytes: *byte_count,
6329 });
6330 }
6331 None => {
6332 let Some(current) = remote_file else {
6333 continue;
6334 };
6335 operations.push(json!({
6336 "op": "delete",
6337 "path": path,
6338 "expected": { "kind": "blob", "hash": current.sha256 },
6339 }));
6340 }
6341 }
6342 }
6343 if !conflicts.is_empty() {
6344 conflicts.truncate(100);
6345 let (bundle, paths) = create_v2_conflict_bundle(
6346 cfg,
6347 store,
6348 &head,
6349 baseline.as_ref(),
6350 local,
6351 &remote,
6352 &conflicts,
6353 )?;
6354 return Err(LinkError::ConflictBundle { bundle, paths });
6355 }
6356 let base_assets = match baseline.as_ref() {
6357 Some(state) => &state.assets,
6358 None if remote_assets.is_empty() => &remote_assets,
6359 None => {
6360 let mismatched = remote_assets.iter().any(|(path, remote)| {
6361 local_assets.get(path) != Some(&v2_asset_record(remote, path))
6362 }) || local_assets.len() != remote_assets.len();
6363 if mismatched {
6364 return Err(LinkError::Conflict {
6365 paths: vec!["assets.jsonl".to_string()],
6366 });
6367 }
6368 &remote_assets
6369 }
6370 };
6371 let asset_paths = base_assets
6372 .keys()
6373 .chain(remote_assets.keys())
6374 .chain(local_assets.keys())
6375 .cloned()
6376 .collect::<std::collections::BTreeSet<_>>();
6377 let mut asset_policy_transitions = Vec::new();
6378 for path in asset_paths {
6379 let base_record = base_assets
6380 .get(&path)
6381 .map(|asset| v2_asset_record(asset, &path));
6382 let remote = remote_assets.get(&path);
6383 let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
6384 let local_record = local_assets.get(&path);
6385 let mut raw_present = false;
6386 let mut disposition = "withheld";
6387 let mut resumes_hosting = false;
6388 if let Some(record) = local_record {
6389 crate::linkmd_v2::normalize_path(&record.path)
6390 .map_err(|error| invalid_feed(error.to_string()))?;
6391 let kept_home = local_view.policy.keeps_home(&path);
6392 raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
6393 disposition = if kept_home || !raw_present {
6394 "withheld"
6395 } else {
6396 "hosted"
6397 };
6398 if !raw_present && record.required && !kept_home {
6399 return Err(LinkError::InvalidPack {
6400 message: format!("required asset {path} is missing"),
6401 });
6402 }
6403 resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
6404 }
6405 if local_record == base_record.as_ref() && !resumes_hosting {
6406 continue;
6407 }
6408 if remote_record != base_record && local_record != remote_record.as_ref() {
6409 conflicts.push(path);
6410 continue;
6411 }
6412 let Some(record) = local_record else {
6413 if let Some(remote) = remote {
6414 operations.push(json!({
6415 "op": "asset_delete",
6416 "path": path,
6417 "expected": v2_asset_expected(Some(remote)),
6418 }));
6419 }
6420 continue;
6421 };
6422 let raw = if raw_present {
6423 verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
6424 Some(())
6425 } else {
6426 None
6427 };
6428 let op = if resumes_hosting {
6429 if !resume_local_policy {
6430 asset_policy_transitions.push(path);
6431 continue;
6432 }
6433 "asset_resume"
6434 } else {
6435 "asset_put"
6436 };
6437 operations.push(json!({
6438 "op": op,
6439 "path": path,
6440 "expected": v2_asset_expected(remote),
6441 "asset": v2_asset_value(record, disposition),
6442 }));
6443 if disposition == "hosted" {
6444 raw.expect("hosted asset was checked present");
6445 upload_sources
6446 .entry(record.sha256.clone())
6447 .or_insert_with(|| V2UploadSource {
6448 path: path.clone(),
6449 bytes: record.bytes,
6450 });
6451 }
6452 }
6453 if !conflicts.is_empty() {
6454 conflicts.truncate(100);
6455 return Err(LinkError::Conflict { paths: conflicts });
6456 }
6457 if !asset_policy_transitions.is_empty() {
6458 asset_policy_transitions.truncate(100);
6459 return Err(LinkError::LocalPolicyTransition {
6460 paths: asset_policy_transitions,
6461 });
6462 }
6463 if operations.is_empty() {
6464 let final_head = v2_verified_head(cfg, requested_brain)?
6465 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
6466 if !same_v2_head(&head, &final_head) {
6467 return Err(LinkError::RemoteAdvancedDuringSync);
6468 }
6469 let mut final_local = v2_local_files(store)?;
6470 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
6471 let final_assets = v2_local_asset_records(store)?;
6472 let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
6473 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
6474 final_local.policy.keeps_home(path)
6475 }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
6476 let next = v2_baseline_from_head(cfg, &head, remote, remote_assets, Some(&final_local))?;
6477 let split_count = next.remote_copy_remains.len();
6478 accept_v2_head(cfg, &final_head)?;
6479 if !local_changed && !remote_ahead {
6480 refresh_scoped_view_marker(store, &head, next.files.len())?;
6481 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
6482 }
6483 return Ok(json!({
6484 "v": 2,
6485 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
6486 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
6487 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
6488 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
6489 "local_policy": {
6490 "remote_copy_remains": split_count,
6491 },
6492 }));
6493 }
6494 let includes_contract = operations
6495 .iter()
6496 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
6497 let rebase = if head.pointer.is_none() || includes_contract {
6498 "strict"
6499 } else {
6500 "disjoint"
6501 };
6502 let base_value = head.pointer.as_ref().map(|pointer| {
6503 json!({
6504 "seq": pointer.seq,
6505 "commit_hash": pointer.commit_hash,
6506 "content_root": pointer.content_root,
6507 "asset_root": pointer.asset_root,
6508 })
6509 });
6510 let entropy = format!(
6514 "{}\0{}\0{}\0{}",
6515 normalized_origin(&cfg.hub)?,
6516 head.brain_id,
6517 serde_json::to_string(&base_value).unwrap_or_default(),
6518 serde_json::to_string(&operations).unwrap_or_default()
6519 );
6520 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
6521 let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
6522 total
6523 .checked_add(source.bytes)
6524 .ok_or_else(|| LinkError::PushTooLarge {
6525 detail: "v2 changed-byte total overflow".to_string(),
6526 })
6527 })?;
6528 let inline = changed_bytes <= 3 * 1024 * 1024;
6529 let inline_blobs = if inline {
6530 upload_sources
6531 .iter()
6532 .map(|(sha256, source)| {
6533 let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
6534 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
6535 return Err(LinkError::InvalidPack {
6536 message: format!("local path `{}` changed before upload", source.path),
6537 });
6538 }
6539 Ok(json!({
6540 "sha256": sha256,
6541 "bytes": source.bytes,
6542 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
6543 }))
6544 })
6545 .collect::<LinkResult<Vec<_>>>()?
6546 } else {
6547 Vec::new()
6548 };
6549 let mut body = json!({
6550 "mutation_id": mutation_id,
6551 "base": base_value,
6552 "rebase": rebase,
6553 "reason": "dbmd sync",
6554 "operations": operations,
6555 "blobs": inline_blobs,
6556 });
6557 if let Some(confirmation) = bulk_confirmation {
6558 if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
6559 return Err(LinkError::InvalidPack {
6560 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
6561 .to_string(),
6562 });
6563 }
6564 body["rebase"] = Value::String("strict".to_string());
6568 body["bulk_preview_id"] = Value::String(confirmation.id.clone());
6569 body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
6570 }
6571 if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
6572 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6573 for operation in &operations {
6574 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
6575 return Err(invalid_feed("v2 upload operation has no kind"));
6576 };
6577 let hash = match kind {
6578 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
6579 "asset_put" | "asset_resume" => operation
6580 .get("asset")
6581 .and_then(|asset| asset.get("blob_sha256"))
6582 .and_then(Value::as_str),
6583 _ => None,
6584 };
6585 let Some(hash) = hash else { continue };
6586 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
6587 if kind == "rename" {
6588 for field in ["from", "to"] {
6589 coordinates.insert(
6590 operation
6591 .get(field)
6592 .and_then(Value::as_str)
6593 .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
6594 .to_string(),
6595 );
6596 }
6597 } else {
6598 let path = operation
6599 .get("path")
6600 .and_then(Value::as_str)
6601 .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
6602 coordinates.insert(if kind.starts_with("asset_") {
6603 format!("assets/{path}")
6604 } else {
6605 path.to_string()
6606 });
6607 }
6608 }
6609 let declarations = upload_sources
6610 .iter()
6611 .map(|(sha256, source)| {
6612 json!({
6613 "sha256": sha256,
6614 "bytes": source.bytes,
6615 "coordinates": coordinates_by_hash
6616 .get(sha256)
6617 .into_iter()
6618 .flatten()
6619 .collect::<Vec<_>>(),
6620 })
6621 })
6622 .collect::<Vec<_>>();
6623 let reserved = ensure_ok(
6624 request(
6625 cfg,
6626 "POST",
6627 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
6628 Some(&json!({ "blobs": declarations })),
6629 Auth::Required,
6630 )?,
6631 "prepare v2 changed-byte uploads",
6632 )?;
6633 let items = reserved
6634 .get("uploads")
6635 .and_then(Value::as_array)
6636 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
6637 if items.len() != upload_sources.len() {
6638 return Err(invalid_feed(
6639 "v2 upload reservation response changed the requested set",
6640 ));
6641 }
6642 let mut references = Vec::with_capacity(items.len());
6643 let mut seen = std::collections::BTreeSet::new();
6644 for item in items {
6645 let sha256 = item
6646 .get("sha256")
6647 .and_then(Value::as_str)
6648 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
6649 let source = upload_sources
6650 .get(sha256)
6651 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
6652 let declared_bytes = item
6653 .get("bytes")
6654 .and_then(Value::as_u64)
6655 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
6656 let reservation_id = item
6657 .get("reservation_id")
6658 .and_then(Value::as_str)
6659 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
6660 let expected_coordinates = coordinates_by_hash
6661 .get(sha256)
6662 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinate binding"))?;
6663 let returned_coordinates = item
6664 .get("coordinates")
6665 .and_then(Value::as_array)
6666 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
6667 if declared_bytes != source.bytes
6668 || !crate::ulid::is_ulid(reservation_id)
6669 || !seen.insert(sha256.to_string())
6670 || returned_coordinates.len() != expected_coordinates.len()
6671 || returned_coordinates
6672 .iter()
6673 .zip(expected_coordinates)
6674 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
6675 {
6676 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
6677 }
6678 match item.get("status").and_then(Value::as_str) {
6679 Some("upload") => {
6680 let url = item
6681 .get("url")
6682 .and_then(Value::as_str)
6683 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
6684 put_presigned_source(
6685 cfg,
6686 url,
6687 item.get("headers").unwrap_or(&Value::Null),
6688 store,
6689 source,
6690 )?;
6691 verify_v2_upload_source(store, &source.path, sha256, source.bytes)?;
6692 }
6693 Some("already_present") => {}
6694 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
6695 }
6696 references.push(json!({
6697 "sha256": sha256,
6698 "bytes": source.bytes,
6699 "reservation_id": reservation_id,
6700 }));
6701 }
6702 body["blobs"] = Value::Array(references);
6703 }
6704 if body.to_string().len() > MAX_PUSH_BYTES {
6705 return Err(LinkError::PushTooLarge {
6706 detail: "v2 operation metadata exceeds the bounded commit request".to_string(),
6707 });
6708 }
6709 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
6710 let mut candidate_hub_signer: Option<String> = None;
6711 let mut response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
6712 let bulk_preview_required = !(200..300).contains(&response.status)
6713 && response.body.as_ref().is_some_and(|value| {
6714 value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
6715 || value
6716 .get("details")
6717 .and_then(|details| details.get("code"))
6718 .and_then(Value::as_str)
6719 == Some("bulk_preview_required")
6720 });
6721 if bulk_preview_required && bulk_confirmation.is_none() {
6722 body["rebase"] = Value::String("strict".to_string());
6723 body["preview_only"] = Value::Bool(true);
6724 let preview = ensure_ok(
6725 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
6726 "v2 bulk preview",
6727 )?;
6728 let preview_code = preview.get("code").and_then(Value::as_str);
6729 let required = preview.get("required").and_then(Value::as_bool);
6730 if preview.get("v").and_then(Value::as_u64) != Some(2)
6731 || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
6732 || !matches!(
6733 preview_code,
6734 Some("bulk_preview_created" | "bulk_preview_not_required")
6735 )
6736 || required.is_none()
6737 {
6738 return Err(invalid_feed(
6739 "bulk preview response is not bound to the requested mutation",
6740 ));
6741 }
6742 if required == Some(true) {
6743 let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
6744 let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
6745 if preview_code != Some("bulk_preview_created")
6746 || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
6747 || preview_digest.is_none_or(|value| !is_sha256(value))
6748 || preview.get("expires_at").and_then(Value::as_str).is_none()
6749 || !preview.get("impact").is_some_and(Value::is_object)
6750 {
6751 return Err(invalid_feed("bulk preview receipt is malformed"));
6752 }
6753 return Err(LinkError::BulkPreviewRequired { preview });
6754 }
6755 if preview_code != Some("bulk_preview_not_required") {
6756 return Err(invalid_feed("bulk preview requirement is inconsistent"));
6757 }
6758 body.as_object_mut()
6761 .expect("v2 commit request is an object")
6762 .remove("preview_only");
6763 response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
6764 }
6765 let mut result = ensure_ok(response, "v2 sync push")?;
6766 if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
6767 if let Some(object) = result.as_object_mut() {
6768 object.insert(
6769 "sync_status".to_string(),
6770 Value::String("proposal_pending".to_string()),
6771 );
6772 }
6773 return Ok(result);
6774 }
6775 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
6776 let challenge = result
6777 .get("signing_challenge")
6778 .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
6779 let mut expected_candidate = remote.clone();
6780 let mut expected_candidate_assets = remote_assets.clone();
6781 apply_generated_v2_operations(
6782 &operations,
6783 &local_assets,
6784 &mut expected_candidate,
6785 &mut expected_candidate_assets,
6786 )?;
6787 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
6788 cfg,
6789 &head,
6790 &expected_candidate,
6791 &expected_candidate_assets,
6792 &mutation_id,
6793 &body,
6794 challenge,
6795 )?;
6796 body["signing_challenge_id"] = Value::String(challenge_id);
6797 body["signature_base64url"] = Value::String(signature);
6798 candidate_hub_signer = Some(actor_signer);
6799 result = ensure_ok(
6800 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
6801 "v2 self-custody commit",
6802 )?;
6803 }
6804 let refreshed = v2_verified_head(cfg, requested_brain)?
6805 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
6806 if candidate_hub_signer
6807 .as_ref()
6808 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
6809 {
6810 return Err(invalid_feed(
6811 "self-custody actor signer differs from the committed hub pointer signer",
6812 ));
6813 }
6814 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
6815 if refreshed
6816 .pointer
6817 .as_ref()
6818 .map(|pointer| pointer.commit_hash.as_str())
6819 != accepted_hash
6820 {
6821 return Err(LinkError::RemoteAdvancedDuringSync);
6822 }
6823 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
6824 let rebased = result
6825 .get("rebased")
6826 .and_then(Value::as_bool)
6827 .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
6828 let (refreshed_files, refreshed_assets) = if rebased {
6829 (
6830 files_for_v2_view(
6831 &refreshed,
6832 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
6833 ),
6834 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
6835 )
6836 } else {
6837 let asset_changed = apply_generated_v2_operations(
6838 &operations,
6839 &local_assets,
6840 &mut remote,
6841 &mut remote_assets,
6842 )?;
6843 let assets = if asset_changed {
6844 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
6847 } else {
6848 remote_assets
6849 };
6850 (remote, assets)
6851 };
6852 let mut final_local = v2_local_files(store)?;
6853 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
6854 let final_assets = v2_local_asset_records(store)?;
6855 let local_dirty = final_local.riding != local_view.riding
6856 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
6857 final_local.policy.keeps_home(path)
6858 })
6859 || final_assets != local_assets
6860 || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
6861 let next = v2_baseline_from_head(
6862 cfg,
6863 &refreshed,
6864 refreshed_files,
6865 refreshed_assets,
6866 Some(&final_local),
6867 )?;
6868 let split_count = next.remote_copy_remains.len();
6869 accept_v2_head(cfg, &refreshed)?;
6870 if !local_dirty {
6871 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
6872 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
6873 }
6874 if let Some(object) = result.as_object_mut() {
6875 object.insert(
6876 "local_policy".to_string(),
6877 json!({ "remote_copy_remains": split_count }),
6878 );
6879 object.insert(
6880 "sync_status".to_string(),
6881 Value::String(if local_dirty {
6882 "remote_committed_local_dirty".to_string()
6883 } else {
6884 "synced".to_string()
6885 }),
6886 );
6887 }
6888 Ok(result)
6889}
6890
6891pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
6894 sync_push_incremental_with_policy(cfg, brain, store, false)
6895}
6896
6897pub fn sync_push_incremental_with_policy(
6900 cfg: &HubConfig,
6901 brain: &str,
6902 store: &Store,
6903 resume_local_policy: bool,
6904) -> LinkResult<Value> {
6905 sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
6906}
6907
6908pub fn sync_push_incremental_with_options(
6911 cfg: &HubConfig,
6912 brain: &str,
6913 store: &Store,
6914 resume_local_policy: bool,
6915 bulk_confirmation: Option<&V2BulkConfirmation>,
6916) -> LinkResult<Value> {
6917 require_safe_ref(brain)?;
6918 if let Some(head) = v2_verified_head(cfg, brain)? {
6919 return v2_sync_push(
6920 cfg,
6921 brain,
6922 store,
6923 head,
6924 V2SyncPushOptions {
6925 resume_local_policy,
6926 bulk_confirmation,
6927 resolution: None,
6928 pulled: None,
6929 },
6930 );
6931 }
6932 legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
6933}
6934
6935pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
6939 require_safe_ref(brain)?;
6940 Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
6941}
6942
6943#[cfg(windows)]
6944fn legacy_sync_push_incremental(
6945 _cfg: &HubConfig,
6946 _brain: &str,
6947 _store: &Store,
6948 _resume_local_policy: bool,
6949 _bulk_confirmation: Option<&V2BulkConfirmation>,
6950) -> LinkResult<Value> {
6951 Err(LinkError::UnsupportedPlatform {
6952 operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
6953 })
6954}
6955
6956#[cfg(not(windows))]
6957fn legacy_sync_push_incremental(
6958 cfg: &HubConfig,
6959 brain: &str,
6960 store: &Store,
6961 resume_local_policy: bool,
6962 bulk_confirmation: Option<&V2BulkConfirmation>,
6963) -> LinkResult<Value> {
6964 if resume_local_policy || bulk_confirmation.is_some() {
6965 return Err(LinkError::InvalidPack {
6966 message: "v2 sync options require a link.md v2 brain".to_string(),
6967 });
6968 }
6969 let files = collect_push_files(store)?;
6970 sync_push(cfg, brain, &files)
6971}
6972
6973#[derive(Debug, Clone)]
6975pub enum V2ConflictChoice {
6976 KeepLocal,
6977 TakeRemote,
6978 From(PathBuf),
6979}
6980
6981fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
6982 if !crate::ulid::is_ulid(bundle) {
6983 return Err(LinkError::InvalidPack {
6984 message: "conflict bundle must be a lowercase ULID".to_string(),
6985 });
6986 }
6987 let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
6988 let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
6989 .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
6990 if plan.v != 2
6991 || plan.class != "content_resolution_required"
6992 || plan.bundle != bundle
6993 || !crate::ulid::is_ulid(&plan.brain)
6994 || plan.files.is_empty()
6995 || plan.files.len() > 100
6996 || plan.files.iter().any(|file| {
6997 crate::linkmd_v2::normalize_path(&file.path).is_err()
6998 || [&file.base, &file.local, &file.remote]
6999 .into_iter()
7000 .any(|coordinate| {
7001 coordinate
7002 .sha256
7003 .as_deref()
7004 .is_some_and(|hash| !is_sha256(hash))
7005 || coordinate.file.as_deref().is_some_and(|name| {
7006 name.starts_with('/')
7007 || name
7008 .split('/')
7009 .any(|part| part.is_empty() || part == "." || part == "..")
7010 })
7011 })
7012 })
7013 {
7014 return Err(invalid_feed("private conflict plan failed validation"));
7015 }
7016 Ok(plan)
7017}
7018
7019pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
7024 require_hardened_filesystem("private conflict maintenance")?;
7025 if all && !prune {
7026 return Err(LinkError::InvalidPack {
7027 message: "discarding all conflict bundles requires prune=true".to_string(),
7028 });
7029 }
7030 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7031 message: format!("conflict checkout is not a valid db.md store: {error}"),
7032 })?;
7033 let _transaction = store.transaction()?;
7034 let root = Path::new(".dbmd/conflicts");
7035 let names = match store.directory_names(root) {
7036 Ok(names) => names,
7037 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
7038 Err(error) => return Err(error.into()),
7039 };
7040 let now = SystemTime::now()
7041 .duration_since(UNIX_EPOCH)
7042 .unwrap_or_default()
7043 .as_secs();
7044 let mut bundles = Vec::new();
7045 let mut pruned = 0_u64;
7046 for name in names {
7047 let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
7048 continue;
7049 };
7050 let plan_path = v2_conflict_relative(bundle, "plan.json");
7051 let plan_exists = store.regular_file_exists(&plan_path)?;
7052 let expired = if plan_exists {
7053 match load_v2_conflict_plan(&store, bundle) {
7054 Ok(plan) => plan.expires_unix < now,
7055 Err(error) if all => {
7056 let _ = error;
7057 true
7058 }
7059 Err(error) => return Err(error),
7060 }
7061 } else {
7062 true
7063 };
7064 if prune && (all || expired) {
7065 store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
7066 pruned += 1;
7067 continue;
7068 }
7069 bundles.push(json!({
7070 "bundle": bundle,
7071 "complete": plan_exists,
7072 "expired": expired,
7073 }));
7074 }
7075 Ok(json!({
7076 "v": 2,
7077 "class": "private_conflict_state",
7078 "bundles": bundles.len(),
7079 "pruned": pruned,
7080 "items": bundles,
7081 }))
7082}
7083
7084pub fn sync_resolve_conflict(
7088 cfg: &HubConfig,
7089 checkout: &Path,
7090 bundle: &str,
7091 choice: V2ConflictChoice,
7092 bulk_confirmation: Option<&V2BulkConfirmation>,
7093) -> LinkResult<Value> {
7094 require_hardened_filesystem("conflict resolution")?;
7095 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7096 message: format!("conflict checkout is not a valid db.md store: {error}"),
7097 })?;
7098 let plan = load_v2_conflict_plan(&store, bundle)?;
7099 if plan.origin != normalized_origin(&cfg.hub)? {
7100 return Err(invalid_feed(
7101 "conflict bundle belongs to another hub origin",
7102 ));
7103 }
7104 let now = SystemTime::now()
7105 .duration_since(UNIX_EPOCH)
7106 .unwrap_or_default()
7107 .as_secs();
7108 if now > plan.expires_unix {
7109 return Err(LinkError::InvalidPack {
7110 message: "conflict bundle expired; rerun sync to obtain current coordinates"
7111 .to_string(),
7112 });
7113 }
7114 let head = v2_verified_head(cfg, &plan.brain)?
7115 .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
7116 let pointer = head.pointer.as_ref();
7117 if pointer.map_or(0, |value| value.seq) != plan.remote_seq
7118 || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
7119 || pointer.and_then(|value| value.content_root.as_deref())
7120 != plan.remote_content_root.as_deref()
7121 || head.view_kind != plan.view_kind
7122 || head.view_revision != plan.view_revision
7123 {
7124 return Err(LinkError::RemoteAdvancedDuringSync);
7125 }
7126
7127 for file in &plan.files {
7129 let actual = match store.regular_file_exists(Path::new(&file.path))? {
7130 true => Some(content_sha256(&store.read_bounded(
7131 Path::new(&file.path),
7132 file.local.bytes.unwrap_or(MAX_STORE_BYTES),
7133 )?)),
7134 false => None,
7135 };
7136 if actual.as_deref() != file.local.sha256.as_deref() {
7137 return Err(LinkError::InvalidPack {
7138 message: format!(
7139 "local conflict path `{}` changed after the bundle was created",
7140 file.path
7141 ),
7142 });
7143 }
7144 }
7145
7146 let from_source = match &choice {
7147 V2ConflictChoice::From(source) => Some(source.clone()),
7148 _ => None,
7149 };
7150 let result = match choice {
7151 V2ConflictChoice::TakeRemote => {
7152 if bulk_confirmation.is_some() {
7153 return Err(LinkError::InvalidPack {
7154 message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
7155 });
7156 }
7157 let current_remote =
7161 files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
7162 let _ = v2_take_remote_selection(&plan.files, ¤t_remote)?;
7163 let selected = plan
7164 .files
7165 .iter()
7166 .map(|file| file.path.clone())
7167 .collect::<std::collections::BTreeSet<_>>();
7168 serde_json::to_value(
7169 v2_sync_pull_with_resolution(
7170 cfg,
7171 &plan.brain,
7172 head,
7173 Some(checkout),
7174 Some(&selected),
7175 )?
7176 .report,
7177 )
7178 .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
7179 }
7180 V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
7181 if let Some(source) = from_source.as_ref() {
7182 if plan.files.len() != 1 {
7183 return Err(LinkError::InvalidPack {
7184 message: "--from requires a bundle with exactly one conflict".to_string(),
7185 });
7186 }
7187 let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
7188 if std::str::from_utf8(&candidate).is_err() {
7189 return Err(LinkError::NotUtf8 {
7190 path: source.display().to_string(),
7191 });
7192 }
7193 store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
7194 }
7195 let refreshed_store =
7196 Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7197 message: format!("resolved checkout is not a valid db.md store: {error}"),
7198 })?;
7199 let mut overrides = std::collections::BTreeMap::new();
7200 for file in &plan.files {
7201 let selected_local = match refreshed_store
7202 .regular_file_exists(Path::new(&file.path))?
7203 {
7204 true => Some(content_sha256(
7205 &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
7206 )),
7207 false => None,
7208 };
7209 overrides.insert(
7210 file.path.clone(),
7211 V2ResolutionOverride {
7212 expected_remote: file.remote.sha256.clone(),
7213 selected_local,
7214 },
7215 );
7216 }
7217 v2_sync_push(
7218 cfg,
7219 &plan.brain,
7220 &refreshed_store,
7221 head,
7222 V2SyncPushOptions {
7223 resume_local_policy: true,
7224 bulk_confirmation,
7225 resolution: Some(&overrides),
7226 pulled: None,
7227 },
7228 )?
7229 }
7230 };
7231
7232 if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
7233 let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7234 message: format!("resolved checkout is not a valid db.md store: {error}"),
7235 })?;
7236 installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
7237 }
7238 Ok(json!({
7239 "v": 2,
7240 "class": "auto_converged",
7241 "bundle": bundle,
7242 "receipt": result,
7243 }))
7244}
7245
7246pub fn sync_converge(
7257 cfg: &HubConfig,
7258 brain: &str,
7259 checkout: &Path,
7260 resume_local_policy: bool,
7261) -> LinkResult<Value> {
7262 sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
7263}
7264
7265pub fn sync_converge_with_options(
7267 cfg: &HubConfig,
7268 brain: &str,
7269 checkout: &Path,
7270 resume_local_policy: bool,
7271 bulk_confirmation: Option<&V2BulkConfirmation>,
7272) -> LinkResult<Value> {
7273 require_hardened_filesystem("bidirectional sync")?;
7274 require_safe_ref(brain)?;
7275 let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
7276 message:
7277 "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
7278 .to_string(),
7279 })?;
7280 let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
7281 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7282 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
7283 })?;
7284 let _transaction = store.transaction()?;
7285 let pulled_report = pulled.report.clone();
7286 let pulled_head = pulled.head.clone();
7287 let mut result = v2_sync_push(
7288 cfg,
7289 brain,
7290 &store,
7291 pulled_head,
7292 V2SyncPushOptions {
7293 resume_local_policy,
7294 bulk_confirmation,
7295 resolution: None,
7296 pulled: Some(pulled),
7297 },
7298 )?;
7299 if let Some(object) = result.as_object_mut() {
7300 object.insert("pulled_files".to_string(), json!(pulled_report.files));
7301 object.insert("checkout".to_string(), Value::String(pulled_report.dest));
7302 object.insert(
7303 "mode".to_string(),
7304 Value::String("bidirectional".to_string()),
7305 );
7306 }
7307 Ok(result)
7308}
7309
7310pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7316 require_hardened_filesystem("sync pull")?;
7317 require_safe_ref(brain)?;
7318 if let Some(head) = v2_verified_head(cfg, brain)? {
7319 return v2_sync_pull(cfg, brain, head, out);
7320 }
7321 legacy_sync_pull(cfg, brain, out)
7322}
7323
7324#[cfg(windows)]
7325fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
7326 Err(LinkError::UnsupportedPlatform {
7327 operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
7328 })
7329}
7330
7331#[cfg(not(windows))]
7332fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7333 let remote = verified_remote_head(cfg, brain, false)?;
7334 if !remote.head.verified {
7335 return Err(invalid_feed(
7336 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
7337 ));
7338 }
7339 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
7340 let path = format!(
7341 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
7342 remote.head.seq
7343 );
7344 let body = ensure_ok(
7345 request(cfg, "GET", &path, None, Auth::Required)?,
7346 "sync pull",
7347 )?;
7348 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
7349 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
7350 {
7351 return Err(invalid_feed(
7352 "export response is not bound to the verified snapshot token",
7353 ));
7354 }
7355
7356 let remote_slug = body
7357 .get("slug")
7358 .and_then(Value::as_str)
7359 .filter(|slug| is_safe_slug(slug));
7360 let slug = remote_slug
7361 .or_else(|| is_safe_slug(brain).then_some(brain))
7362 .unwrap_or("brain")
7363 .to_string();
7364 let brain_id = body
7365 .get("brain")
7366 .and_then(Value::as_str)
7367 .unwrap_or(&remote.head.brain)
7368 .to_string();
7369 if brain_id != remote.head.brain {
7370 return Err(invalid_feed(
7371 "export response names a different brain than the verified head",
7372 ));
7373 }
7374 let head_seq = remote.head.seq;
7375 let dest: PathBuf = match out {
7376 Some(p) => p.to_path_buf(),
7377 None => PathBuf::from(&slug),
7378 };
7379 let entries = if head_seq == 0 {
7380 let files = body
7381 .get("files")
7382 .and_then(Value::as_array)
7383 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
7384 if !files.is_empty() || body.get("url").is_some() {
7385 return Err(invalid_feed(
7386 "empty signed feed cannot authorize non-empty exported content",
7387 ));
7388 }
7389 Vec::new()
7390 } else {
7391 let signed_head = remote
7392 .head_entry
7393 .as_ref()
7394 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
7395 let expected = &signed_head.entry.pack_sha256;
7396 if !is_sha256(expected) {
7397 return Err(invalid_feed(
7398 "signed head carries an invalid snapshot pack digest",
7399 ));
7400 }
7401 if let Some(url) = body.get("url").and_then(Value::as_str) {
7402 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
7403 return Err(invalid_feed(
7404 "export pack digest does not match the signed head entry",
7405 ));
7406 }
7407 let bytes = get_presigned(cfg, url)?;
7408 let actual = format!("{:x}", Sha256::digest(&bytes));
7409 if actual != *expected {
7410 return Err(LinkError::InvalidPack {
7411 message: "downloaded pack does not match the signed snapshot digest"
7412 .to_string(),
7413 });
7414 }
7415 let entries = parse_store_pack(bytes)?;
7416 if signed_head.entry.kind == "push" {
7417 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7418 }
7419 entries
7420 } else {
7421 if signed_head.entry.kind != "push" {
7422 return Err(invalid_feed(
7423 "delta snapshots must export the exact signed pack",
7424 ));
7425 }
7426 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
7427 invalid_feed("verified snapshot export carried neither a pack nor files")
7428 })?;
7429 let mut entries = Vec::with_capacity(files.len());
7430 for file in files {
7431 let path = file
7432 .get("path")
7433 .and_then(Value::as_str)
7434 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
7435 let content = file
7436 .get("content")
7437 .and_then(Value::as_str)
7438 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
7439 entries.push((path.to_string(), content.as_bytes().to_vec()));
7440 }
7441 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7442 entries
7443 }
7444 };
7445
7446 let mut seen = std::collections::HashSet::new();
7448 for (path, _) in &entries {
7449 if !safe_store_rel_path(path) {
7450 return Err(LinkError::UnsafePath { path: path.clone() });
7451 }
7452 if !seen.insert(path) {
7453 return Err(LinkError::InvalidPack {
7454 message: format!("duplicate path `{path}`"),
7455 });
7456 }
7457 }
7458 let pulled: std::collections::BTreeSet<&str> =
7461 entries.iter().map(|(p, _)| p.as_str()).collect();
7462 let mut extra_local = Vec::new();
7463 if let Ok(store) = Store::open(&dest) {
7464 if let Ok(walked) = store.walk() {
7465 for rel in walked {
7466 let rel_str = rel.to_string_lossy().replace('\\', "/");
7467 if !pulled.contains(rel_str.as_str()) {
7468 extra_local.push(rel_str);
7469 }
7470 }
7471 }
7472 }
7473 #[cfg(unix)]
7474 install_pulled_snapshot(&dest, &entries)?;
7475
7476 Ok(PullReport {
7477 brain: brain_id,
7478 slug,
7479 head_seq,
7480 files: entries.len(),
7481 dest: dest.to_string_lossy().into_owned(),
7482 extra_local,
7483 sync_status: "synced".to_string(),
7484 })
7485}
7486
7487#[cfg(unix)]
7488fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
7489 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
7490 path: display.to_string(),
7491 })
7492}
7493
7494#[cfg(unix)]
7495fn open_dir_at(
7496 parent: std::os::fd::RawFd,
7497 name: &std::ffi::CStr,
7498 display: &str,
7499) -> LinkResult<std::fs::File> {
7500 use std::os::fd::FromRawFd as _;
7501 let fd = unsafe {
7502 libc::openat(
7503 parent,
7504 name.as_ptr(),
7505 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7506 )
7507 };
7508 if fd < 0 {
7509 return Err(LinkError::UnsafePath {
7510 path: display.to_string(),
7511 });
7512 }
7513 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
7514}
7515
7516#[cfg(unix)]
7520fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
7521 use std::os::fd::AsRawFd as _;
7522
7523 #[cfg(target_os = "macos")]
7527 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
7528 .into_iter()
7529 .find_map(|(alias, real)| {
7530 path.strip_prefix(alias)
7531 .ok()
7532 .map(|rest| Path::new(real).join(rest))
7533 })
7534 .unwrap_or_else(|| path.to_path_buf());
7535 #[cfg(not(target_os = "macos"))]
7536 let normalized = path.to_path_buf();
7537
7538 let start = if normalized.is_absolute() {
7539 std::fs::File::open("/")?
7540 } else {
7541 std::fs::File::open(".")?
7542 };
7543 let mut directory = start;
7544 for component in normalized.components() {
7545 use std::path::Component;
7546 let name = match component {
7547 Component::RootDir | Component::CurDir => continue,
7548 Component::Normal(name) => name,
7549 Component::ParentDir | Component::Prefix(_) => {
7550 return Err(LinkError::UnsafePath {
7551 path: path.display().to_string(),
7552 });
7553 }
7554 };
7555 use std::os::unix::ffi::OsStrExt as _;
7556 let name = c_name(name.as_bytes(), &path.display().to_string())?;
7557 if create {
7558 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
7559 if made != 0 {
7560 let error = std::io::Error::last_os_error();
7561 if error.raw_os_error() != Some(libc::EEXIST) {
7562 return Err(error.into());
7563 }
7564 }
7565 }
7566 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
7567 }
7568 Ok(directory)
7569}
7570
7571#[cfg(unix)]
7572fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
7573 open_dir_path_nofollow(path, true)
7574}
7575
7576#[cfg(unix)]
7577fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
7578 open_dir_path_nofollow(path, false)
7579}
7580
7581#[cfg(unix)]
7582fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
7583 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
7584 let result =
7585 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
7586 if result == 0 {
7587 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
7588 }
7589 let error = std::io::Error::last_os_error();
7590 if error.kind() == std::io::ErrorKind::NotFound {
7591 Ok(None)
7592 } else {
7593 Err(error.into())
7594 }
7595}
7596
7597#[cfg(unix)]
7598fn create_dir_exclusive_at(
7599 parent: std::os::fd::RawFd,
7600 name: &std::ffi::CStr,
7601 display: &str,
7602) -> LinkResult<std::fs::File> {
7603 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
7604 if made != 0 {
7605 return Err(LinkError::UnsafePath {
7606 path: display.to_string(),
7607 });
7608 }
7609 open_dir_at(parent, name, display)
7610}
7611
7612#[cfg(unix)]
7613fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
7614 use std::os::fd::AsRawFd as _;
7615
7616 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
7617 if duplicate < 0 {
7618 return Err(std::io::Error::last_os_error().into());
7619 }
7620 let stream = unsafe { libc::fdopendir(duplicate) };
7621 if stream.is_null() {
7622 let error = std::io::Error::last_os_error();
7623 unsafe {
7624 libc::close(duplicate);
7625 }
7626 return Err(error.into());
7627 }
7628 let mut names = Vec::new();
7629 loop {
7630 let entry = unsafe { libc::readdir(stream) };
7631 if entry.is_null() {
7632 break;
7633 }
7634 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
7635 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
7636 names.push(raw.to_owned());
7637 }
7638 }
7639 if unsafe { libc::closedir(stream) } != 0 {
7640 return Err(std::io::Error::last_os_error().into());
7641 }
7642 Ok(names)
7643}
7644
7645#[cfg(unix)]
7648fn remove_tree_at(
7649 parent: std::os::fd::RawFd,
7650 name: &std::ffi::CStr,
7651 display: &str,
7652) -> LinkResult<()> {
7653 use std::os::fd::AsRawFd as _;
7654
7655 match entry_is_dir_at(parent, name)? {
7656 None => return Ok(()),
7657 Some(false) => {
7658 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
7659 return Err(std::io::Error::last_os_error().into());
7660 }
7661 }
7662 Some(true) => {
7663 let directory = open_dir_at(parent, name, display)?;
7664 for child in directory_entry_names(&directory)? {
7665 let child_display =
7666 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
7667 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
7668 }
7669 drop(directory);
7670 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
7671 return Err(std::io::Error::last_os_error().into());
7672 }
7673 }
7674 }
7675 Ok(())
7676}
7677
7678#[cfg(unix)]
7682fn clone_tree_contents(
7683 source: &std::fs::File,
7684 destination: &std::fs::File,
7685 display: &str,
7686) -> LinkResult<()> {
7687 use std::os::fd::{AsRawFd as _, FromRawFd as _};
7688
7689 for name in directory_entry_names(source)? {
7690 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
7691 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
7692 if unsafe {
7693 libc::fstatat(
7694 source.as_raw_fd(),
7695 name.as_ptr(),
7696 &mut stat,
7697 libc::AT_SYMLINK_NOFOLLOW,
7698 )
7699 } != 0
7700 {
7701 return Err(std::io::Error::last_os_error().into());
7702 }
7703 match stat.st_mode & libc::S_IFMT {
7704 libc::S_IFDIR => {
7705 if unsafe {
7706 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
7707 } != 0
7708 {
7709 return Err(std::io::Error::last_os_error().into());
7710 }
7711 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
7712 let destination_child =
7713 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
7714 clone_tree_contents(&source_child, &destination_child, &child_display)?;
7715 destination_child.sync_all()?;
7716 }
7717 libc::S_IFREG => {
7718 let source_fd = unsafe {
7719 libc::openat(
7720 source.as_raw_fd(),
7721 name.as_ptr(),
7722 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7723 )
7724 };
7725 if source_fd < 0 {
7726 return Err(std::io::Error::last_os_error().into());
7727 }
7728 let destination_fd = unsafe {
7729 libc::openat(
7730 destination.as_raw_fd(),
7731 name.as_ptr(),
7732 libc::O_WRONLY
7733 | libc::O_CREAT
7734 | libc::O_EXCL
7735 | libc::O_CLOEXEC
7736 | libc::O_NOFOLLOW,
7737 (stat.st_mode & 0o777) as libc::c_uint,
7738 )
7739 };
7740 if destination_fd < 0 {
7741 unsafe {
7742 libc::close(source_fd);
7743 }
7744 return Err(std::io::Error::last_os_error().into());
7745 }
7746 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
7747 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
7748 std::io::copy(&mut input, &mut output)?;
7749 output.sync_all()?;
7750 }
7751 libc::S_IFLNK => {
7752 let mut target = vec![0_u8; 4097];
7753 let length = unsafe {
7754 libc::readlinkat(
7755 source.as_raw_fd(),
7756 name.as_ptr(),
7757 target.as_mut_ptr().cast(),
7758 target.len(),
7759 )
7760 };
7761 if length < 0 || length as usize >= target.len() {
7762 return Err(LinkError::UnsafePath {
7763 path: child_display,
7764 });
7765 }
7766 target.truncate(length as usize);
7767 let target = c_name(&target, &child_display)?;
7768 if unsafe {
7769 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
7770 } != 0
7771 {
7772 return Err(std::io::Error::last_os_error().into());
7773 }
7774 }
7775 _ => {
7776 return Err(LinkError::UnsafePath {
7777 path: child_display,
7778 });
7779 }
7780 }
7781 }
7782 destination.sync_all()?;
7783 Ok(())
7784}
7785
7786#[cfg(target_os = "linux")]
7787fn install_stage_at(
7788 parent: std::os::fd::RawFd,
7789 stage: &std::ffi::CStr,
7790 dest: &std::ffi::CStr,
7791 dest_exists: bool,
7792) -> LinkResult<()> {
7793 let flags = if dest_exists {
7794 libc::RENAME_EXCHANGE
7795 } else {
7796 libc::RENAME_NOREPLACE
7797 };
7798 let result = unsafe {
7802 libc::syscall(
7803 libc::SYS_renameat2,
7804 parent,
7805 stage.as_ptr(),
7806 parent,
7807 dest.as_ptr(),
7808 flags,
7809 )
7810 };
7811 if result == 0 {
7812 Ok(())
7813 } else {
7814 Err(std::io::Error::last_os_error().into())
7815 }
7816}
7817
7818#[cfg(target_os = "macos")]
7819fn install_stage_at(
7820 parent: std::os::fd::RawFd,
7821 stage: &std::ffi::CStr,
7822 dest: &std::ffi::CStr,
7823 dest_exists: bool,
7824) -> LinkResult<()> {
7825 let flags = if dest_exists {
7826 libc::RENAME_SWAP
7827 } else {
7828 libc::RENAME_EXCL
7829 };
7830 let result =
7831 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
7832 if result == 0 {
7833 Ok(())
7834 } else {
7835 Err(std::io::Error::last_os_error().into())
7836 }
7837}
7838
7839#[cfg(unix)]
7840fn write_pull_entries_beneath_dir(
7841 root: &std::fs::File,
7842 entries: &[(String, Vec<u8>)],
7843) -> LinkResult<()> {
7844 use std::os::fd::{AsRawFd as _, FromRawFd as _};
7845
7846 for (path, content) in entries {
7847 let components: Vec<&str> = path.split('/').collect();
7848 let (leaf, parents) = components
7849 .split_last()
7850 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
7851 let mut directory = root.try_clone()?;
7852 for component in parents {
7853 let name = c_name(component.as_bytes(), path)?;
7854 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
7855 if made != 0 {
7856 let error = std::io::Error::last_os_error();
7857 if error.raw_os_error() != Some(libc::EEXIST) {
7858 return Err(error.into());
7859 }
7860 }
7861 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
7862 }
7863
7864 let leaf_name = c_name(leaf.as_bytes(), path)?;
7865 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
7866 let inspected = unsafe {
7867 libc::fstatat(
7868 directory.as_raw_fd(),
7869 leaf_name.as_ptr(),
7870 &mut existing,
7871 libc::AT_SYMLINK_NOFOLLOW,
7872 )
7873 };
7874 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
7875 return Err(LinkError::UnsafePath { path: path.clone() });
7876 }
7877
7878 let nonce = std::time::SystemTime::now()
7879 .duration_since(std::time::UNIX_EPOCH)
7880 .unwrap_or_default()
7881 .as_nanos();
7882 let temp_name = format!(
7883 ".dbmd-pull-{}-{nonce}-{}",
7884 std::process::id(),
7885 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
7886 );
7887 let temp = c_name(temp_name.as_bytes(), path)?;
7888 let fd = unsafe {
7889 libc::openat(
7890 directory.as_raw_fd(),
7891 temp.as_ptr(),
7892 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7893 0o600,
7894 )
7895 };
7896 if fd < 0 {
7897 return Err(std::io::Error::last_os_error().into());
7898 }
7899 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
7900 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
7901 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
7902 return Err(error.into());
7903 }
7904 drop(file);
7905 let renamed = unsafe {
7906 libc::renameat(
7907 directory.as_raw_fd(),
7908 temp.as_ptr(),
7909 directory.as_raw_fd(),
7910 leaf_name.as_ptr(),
7911 )
7912 };
7913 if renamed != 0 {
7914 let error = std::io::Error::last_os_error();
7915 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
7916 return Err(error.into());
7917 }
7918 directory.sync_all()?;
7919 }
7920 root.sync_all()?;
7921 Ok(())
7922}
7923
7924#[cfg(unix)]
7925fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
7926 use std::os::fd::{AsRawFd as _, FromRawFd as _};
7927
7928 let path = &entry.path;
7929 let components: Vec<&str> = path.split('/').collect();
7930 let (leaf, parents) = components
7931 .split_last()
7932 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
7933 let mut directory = root.try_clone()?;
7934 for component in parents {
7935 let name = c_name(component.as_bytes(), path)?;
7936 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
7937 if made != 0 {
7938 let error = std::io::Error::last_os_error();
7939 if error.raw_os_error() != Some(libc::EEXIST) {
7940 return Err(error.into());
7941 }
7942 }
7943 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
7944 }
7945 let leaf_name = c_name(leaf.as_bytes(), path)?;
7946 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
7947 if unsafe {
7948 libc::fstatat(
7949 directory.as_raw_fd(),
7950 leaf_name.as_ptr(),
7951 &mut existing,
7952 libc::AT_SYMLINK_NOFOLLOW,
7953 )
7954 } == 0
7955 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
7956 {
7957 return Err(LinkError::UnsafePath { path: path.clone() });
7958 }
7959 let nonce = SystemTime::now()
7960 .duration_since(UNIX_EPOCH)
7961 .unwrap_or_default()
7962 .as_nanos();
7963 let temp_name = format!(
7964 ".dbmd-pull-{}-{nonce}-{}",
7965 std::process::id(),
7966 content_sha256(path.as_bytes())
7967 );
7968 let temp = c_name(temp_name.as_bytes(), path)?;
7969 let fd = unsafe {
7970 libc::openat(
7971 directory.as_raw_fd(),
7972 temp.as_ptr(),
7973 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7974 0o600,
7975 )
7976 };
7977 if fd < 0 {
7978 return Err(std::io::Error::last_os_error().into());
7979 }
7980 let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
7981 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
7982 let mut digest = Sha256::new();
7983 let mut total = 0_u64;
7984 let mut buffer = [0_u8; 64 * 1024];
7985 let copied = (|| -> std::io::Result<()> {
7986 loop {
7987 let read = input.read(&mut buffer)?;
7988 if read == 0 {
7989 break;
7990 }
7991 total = total.saturating_add(read as u64);
7992 if total > entry.bytes {
7993 return Err(std::io::Error::new(
7994 std::io::ErrorKind::InvalidData,
7995 "staged sync source grew beyond its verified length",
7996 ));
7997 }
7998 digest.update(&buffer[..read]);
7999 output.write_all(&buffer[..read])?;
8000 }
8001 Ok(())
8002 })();
8003 if let Err(error) = copied {
8004 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8005 return Err(error.into());
8006 }
8007 drop(output);
8008 if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
8009 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8010 return Err(invalid_feed(
8011 "private staged sync source failed final integrity verification",
8012 ));
8013 }
8014 if unsafe {
8015 libc::renameat(
8016 directory.as_raw_fd(),
8017 temp.as_ptr(),
8018 directory.as_raw_fd(),
8019 leaf_name.as_ptr(),
8020 )
8021 } != 0
8022 {
8023 let error = std::io::Error::last_os_error();
8024 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8025 return Err(error.into());
8026 }
8027 Ok(())
8028}
8029
8030#[cfg(unix)]
8031fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
8032 use std::os::fd::{AsRawFd as _, FromRawFd as _};
8033
8034 let path = &entry.path;
8035 let components: Vec<&str> = path.split('/').collect();
8036 let (leaf, parents) = components
8037 .split_last()
8038 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8039 let mut directory = root.try_clone()?;
8040 for component in parents {
8041 directory = open_dir_at(
8042 directory.as_raw_fd(),
8043 &c_name(component.as_bytes(), path)?,
8044 path,
8045 )?;
8046 }
8047 let leaf = c_name(leaf.as_bytes(), path)?;
8048 let fd = unsafe {
8049 libc::openat(
8050 directory.as_raw_fd(),
8051 leaf.as_ptr(),
8052 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8053 )
8054 };
8055 if fd < 0 {
8056 return Err(std::io::Error::last_os_error().into());
8057 }
8058 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
8059 if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
8060 return Err(invalid_feed(
8061 "private pull stage changed before its durability barrier",
8062 ));
8063 }
8064 file.sync_all()?;
8065 Ok(())
8066}
8067
8068#[cfg(unix)]
8069fn run_pull_source_workers(
8070 root: &std::fs::File,
8071 entries: &[V2StagedFile],
8072 operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
8073) -> LinkResult<()> {
8074 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
8075
8076 let next = AtomicUsize::new(0);
8077 let failed = AtomicBool::new(false);
8078 let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
8079 let mut first_error = None;
8080 std::thread::scope(|scope| {
8081 let (sender, receiver) = std::sync::mpsc::channel();
8082 for _ in 0..worker_count {
8083 let sender = sender.clone();
8084 let next = &next;
8085 let failed = &failed;
8086 scope.spawn(move || {
8087 while !failed.load(Ordering::Acquire) {
8088 let index = next.fetch_add(1, Ordering::Relaxed);
8089 let Some(entry) = entries.get(index) else {
8090 break;
8091 };
8092 let result = operation(root, entry);
8093 if result.is_err() {
8094 failed.store(true, Ordering::Release);
8095 }
8096 if sender.send(result).is_err() {
8097 break;
8098 }
8099 }
8100 });
8101 }
8102 drop(sender);
8103 for result in receiver {
8104 if let Err(error) = result {
8105 if first_error.is_none() {
8106 first_error = Some(error);
8107 }
8108 }
8109 }
8110 });
8111 if let Some(error) = first_error {
8112 return Err(error);
8113 }
8114 if next.load(Ordering::Relaxed) < entries.len() {
8115 return Err(invalid_feed(
8116 "a bounded pull worker stopped before reporting every file",
8117 ));
8118 }
8119 Ok(())
8120}
8121
8122#[cfg(unix)]
8123fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
8124 use std::os::fd::AsRawFd as _;
8125
8126 for name in directory_entry_names(root)? {
8127 if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
8128 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
8129 let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
8130 sync_pull_directory_tree(&child, &child_display)?;
8131 }
8132 }
8133 root.sync_all()?;
8134 Ok(())
8135}
8136
8137#[cfg(unix)]
8138fn write_pull_sources_beneath_dir(
8139 root: &std::fs::File,
8140 entries: &[V2StagedFile],
8141) -> LinkResult<()> {
8142 run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
8149 run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
8150 sync_pull_directory_tree(root, "v2 pull stage")
8151}
8152
8153#[cfg(unix)]
8154fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
8155 use std::os::fd::AsRawFd as _;
8156 for path in paths {
8157 if !safe_store_rel_path(path) {
8158 return Err(LinkError::UnsafePath { path: path.clone() });
8159 }
8160 let components = path.split('/').collect::<Vec<_>>();
8161 let Some((leaf, parents)) = components.split_last() else {
8162 return Err(LinkError::UnsafePath { path: path.clone() });
8163 };
8164 let mut directory = root.try_clone()?;
8165 let mut missing = false;
8166 for component in parents {
8167 let name = c_name(component.as_bytes(), path)?;
8168 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
8169 None => {
8170 missing = true;
8171 break;
8172 }
8173 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
8174 Some(true) => {
8175 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8176 }
8177 }
8178 }
8179 if missing {
8180 continue;
8181 }
8182 let leaf = c_name(leaf.as_bytes(), path)?;
8183 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
8184 None => {}
8185 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
8186 Some(false) => {
8187 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
8188 return Err(std::io::Error::last_os_error().into());
8189 }
8190 directory.sync_all()?;
8191 }
8192 }
8193 }
8194 Ok(())
8195}
8196
8197#[cfg(unix)]
8198fn install_pulled_delta(
8199 dest: &Path,
8200 entries: &[(String, Vec<u8>)],
8201 deleted: &[String],
8202 rebuild_indexes: bool,
8203) -> LinkResult<()> {
8204 use ring::rand::SecureRandom as _;
8205 use std::os::fd::AsRawFd as _;
8206 use std::os::unix::ffi::OsStrExt as _;
8207
8208 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8209 let name = dest
8210 .file_name()
8211 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
8212 .ok_or_else(|| LinkError::UnsafePath {
8213 path: dest.display().to_string(),
8214 })?;
8215 let parent_dir = open_or_create_dir_nofollow(parent)?;
8216 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
8217 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
8218 None => false,
8219 Some(true) => true,
8220 Some(false) => {
8221 return Err(LinkError::UnsafePath {
8222 path: dest.display().to_string(),
8223 });
8224 }
8225 };
8226
8227 let mut nonce = [0_u8; 16];
8228 ring::rand::SystemRandom::new()
8229 .fill(&mut nonce)
8230 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
8231 let stage_label = format!(
8232 ".{}.dbmd-pull-stage-{}",
8233 name.to_string_lossy(),
8234 URL_SAFE_NO_PAD.encode(nonce)
8235 );
8236 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
8237 let stage_dir = create_dir_exclusive_at(
8238 parent_dir.as_raw_fd(),
8239 &stage_name,
8240 &dest.display().to_string(),
8241 )?;
8242
8243 let prepared = (|| -> LinkResult<()> {
8244 if dest_exists {
8245 let live = open_dir_at(
8246 parent_dir.as_raw_fd(),
8247 &dest_name,
8248 &dest.display().to_string(),
8249 )?;
8250 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
8251 }
8252 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
8253 write_pull_entries_beneath_dir(&stage_dir, entries)?;
8254 if rebuild_indexes {
8255 let stage_store =
8256 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
8257 .map_err(|error| LinkError::InvalidPack {
8258 message: format!("v2 staging tree is not a valid db.md store: {error}"),
8259 })?;
8260 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
8261 LinkError::InvalidPack {
8262 message: format!("could not materialize v2 local catalogs: {error}"),
8263 }
8264 })?;
8265 }
8266 stage_dir.sync_all()?;
8267 Ok(())
8268 })();
8269 if let Err(error) = prepared {
8270 let _ = remove_tree_at(
8271 parent_dir.as_raw_fd(),
8272 &stage_name,
8273 &dest.display().to_string(),
8274 );
8275 return Err(error);
8276 }
8277
8278 if let Err(error) =
8279 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
8280 {
8281 let _ = remove_tree_at(
8282 parent_dir.as_raw_fd(),
8283 &stage_name,
8284 &dest.display().to_string(),
8285 );
8286 return Err(error);
8287 }
8288 parent_dir.sync_all()?;
8289 if dest_exists {
8290 let _ = remove_tree_at(
8294 parent_dir.as_raw_fd(),
8295 &stage_name,
8296 &dest.display().to_string(),
8297 );
8298 let _ = parent_dir.sync_all();
8299 }
8300 Ok(())
8301}
8302
8303#[cfg(unix)]
8304fn install_pulled_delta_sources(
8305 dest: &Path,
8306 entries: &[V2StagedFile],
8307 deleted: &[String],
8308 rebuild_indexes: bool,
8309 _previous: Option<&V2SyncBaseline>,
8310 _next: &V2VerifiedHead,
8311) -> LinkResult<()> {
8312 use ring::rand::SecureRandom as _;
8313 use std::os::fd::AsRawFd as _;
8314 use std::os::unix::ffi::OsStrExt as _;
8315
8316 if let Ok(store) = Store::open_strict(dest) {
8320 return install_established_v2_delta(
8321 store,
8322 entries,
8323 deleted,
8324 rebuild_indexes,
8325 _previous,
8326 _next,
8327 );
8328 }
8329
8330 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8331 let name = dest
8332 .file_name()
8333 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
8334 .ok_or_else(|| LinkError::UnsafePath {
8335 path: dest.display().to_string(),
8336 })?;
8337 let parent_dir = open_or_create_dir_nofollow(parent)?;
8338 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
8339 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
8340 None => false,
8341 Some(true) => true,
8342 Some(false) => {
8343 return Err(LinkError::UnsafePath {
8344 path: dest.display().to_string(),
8345 })
8346 }
8347 };
8348 let mut nonce = [0_u8; 16];
8349 ring::rand::SystemRandom::new()
8350 .fill(&mut nonce)
8351 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
8352 let stage_label = format!(
8353 ".{}.dbmd-pull-stage-{}",
8354 name.to_string_lossy(),
8355 URL_SAFE_NO_PAD.encode(nonce)
8356 );
8357 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
8358 let stage_dir = create_dir_exclusive_at(
8359 parent_dir.as_raw_fd(),
8360 &stage_name,
8361 &dest.display().to_string(),
8362 )?;
8363 let prepared = (|| -> LinkResult<()> {
8364 if dest_exists {
8365 let live = open_dir_at(
8366 parent_dir.as_raw_fd(),
8367 &dest_name,
8368 &dest.display().to_string(),
8369 )?;
8370 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
8371 }
8372 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
8373 write_pull_sources_beneath_dir(&stage_dir, entries)?;
8374 if rebuild_indexes {
8375 let stage_store =
8376 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
8377 .map_err(|error| LinkError::InvalidPack {
8378 message: format!("v2 staging tree is not a valid db.md store: {error}"),
8379 })?;
8380 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
8381 LinkError::InvalidPack {
8382 message: format!("could not materialize v2 local catalogs: {error}"),
8383 }
8384 })?;
8385 }
8386 stage_dir.sync_all()?;
8387 Ok(())
8388 })();
8389 if let Err(error) = prepared {
8390 let _ = remove_tree_at(
8391 parent_dir.as_raw_fd(),
8392 &stage_name,
8393 &dest.display().to_string(),
8394 );
8395 return Err(error);
8396 }
8397 if let Err(error) =
8398 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
8399 {
8400 let _ = remove_tree_at(
8401 parent_dir.as_raw_fd(),
8402 &stage_name,
8403 &dest.display().to_string(),
8404 );
8405 return Err(error);
8406 }
8407 parent_dir.sync_all()?;
8408 if dest_exists {
8409 let _ = remove_tree_at(
8410 parent_dir.as_raw_fd(),
8411 &stage_name,
8412 &dest.display().to_string(),
8413 );
8414 let _ = parent_dir.sync_all();
8415 }
8416 Ok(())
8417}
8418
8419#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8420struct V2PullCoordinate {
8421 head_seq: Option<u64>,
8422 commit_hash: Option<String>,
8423 view_kind: Option<String>,
8424 view_revision: Option<String>,
8425}
8426
8427#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8428struct V2PullFileCoordinate {
8429 sha256: String,
8430 bytes: u64,
8431}
8432
8433#[derive(Debug, Clone, Deserialize, Serialize)]
8434struct V2PullJournalEntry {
8435 path: String,
8436 old: Option<V2PullFileCoordinate>,
8437 new: Option<V2PullFileCoordinate>,
8438 backup: Option<String>,
8439}
8440
8441#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8442#[serde(rename_all = "snake_case")]
8443enum V2PullPhase {
8444 Preparing,
8445 Ready,
8446}
8447
8448#[derive(Debug, Clone, Deserialize, Serialize)]
8449struct V2PullJournal {
8450 v: u8,
8451 phase: V2PullPhase,
8452 brain: String,
8453 previous: V2PullCoordinate,
8454 next: V2PullCoordinate,
8455 backup_dir: String,
8456 entries: Vec<V2PullJournalEntry>,
8457}
8458
8459const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
8460
8461fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
8462 V2PullCoordinate {
8463 head_seq: baseline.and_then(|value| value.head_seq),
8464 commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
8465 view_kind: baseline.and_then(|value| value.view_kind.clone()),
8466 view_revision: baseline.and_then(|value| value.view_revision.clone()),
8467 }
8468}
8469
8470fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
8471 V2PullCoordinate {
8472 head_seq: head.pointer.as_ref().map(|value| value.seq),
8473 commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
8474 view_kind: Some(head.view_kind.clone()),
8475 view_revision: Some(head.view_revision.clone()),
8476 }
8477}
8478
8479fn v2_pull_file_coordinate(
8480 store: &Store,
8481 path: &str,
8482 limit: u64,
8483) -> LinkResult<Option<V2PullFileCoordinate>> {
8484 let file = match store.open_regular(Path::new(path)) {
8485 Ok(file) => file,
8486 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8487 Err(error) => return Err(error.into()),
8488 };
8489 let bytes = file.metadata()?.len();
8490 if bytes > limit || bytes > MAX_STORE_BYTES {
8491 return Err(invalid_feed(
8492 "pull transaction file exceeds its declared bound",
8493 ));
8494 }
8495 Ok(Some(V2PullFileCoordinate {
8496 sha256: content_sha256_reader(file)?,
8497 bytes,
8498 }))
8499}
8500
8501fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
8502 let mut bytes = serde_json::to_vec_pretty(journal)
8503 .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
8504 bytes.push(b'\n');
8505 Ok(bytes)
8506}
8507
8508fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
8509 let backup_prefix = ".dbmd/pull-backup-";
8510 let suffix = journal
8511 .backup_dir
8512 .strip_prefix(backup_prefix)
8513 .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
8514 let mut paths = std::collections::BTreeSet::new();
8515 if journal.v != 1
8516 || !crate::ulid::is_ulid(&journal.brain)
8517 || !crate::ulid::is_ulid(suffix)
8518 || journal.entries.is_empty()
8519 || journal.entries.len() > MAX_PUSH_FILES + 4
8520 || journal.previous == journal.next
8521 {
8522 return Err(invalid_feed("v2 pull journal failed validation"));
8523 }
8524 for (index, entry) in journal.entries.iter().enumerate() {
8525 if !safe_store_rel_path(&entry.path)
8526 || entry.path == V2_PULL_JOURNAL
8527 || entry.path.starts_with(backup_prefix)
8528 || !paths.insert(entry.path.clone())
8529 || (entry.old.is_none() && entry.new.is_none())
8530 || entry
8531 .old
8532 .iter()
8533 .chain(entry.new.iter())
8534 .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
8535 || entry.backup.as_deref()
8536 != entry
8537 .old
8538 .as_ref()
8539 .map(|_| format!("{index:08x}"))
8540 .as_deref()
8541 {
8542 return Err(invalid_feed("v2 pull journal entry failed validation"));
8543 }
8544 }
8545 Ok(())
8546}
8547
8548fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
8549 #[cfg(unix)]
8550 {
8551 use std::os::unix::fs::PermissionsExt as _;
8552 match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
8553 Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
8554 return Err(invalid_feed(
8555 "v2 pull journal is accessible to group/other; set mode 0600",
8556 ));
8557 }
8558 Ok(_) => {}
8559 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8560 Err(error) => return Err(error.into()),
8561 }
8562 }
8563 let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
8564 Ok(bytes) => bytes,
8565 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8566 Err(error) => return Err(error.into()),
8567 };
8568 let journal: V2PullJournal =
8569 serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
8570 validate_v2_pull_journal(&journal)?;
8571 Ok(Some(journal))
8572}
8573
8574fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
8575 match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
8579 Ok(()) => {}
8580 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8581 Err(error) => return Err(error.into()),
8582 }
8583 match store.remove_private_tree(Path::new(&journal.backup_dir)) {
8584 Ok(()) => Ok(()),
8585 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
8586 Err(error) => Err(error.into()),
8587 }
8588}
8589
8590fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
8591 let names = match store.directory_names(Path::new(".dbmd")) {
8592 Ok(names) => names,
8593 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
8594 Err(error) => return Err(error.into()),
8595 };
8596 for name in names {
8597 let Some(name) = name.to_str() else {
8598 continue;
8599 };
8600 let Some(suffix) = name.strip_prefix("pull-backup-") else {
8601 continue;
8602 };
8603 if crate::ulid::is_ulid(suffix) {
8604 store.remove_private_tree(&Path::new(".dbmd").join(name))?;
8605 }
8606 }
8607 Ok(())
8608}
8609
8610fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
8611 for entry in &journal.entries {
8613 let limit = entry
8614 .old
8615 .as_ref()
8616 .into_iter()
8617 .chain(entry.new.iter())
8618 .map(|value| value.bytes)
8619 .max()
8620 .unwrap_or(0);
8621 let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
8622 if current != entry.old && current != entry.new {
8623 return Err(LinkError::InvalidPack {
8624 message: format!(
8625 "cannot recover interrupted pull because `{}` changed afterward",
8626 entry.path
8627 ),
8628 });
8629 }
8630 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
8631 let path = Path::new(&journal.backup_dir).join(backup);
8632 let file = store.open_regular(&path)?;
8633 if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
8634 return Err(invalid_feed("v2 pull recovery backup failed verification"));
8635 }
8636 }
8637 }
8638 for entry in journal.entries.iter().rev() {
8639 match (&entry.old, &entry.backup) {
8640 (Some(old), Some(backup)) => {
8641 let bytes =
8642 store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
8643 store.write_atomic(Path::new(&entry.path), &bytes)?;
8644 }
8645 (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
8646 store.remove_file(Path::new(&entry.path))?;
8647 }
8648 (None, None) => {}
8649 _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
8650 }
8651 }
8652 crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
8653 message: format!("could not rebuild catalogs after pull recovery: {error}"),
8654 })?;
8655 cleanup_v2_pull_journal(store, journal)
8656}
8657
8658fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
8659 let Ok(store) = Store::open_strict(dest) else {
8660 return Ok(());
8661 };
8662 if let Some(journal) = load_v2_pull_journal(&store)? {
8663 if journal.brain != brain {
8664 return Err(invalid_feed("v2 pull journal belongs to another brain"));
8665 }
8666 if journal.phase == V2PullPhase::Preparing {
8667 cleanup_v2_pull_journal(&store, &journal)?;
8668 } else {
8669 let baseline = load_v2_baseline(cfg, brain, dest)?;
8670 let current = v2_pull_baseline_coordinate(baseline.as_ref());
8671 if current == journal.next {
8672 cleanup_v2_pull_journal(&store, &journal)?;
8673 } else {
8674 if current != journal.previous {
8675 return Err(invalid_feed(
8676 "cannot recover interrupted pull because its baseline changed afterward",
8677 ));
8678 }
8679 rollback_v2_pull(&store, &journal)?;
8680 }
8681 }
8682 }
8683 prune_orphan_v2_pull_backups(&store)
8688}
8689
8690fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
8691 let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
8692 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
8693 })?;
8694 if let Some(journal) = load_v2_pull_journal(&store)? {
8695 cleanup_v2_pull_journal(&store, &journal)?;
8696 }
8697 Ok(())
8698}
8699
8700#[cfg(windows)]
8701fn install_windows_initial_sources(
8702 dest: &Path,
8703 entries: &[V2StagedFile],
8704 rebuild_indexes: bool,
8705) -> LinkResult<()> {
8706 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8707 let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
8708 path: dest.display().to_string(),
8709 })?;
8710 let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
8711 if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
8712 return Err(LinkError::UnsafePath {
8713 path: dest.display().to_string(),
8714 });
8715 }
8716 let stage_name = format!(
8717 ".{}.dbmd-pull-stage-{}",
8718 name.to_string_lossy(),
8719 crate::ulid::mint()
8720 );
8721 let stage_path = parent.join(&stage_name);
8722 let stage_capability =
8723 crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
8724 let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
8725 let prepared = (|| -> LinkResult<()> {
8726 for entry in entries {
8727 let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
8728 if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
8729 return Err(invalid_feed(
8730 "private staged sync source failed final integrity verification",
8731 ));
8732 }
8733 stage.write_atomic(Path::new(&entry.path), &bytes)?;
8734 }
8735 let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
8736 .map_err(|error| LinkError::InvalidPack {
8737 message: format!("v2 staging tree is not a valid db.md store: {error}"),
8738 })?;
8739 if rebuild_indexes {
8740 crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
8741 message: format!("could not materialize v2 local catalogs: {error}"),
8742 })?;
8743 }
8744 Ok(())
8745 })();
8746 if let Err(error) = prepared {
8747 let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
8748 return Err(error);
8749 }
8750 crate::fsx::rename_directory_beneath(
8751 &parent_capability,
8752 Path::new(&stage_name),
8753 Path::new(name),
8754 )?;
8755 Ok(())
8756}
8757
8758fn install_established_v2_delta(
8759 store: Store,
8760 entries: &[V2StagedFile],
8761 deleted: &[String],
8762 rebuild_indexes: bool,
8763 previous: Option<&V2SyncBaseline>,
8764 next: &V2VerifiedHead,
8765) -> LinkResult<()> {
8766 if load_v2_pull_journal(&store)?.is_some() {
8767 return Err(invalid_feed(
8768 "an interrupted pull must be recovered before installing",
8769 ));
8770 }
8771 let mut sources = std::collections::BTreeMap::new();
8772 for entry in entries {
8773 if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
8774 return Err(invalid_feed("pull mutation repeats a path"));
8775 }
8776 }
8777 let mut paths = sources.keys().cloned().collect::<Vec<_>>();
8778 paths.extend(deleted.iter().cloned());
8779 paths.sort();
8780 paths.dedup();
8781 if paths.is_empty() {
8782 return Ok(());
8783 }
8784 let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
8785 let mut journal = V2PullJournal {
8786 v: 1,
8787 phase: V2PullPhase::Preparing,
8788 brain: next.brain_id.clone(),
8789 previous: v2_pull_baseline_coordinate(previous),
8790 next: v2_pull_head_coordinate(next),
8791 backup_dir: backup_dir.clone(),
8792 entries: Vec::with_capacity(paths.len()),
8793 };
8794 for path in &paths {
8795 let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
8796 let new = sources.get(path).map(|entry| V2PullFileCoordinate {
8797 sha256: entry.sha256.clone(),
8798 bytes: entry.bytes,
8799 });
8800 if old == new {
8801 continue;
8802 }
8803 let index = journal.entries.len();
8804 journal.entries.push(V2PullJournalEntry {
8805 path: path.clone(),
8806 backup: old.as_ref().map(|_| format!("{index:08x}")),
8807 old,
8808 new,
8809 });
8810 }
8811 if journal.entries.is_empty() {
8812 return Ok(());
8813 }
8814 let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
8815 entry
8816 .old
8817 .as_ref()
8818 .map_or(Some(total), |old| total.checked_add(old.bytes))
8819 });
8820 if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
8821 return Err(LinkError::InvalidPack {
8822 message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
8823 });
8824 }
8825 validate_v2_pull_journal(&journal)?;
8826 store.write_private_atomic_new(
8827 Path::new(V2_PULL_JOURNAL),
8828 &v2_pull_journal_bytes(&journal)?,
8829 )?;
8830 let prepared = (|| -> LinkResult<()> {
8831 store.create_private_dir_all(Path::new(&backup_dir))?;
8832 for entry in &journal.entries {
8833 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
8834 let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
8835 if content_sha256(&bytes) != old.sha256 {
8836 return Err(invalid_feed("live pull source changed during backup"));
8837 }
8838 store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
8839 }
8840 }
8841 journal.phase = V2PullPhase::Ready;
8842 store.write_private_atomic(
8843 Path::new(V2_PULL_JOURNAL),
8844 &v2_pull_journal_bytes(&journal)?,
8845 )?;
8846 Ok(())
8847 })();
8848 if let Err(error) = prepared {
8849 let cleanup = cleanup_v2_pull_journal(&store, &journal);
8850 return match cleanup {
8851 Ok(()) => Err(error),
8852 Err(cleanup) => Err(LinkError::InvalidPack {
8853 message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
8854 }),
8855 };
8856 }
8857 let installed = (|| -> LinkResult<()> {
8858 for entry in &journal.entries {
8859 if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
8860 return Err(LinkError::InvalidPack {
8861 message: format!("local path `{}` changed during pull", entry.path),
8862 });
8863 }
8864 if let Some(source) = sources.get(&entry.path) {
8865 let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
8866 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
8867 return Err(invalid_feed(
8868 "private staged sync source failed final integrity verification",
8869 ));
8870 }
8871 store.write_atomic(Path::new(&entry.path), &bytes)?;
8872 } else if entry.old.is_some() {
8873 store.remove_file(Path::new(&entry.path))?;
8874 }
8875 }
8876 if rebuild_indexes {
8877 crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
8878 message: format!("could not materialize v2 local catalogs: {error}"),
8879 })?;
8880 }
8881 Ok(())
8882 })();
8883 if let Err(error) = installed {
8884 return match rollback_v2_pull(&store, &journal) {
8885 Ok(()) => Err(error),
8886 Err(rollback) => Err(LinkError::InvalidPack {
8887 message: format!("{error}; durable pull rollback also failed: {rollback}"),
8888 }),
8889 };
8890 }
8891 Ok(())
8892}
8893
8894#[cfg(windows)]
8895fn install_pulled_delta_sources(
8896 dest: &Path,
8897 entries: &[V2StagedFile],
8898 deleted: &[String],
8899 rebuild_indexes: bool,
8900 previous: Option<&V2SyncBaseline>,
8901 next: &V2VerifiedHead,
8902) -> LinkResult<()> {
8903 match Store::open_strict(dest) {
8904 Ok(store) => {
8905 install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
8906 }
8907 Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
8908 }
8909}
8910
8911#[cfg(not(any(unix, windows)))]
8912fn install_pulled_delta_sources(
8913 _dest: &Path,
8914 _entries: &[V2StagedFile],
8915 _deleted: &[String],
8916 _rebuild_indexes: bool,
8917 _previous: Option<&V2SyncBaseline>,
8918 _next: &V2VerifiedHead,
8919) -> LinkResult<()> {
8920 Err(LinkError::UnsupportedPlatform {
8921 operation: "atomic v2 pull install",
8922 })
8923}
8924
8925#[cfg(unix)]
8926fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
8927 install_pulled_delta(dest, entries, &[], false)
8928}
8929
8930#[cfg(not(windows))]
8931fn is_safe_slug(slug: &str) -> bool {
8932 !slug.is_empty()
8933 && slug.len() <= 63
8934 && !slug.starts_with('-')
8935 && !slug.ends_with('-')
8936 && slug
8937 .bytes()
8938 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
8939}
8940
8941fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
8942 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
8943}
8944
8945fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
8946 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
8947}
8948
8949fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
8950 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
8951}
8952
8953fn preflight_zip_central_directory(
8954 bytes: &[u8],
8955 offset: usize,
8956 size: usize,
8957 count: u64,
8958) -> LinkResult<()> {
8959 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
8960 let end = offset
8961 .checked_add(size)
8962 .filter(|end| *end <= bytes.len())
8963 .ok_or_else(|| LinkError::InvalidPack {
8964 message: "ZIP central directory is out of bounds".to_string(),
8965 })?;
8966 let mut cursor = offset;
8967 for _ in 0..count {
8968 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
8969 return Err(LinkError::InvalidPack {
8970 message: "ZIP central directory entry count is inconsistent".to_string(),
8971 });
8972 }
8973 if le_u16(bytes, cursor + 34) != Some(0) {
8974 return Err(LinkError::InvalidPack {
8975 message: "multi-disk ZIP archives are not supported".to_string(),
8976 });
8977 }
8978 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
8979 total.checked_add(le_u16(bytes, cursor + at)? as usize)
8980 });
8981 cursor = cursor
8982 .checked_add(46)
8983 .and_then(|fixed| fixed.checked_add(variable?))
8984 .filter(|cursor| *cursor <= end)
8985 .ok_or_else(|| LinkError::InvalidPack {
8986 message: "ZIP central directory entry is truncated".to_string(),
8987 })?;
8988 }
8989 if cursor != end {
8990 return Err(LinkError::InvalidPack {
8991 message: "ZIP central directory size is inconsistent".to_string(),
8992 });
8993 }
8994 Ok(())
8995}
8996
8997fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
9001 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
9002 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
9003 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
9004 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
9005 let eocd = bytes[search_start..]
9006 .windows(4)
9007 .rposition(|window| window == EOCD_SIG)
9008 .map(|offset| search_start + offset)
9009 .ok_or_else(|| LinkError::InvalidPack {
9010 message: "ZIP has no end-of-central-directory record".to_string(),
9011 })?;
9012 let invalid_end = || LinkError::InvalidPack {
9013 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
9014 };
9015 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
9016 if eocd
9017 .checked_add(22)
9018 .and_then(|end| end.checked_add(comment_len))
9019 != Some(bytes.len())
9020 {
9021 return Err(invalid_end());
9025 }
9026 let disk = le_u16(bytes, eocd + 4);
9027 let central_disk = le_u16(bytes, eocd + 6);
9028 if disk != Some(0) || central_disk != Some(0) {
9029 return Err(LinkError::InvalidPack {
9030 message: "multi-disk ZIP archives are not supported".to_string(),
9031 });
9032 }
9033 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
9034 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
9035 if entries_on_disk != ordinary {
9036 return Err(LinkError::InvalidPack {
9037 message: "multi-disk ZIP archives are not supported".to_string(),
9038 });
9039 }
9040 let zip64_locator = eocd
9041 .checked_sub(20)
9042 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
9043 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
9044 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
9045 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
9046 if central_offset
9047 .checked_add(central_size)
9048 .filter(|end| *end == eocd)
9049 .is_none()
9050 {
9051 return Err(invalid_end());
9052 }
9053 (ordinary as u64, central_offset, central_size)
9054 } else {
9055 let Some(locator) = zip64_locator else {
9056 return Err(invalid_end());
9057 };
9058 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
9059 return Err(LinkError::InvalidPack {
9060 message: "multi-disk ZIP64 archives are not supported".to_string(),
9061 });
9062 }
9063 let record = le_u64(bytes, locator + 8)
9064 .and_then(|offset| usize::try_from(offset).ok())
9065 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
9066 .ok_or_else(|| LinkError::InvalidPack {
9067 message: "ZIP64 archive has an invalid end record".to_string(),
9068 })?;
9069 let record_size = le_u64(bytes, record + 4)
9070 .and_then(|size| usize::try_from(size).ok())
9071 .filter(|size| *size >= 44)
9072 .ok_or_else(invalid_end)?;
9073 if record
9074 .checked_add(12)
9075 .and_then(|end| end.checked_add(record_size))
9076 != Some(locator)
9077 || le_u32(bytes, record + 16) != Some(0)
9078 || le_u32(bytes, record + 20) != Some(0)
9079 {
9080 return Err(invalid_end());
9081 }
9082 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
9083 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
9084 let central_size = le_u64(bytes, record + 40)
9085 .and_then(|size| usize::try_from(size).ok())
9086 .ok_or_else(invalid_end)?;
9087 let central_offset = le_u64(bytes, record + 48)
9088 .and_then(|offset| usize::try_from(offset).ok())
9089 .ok_or_else(invalid_end)?;
9090 if zip64_on_disk != zip64_total
9091 || central_offset
9092 .checked_add(central_size)
9093 .filter(|end| *end == record)
9094 .is_none()
9095 {
9096 return Err(invalid_end());
9097 }
9098 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
9099 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
9100 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
9101 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
9102 {
9103 return Err(invalid_end());
9104 }
9105 (zip64_total, central_offset, central_size)
9106 };
9107 if count == 0 || count > max_entries as u64 {
9108 return Err(LinkError::InvalidPack {
9109 message: format!("invalid file count {count}"),
9110 });
9111 }
9112 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
9113 Ok(())
9114}
9115
9116fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
9117 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
9118 let mut archive =
9119 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
9120 message: format!("ZIP parse failed: {err}"),
9121 })?;
9122 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
9123 return Err(LinkError::InvalidPack {
9124 message: format!("invalid file count {}", archive.len()),
9125 });
9126 }
9127 let mut total = 0u64;
9128 let mut seen = std::collections::HashSet::new();
9129 let mut entries = Vec::with_capacity(archive.len());
9130 for index in 0..archive.len() {
9131 let mut file = archive
9132 .by_index(index)
9133 .map_err(|err| LinkError::InvalidPack {
9134 message: format!("ZIP entry failed: {err}"),
9135 })?;
9136 if file.is_dir() {
9137 continue;
9138 }
9139 let path = file.name().to_string();
9140 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
9141 return Err(LinkError::UnsafePath { path });
9142 }
9143 if file
9144 .unix_mode()
9145 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
9146 {
9147 return Err(LinkError::InvalidPack {
9148 message: format!("non-file entry `{path}`"),
9149 });
9150 }
9151 if !seen.insert(path.clone()) {
9152 return Err(LinkError::InvalidPack {
9153 message: format!("duplicate path `{path}`"),
9154 });
9155 }
9156 let remaining = MAX_STORE_BYTES.saturating_sub(total);
9157 if file.size() > remaining {
9158 return Err(LinkError::InvalidPack {
9159 message: "expanded content exceeds the 512 MB limit".to_string(),
9160 });
9161 }
9162 let mut content = Vec::new();
9163 (&mut file)
9164 .take(remaining + 1)
9165 .read_to_end(&mut content)
9166 .map_err(|err| LinkError::InvalidPack {
9167 message: format!("could not decompress `{path}`: {err}"),
9168 })?;
9169 if content.len() as u64 > remaining {
9170 return Err(LinkError::InvalidPack {
9171 message: "expanded content exceeds the 512 MB limit".to_string(),
9172 });
9173 }
9174 if content.len() as u64 != file.size() {
9175 return Err(LinkError::InvalidPack {
9176 message: format!("length mismatch for `{path}`"),
9177 });
9178 }
9179 total += content.len() as u64;
9180 entries.push((path, content));
9181 }
9182 if entries.is_empty() {
9183 return Err(LinkError::InvalidPack {
9184 message: "pack contains no files".to_string(),
9185 });
9186 }
9187 Ok(entries)
9188}
9189
9190fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
9191 let mut expected = std::collections::BTreeMap::new();
9192 for file in signed {
9193 if !safe_store_rel_path(&file.path) {
9194 return Err(LinkError::UnsafePath {
9195 path: file.path.clone(),
9196 });
9197 }
9198 if !is_sha256(&file.sha256)
9199 || expected
9200 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
9201 .is_some()
9202 {
9203 return Err(invalid_feed(
9204 "signed snapshot manifest contains an invalid or duplicate file",
9205 ));
9206 }
9207 }
9208 if expected.len() != entries.len() {
9209 return Err(invalid_feed(
9210 "downloaded pack file set differs from the signed snapshot manifest",
9211 ));
9212 }
9213 for (path, bytes) in entries {
9214 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
9215 return Err(invalid_feed(format!(
9216 "downloaded pack contains unsigned path `{path}`"
9217 )));
9218 };
9219 if *declared_bytes != bytes.len() as u64
9220 || *sha256 != format!("{:x}", Sha256::digest(bytes))
9221 {
9222 return Err(invalid_feed(format!(
9223 "downloaded file `{path}` differs from its signed manifest"
9224 )));
9225 }
9226 }
9227 Ok(())
9228}
9229
9230pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
9237 require_hardened_filesystem("sync push")?;
9238 preflight_push_ownership(store)?;
9239 let mut out: Vec<(String, String)> = Vec::new();
9240 let mut total = 0u64;
9241
9242 let mut read_text = |rel: &str| -> LinkResult<String> {
9243 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
9244 total = total
9245 .checked_add(bytes.len() as u64)
9246 .ok_or_else(|| LinkError::PushTooLarge {
9247 detail: "uncompressed byte count overflow".to_string(),
9248 })?;
9249 if total > MAX_STORE_BYTES {
9250 return Err(LinkError::PushTooLarge {
9251 detail: format!("{total} uncompressed bytes"),
9252 });
9253 }
9254 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
9255 path: rel.to_string(),
9256 })
9257 };
9258
9259 out.push(("DB.md".to_string(), read_text("DB.md")?));
9260 if store
9261 .regular_file_exists(Path::new("assets.jsonl"))
9262 .unwrap_or(false)
9263 {
9264 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
9265 }
9266
9267 for rel in store.walk()? {
9268 let rel_str = rel.to_string_lossy().replace('\\', "/");
9269 if !safe_store_rel_path(&rel_str) {
9270 return Err(LinkError::UnsafePath { path: rel_str });
9273 }
9274 let content = read_text(&rel_str)?;
9275 out.push((rel_str, content));
9276 }
9277
9278 out.sort_by(|a, b| a.0.cmp(&b.0));
9279 Ok(out)
9280}
9281
9282fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
9286 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
9287 return Err(LinkError::from(std::io::Error::new(
9288 std::io::ErrorKind::PermissionDenied,
9289 format!("cannot push: nested db.md store at {}", nested.display()),
9290 )));
9291 }
9292
9293 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
9294 return Err(LinkError::from(std::io::Error::new(
9295 std::io::ErrorKind::PermissionDenied,
9296 format!(
9297 "cannot push: {} is a symlink outside the store ownership model",
9298 symlink.display()
9299 ),
9300 )));
9301 }
9302 Ok(())
9303}
9304
9305pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
9311 require_safe_ref(brain)?;
9312 let remote = verified_remote_head(cfg, brain, false)?;
9313 if files.len() > MAX_PUSH_FILES {
9314 return Err(LinkError::PushTooLarge {
9315 detail: format!("{} files", files.len()),
9316 });
9317 }
9318 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
9319 if raw_total > MAX_STORE_BYTES {
9320 return Err(LinkError::PushTooLarge {
9321 detail: format!("{raw_total} uncompressed bytes"),
9322 });
9323 }
9324
9325 if cfg.brain_key.is_none() {
9329 let body = json!({
9330 "files": files
9331 .iter()
9332 .map(|(p, c)| json!({ "path": p, "content": c }))
9333 .collect::<Vec<_>>(),
9334 });
9335 if body.to_string().len() <= MAX_PUSH_BYTES {
9336 let path = format!("/api/hub/brains/{brain}/push");
9337 let pushed = ensure_ok(
9338 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9339 "sync push",
9340 )?;
9341 return Ok(pushed);
9342 }
9343 }
9344
9345 let pack = build_store_pack(files)?;
9346 if pack.len() as u64 > MAX_PACK_BYTES {
9347 return Err(LinkError::PushTooLarge {
9348 detail: format!("{} pack bytes", pack.len()),
9349 });
9350 }
9351 let sha256 = format!("{:x}", Sha256::digest(&pack));
9352 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
9353 if let Some(key) = &cfg.brain_key {
9354 if !remote.head.verified {
9355 return Err(invalid_feed(
9356 "self-custody push requires a fully verified, unscoped feed head",
9357 ));
9358 }
9359 let identity = remote
9360 .identity
9361 .as_ref()
9362 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
9363 let current_multikey = format!("ed25519:{}", identity.fingerprint);
9364 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
9365 return Err(invalid_feed(
9366 "configured brain key is not the verified current brain identity",
9367 ));
9368 }
9369 let next_seq = remote
9372 .head
9373 .seq
9374 .checked_add(1)
9375 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
9376 let mut manifest: Vec<WireFeedFile> = files
9377 .iter()
9378 .map(|(path, content)| WireFeedFile {
9379 path: path.clone(),
9380 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
9381 bytes: content.len() as u64,
9382 })
9383 .collect();
9384 manifest.sort_by(|a, b| a.path.cmp(&b.path));
9385 let ts = crate::now()
9386 .with_timezone(&chrono::Utc)
9387 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
9388 .to_string();
9389 let entry = self_custody_entry(
9390 key,
9391 next_seq,
9392 ts,
9393 &sha256,
9394 &manifest,
9395 remote.head.feed_hash.as_deref(),
9396 )?;
9397 meta["entry"] = Value::String(entry);
9398 }
9399 let presigned = ensure_ok(
9400 request(
9401 cfg,
9402 "POST",
9403 &format!("/api/hub/brains/{brain}/packs/presign"),
9404 Some(&meta),
9405 Auth::Required,
9406 )?,
9407 "prepare pack upload",
9408 )?;
9409 let url = presigned
9410 .get("url")
9411 .and_then(Value::as_str)
9412 .ok_or_else(|| LinkError::InvalidPack {
9413 message: "the hub returned no upload URL".to_string(),
9414 })?;
9415 put_presigned(
9416 cfg,
9417 url,
9418 presigned.get("headers").unwrap_or(&Value::Null),
9419 &pack,
9420 )?;
9421 let committed = ensure_ok(
9422 request(
9423 cfg,
9424 "POST",
9425 &format!("/api/hub/brains/{brain}/packs/commit"),
9426 Some(&meta),
9427 Auth::Required,
9428 )?,
9429 "commit pack",
9430 )?;
9431 Ok(committed)
9432}
9433
9434fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
9435 const LOCAL_HEADER: u32 = 0x0403_4b50;
9436 const CENTRAL_HEADER: u32 = 0x0201_4b50;
9437 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
9438 const VERSION_20: u16 = 20;
9439 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
9440 const UTF8_FLAG: u16 = 1 << 11;
9441 const STORED: u16 = 0;
9442 const DOS_TIME_MIDNIGHT: u16 = 0;
9443 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
9444 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
9445
9446 struct CentralEntry<'a> {
9447 name: &'a [u8],
9448 crc32: u32,
9449 size: u32,
9450 local_offset: u32,
9451 }
9452
9453 fn push_u16(out: &mut Vec<u8>, value: u16) {
9454 out.extend_from_slice(&value.to_le_bytes());
9455 }
9456
9457 fn push_u32(out: &mut Vec<u8>, value: u32) {
9458 out.extend_from_slice(&value.to_le_bytes());
9459 }
9460
9461 if files.is_empty() {
9462 return Err(LinkError::InvalidPack {
9463 message: "cannot create an empty snapshot pack".to_string(),
9464 });
9465 }
9466 if files.len() > u16::MAX as usize {
9467 return Err(LinkError::PushTooLarge {
9468 detail: format!(
9469 "{} files (canonical ZIP32 packs cap at {})",
9470 files.len(),
9471 u16::MAX
9472 ),
9473 });
9474 }
9475
9476 let mut sorted: Vec<_> = files.iter().collect();
9477 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
9478 let mut previous: Option<&str> = None;
9479 for (path, content) in &sorted {
9480 if !safe_store_rel_path(path) {
9481 return Err(LinkError::UnsafePath {
9482 path: (*path).clone(),
9483 });
9484 }
9485 if previous == Some(path.as_str()) {
9486 return Err(LinkError::InvalidPack {
9487 message: format!("duplicate path `{path}`"),
9488 });
9489 }
9490 previous = Some(path.as_str());
9491 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
9492 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9493 })?;
9494 }
9495
9496 let mut out = Vec::new();
9497 let mut central = Vec::with_capacity(sorted.len());
9498 for (path, content) in sorted {
9499 let name = path.as_bytes();
9500 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
9501 message: format!("ZIP entry name is too long: `{path}`"),
9502 })?;
9503 let bytes = content.as_bytes();
9504 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
9505 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9506 })?;
9507 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
9508 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
9509 })?;
9510 let crc32 = crc32fast::hash(bytes);
9511
9512 push_u32(&mut out, LOCAL_HEADER);
9515 push_u16(&mut out, VERSION_20);
9516 push_u16(&mut out, UTF8_FLAG);
9517 push_u16(&mut out, STORED);
9518 push_u16(&mut out, DOS_TIME_MIDNIGHT);
9519 push_u16(&mut out, DOS_DATE_1980_01_01);
9520 push_u32(&mut out, crc32);
9521 push_u32(&mut out, size);
9522 push_u32(&mut out, size);
9523 push_u16(&mut out, name_len);
9524 push_u16(&mut out, 0); out.extend_from_slice(name);
9526 out.extend_from_slice(bytes);
9527
9528 central.push(CentralEntry {
9529 name,
9530 crc32,
9531 size,
9532 local_offset,
9533 });
9534 }
9535
9536 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
9537 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
9538 })?;
9539 for entry in ¢ral {
9540 push_u32(&mut out, CENTRAL_HEADER);
9541 push_u16(&mut out, MADE_BY_UNIX_20);
9542 push_u16(&mut out, VERSION_20);
9543 push_u16(&mut out, UTF8_FLAG);
9544 push_u16(&mut out, STORED);
9545 push_u16(&mut out, DOS_TIME_MIDNIGHT);
9546 push_u16(&mut out, DOS_DATE_1980_01_01);
9547 push_u32(&mut out, entry.crc32);
9548 push_u32(&mut out, entry.size);
9549 push_u32(&mut out, entry.size);
9550 push_u16(&mut out, entry.name.len() as u16);
9551 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);
9556 push_u32(&mut out, entry.local_offset);
9557 out.extend_from_slice(entry.name);
9558 }
9559 let central_size = u32::try_from(out.len())
9560 .ok()
9561 .and_then(|end| end.checked_sub(central_offset))
9562 .ok_or_else(|| LinkError::PushTooLarge {
9563 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
9564 })?;
9565 let entry_count = central.len() as u16;
9566
9567 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
9568 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
9571 push_u16(&mut out, entry_count);
9572 push_u32(&mut out, central_size);
9573 push_u32(&mut out, central_offset);
9574 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
9577 return Err(LinkError::PushTooLarge {
9578 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
9579 });
9580 }
9581 Ok(out)
9582}
9583
9584#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9590pub enum Capability {
9591 Read,
9593 Write,
9595}
9596
9597impl Capability {
9598 pub fn as_str(self) -> &'static str {
9600 match self {
9601 Capability::Read => "read",
9602 Capability::Write => "write",
9603 }
9604 }
9605}
9606
9607pub fn grant_issue(
9613 cfg: &HubConfig,
9614 brain: &str,
9615 grantee: &str,
9616 can: Capability,
9617 scope: Option<&str>,
9618 until: Option<&str>,
9619) -> LinkResult<Value> {
9620 require_safe_ref(brain)?;
9621 let _ = verified_remote_head(cfg, brain, false)?;
9622 let is_key_grantee = URL_SAFE_NO_PAD
9627 .decode(grantee)
9628 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
9629 .unwrap_or(false);
9630 let mut body = if is_key_grantee {
9631 json!({ "keySpki": grantee, "capability": can.as_str() })
9632 } else {
9633 json!({ "email": grantee, "capability": can.as_str() })
9634 };
9635 if let Some(s) = scope {
9636 body["scopePrefix"] = json!(s);
9637 }
9638 if let Some(u) = until {
9639 body["expiresAt"] = json!(u);
9640 }
9641 let path = format!("/api/hub/brains/{brain}/grants");
9642 ensure_ok(
9643 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9644 "grant issue",
9645 )
9646}
9647
9648pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
9650 require_safe_ref(brain)?;
9651 let _ = verified_remote_head(cfg, brain, false)?;
9652 let path = format!("/api/hub/brains/{brain}/grants");
9653 ensure_ok(
9654 request(cfg, "GET", &path, None, Auth::Required)?,
9655 "grant list",
9656 )
9657}
9658
9659pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
9662 require_safe_ref(brain)?;
9663 require_safe_grant_id(grant_id)?;
9664 let _ = verified_remote_head(cfg, brain, false)?;
9665 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
9666 ensure_ok(
9667 request(cfg, "DELETE", &path, None, Auth::Required)?,
9668 "grant revoke",
9669 )
9670}
9671
9672#[derive(Debug)]
9677struct VerifiedV2Proposal {
9678 value: Value,
9679 changes: Value,
9680 blobs: Vec<(String, u64, String)>,
9681}
9682
9683fn require_proposal_id(id: &str) -> LinkResult<()> {
9684 if crate::ulid::is_ulid(id) {
9685 Ok(())
9686 } else {
9687 Err(invalid_feed("proposal id is not a lowercase ULID"))
9688 }
9689}
9690
9691fn verified_v2_proposal(
9692 cfg: &HubConfig,
9693 head: &V2VerifiedHead,
9694 proposal_id: &str,
9695) -> LinkResult<VerifiedV2Proposal> {
9696 require_proposal_id(proposal_id)?;
9697 if head.view_kind != "full" {
9698 return Err(invalid_feed(
9699 "proposal review requires a full readable view",
9700 ));
9701 }
9702 let path = format!(
9703 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
9704 head.brain_id
9705 );
9706 let value = ensure_ok(
9707 request_capped(
9708 cfg,
9709 "GET",
9710 &path,
9711 None,
9712 Auth::Required,
9713 MAX_FEED_RESPONSE_BYTES,
9714 )?,
9715 "v2 proposal",
9716 )?;
9717 verify_v2_proposal_value(head, proposal_id, value)
9718}
9719
9720fn verify_v2_proposal_value(
9721 head: &V2VerifiedHead,
9722 proposal_id: &str,
9723 value: Value,
9724) -> LinkResult<VerifiedV2Proposal> {
9725 if value.get("v").and_then(Value::as_u64) != Some(2) {
9726 return Err(invalid_feed("proposal response has an invalid version"));
9727 }
9728 let proposal = value
9729 .get("proposal")
9730 .and_then(Value::as_object)
9731 .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
9732 if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
9733 return Err(invalid_feed("proposal response changed its id"));
9734 }
9735 let payload_hash = proposal
9736 .get("payload_sha256")
9737 .and_then(Value::as_str)
9738 .filter(|hash| is_sha256(hash))
9739 .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
9740 let clear_hash = proposal
9741 .get("clear_sha256")
9742 .and_then(Value::as_str)
9743 .filter(|hash| is_sha256(hash))
9744 .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
9745 let submission_hash = proposal
9746 .get("submission_claim_sha256")
9747 .and_then(Value::as_str)
9748 .filter(|hash| is_sha256(hash))
9749 .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
9750 let submission = STANDARD
9751 .decode(
9752 proposal
9753 .get("submission_claim_base64")
9754 .and_then(Value::as_str)
9755 .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
9756 )
9757 .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
9758 let submission_value: Value = serde_json::from_slice(&submission)
9759 .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
9760 if crate::linkmd_v2::canonical_bytes(&submission_value)
9761 .map_err(|error| invalid_feed(error.to_string()))?
9762 != submission
9763 || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
9764 .map_err(|error| invalid_feed(error.to_string()))?
9765 != submission_hash
9766 {
9767 return Err(invalid_feed(
9768 "proposal submission claim is not canonical or addressed",
9769 ));
9770 }
9771 let envelope = submission_value
9772 .as_object()
9773 .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
9774 let claim = envelope
9775 .get("claim")
9776 .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
9777 let claim_object = claim
9778 .as_object()
9779 .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
9780 let actor_root = claim_object
9781 .get("actor_root")
9782 .and_then(Value::as_object)
9783 .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
9784 let public_key = envelope
9785 .get("public_key")
9786 .and_then(Value::as_str)
9787 .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
9788 let fingerprint = envelope
9789 .get("fingerprint")
9790 .and_then(Value::as_str)
9791 .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
9792 let signature = envelope
9793 .get("sig")
9794 .and_then(Value::as_str)
9795 .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
9796 let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
9797 .map_err(|error| invalid_feed(error.to_string()))?;
9798 let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
9799 let signer = format!("{fingerprint}:{public_key}");
9800 let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
9801 let grants = actor_root.get("grants").and_then(Value::as_array);
9802 let grants_are_canonical = grants.is_some_and(|items| {
9803 let mut prior: Option<&str> = None;
9804 items.iter().all(|item| {
9805 let Some(grant) = item.as_str() else {
9806 return false;
9807 };
9808 if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
9809 return false;
9810 }
9811 prior = Some(grant);
9812 true
9813 })
9814 });
9815 let optional_actor_field = |name: &str| {
9816 actor_root.get(name).is_some_and(|value| {
9817 value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
9818 })
9819 };
9820 let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
9821 if claim_object.get("v").and_then(Value::as_u64) != Some(2)
9822 || format!("{:x}", Sha256::digest(&der)) != fingerprint
9823 || head
9824 .trust
9825 .hub_signer
9826 .as_ref()
9827 .is_some_and(|known| known != &signer)
9828 || !matches!(
9829 actor_class,
9830 Some(
9831 "user"
9832 | "owned_agent"
9833 | "foreign_key"
9834 | "curation"
9835 | "inbox"
9836 | "restore"
9837 | "migration"
9838 | "operator_recovery"
9839 )
9840 )
9841 || actor_root
9842 .get("principal")
9843 .and_then(Value::as_str)
9844 .is_none_or(|value| value.is_empty())
9845 || actor_root
9846 .get("credential")
9847 .and_then(Value::as_str)
9848 .is_none_or(|value| value.is_empty())
9849 || !optional_actor_field("organization")
9850 || !optional_actor_field("role")
9851 || !grants_are_canonical
9852 || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
9853 || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
9854 || !claim_object
9855 .get("mutation_id")
9856 .and_then(Value::as_str)
9857 .is_some_and(|value| {
9858 !value.is_empty()
9859 && value.len() <= 128
9860 && value.chars().enumerate().all(|(index, char)| {
9861 char.is_ascii_alphanumeric()
9862 || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
9863 })
9864 })
9865 || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
9866 || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
9867 || !claim_object
9868 .get("control_revision")
9869 .and_then(Value::as_str)
9870 .is_some_and(is_sha256)
9871 || submitted_at.is_none_or(|value| {
9872 chrono::DateTime::parse_from_rfc3339(value).is_err()
9873 || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
9874 })
9875 || !proposal
9876 .get("state")
9877 .and_then(Value::as_str)
9878 .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
9879 || proposal
9880 .get("expires_at")
9881 .and_then(Value::as_str)
9882 .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
9883 || proposal
9884 .get("proposer")
9885 .and_then(Value::as_object)
9886 .and_then(|value| value.get("class"))
9887 .and_then(Value::as_str)
9888 != actor_class
9889 {
9890 return Err(invalid_feed(
9891 "proposal submission claim does not bind the verified proposal",
9892 ));
9893 }
9894 let changes_b64 = proposal
9895 .get("changes_base64")
9896 .and_then(Value::as_str)
9897 .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
9898 let changes_bytes = STANDARD
9899 .decode(changes_b64)
9900 .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
9901 let changes: Value = serde_json::from_slice(&changes_bytes)
9902 .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
9903 if crate::linkmd_v2::canonical_bytes(&changes)
9904 .map_err(|error| invalid_feed(error.to_string()))?
9905 != changes_bytes
9906 || changes.get("v").and_then(Value::as_u64) != Some(2)
9907 || !changes.get("operations").is_some_and(Value::is_array)
9908 {
9909 return Err(invalid_feed("proposal changeset is not canonical v2"));
9910 }
9911 let blob_values = proposal
9912 .get("blobs")
9913 .and_then(Value::as_array)
9914 .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
9915 let mut blobs = Vec::with_capacity(blob_values.len());
9916 let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
9917 let mut prior_hash: Option<String> = None;
9918 for item in blob_values {
9919 let hash = item
9920 .get("sha256")
9921 .and_then(Value::as_str)
9922 .filter(|hash| is_sha256(hash))
9923 .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
9924 let bytes = item
9925 .get("bytes")
9926 .and_then(Value::as_u64)
9927 .filter(|bytes| *bytes <= MAX_STORE_BYTES)
9928 .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
9929 if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
9930 return Err(invalid_feed(
9931 "proposal blob declarations are not unique and sorted",
9932 ));
9933 }
9934 prior_hash = Some(hash.to_string());
9935 let endpoint = item
9936 .get("endpoint")
9937 .and_then(Value::as_str)
9938 .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
9939 let expected_endpoint = format!(
9940 "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
9941 head.brain_id
9942 );
9943 if endpoint != expected_endpoint {
9944 return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
9945 }
9946 descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
9947 blobs.push((hash.to_string(), bytes, endpoint.to_string()));
9948 }
9949 let descriptor = json!({
9950 "base": proposal.get("base").cloned().unwrap_or(Value::Null),
9951 "blobs": descriptor_blobs,
9952 "changes_base64": changes_b64,
9953 "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
9954 "v": 2,
9955 });
9956 let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
9957 .map_err(|error| invalid_feed(error.to_string()))?;
9958 if content_sha256(&descriptor_bytes) != clear_hash {
9959 return Err(invalid_feed(
9960 "proposal clear payload differs from its signed submission claim",
9961 ));
9962 }
9963 Ok(VerifiedV2Proposal {
9964 value,
9965 changes,
9966 blobs,
9967 })
9968}
9969
9970pub fn proposal_list(
9971 cfg: &HubConfig,
9972 brain: &str,
9973 state: &str,
9974 after: Option<&str>,
9975 limit: usize,
9976) -> LinkResult<Value> {
9977 require_safe_ref(brain)?;
9978 if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
9979 return Err(invalid_feed("proposal state is invalid"));
9980 }
9981 if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
9982 return Err(invalid_feed("proposal cursor is invalid"));
9983 }
9984 let head = v2_verified_head(cfg, brain)?
9985 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
9986 let path = format!(
9987 "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
9988 head.brain_id,
9989 limit.clamp(1, 100),
9990 after.map_or_else(String::new, |value| format!("&after={value}"))
9991 );
9992 ensure_ok(
9993 request_capped(
9994 cfg,
9995 "GET",
9996 &path,
9997 None,
9998 Auth::Required,
9999 MAX_FEED_RESPONSE_BYTES,
10000 )?,
10001 "v2 proposal list",
10002 )
10003}
10004
10005pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
10006 require_safe_ref(brain)?;
10007 let head = v2_verified_head(cfg, brain)?
10008 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10009 Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
10010}
10011
10012pub fn proposal_reject(
10013 cfg: &HubConfig,
10014 brain: &str,
10015 proposal_id: &str,
10016 mutation_id: &str,
10017 reason: &str,
10018) -> LinkResult<Value> {
10019 require_safe_ref(brain)?;
10020 require_proposal_id(proposal_id)?;
10021 let head = v2_verified_head(cfg, brain)?
10022 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10023 let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
10024 let body = json!({
10025 "mutation_id": mutation_id,
10026 "control_revision": head.control_revision,
10027 "reason": reason,
10028 });
10029 let path = format!(
10030 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
10031 head.brain_id
10032 );
10033 ensure_ok(
10034 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
10035 "v2 proposal rejection",
10036 )
10037}
10038
10039pub fn proposal_accept_exact(
10040 cfg: &HubConfig,
10041 brain: &str,
10042 proposal_id: &str,
10043 mutation_id: &str,
10044 reason: &str,
10045) -> LinkResult<Value> {
10046 require_safe_ref(brain)?;
10047 require_proposal_id(proposal_id)?;
10048 let head = v2_verified_head(cfg, brain)?
10049 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
10050 let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
10051 let operations = proposal
10052 .changes
10053 .get("operations")
10054 .and_then(Value::as_array)
10055 .cloned()
10056 .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
10057 if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
10058 return Err(invalid_feed("proposal operation count is invalid"));
10059 }
10060 let mut downloaded = std::collections::BTreeMap::new();
10061 for (hash, bytes, endpoint) in &proposal.blobs {
10062 let body = ensure_raw_ok(
10063 request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
10064 "v2 proposal blob",
10065 )?;
10066 if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
10067 return Err(invalid_feed("proposal blob does not match its declaration"));
10068 }
10069 downloaded.insert(hash.clone(), body);
10070 }
10071 let remote = files_for_v2_view(
10072 &head,
10073 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
10074 );
10075 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
10076 let mut expected_candidate = remote.clone();
10077 let mut expected_candidate_assets = remote_assets;
10078 for operation in &operations {
10079 let op = operation
10080 .get("op")
10081 .and_then(Value::as_str)
10082 .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
10083 match op {
10084 "put" | "restore" => {
10085 let path = operation
10086 .get("path")
10087 .and_then(Value::as_str)
10088 .ok_or_else(|| invalid_feed("proposal write has no path"))?;
10089 crate::linkmd_v2::normalize_path(path)
10090 .map_err(|error| invalid_feed(error.to_string()))?;
10091 let hash = operation
10092 .get("blob")
10093 .and_then(Value::as_str)
10094 .filter(|hash| is_sha256(hash))
10095 .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
10096 let bytes = operation
10097 .get("bytes")
10098 .and_then(Value::as_u64)
10099 .ok_or_else(|| invalid_feed("proposal write has no size"))?;
10100 expected_candidate.insert(
10101 path.to_string(),
10102 V2BaselineFile {
10103 sha256: hash.to_string(),
10104 bytes,
10105 proof: None,
10106 },
10107 );
10108 }
10109 "delete" | "withdraw_from_hosting" => {
10110 let path = operation
10111 .get("path")
10112 .and_then(Value::as_str)
10113 .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
10114 crate::linkmd_v2::normalize_path(path)
10115 .map_err(|error| invalid_feed(error.to_string()))?;
10116 expected_candidate.remove(path);
10117 }
10118 "rename" => {
10119 let from = operation
10120 .get("from")
10121 .and_then(Value::as_str)
10122 .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
10123 let to = operation
10124 .get("to")
10125 .and_then(Value::as_str)
10126 .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
10127 crate::linkmd_v2::normalize_path(from)
10128 .and_then(|_| crate::linkmd_v2::normalize_path(to))
10129 .map_err(|error| invalid_feed(error.to_string()))?;
10130 let hash = operation
10131 .get("blob")
10132 .and_then(Value::as_str)
10133 .filter(|hash| is_sha256(hash))
10134 .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
10135 let bytes = operation
10136 .get("bytes")
10137 .and_then(Value::as_u64)
10138 .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
10139 expected_candidate.remove(from);
10140 expected_candidate.insert(
10141 to.to_string(),
10142 V2BaselineFile {
10143 sha256: hash.to_string(),
10144 bytes,
10145 proof: None,
10146 },
10147 );
10148 }
10149 "asset_delete" => {
10150 let path = operation
10151 .get("path")
10152 .and_then(Value::as_str)
10153 .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
10154 expected_candidate_assets.remove(path);
10155 }
10156 "asset_withdraw" => {
10157 let path = operation
10158 .get("path")
10159 .and_then(Value::as_str)
10160 .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
10161 let asset = expected_candidate_assets
10162 .get_mut(path)
10163 .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
10164 asset.disposition = "withheld".to_string();
10165 asset.leaf_hash.clear();
10166 }
10167 "asset_put" | "asset_resume" => {
10168 let path = operation
10169 .get("path")
10170 .and_then(Value::as_str)
10171 .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
10172 let asset = operation
10173 .get("asset")
10174 .and_then(Value::as_object)
10175 .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
10176 let blob_sha256 = asset
10177 .get("blob_sha256")
10178 .and_then(Value::as_str)
10179 .filter(|hash| is_sha256(hash))
10180 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
10181 let bytes = asset
10182 .get("bytes")
10183 .and_then(Value::as_u64)
10184 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
10185 let media_type = asset
10186 .get("media_type")
10187 .and_then(Value::as_str)
10188 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
10189 let wrappers = asset
10190 .get("wrappers")
10191 .and_then(Value::as_array)
10192 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
10193 .iter()
10194 .map(|wrapper| {
10195 wrapper
10196 .as_str()
10197 .map(str::to_string)
10198 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
10199 })
10200 .collect::<LinkResult<Vec<_>>>()?;
10201 let required = asset
10202 .get("required")
10203 .and_then(Value::as_bool)
10204 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
10205 let disposition = asset
10206 .get("disposition")
10207 .and_then(Value::as_str)
10208 .filter(|value| matches!(*value, "hosted" | "withheld"))
10209 .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
10210 expected_candidate_assets.insert(
10211 path.to_string(),
10212 V2BaselineAsset {
10213 blob_sha256: blob_sha256.to_string(),
10214 bytes,
10215 media_type: media_type.to_string(),
10216 wrappers,
10217 required,
10218 disposition: disposition.to_string(),
10219 leaf_hash: String::new(),
10220 },
10221 );
10222 }
10223 _ => return Err(invalid_feed("proposal operation kind is unsupported")),
10224 }
10225 }
10226 let base = head.pointer.as_ref().map(|pointer| {
10227 json!({
10228 "seq": pointer.seq,
10229 "commit_hash": pointer.commit_hash,
10230 "content_root": pointer.content_root,
10231 "asset_root": pointer.asset_root,
10232 })
10233 });
10234 let mut body = json!({
10235 "mutation_id": mutation_id,
10236 "base": base,
10237 "rebase": "strict",
10238 "reason": reason,
10239 "operations": operations,
10240 "blobs": downloaded
10241 .iter()
10242 .map(|(sha256, bytes)| json!({
10243 "sha256": sha256,
10244 "bytes": bytes.len(),
10245 "content_base64": STANDARD.encode(bytes),
10246 }))
10247 .collect::<Vec<_>>(),
10248 "proposal_id": proposal_id,
10249 "proposal_mode": "exact",
10250 });
10251 let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
10252 total
10253 .checked_add(bytes.len())
10254 .ok_or_else(|| LinkError::PushTooLarge {
10255 detail: "proposal changed-byte total overflow".to_string(),
10256 })
10257 })?;
10258 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
10259 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10260 for operation in &operations {
10261 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
10262 return Err(invalid_feed("proposal upload operation has no kind"));
10263 };
10264 let hash = match kind {
10265 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
10266 "asset_put" | "asset_resume" => operation
10267 .get("asset")
10268 .and_then(|asset| asset.get("blob_sha256"))
10269 .and_then(Value::as_str),
10270 _ => None,
10271 };
10272 let Some(hash) = hash else { continue };
10273 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
10274 if kind == "rename" {
10275 for field in ["from", "to"] {
10276 coordinates.insert(
10277 operation
10278 .get(field)
10279 .and_then(Value::as_str)
10280 .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
10281 .to_string(),
10282 );
10283 }
10284 } else {
10285 let path = operation
10286 .get("path")
10287 .and_then(Value::as_str)
10288 .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
10289 coordinates.insert(if kind.starts_with("asset_") {
10290 format!("assets/{path}")
10291 } else {
10292 path.to_string()
10293 });
10294 }
10295 }
10296 let declarations = downloaded
10297 .iter()
10298 .map(|(sha256, bytes)| {
10299 json!({
10300 "sha256": sha256,
10301 "bytes": bytes.len(),
10302 "coordinates": coordinates_by_hash
10303 .get(sha256)
10304 .into_iter()
10305 .flatten()
10306 .collect::<Vec<_>>(),
10307 })
10308 })
10309 .collect::<Vec<_>>();
10310 let reserved = ensure_ok(
10311 request(
10312 cfg,
10313 "POST",
10314 &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
10315 Some(&json!({ "blobs": declarations })),
10316 Auth::Required,
10317 )?,
10318 "prepare proposal blob transport",
10319 )?;
10320 let items = reserved
10321 .get("uploads")
10322 .and_then(Value::as_array)
10323 .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
10324 if items.len() != downloaded.len() {
10325 return Err(invalid_feed("proposal upload reservation changed the set"));
10326 }
10327 let mut references = Vec::with_capacity(items.len());
10328 for item in items {
10329 let hash = item
10330 .get("sha256")
10331 .and_then(Value::as_str)
10332 .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
10333 let bytes = downloaded
10334 .get(hash)
10335 .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
10336 let reservation_id = item
10337 .get("reservation_id")
10338 .and_then(Value::as_str)
10339 .filter(|id| crate::ulid::is_ulid(id))
10340 .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
10341 let expected_coordinates = coordinates_by_hash
10342 .get(hash)
10343 .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
10344 let returned_coordinates = item
10345 .get("coordinates")
10346 .and_then(Value::as_array)
10347 .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
10348 if returned_coordinates.len() != expected_coordinates.len()
10349 || returned_coordinates
10350 .iter()
10351 .zip(expected_coordinates)
10352 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
10353 {
10354 return Err(invalid_feed(
10355 "proposal upload reservation changed its coordinates",
10356 ));
10357 }
10358 match item.get("status").and_then(Value::as_str) {
10359 Some("upload") => put_presigned(
10360 cfg,
10361 item.get("url")
10362 .and_then(Value::as_str)
10363 .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
10364 item.get("headers").unwrap_or(&Value::Null),
10365 bytes,
10366 )?,
10367 Some("already_present") => {}
10368 _ => return Err(invalid_feed("proposal upload status is invalid")),
10369 }
10370 references.push(json!({
10371 "sha256": hash,
10372 "bytes": bytes.len(),
10373 "reservation_id": reservation_id,
10374 }));
10375 }
10376 body["blobs"] = Value::Array(references);
10377 }
10378 if body.to_string().len() > MAX_PUSH_BYTES {
10379 return Err(LinkError::PushTooLarge {
10380 detail: "proposal operation metadata exceeds the commit request cap".to_string(),
10381 });
10382 }
10383 let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
10384 let mut result = ensure_ok(
10385 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10386 "exact proposal acceptance",
10387 )?;
10388 let mut candidate_hub_signer = None;
10389 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
10390 let challenge = result
10391 .get("signing_challenge")
10392 .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
10393 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
10394 cfg,
10395 &head,
10396 &expected_candidate,
10397 &expected_candidate_assets,
10398 mutation_id,
10399 &body,
10400 challenge,
10401 )?;
10402 body["signing_challenge_id"] = Value::String(challenge_id);
10403 body["signature_base64url"] = Value::String(signature);
10404 candidate_hub_signer = Some(actor_signer);
10405 result = ensure_ok(
10406 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10407 "signed exact proposal acceptance",
10408 )?;
10409 }
10410 let refreshed = v2_verified_head(cfg, brain)?
10411 .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
10412 if candidate_hub_signer
10413 .as_ref()
10414 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
10415 || refreshed
10416 .pointer
10417 .as_ref()
10418 .map(|pointer| pointer.commit_hash.as_str())
10419 != result.get("commit_hash").and_then(Value::as_str)
10420 || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
10421 || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
10422 {
10423 return Err(LinkError::RemoteAdvancedDuringSync);
10424 }
10425 accept_v2_head(cfg, &refreshed)?;
10426 Ok(result)
10427}
10428
10429pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
10440 require_valid_handle(handle)?;
10441 if body.len() as u64 > MAX_PROPOSE_BYTES {
10442 return Err(LinkError::ProposeTooLarge {
10443 bytes: body.len() as u64,
10444 });
10445 }
10446 let payload = json!({ "app": app, "body": body });
10447 let (path, auth) = if crate::ulid::is_ulid(handle) {
10452 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
10453 } else {
10454 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
10455 };
10456 ensure_ok(
10457 request(cfg, "POST", &path, Some(&payload), auth)?,
10458 "propose",
10459 )
10460}
10461
10462#[derive(Debug, serde::Serialize)]
10468pub struct Head {
10469 pub brain: String,
10471 pub seq: u64,
10473 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
10475 pub updated_at: Option<String>,
10476 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
10478 pub feed_hash: Option<String>,
10479 pub verified: bool,
10482}
10483
10484struct BoundedVecVisitor<T, const MAX: usize> {
10485 label: &'static str,
10486 marker: std::marker::PhantomData<T>,
10487}
10488
10489impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
10490where
10491 T: Deserialize<'de>,
10492{
10493 type Value = Vec<T>;
10494
10495 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10496 write!(formatter, "at most {MAX} {}", self.label)
10497 }
10498
10499 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
10500 where
10501 A: serde::de::SeqAccess<'de>,
10502 {
10503 if sequence.size_hint().is_some_and(|size| size > MAX) {
10504 return Err(serde::de::Error::custom(format!(
10505 "{} exceeds the {MAX}-item limit",
10506 self.label
10507 )));
10508 }
10509 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
10510 while let Some(value) = sequence.next_element()? {
10511 if values.len() == MAX {
10512 return Err(serde::de::Error::custom(format!(
10513 "{} exceeds the {MAX}-item limit",
10514 self.label
10515 )));
10516 }
10517 values.push(value);
10518 }
10519 Ok(values)
10520 }
10521}
10522
10523fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
10524 deserializer: D,
10525 label: &'static str,
10526) -> Result<Vec<T>, D::Error>
10527where
10528 D: serde::Deserializer<'de>,
10529 T: Deserialize<'de>,
10530{
10531 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
10532 label,
10533 marker: std::marker::PhantomData,
10534 })
10535}
10536
10537fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
10538where
10539 D: serde::Deserializer<'de>,
10540{
10541 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
10542}
10543
10544fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
10545where
10546 D: serde::Deserializer<'de>,
10547{
10548 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
10549}
10550
10551fn deserialize_previous_identities<'de, D>(
10552 deserializer: D,
10553) -> Result<Vec<PreviousIdentity>, D::Error>
10554where
10555 D: serde::Deserializer<'de>,
10556{
10557 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
10558 deserializer,
10559 "previous identities",
10560 )
10561}
10562
10563fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
10564where
10565 D: serde::Deserializer<'de>,
10566{
10567 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
10568 deserializer,
10569 "rotation statements",
10570 )
10571}
10572
10573fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
10574where
10575 D: serde::Deserializer<'de>,
10576{
10577 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
10578}
10579
10580#[derive(Debug, Clone, Deserialize, Serialize)]
10581struct FeedFile {
10582 path: String,
10583 sha256: String,
10584 bytes: u64,
10585}
10586
10587#[cfg(test)]
10588#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10589enum V1DisclosureError {
10590 DuplicateFile,
10591 DuplicateRemoved,
10592 PushManifestMismatch,
10593 EditMissingChange,
10594 EditFalseFile,
10595 RemovedMismatch,
10596}
10597
10598#[cfg(test)]
10602fn verify_v1_manifest_disclosure(
10603 kind: &str,
10604 previous: &[FeedFile],
10605 resulting: &[FeedFile],
10606 files: &[FeedFile],
10607 removed: &[String],
10608) -> Result<(), V1DisclosureError> {
10609 fn as_map(
10610 files: &[FeedFile],
10611 ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
10612 let mut result = std::collections::BTreeMap::new();
10613 for file in files {
10614 if result
10615 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10616 .is_some()
10617 {
10618 return Err(V1DisclosureError::DuplicateFile);
10619 }
10620 }
10621 Ok(result)
10622 }
10623 let previous = as_map(previous)?;
10624 let resulting = as_map(resulting)?;
10625 let disclosed = as_map(files)?;
10626 let removed_set: std::collections::BTreeSet<&str> =
10627 removed.iter().map(String::as_str).collect();
10628 if removed_set.len() != removed.len() {
10629 return Err(V1DisclosureError::DuplicateRemoved);
10630 }
10631 let expected_removed: std::collections::BTreeSet<&str> = previous
10632 .keys()
10633 .copied()
10634 .filter(|path| !resulting.contains_key(path))
10635 .collect();
10636 if removed_set != expected_removed {
10637 return Err(V1DisclosureError::RemovedMismatch);
10638 }
10639 if kind == "push" {
10640 return if disclosed == resulting {
10641 Ok(())
10642 } else {
10643 Err(V1DisclosureError::PushManifestMismatch)
10644 };
10645 }
10646 if kind != "edit" {
10647 return Err(V1DisclosureError::EditFalseFile);
10648 }
10649 if disclosed
10650 .iter()
10651 .any(|(path, value)| resulting.get(path) != Some(value))
10652 {
10653 return Err(V1DisclosureError::EditFalseFile);
10654 }
10655 for (path, value) in &resulting {
10656 if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
10657 return Err(V1DisclosureError::EditMissingChange);
10658 }
10659 }
10660 Ok(())
10661}
10662
10663#[derive(Debug, Clone, Deserialize, Serialize)]
10664struct FeedEntry {
10665 v: u8,
10666 seq: u64,
10667 ts: String,
10668 brain: String,
10669 public_key: String,
10670 kind: String,
10671 op: String,
10672 pack_sha256: String,
10673 #[serde(deserialize_with = "deserialize_feed_files")]
10674 files: Vec<FeedFile>,
10675 #[serde(deserialize_with = "deserialize_removed_paths")]
10676 removed: Vec<String>,
10677 prev_entry_hash: Option<String>,
10678 sig: String,
10679}
10680
10681#[derive(Serialize)]
10682struct UnsignedFeedEntry<'a> {
10683 v: u8,
10684 seq: u64,
10685 ts: &'a str,
10686 brain: &'a str,
10687 public_key: &'a str,
10688 kind: &'a str,
10689 op: &'a str,
10690 pack_sha256: &'a str,
10691 files: &'a [FeedFile],
10692 removed: &'a [String],
10693 prev_entry_hash: &'a Option<String>,
10694}
10695
10696#[derive(Debug, Clone, Deserialize, Serialize)]
10697struct FeedItem {
10698 hash: String,
10699 entry: FeedEntry,
10700}
10701
10702#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
10703struct FeedIdentity {
10704 fingerprint: String,
10705 #[serde(rename = "publicKeySpki")]
10706 public_key_spki: String,
10707 #[serde(default, deserialize_with = "deserialize_previous_identities")]
10711 previous: Vec<PreviousIdentity>,
10712 #[serde(default, deserialize_with = "deserialize_rotations")]
10715 rotations: Vec<String>,
10716}
10717
10718#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
10719struct PreviousIdentity {
10720 fingerprint: String,
10721 #[serde(rename = "publicKeySpki")]
10722 public_key_spki: String,
10723}
10724
10725#[derive(Debug, Deserialize)]
10726struct FeedResponse {
10727 #[serde(rename = "headSeq")]
10728 head_seq: u64,
10729 #[serde(rename = "feedHash")]
10730 feed_hash: Option<String>,
10731 identity: Option<FeedIdentity>,
10732 #[serde(deserialize_with = "deserialize_feed_items")]
10733 entries: Vec<FeedItem>,
10734 #[serde(rename = "scopeLimited")]
10735 scope_limited: bool,
10736}
10737
10738#[derive(Debug, Deserialize, Serialize)]
10739#[serde(deny_unknown_fields)]
10740struct RotationStatement {
10741 v: u8,
10742 op: String,
10743 brain: String,
10744 public_key: String,
10745 new_brain: String,
10746 new_public_key: String,
10747 prior_head_seq: u64,
10748 prior_feed_hash: Option<String>,
10749 ts: String,
10750 sig: String,
10751}
10752
10753#[derive(Debug, Clone, Deserialize, Serialize)]
10754struct TrustState {
10755 v: u8,
10756 origin: String,
10757 #[serde(default)]
10761 requested: String,
10762 brain: String,
10764 #[serde(default, skip_serializing_if = "Option::is_none")]
10767 home: Option<String>,
10768 anchor: String,
10769 current: String,
10770 #[serde(rename = "headSeq")]
10771 head_seq: u64,
10772 #[serde(rename = "feedHash")]
10773 feed_hash: Option<String>,
10774 #[serde(default)]
10778 rotations: Vec<String>,
10779 #[serde(default, skip_serializing_if = "Option::is_none")]
10782 hub_signer: Option<String>,
10783 #[serde(default, skip_serializing_if = "Option::is_none")]
10786 protocol_profile: Option<String>,
10787}
10788
10789fn accepted_as_v2(state: &TrustState) -> bool {
10790 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
10791}
10792
10793fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
10794 let directory = open_trust_dir(cfg)?;
10795 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
10796 return Ok(true);
10797 }
10798 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
10799 return Ok(false);
10800 };
10801 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
10802}
10803
10804#[derive(Debug, Clone, Deserialize, Serialize)]
10805struct AliasBinding {
10806 v: u8,
10807 origin: String,
10808 requested: String,
10809 brain: String,
10810 #[serde(default, skip_serializing_if = "Option::is_none")]
10811 home: Option<String>,
10812}
10813
10814struct VerifiedRemote {
10815 head: Head,
10816 identity: Option<FeedIdentity>,
10817 head_entry: Option<FeedItem>,
10818 entries: Vec<FeedItem>,
10820 anchor: Option<String>,
10821}
10822
10823fn invalid_feed(message: impl Into<String>) -> LinkError {
10824 LinkError::InvalidFeed {
10825 message: message.into(),
10826 }
10827}
10828
10829fn is_sha256(value: &str) -> bool {
10830 value.len() == 64
10831 && value
10832 .bytes()
10833 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
10834}
10835
10836fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
10837 let der = URL_SAFE_NO_PAD
10838 .decode(public_key_spki)
10839 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
10840 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
10841 return Err(invalid_feed(
10842 "identity public key is not a valid Ed25519 SPKI",
10843 ));
10844 }
10845 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
10846}
10847
10848fn verify_identity_chain(
10852 identity: &FeedIdentity,
10853 pinned: Option<&TrustState>,
10854) -> LinkResult<String> {
10855 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
10856 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
10857 {
10858 return Err(invalid_feed(
10859 "identity rotation history exceeds the client cap",
10860 ));
10861 }
10862 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
10863 return Err(invalid_feed(
10864 "current identity fingerprint does not match its public key",
10865 ));
10866 }
10867 for previous in &identity.previous {
10868 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
10869 return Err(invalid_feed(
10870 "previous identity fingerprint does not match its public key",
10871 ));
10872 }
10873 }
10874 if identity.rotations.len() != identity.previous.len() {
10875 return Err(invalid_feed(
10876 "identity history is missing an old-key-signed rotation statement",
10877 ));
10878 }
10879
10880 let mut chain: Vec<(&str, &str)> = identity
10884 .previous
10885 .iter()
10886 .rev()
10887 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
10888 .collect();
10889 chain.push((&identity.fingerprint, &identity.public_key_spki));
10890
10891 for (index, raw) in identity.rotations.iter().enumerate() {
10892 let statement: RotationStatement = serde_json::from_str(raw)
10893 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
10894 let (old_fingerprint, old_spki) = chain[index];
10895 let (new_fingerprint, new_spki) = chain[index + 1];
10896 if statement.v != 1
10897 || statement.op != "rotate"
10898 || statement.brain != format!("ed25519:{old_fingerprint}")
10899 || statement.public_key != old_spki
10900 || statement.new_brain != format!("ed25519:{new_fingerprint}")
10901 || statement.new_public_key != new_spki
10902 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
10903 || (statement.prior_head_seq > 0
10904 && statement
10905 .prior_feed_hash
10906 .as_deref()
10907 .is_none_or(|hash| !is_sha256(hash)))
10908 {
10909 return Err(invalid_feed(
10910 "rotation statement does not connect adjacent identities",
10911 ));
10912 }
10913 let unsigned = serde_json::to_string(&UnsignedRotation {
10914 v: statement.v,
10915 op: &statement.op,
10916 brain: &statement.brain,
10917 public_key: &statement.public_key,
10918 new_brain: &statement.new_brain,
10919 new_public_key: &statement.new_public_key,
10920 prior_head_seq: statement.prior_head_seq,
10921 prior_feed_hash: statement.prior_feed_hash.as_deref(),
10922 ts: statement.ts.clone(),
10923 })
10924 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
10925 let exact = format!(
10926 "{},\"sig\":\"{}\"}}",
10927 &unsigned[..unsigned.len() - 1],
10928 statement.sig
10929 );
10930 if exact != *raw {
10931 return Err(invalid_feed(
10932 "rotation statement is not in normative serialization",
10933 ));
10934 }
10935 let der = URL_SAFE_NO_PAD
10936 .decode(old_spki)
10937 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
10938 let signature = URL_SAFE_NO_PAD
10939 .decode(&statement.sig)
10940 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
10941 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
10942 .verify(unsigned.as_bytes(), &signature)
10943 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
10944 if index > 0 {
10945 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
10946 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
10947 if statement.prior_head_seq < prior.prior_head_seq {
10948 return Err(invalid_feed("rotation feed boundaries move backward"));
10949 }
10950 }
10951 }
10952
10953 let anchor = format!("ed25519:{}", chain[0].0);
10954 let current = format!("ed25519:{}", identity.fingerprint);
10955 if let Some(pin) = pinned {
10956 if pin.anchor != anchor {
10957 return Err(invalid_feed(
10958 "served identity chain does not descend from the pinned anchor",
10959 ));
10960 }
10961 if !chain
10962 .iter()
10963 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
10964 {
10965 return Err(invalid_feed(
10966 "served identity chain forked away from the last pinned identity",
10967 ));
10968 }
10969 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
10970 return Err(invalid_feed("served identity discarded its rotation chain"));
10971 }
10972 if pin.v >= 2
10973 && (identity.rotations.len() < pin.rotations.len()
10974 || identity.rotations[..pin.rotations.len()] != pin.rotations)
10975 {
10976 return Err(invalid_feed(
10977 "served identity rewrote the locally accepted rotation history",
10978 ));
10979 }
10980 }
10981 Ok(anchor)
10982}
10983
10984fn verify_rotation_feed_boundaries(
10985 identity: &FeedIdentity,
10986 pinned: Option<&TrustState>,
10987 observed: &[FeedItem],
10988 advertised_seq: u64,
10989) -> LinkResult<()> {
10990 let mut chain: Vec<String> = identity
10991 .previous
10992 .iter()
10993 .rev()
10994 .map(|previous| format!("ed25519:{}", previous.fingerprint))
10995 .collect();
10996 chain.push(format!("ed25519:{}", identity.fingerprint));
10997 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
10998
10999 for (index, raw) in identity.rotations.iter().enumerate() {
11000 let rotation: RotationStatement = serde_json::from_str(raw)
11001 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
11002 if rotation.prior_head_seq > advertised_seq {
11003 return Err(invalid_feed(
11004 "rotation claims a feed boundary beyond the advertised head",
11005 ));
11006 }
11007 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
11008 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
11009 return Err(invalid_feed(
11010 "newly disclosed rotation predates the local feed checkpoint",
11011 ));
11012 }
11013 }
11014 let actual = if rotation.prior_head_seq == 0 {
11015 None
11016 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
11017 pinned.and_then(|pin| pin.feed_hash.as_deref())
11018 } else {
11019 observed
11020 .iter()
11021 .find(|item| item.entry.seq == rotation.prior_head_seq)
11022 .map(|item| item.hash.as_str())
11023 };
11024 if let Some(actual) = actual {
11025 if rotation.prior_feed_hash.as_deref() != Some(actual) {
11026 return Err(invalid_feed(
11027 "rotation statement does not commit the verified feed boundary",
11028 ));
11029 }
11030 } else if rotation.prior_head_seq == 0 {
11031 } else if pinned.is_some_and(|pin| {
11034 pinned_index.is_some_and(|pin_index| index >= pin_index)
11035 || rotation.prior_head_seq >= pin.head_seq
11036 }) {
11037 return Err(invalid_feed(
11038 "rotation feed boundary was not present in the verified chain",
11039 ));
11040 }
11041 }
11042 Ok(())
11043}
11044
11045fn reject_retired_signer_after_checkpoint(
11050 identity: &FeedIdentity,
11051 pinned: Option<&TrustState>,
11052 item: &FeedItem,
11053) -> LinkResult<()> {
11054 let Some(pin) = pinned else {
11055 return Ok(());
11056 };
11057 if item.entry.seq <= pin.head_seq {
11058 return Ok(());
11059 }
11060 let mut chain: Vec<String> = identity
11061 .previous
11062 .iter()
11063 .rev()
11064 .map(|previous| format!("ed25519:{}", previous.fingerprint))
11065 .collect();
11066 chain.push(format!("ed25519:{}", identity.fingerprint));
11067 let pinned_index = chain
11068 .iter()
11069 .position(|key| key == &pin.current)
11070 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
11071 let signer_index = chain
11072 .iter()
11073 .position(|key| key == &item.entry.brain)
11074 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
11075 if signer_index < pinned_index {
11076 return Err(invalid_feed(
11077 "a retired identity attempted to sign after the local checkpoint",
11078 ));
11079 }
11080 Ok(())
11081}
11082
11083fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
11084 let origin = normalized_origin(&cfg.hub)?;
11085 let key = format!(
11086 "{:x}",
11087 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
11088 );
11089 Ok(format!("{key}.json"))
11090}
11091
11092fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
11093 let origin = normalized_origin(&cfg.hub)?;
11094 let key = format!(
11095 "{:x}",
11096 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
11097 );
11098 Ok(format!("alias-{key}.json"))
11099}
11100
11101#[cfg(any(unix, windows))]
11102struct TrustLock {
11103 _file: std::fs::File,
11104}
11105
11106#[cfg(unix)]
11107fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
11108 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11109
11110 let lock_string = format!(".{state_name}.lock");
11111 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
11112 let fd = unsafe {
11113 libc::openat(
11114 directory.as_raw_fd(),
11115 lock_name.as_ptr(),
11116 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11117 0o600,
11118 )
11119 };
11120 if fd < 0 {
11121 return Err(std::io::Error::last_os_error().into());
11122 }
11123 let file = unsafe { std::fs::File::from_raw_fd(fd) };
11124 if !file.metadata()?.is_file() {
11125 return Err(LinkError::UnsafePath { path: lock_string });
11126 }
11127 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
11128 return Err(std::io::Error::last_os_error().into());
11129 }
11130 Ok(TrustLock { _file: file })
11131}
11132
11133#[cfg(windows)]
11134fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
11135 let lock_name = format!(".{state_name}.lock");
11136 let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
11137 Ok(TrustLock { _file: file })
11138}
11139
11140#[cfg(any(unix, windows))]
11141fn lock_trust_many(
11142 cfg: &HubConfig,
11143 directory: &std::fs::File,
11144 refs: &[&str],
11145) -> LinkResult<Vec<TrustLock>> {
11146 let mut names = refs
11147 .iter()
11148 .map(|reference| trust_file_name(cfg, reference))
11149 .collect::<LinkResult<Vec<_>>>()?;
11150 names.sort();
11151 names.dedup();
11152 names
11153 .iter()
11154 .map(|name| lock_trust_name(directory, name))
11155 .collect()
11156}
11157
11158#[cfg(not(any(unix, windows)))]
11159fn lock_trust_many(
11160 _cfg: &HubConfig,
11161 _directory: &TrustDirectory,
11162 _refs: &[&str],
11163) -> LinkResult<Vec<()>> {
11164 Err(LinkError::UnsupportedPlatform {
11165 operation: "verified link.md state",
11166 })
11167}
11168
11169#[cfg(any(unix, windows))]
11170type TrustDirectory = std::fs::File;
11171
11172#[cfg(not(any(unix, windows)))]
11173struct TrustDirectory;
11174
11175#[cfg(unix)]
11176fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11177 use std::os::fd::AsRawFd as _;
11178
11179 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
11180 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
11181 return Err(std::io::Error::last_os_error().into());
11182 }
11183 directory.sync_all()?;
11184 Ok(directory)
11185}
11186
11187#[cfg(windows)]
11188fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11189 let marker = cfg.state_dir.join("trust").join(".directory");
11190 crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
11191 Ok(crate::fsx::open_directory_nofollow(
11192 marker.parent().expect("trust marker has a parent"),
11193 )?)
11194}
11195
11196#[cfg(not(any(unix, windows)))]
11197fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
11198 Err(LinkError::UnsupportedPlatform {
11199 operation: "verified link.md state",
11200 })
11201}
11202
11203#[cfg(unix)]
11204fn load_trust_in(
11205 cfg: &HubConfig,
11206 directory: &TrustDirectory,
11207 requested: &str,
11208) -> LinkResult<Option<TrustState>> {
11209 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11210
11211 let name_string = trust_file_name(cfg, requested)?;
11212 let name = c_name(name_string.as_bytes(), &name_string)?;
11213 let fd = unsafe {
11214 libc::openat(
11215 directory.as_raw_fd(),
11216 name.as_ptr(),
11217 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11218 )
11219 };
11220 if fd < 0 {
11221 let error = std::io::Error::last_os_error();
11222 if error.kind() == std::io::ErrorKind::NotFound {
11223 return Ok(None);
11224 }
11225 return Err(LinkError::UnsafePath { path: name_string });
11226 }
11227 let file = unsafe { std::fs::File::from_raw_fd(fd) };
11228 if !file.metadata()?.is_file() {
11229 return Err(LinkError::UnsafePath { path: name_string });
11230 }
11231 let mut bytes = Vec::new();
11232 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
11233 if bytes.len() > 1024 * 1024 {
11234 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
11235 }
11236 let mut state: TrustState = serde_json::from_slice(&bytes)
11237 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
11238 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
11239 return Err(invalid_feed(
11240 "local identity/feed checkpoint does not match this hub and brain",
11241 ));
11242 }
11243 if state.v == 1 {
11244 if state.brain != requested {
11248 return Err(invalid_feed(
11249 "legacy checkpoint is not bound to the requested brain id",
11250 ));
11251 }
11252 state.requested = requested.to_string();
11253 } else if state.requested != requested {
11254 return Err(invalid_feed(
11255 "local identity/feed checkpoint is bound to a different requested ref",
11256 ));
11257 }
11258 Ok(Some(state))
11259}
11260
11261#[cfg(windows)]
11262fn load_trust_in(
11263 cfg: &HubConfig,
11264 directory: &TrustDirectory,
11265 requested: &str,
11266) -> LinkResult<Option<TrustState>> {
11267 let name = trust_file_name(cfg, requested)?;
11268 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
11269 let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
11270 Ok(bytes) => bytes,
11271 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11272 Err(_) => return Err(LinkError::UnsafePath { path: name }),
11273 };
11274 let mut state: TrustState = serde_json::from_slice(&bytes)
11275 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
11276 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
11277 return Err(invalid_feed(
11278 "local identity/feed checkpoint does not match this hub and brain",
11279 ));
11280 }
11281 if state.v == 1 {
11282 if state.brain != requested {
11283 return Err(invalid_feed(
11284 "legacy checkpoint is not bound to the requested brain id",
11285 ));
11286 }
11287 state.requested = requested.to_string();
11288 } else if state.requested != requested {
11289 return Err(invalid_feed(
11290 "local identity/feed checkpoint is bound to a different requested ref",
11291 ));
11292 }
11293 Ok(Some(state))
11294}
11295
11296#[cfg(not(any(unix, windows)))]
11297fn load_trust_in(
11298 _cfg: &HubConfig,
11299 _directory: &TrustDirectory,
11300 _brain: &str,
11301) -> LinkResult<Option<TrustState>> {
11302 Err(LinkError::UnsupportedPlatform {
11303 operation: "verified link.md state",
11304 })
11305}
11306
11307#[cfg(all(test, any(unix, windows)))]
11308fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
11309 let directory = open_trust_dir(cfg)?;
11310 load_trust_in(cfg, &directory, requested)
11311}
11312
11313#[cfg(unix)]
11314fn save_trust_in(
11315 cfg: &HubConfig,
11316 directory: &TrustDirectory,
11317 state: &TrustState,
11318) -> LinkResult<()> {
11319 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11320
11321 let name_string = trust_file_name(cfg, &state.requested)?;
11322 let name = c_name(name_string.as_bytes(), &name_string)?;
11323 let mut bytes = serde_json::to_vec(state)
11324 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
11325 bytes.push(b'\n');
11326
11327 let nonce = std::time::SystemTime::now()
11328 .duration_since(std::time::UNIX_EPOCH)
11329 .unwrap_or_default()
11330 .as_nanos();
11331 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
11332 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
11333 let fd = unsafe {
11334 libc::openat(
11335 directory.as_raw_fd(),
11336 temp.as_ptr(),
11337 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11338 0o600,
11339 )
11340 };
11341 if fd < 0 {
11342 return Err(std::io::Error::last_os_error().into());
11343 }
11344 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
11345 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
11346 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11347 return Err(error.into());
11348 }
11349 drop(file);
11350 if unsafe {
11351 libc::renameat(
11352 directory.as_raw_fd(),
11353 temp.as_ptr(),
11354 directory.as_raw_fd(),
11355 name.as_ptr(),
11356 )
11357 } != 0
11358 {
11359 let error = std::io::Error::last_os_error();
11360 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11361 return Err(error.into());
11362 }
11363 directory.sync_all()?;
11364 Ok(())
11365}
11366
11367#[cfg(windows)]
11368fn save_trust_in(
11369 cfg: &HubConfig,
11370 directory: &TrustDirectory,
11371 state: &TrustState,
11372) -> LinkResult<()> {
11373 let name = trust_file_name(cfg, &state.requested)?;
11374 let mut bytes = serde_json::to_vec(state)
11375 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
11376 bytes.push(b'\n');
11377 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
11378 Ok(())
11379}
11380
11381#[cfg(not(any(unix, windows)))]
11382fn save_trust_in(
11383 _cfg: &HubConfig,
11384 _directory: &TrustDirectory,
11385 _state: &TrustState,
11386) -> LinkResult<()> {
11387 Err(LinkError::UnsupportedPlatform {
11388 operation: "verified link.md state",
11389 })
11390}
11391
11392#[cfg(unix)]
11393fn load_alias_in(
11394 cfg: &HubConfig,
11395 directory: &TrustDirectory,
11396 requested: &str,
11397) -> LinkResult<Option<AliasBinding>> {
11398 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11399
11400 let name_string = alias_file_name(cfg, requested)?;
11401 let name = c_name(name_string.as_bytes(), &name_string)?;
11402 let fd = unsafe {
11403 libc::openat(
11404 directory.as_raw_fd(),
11405 name.as_ptr(),
11406 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11407 )
11408 };
11409 if fd < 0 {
11410 let error = std::io::Error::last_os_error();
11411 if error.kind() == std::io::ErrorKind::NotFound {
11412 return Ok(None);
11413 }
11414 return Err(LinkError::UnsafePath { path: name_string });
11415 }
11416 let file = unsafe { std::fs::File::from_raw_fd(fd) };
11417 if !file.metadata()?.is_file() {
11418 return Err(LinkError::UnsafePath { path: name_string });
11419 }
11420 let mut bytes = Vec::new();
11421 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
11422 if bytes.len() > 64 * 1024 {
11423 return Err(invalid_feed("local alias binding is oversized"));
11424 }
11425 let alias: AliasBinding = serde_json::from_slice(&bytes)
11426 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
11427 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
11428 {
11429 return Err(invalid_feed(
11430 "local alias binding does not match this hub and requested ref",
11431 ));
11432 }
11433 Ok(Some(alias))
11434}
11435
11436#[cfg(windows)]
11437fn load_alias_in(
11438 cfg: &HubConfig,
11439 directory: &TrustDirectory,
11440 requested: &str,
11441) -> LinkResult<Option<AliasBinding>> {
11442 let name = alias_file_name(cfg, requested)?;
11443 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
11444 let bytes = match reader.read(Path::new(&name), 64 * 1024) {
11445 Ok(bytes) => bytes,
11446 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11447 Err(_) => return Err(LinkError::UnsafePath { path: name }),
11448 };
11449 let alias: AliasBinding = serde_json::from_slice(&bytes)
11450 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
11451 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
11452 {
11453 return Err(invalid_feed(
11454 "local alias binding does not match this hub and requested ref",
11455 ));
11456 }
11457 Ok(Some(alias))
11458}
11459
11460#[cfg(not(any(unix, windows)))]
11461fn load_alias_in(
11462 _cfg: &HubConfig,
11463 _directory: &TrustDirectory,
11464 _requested: &str,
11465) -> LinkResult<Option<AliasBinding>> {
11466 Err(LinkError::UnsupportedPlatform {
11467 operation: "verified link.md state",
11468 })
11469}
11470
11471#[cfg(unix)]
11472fn save_alias_in(
11473 cfg: &HubConfig,
11474 directory: &TrustDirectory,
11475 alias: &AliasBinding,
11476) -> LinkResult<()> {
11477 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11478
11479 let name_string = alias_file_name(cfg, &alias.requested)?;
11480 let name = c_name(name_string.as_bytes(), &name_string)?;
11481 let mut bytes = serde_json::to_vec(alias)
11482 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
11483 bytes.push(b'\n');
11484 let nonce = std::time::SystemTime::now()
11485 .duration_since(std::time::UNIX_EPOCH)
11486 .unwrap_or_default()
11487 .as_nanos();
11488 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
11489 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
11490 let fd = unsafe {
11491 libc::openat(
11492 directory.as_raw_fd(),
11493 temp.as_ptr(),
11494 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11495 0o600,
11496 )
11497 };
11498 if fd < 0 {
11499 return Err(std::io::Error::last_os_error().into());
11500 }
11501 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
11502 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
11503 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11504 return Err(error.into());
11505 }
11506 drop(file);
11507 if unsafe {
11508 libc::renameat(
11509 directory.as_raw_fd(),
11510 temp.as_ptr(),
11511 directory.as_raw_fd(),
11512 name.as_ptr(),
11513 )
11514 } != 0
11515 {
11516 let error = std::io::Error::last_os_error();
11517 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11518 return Err(error.into());
11519 }
11520 directory.sync_all()?;
11521 Ok(())
11522}
11523
11524#[cfg(windows)]
11525fn save_alias_in(
11526 cfg: &HubConfig,
11527 directory: &TrustDirectory,
11528 alias: &AliasBinding,
11529) -> LinkResult<()> {
11530 let name = alias_file_name(cfg, &alias.requested)?;
11531 let mut bytes = serde_json::to_vec(alias)
11532 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
11533 bytes.push(b'\n');
11534 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
11535 Ok(())
11536}
11537
11538#[cfg(not(any(unix, windows)))]
11539fn save_alias_in(
11540 _cfg: &HubConfig,
11541 _directory: &TrustDirectory,
11542 _alias: &AliasBinding,
11543) -> LinkResult<()> {
11544 Err(LinkError::UnsupportedPlatform {
11545 operation: "verified link.md state",
11546 })
11547}
11548
11549fn load_canonical_pin(
11554 cfg: &HubConfig,
11555 directory: &TrustDirectory,
11556 requested: &str,
11557 resolved_brain: &str,
11558) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
11559 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
11560 if requested == resolved_brain {
11561 return Ok((canonical, None));
11562 }
11563
11564 let mut alias = load_alias_in(cfg, directory, requested)?;
11565 if let Some(binding) = &alias {
11566 if binding.brain != resolved_brain {
11567 return Err(invalid_feed(
11568 "requested brain alias now resolves to a different canonical brain",
11569 ));
11570 }
11571 return Ok((canonical, alias));
11572 }
11573
11574 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
11578 if legacy.brain != resolved_brain {
11579 return Err(invalid_feed(
11580 "legacy alias checkpoint names a different canonical brain",
11581 ));
11582 }
11583 if let Some(existing) = &canonical {
11584 if existing.brain != legacy.brain
11585 || existing.anchor != legacy.anchor
11586 || existing.current != legacy.current
11587 || existing.head_seq != legacy.head_seq
11588 || existing.feed_hash != legacy.feed_hash
11589 || existing.rotations != legacy.rotations
11590 {
11591 return Err(invalid_feed(
11592 "legacy alias checkpoint conflicts with the canonical checkpoint",
11593 ));
11594 }
11595 } else {
11596 let mut promoted = legacy.clone();
11597 promoted.requested = resolved_brain.to_string();
11598 promoted.home = None;
11599 save_trust_in(cfg, directory, &promoted)?;
11600 canonical = Some(promoted);
11601 }
11602 alias = Some(AliasBinding {
11603 v: 1,
11604 origin: normalized_origin(&cfg.hub)?,
11605 requested: requested.to_string(),
11606 brain: resolved_brain.to_string(),
11607 home: legacy.home,
11608 });
11609 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
11610 }
11611 Ok((canonical, alias))
11612}
11613
11614fn save_canonical_pin_and_alias(
11615 cfg: &HubConfig,
11616 directory: &TrustDirectory,
11617 requested: &str,
11618 resolved_brain: &str,
11619 mut state: TrustState,
11620 existing_alias: Option<&AliasBinding>,
11621) -> LinkResult<()> {
11622 state.requested = resolved_brain.to_string();
11623 state.brain = resolved_brain.to_string();
11624 state.home = None;
11625 save_trust_in(cfg, directory, &state)?;
11626 if requested != resolved_brain {
11627 save_alias_in(
11628 cfg,
11629 directory,
11630 &AliasBinding {
11631 v: 1,
11632 origin: normalized_origin(&cfg.hub)?,
11633 requested: requested.to_string(),
11634 brain: resolved_brain.to_string(),
11635 home: existing_alias.and_then(|alias| alias.home.clone()),
11636 },
11637 )?;
11638 }
11639 Ok(())
11640}
11641
11642fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
11643 const ED25519_SPKI_PREFIX: &[u8] = &[
11644 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
11645 ];
11646 let entry = &item.entry;
11647 let public_der = URL_SAFE_NO_PAD
11648 .decode(&entry.public_key)
11649 .map_err(|_| invalid_feed("public key is not base64url"))?;
11650 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
11651 || !public_der.starts_with(ED25519_SPKI_PREFIX)
11652 {
11653 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
11654 }
11655 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
11656 if entry.brain != format!("ed25519:{fingerprint}") {
11657 return Err(invalid_feed(
11658 "brain fingerprint does not match its public key",
11659 ));
11660 }
11661 let _ = verify_identity_chain(identity, None)?;
11663 let mut chain: Vec<(&str, &str)> = identity
11664 .previous
11665 .iter()
11666 .rev()
11667 .map(|previous| {
11668 (
11669 previous.fingerprint.as_str(),
11670 previous.public_key_spki.as_str(),
11671 )
11672 })
11673 .collect();
11674 chain.push((&identity.fingerprint, &identity.public_key_spki));
11675 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
11676 *known_fingerprint == fingerprint && *spki == entry.public_key
11677 });
11678 let Some(signer_index) = signer_index else {
11679 return Err(invalid_feed(
11680 "entry signer is not this brain's identity (current or rotated-from)",
11681 ));
11682 };
11683 let lower_boundary = if signer_index == 0 {
11684 None
11685 } else {
11686 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
11687 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
11688 Some(prior.prior_head_seq)
11689 };
11690 let upper_boundary = if signer_index == identity.rotations.len() {
11691 None
11692 } else {
11693 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
11694 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
11695 Some(next.prior_head_seq)
11696 };
11697 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
11698 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
11699 {
11700 return Err(invalid_feed(
11701 "entry signer is outside its authenticated rotation epoch",
11702 ));
11703 }
11704 let unsigned = UnsignedFeedEntry {
11705 v: entry.v,
11706 seq: entry.seq,
11707 ts: &entry.ts,
11708 brain: &entry.brain,
11709 public_key: &entry.public_key,
11710 kind: &entry.kind,
11711 op: &entry.op,
11712 pack_sha256: &entry.pack_sha256,
11713 files: &entry.files,
11714 removed: &entry.removed,
11715 prev_entry_hash: &entry.prev_entry_hash,
11716 };
11717 let message =
11718 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
11719 let signature = URL_SAFE_NO_PAD
11720 .decode(&entry.sig)
11721 .map_err(|_| invalid_feed("signature is not base64url"))?;
11722 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
11723 .verify(&message, &signature)
11724 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
11725
11726 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
11727 exact.push(b'\n');
11728 let actual_hash = format!("{:x}", Sha256::digest(&exact));
11729 if actual_hash != item.hash {
11730 return Err(invalid_feed("entry SHA-256 does not match"));
11731 }
11732 Ok(())
11733}
11734
11735#[derive(Serialize)]
11741struct UnsignedRotation<'a> {
11742 v: u8,
11743 op: &'a str,
11744 brain: &'a str,
11745 public_key: &'a str,
11746 new_brain: &'a str,
11747 new_public_key: &'a str,
11748 prior_head_seq: u64,
11749 prior_feed_hash: Option<&'a str>,
11750 ts: String,
11751}
11752
11753#[derive(Debug, Deserialize, Serialize)]
11758#[serde(deny_unknown_fields)]
11759struct RotationJournal {
11760 v: u8,
11761 origin: String,
11762 brain: String,
11763 old_brain: String,
11764 new_brain: String,
11765 prior_head_seq: u64,
11766 prior_feed_hash: Option<String>,
11767 statement: String,
11768}
11769
11770fn rotation_journal_path(key_path: &Path) -> PathBuf {
11771 let mut path = key_path.as_os_str().to_os_string();
11772 path.push(".rotation.json");
11773 PathBuf::from(path)
11774}
11775
11776fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
11777 #[cfg(unix)]
11778 let file = {
11779 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11780 use std::os::unix::ffi::OsStrExt as _;
11781 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
11782 .map_err(|error| {
11783 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
11784 })?;
11785 let leaf_name = path
11786 .file_name()
11787 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
11788 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
11789 let fd = unsafe {
11790 libc::openat(
11791 parent.as_raw_fd(),
11792 leaf.as_ptr(),
11793 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11794 )
11795 };
11796 if fd < 0 {
11797 return Err(bad_agent_key(
11798 "the rotation journal must be an existing regular file without symlink ancestors",
11799 ));
11800 }
11801 unsafe { std::fs::File::from_raw_fd(fd) }
11802 };
11803 #[cfg(not(unix))]
11804 let file = std::fs::File::open(path)
11805 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
11806 let metadata = file
11807 .metadata()
11808 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
11809 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
11810 return Err(bad_agent_key(
11811 "the rotation journal must be a bounded regular file",
11812 ));
11813 }
11814 #[cfg(unix)]
11815 {
11816 use std::os::unix::fs::PermissionsExt as _;
11817 if metadata.permissions().mode() & 0o077 != 0 {
11818 return Err(bad_agent_key(
11819 "the rotation journal is accessible to group/other; set mode 0600",
11820 ));
11821 }
11822 }
11823 serde_json::from_reader(file)
11824 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
11825}
11826
11827fn remove_rotation_journal(path: &Path) {
11828 #[cfg(unix)]
11829 {
11830 use std::os::fd::AsRawFd as _;
11831 use std::os::unix::ffi::OsStrExt as _;
11832 let Ok(parent) =
11833 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
11834 else {
11835 return;
11836 };
11837 let Some(leaf_name) = path.file_name() else {
11838 return;
11839 };
11840 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
11841 return;
11842 };
11843 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
11844 let _ = parent.sync_all();
11845 }
11846 }
11847 #[cfg(not(unix))]
11848 {
11849 let _ = std::fs::remove_file(path);
11850 }
11851}
11852
11853fn validate_rotation_journal(
11854 journal: &RotationJournal,
11855 cfg: &HubConfig,
11856 canonical_brain: &str,
11857 old_key: &AgentSigningKey,
11858 new_key: &AgentSigningKey,
11859 head: &Head,
11860) -> LinkResult<()> {
11861 if journal.v != 1
11862 || journal.origin != normalized_origin(&cfg.hub)?
11863 || journal.brain != canonical_brain
11864 || journal.old_brain != old_key.multikey
11865 || journal.new_brain != new_key.multikey
11866 || journal.prior_head_seq != head.seq
11867 || journal.prior_feed_hash != head.feed_hash
11868 {
11869 return Err(invalid_feed(
11870 "rotation journal does not match the verified key and feed boundary",
11871 ));
11872 }
11873 let statement: RotationStatement = serde_json::from_str(&journal.statement)
11874 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
11875 if statement.prior_head_seq != journal.prior_head_seq
11876 || statement.prior_feed_hash != journal.prior_feed_hash
11877 || statement.brain != old_key.multikey
11878 || statement.public_key != old_key.public_key_spki
11879 || statement.new_brain != new_key.multikey
11880 || statement.new_public_key != new_key.public_key_spki
11881 {
11882 return Err(invalid_feed(
11883 "rotation journal statement does not match its durable intent",
11884 ));
11885 }
11886 let identity = FeedIdentity {
11887 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
11888 public_key_spki: new_key.public_key_spki.clone(),
11889 previous: vec![PreviousIdentity {
11890 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
11891 public_key_spki: old_key.public_key_spki.clone(),
11892 }],
11893 rotations: vec![journal.statement.clone()],
11894 };
11895 verify_identity_chain(&identity, None)?;
11896 Ok(())
11897}
11898
11899#[derive(Debug, Serialize)]
11901pub struct RotationReport {
11902 pub brain: String,
11904 pub multikey: String,
11906 #[serde(rename = "keyFile")]
11908 pub key_file: String,
11909 pub previous: Vec<String>,
11911}
11912
11913pub fn rotate_brain_key(
11919 cfg: &HubConfig,
11920 brain: &str,
11921 old_key: &AgentSigningKey,
11922 out: &Path,
11923) -> LinkResult<RotationReport> {
11924 require_hardened_filesystem("key rotation")?;
11925 require_safe_ref(brain)?;
11926 let new_key = if out.exists() {
11930 load_signing_key(out)?
11931 } else {
11932 let rng = ring::rand::SystemRandom::new();
11933 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
11934 .map_err(|_| bad_agent_key("key generation failed"))?;
11935 let pair = agent_keypair(pkcs8.as_ref())?;
11936 let (public_key_spki, multikey) = public_identity_for(&pair);
11937 write_secret_new(
11938 out,
11939 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
11940 )?;
11941 AgentSigningKey {
11942 pkcs8: pkcs8.as_ref().to_vec(),
11943 multikey,
11944 public_key_spki,
11945 }
11946 };
11947 let new_spki = new_key.public_key_spki.clone();
11948 let new_multikey = new_key.multikey.clone();
11949 let journal_path = rotation_journal_path(out);
11950 let before = verified_remote_head(cfg, brain, false)?;
11951 let served_identity = before
11952 .identity
11953 .as_ref()
11954 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
11955 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
11956 if served_multikey == new_multikey {
11957 remove_rotation_journal(&journal_path);
11958 return Ok(RotationReport {
11959 brain: brain.to_string(),
11960 multikey: new_multikey,
11961 key_file: out.display().to_string(),
11962 previous: served_identity
11963 .previous
11964 .iter()
11965 .map(|identity| format!("ed25519:{}", identity.fingerprint))
11966 .collect(),
11967 });
11968 }
11969 if served_multikey != old_key.multikey {
11970 return Err(invalid_feed(
11971 "the supplied old key is not the brain's verified current identity",
11972 ));
11973 }
11974
11975 let journal = if journal_path.exists() {
11976 read_rotation_journal(&journal_path)?
11977 } else {
11978 let ts = crate::now()
11979 .with_timezone(&chrono::Utc)
11980 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
11981 .to_string();
11982 let unsigned = serde_json::to_string(&UnsignedRotation {
11983 v: 1,
11984 op: "rotate",
11985 brain: &old_key.multikey,
11986 public_key: &old_key.public_key_spki,
11987 new_brain: &new_multikey,
11988 new_public_key: &new_spki,
11989 prior_head_seq: before.head.seq,
11990 prior_feed_hash: before.head.feed_hash.as_deref(),
11991 ts,
11992 })
11993 .expect("serialize rotation");
11994 let old_pair = agent_keypair(&old_key.pkcs8)?;
11995 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
11996 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
11997 let journal = RotationJournal {
11998 v: 1,
11999 origin: normalized_origin(&cfg.hub)?,
12000 brain: before.head.brain.clone(),
12001 old_brain: old_key.multikey.clone(),
12002 new_brain: new_multikey.clone(),
12003 prior_head_seq: before.head.seq,
12004 prior_feed_hash: before.head.feed_hash.clone(),
12005 statement,
12006 };
12007 let mut exact = serde_json::to_vec(&journal)
12008 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
12009 exact.push(b'\n');
12010 if write_secret_new(&journal_path, &exact).is_err() {
12011 read_rotation_journal(&journal_path)?
12014 } else {
12015 journal
12016 }
12017 };
12018 validate_rotation_journal(
12019 &journal,
12020 cfg,
12021 &before.head.brain,
12022 old_key,
12023 &new_key,
12024 &before.head,
12025 )?;
12026
12027 let body = json!({ "statement": journal.statement });
12028 let path = format!("/api/hub/brains/{brain}/rotate");
12029 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
12030 let attempted_failure = match attempted {
12031 Ok(response) if (200..300).contains(&response.status) => None,
12032 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
12033 Err(error) => Some(error),
12034 };
12035
12036 let after = match verified_remote_head(cfg, brain, false) {
12040 Ok(after) => after,
12041 Err(error) => return Err(attempted_failure.unwrap_or(error)),
12042 };
12043 let identity = after
12044 .identity
12045 .as_ref()
12046 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?;
12047 if format!("ed25519:{}", identity.fingerprint) != new_multikey
12048 || identity.public_key_spki != new_spki
12049 {
12050 return Err(attempted_failure.unwrap_or_else(|| {
12051 invalid_feed("hub acknowledged rotation without committing the verified new identity")
12052 }));
12053 }
12054 let previous = identity
12055 .previous
12056 .iter()
12057 .map(|prior| format!("ed25519:{}", prior.fingerprint))
12058 .collect();
12059 remove_rotation_journal(&journal_path);
12060
12061 Ok(RotationReport {
12062 brain: brain.to_string(),
12063 multikey: new_multikey,
12064 key_file: out.display().to_string(),
12065 previous,
12066 })
12067}
12068
12069#[derive(Debug, Serialize)]
12075pub struct MirrorReport {
12076 pub brain: String,
12078 #[serde(rename = "headSeq")]
12080 pub head_seq: u64,
12081 #[serde(rename = "feedHash")]
12083 pub feed_hash: Option<String>,
12084 pub entries: u64,
12086 pub pinned: String,
12088 pub files: usize,
12090}
12091
12092pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
12094
12095#[derive(Debug)]
12097pub struct VerifiedMirrorMaterial {
12098 pub brain: String,
12099 pub head_seq: u64,
12100 pub feed_hash: Option<String>,
12101 pub identity: serde_json::Value,
12102 pub entries: Vec<(u64, String, String)>,
12104 pub pack_sha256: Option<String>,
12105}
12106
12107#[derive(Deserialize)]
12108#[serde(deny_unknown_fields)]
12109struct StoredMirrorHead {
12110 brain: String,
12111 #[serde(rename = "headSeq")]
12112 head_seq: u64,
12113 #[serde(rename = "feedHash")]
12114 feed_hash: Option<String>,
12115}
12116
12117pub fn verify_mirror_material(
12120 head_bytes: &[u8],
12121 identity_bytes: &[u8],
12122 feed_bytes: &[Vec<u8>],
12123 snapshot_pack: Option<&[u8]>,
12124 expected_anchor: &str,
12125) -> LinkResult<VerifiedMirrorMaterial> {
12126 let snapshot_hash = snapshot_pack
12127 .filter(|pack| !pack.is_empty())
12128 .map(content_sha256);
12129 verify_mirror_material_with_pack_hash(
12130 head_bytes,
12131 identity_bytes,
12132 feed_bytes,
12133 snapshot_hash.as_deref(),
12134 expected_anchor,
12135 )
12136}
12137
12138pub fn verify_mirror_material_with_pack_hash(
12142 head_bytes: &[u8],
12143 identity_bytes: &[u8],
12144 feed_bytes: &[Vec<u8>],
12145 snapshot_pack_sha256: Option<&str>,
12146 expected_anchor: &str,
12147) -> LinkResult<VerifiedMirrorMaterial> {
12148 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
12149 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
12150 require_safe_ref(&head.brain)?;
12151 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
12152 return Err(invalid_feed(
12153 "stored mirror feed count does not match its bounded head sequence",
12154 ));
12155 }
12156 let aggregate = feed_bytes
12157 .iter()
12158 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
12159 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
12160 if aggregate > MAX_FEED_REPLAY_BYTES {
12161 return Err(invalid_feed(
12162 "stored mirror feed metadata exceeds the aggregate limit",
12163 ));
12164 }
12165 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
12166 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
12167 let anchor = verify_identity_chain(&identity, None)?;
12168 if anchor != expected_anchor {
12169 return Err(invalid_feed(
12170 "stored mirror identity does not descend from the explicitly trusted anchor",
12171 ));
12172 }
12173
12174 let mut entries = Vec::with_capacity(feed_bytes.len());
12175 let mut items = Vec::with_capacity(feed_bytes.len());
12176 let mut previous_hash = None;
12177 let mut pack_sha256 = None;
12178 for (index, bytes) in feed_bytes.iter().enumerate() {
12179 let exact = bytes
12180 .strip_suffix(b"\n")
12181 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
12182 if exact.ends_with(b"\n") {
12183 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
12184 }
12185 let entry: FeedEntry = serde_json::from_slice(exact)
12186 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
12187 let expected_seq = index as u64 + 1;
12188 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
12189 return Err(invalid_feed(
12190 "stored mirror feed is not contiguous and hash-chained",
12191 ));
12192 }
12193 let canonical = serde_json::to_vec(&entry)
12194 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
12195 if canonical != exact {
12196 return Err(invalid_feed(
12197 "stored feed entry is not in normative serialization",
12198 ));
12199 }
12200 let hash = content_sha256(bytes);
12201 let item = FeedItem {
12202 hash: hash.clone(),
12203 entry,
12204 };
12205 verify_feed_item(&item, &identity)?;
12206 previous_hash = Some(hash.clone());
12207 if expected_seq == head.head_seq {
12208 pack_sha256 = Some(item.entry.pack_sha256.clone());
12209 }
12210 entries.push((
12211 expected_seq,
12212 std::str::from_utf8(exact)
12213 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
12214 .to_string(),
12215 hash,
12216 ));
12217 items.push(item);
12218 }
12219 if previous_hash != head.feed_hash {
12220 return Err(invalid_feed(
12221 "stored mirror feed does not converge on its advertised head",
12222 ));
12223 }
12224 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
12225 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
12226 (0, None, None) => {}
12227 (_, Some(actual), Some(expected)) if actual == expected => {}
12228 _ => {
12229 return Err(LinkError::InvalidPack {
12230 message: "stored snapshot pack does not match the signed head digest".to_string(),
12231 });
12232 }
12233 }
12234 let identity_value = serde_json::to_value(&identity)
12235 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
12236 Ok(VerifiedMirrorMaterial {
12237 brain: head.brain,
12238 head_seq: head.head_seq,
12239 feed_hash: head.feed_hash,
12240 identity: identity_value,
12241 entries,
12242 pack_sha256,
12243 })
12244}
12245
12246pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
12249 format!(
12250 "{:x}",
12251 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
12252 )
12253}
12254
12255pub fn content_sha256(bytes: &[u8]) -> String {
12258 format!("{:x}", Sha256::digest(bytes))
12259}
12260
12261pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
12263 let mut digest = Sha256::new();
12264 let mut buffer = [0u8; 64 * 1024];
12265 loop {
12266 let read = reader.read(&mut buffer)?;
12267 if read == 0 {
12268 break;
12269 }
12270 digest.update(&buffer[..read]);
12271 }
12272 Ok(format!("{:x}", digest.finalize()))
12273}
12274
12275#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
12283pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
12284 require_hardened_filesystem("mirror")?;
12285 require_safe_ref(brain)?;
12286 #[cfg(windows)]
12287 {
12288 let _ = (cfg, dest);
12289 return Err(LinkError::UnsupportedPlatform {
12290 operation: "atomic whole-mirror replacement on Windows",
12291 });
12292 }
12293 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
12294 let name = dest
12295 .file_name()
12296 .and_then(|name| name.to_str())
12297 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
12298 .ok_or_else(|| LinkError::UnsafePath {
12299 path: dest.display().to_string(),
12300 })?;
12301 #[cfg(unix)]
12302 let parent_dir = open_or_create_dir_nofollow(parent)?;
12303 #[cfg(unix)]
12304 use std::os::fd::AsRawFd as _;
12305 #[cfg(unix)]
12306 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
12307 #[cfg(unix)]
12308 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
12309 None => false,
12310 Some(true) => true,
12311 Some(false) => {
12312 return Err(LinkError::UnsafePath {
12313 path: dest.display().to_string(),
12314 });
12315 }
12316 };
12317
12318 #[cfg(unix)]
12321 let legacy_backup_name = c_name(
12322 format!(".{name}.dbmd-backup").as_bytes(),
12323 &dest.display().to_string(),
12324 )?;
12325 #[cfg(unix)]
12326 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
12327 return Err(LinkError::UnsafePath {
12328 path: parent
12329 .join(format!(".{name}.dbmd-backup"))
12330 .display()
12331 .to_string(),
12332 });
12333 }
12334
12335 let nonce = std::time::SystemTime::now()
12336 .duration_since(std::time::UNIX_EPOCH)
12337 .unwrap_or_default()
12338 .as_nanos();
12339 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
12340 #[cfg(unix)]
12341 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
12342 #[cfg(unix)]
12343 let stage_dir = create_dir_exclusive_at(
12344 parent_dir.as_raw_fd(),
12345 &stage_name,
12346 &dest.display().to_string(),
12347 )?;
12348
12349 let assembled = (|| -> LinkResult<MirrorReport> {
12350 let remote = verified_remote_head(cfg, brain, true)?;
12351 let brain_id = remote.head.brain.clone();
12352 let identity = remote
12353 .identity
12354 .as_ref()
12355 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
12356 let anchor = remote
12357 .anchor
12358 .clone()
12359 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
12360 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
12361 let snapshot_entries = parse_store_pack(pack.clone())?;
12362 let snapshot_count = snapshot_entries.len();
12363 let mut staged_entries = snapshot_entries;
12364 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
12365 for item in &remote.entries {
12366 let mut exact = serde_json::to_vec(&item.entry)
12367 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
12368 exact.push(b'\n');
12369 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
12370 return Err(invalid_feed(
12371 "serialized mirror entry differs from its verified hash",
12372 ));
12373 }
12374 staged_entries.push((
12375 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
12376 exact,
12377 ));
12378 }
12379 let mut identity_bytes = serde_json::to_vec(identity)
12380 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
12381 identity_bytes.push(b'\n');
12382 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
12383 let mut head_bytes = serde_json::to_vec(&json!({
12384 "brain": brain_id,
12385 "headSeq": remote.head.seq,
12386 "feedHash": remote.head.feed_hash,
12387 }))
12388 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
12389 head_bytes.push(b'\n');
12390 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
12391 staged_entries.push((
12392 CONFIG_REL_PATH.to_string(),
12393 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
12394 ));
12395 #[cfg(unix)]
12396 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
12397
12398 Ok(MirrorReport {
12399 brain: brain_id,
12400 head_seq: remote.head.seq,
12401 feed_hash: remote.head.feed_hash,
12402 entries: remote.entries.len() as u64,
12403 pinned: anchor,
12404 files: snapshot_count,
12405 })
12406 })();
12407
12408 let report = match assembled {
12409 Ok(report) => report,
12410 Err(error) => {
12411 #[cfg(unix)]
12412 let _ = remove_tree_at(
12413 parent_dir.as_raw_fd(),
12414 &stage_name,
12415 &dest.display().to_string(),
12416 );
12417 return Err(error);
12418 }
12419 };
12420
12421 #[cfg(unix)]
12422 if let Err(error) =
12423 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
12424 {
12425 let _ = remove_tree_at(
12426 parent_dir.as_raw_fd(),
12427 &stage_name,
12428 &dest.display().to_string(),
12429 );
12430 return Err(error);
12431 }
12432 #[cfg(unix)]
12435 if dest_exists {
12436 remove_tree_at(
12437 parent_dir.as_raw_fd(),
12438 &stage_name,
12439 &dest.display().to_string(),
12440 )?;
12441 }
12442 #[cfg(unix)]
12443 parent_dir.sync_all()?;
12444 Ok(report)
12445}
12446
12447fn verified_remote_head(
12448 cfg: &HubConfig,
12449 brain: &str,
12450 require_full_chain: bool,
12451) -> LinkResult<VerifiedRemote> {
12452 require_hardened_filesystem("verified link.md state")?;
12453 require_safe_ref(brain)?;
12454 let trust_directory = open_trust_dir(cfg)?;
12458 let path = format!("/api/hub/brains/{brain}");
12459 let body = ensure_ok(
12460 request(cfg, "GET", &path, None, Auth::Required)?,
12461 "subscribe",
12462 )?;
12463 let resolved_brain = body
12464 .get("id")
12465 .and_then(Value::as_str)
12466 .filter(|id| crate::ulid::is_ulid(id))
12467 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
12468 .to_string();
12469 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
12470 return Err(invalid_feed(
12471 "brain card id differs from the explicitly requested brain id",
12472 ));
12473 }
12474 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
12479 let (pinned, alias_binding) =
12480 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
12481 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
12482 let advertised_hash = body
12483 .get("feedHash")
12484 .and_then(Value::as_str)
12485 .map(str::to_string);
12486 let updated_at = body
12487 .get("updatedAt")
12488 .and_then(Value::as_str)
12489 .map(str::to_string);
12490 if let Some(pin) = &pinned {
12491 if seq < pin.head_seq {
12492 return Err(invalid_feed(format!(
12493 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
12494 pin.head_seq
12495 )));
12496 }
12497 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
12498 return Err(invalid_feed(
12499 "feed equivocation: the checkpoint sequence now has a different hash",
12500 ));
12501 }
12502 }
12503 if seq == 0 {
12504 if advertised_hash.is_some() {
12505 return Err(invalid_feed("an empty feed advertised a head hash"));
12506 }
12507 let identity: FeedIdentity = serde_json::from_value(
12508 body.get("identity")
12509 .cloned()
12510 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
12511 )
12512 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
12513 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
12514 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
12519 save_canonical_pin_and_alias(
12520 cfg,
12521 &trust_directory,
12522 brain,
12523 &resolved_brain,
12524 TrustState {
12525 v: 2,
12526 origin: normalized_origin(&cfg.hub)?,
12527 requested: resolved_brain.clone(),
12528 brain: resolved_brain.clone(),
12529 home: None,
12530 anchor: anchor.clone(),
12531 current: format!("ed25519:{}", identity.fingerprint),
12532 head_seq: 0,
12533 feed_hash: None,
12534 rotations: identity.rotations.clone(),
12535 hub_signer: None,
12536 protocol_profile: None,
12537 },
12538 alias_binding.as_ref(),
12539 )?;
12540 return Ok(VerifiedRemote {
12541 head: Head {
12542 brain: resolved_brain,
12543 seq,
12544 updated_at,
12545 feed_hash: None,
12546 verified: true,
12547 },
12548 identity: Some(identity),
12549 head_entry: None,
12550 entries: Vec::new(),
12551 anchor: Some(anchor),
12552 });
12553 }
12554 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
12555 return Err(invalid_feed(
12556 "non-empty feed did not advertise a valid SHA-256 head",
12557 ));
12558 }
12559
12560 let replay_head_only = !require_full_chain
12564 && pinned
12565 .as_ref()
12566 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
12567 let mut after = if replay_head_only {
12568 seq - 1
12569 } else if require_full_chain || pinned.is_none() {
12570 0
12571 } else {
12572 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
12573 };
12574 let mut expected_seq = after + 1;
12575 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
12576 None
12577 } else {
12578 pinned
12579 .as_ref()
12580 .and_then(|checkpoint| checkpoint.feed_hash.clone())
12581 };
12582 let mut identity: Option<FeedIdentity> = None;
12583 let mut anchor: Option<String> = None;
12584 let mut head_entry: Option<FeedItem> = None;
12585 let mut all_entries = Vec::new();
12586 let mut observed_entries = Vec::new();
12587 let replay_count = seq
12588 .checked_sub(after)
12589 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
12590 if replay_count > MAX_FEED_REPLAY_ENTRIES {
12591 return Err(invalid_feed(format!(
12592 "feed replay requires {replay_count} entries, over the client cap"
12593 )));
12594 }
12595 let mut replay_bytes = 0u64;
12596
12597 loop {
12598 let feed_bytes = ensure_raw_ok(
12599 request_raw(
12600 cfg,
12601 "GET",
12602 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
12603 None,
12604 Auth::Required,
12605 MAX_FEED_RESPONSE_BYTES,
12606 )?,
12607 "subscribe feed",
12608 )?;
12609 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
12610 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
12611 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
12612 return Err(invalid_feed("brain card and feed head disagree"));
12613 }
12614 if feed.entries.len() > FEED_PAGE_LIMIT {
12615 return Err(invalid_feed("feed page exceeds the requested entry limit"));
12616 }
12617 if feed.scope_limited {
12618 if require_full_chain {
12619 return Err(invalid_feed(
12620 "path-scoped grants cannot verify a full snapshot chain",
12621 ));
12622 }
12623 return Ok(VerifiedRemote {
12624 head: Head {
12625 brain: resolved_brain,
12626 seq,
12627 updated_at,
12628 feed_hash: advertised_hash,
12629 verified: false,
12630 },
12631 identity: None,
12632 head_entry: None,
12633 entries: Vec::new(),
12634 anchor: None,
12635 });
12636 }
12637 let page_identity = feed
12638 .identity
12639 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
12640 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
12641 if identity
12642 .as_ref()
12643 .is_some_and(|existing| existing != &page_identity)
12644 {
12645 return Err(invalid_feed("identity changed while reading the feed"));
12646 }
12647 if anchor
12648 .as_ref()
12649 .is_some_and(|existing| existing != &page_anchor)
12650 {
12651 return Err(invalid_feed(
12652 "identity anchor changed while reading the feed",
12653 ));
12654 }
12655 identity = Some(page_identity.clone());
12656 if anchor.is_none() {
12657 anchor = Some(page_anchor);
12658 }
12659 if feed.entries.is_empty() {
12660 return Err(invalid_feed("feed page was empty before the signed head"));
12661 }
12662
12663 for item in feed.entries {
12664 if item.entry.seq != expected_seq {
12665 return Err(invalid_feed(format!(
12666 "expected entry {expected_seq}, feed served {}",
12667 item.entry.seq
12668 )));
12669 }
12670 if item.entry.seq > seq {
12671 return Err(invalid_feed("feed advanced past the card snapshot"));
12672 }
12673 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
12674 return Err(invalid_feed(format!(
12675 "entry {} does not chain to the local checkpoint",
12676 item.entry.seq
12677 )));
12678 }
12679 verify_feed_item(&item, &page_identity)?;
12680 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
12681 replay_bytes = replay_bytes.saturating_add(
12682 serde_json::to_vec(&item)
12683 .map_err(|_| invalid_feed("could not size feed entry"))?
12684 .len() as u64,
12685 );
12686 if replay_bytes > MAX_FEED_REPLAY_BYTES {
12687 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
12688 }
12689 previous_hash = Some(item.hash.clone());
12690 after = item.entry.seq;
12691 expected_seq = expected_seq
12692 .checked_add(1)
12693 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
12694 if require_full_chain {
12695 all_entries.push(item.clone());
12696 }
12697 observed_entries.push(item.clone());
12698 head_entry = Some(item);
12699 }
12700 if after == seq {
12701 break;
12702 }
12703 }
12704
12705 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
12706 return Err(invalid_feed(
12707 "verified chain does not converge on the advertised head",
12708 ));
12709 }
12710 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
12711 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
12712 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
12713 save_canonical_pin_and_alias(
12714 cfg,
12715 &trust_directory,
12716 brain,
12717 &resolved_brain,
12718 TrustState {
12719 v: 2,
12720 origin: normalized_origin(&cfg.hub)?,
12721 requested: resolved_brain.clone(),
12722 brain: resolved_brain.clone(),
12723 home: None,
12724 anchor: anchor.clone(),
12725 current: format!("ed25519:{}", identity.fingerprint),
12726 head_seq: seq,
12727 feed_hash: advertised_hash.clone(),
12728 rotations: identity.rotations.clone(),
12729 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
12730 protocol_profile: pinned
12731 .as_ref()
12732 .and_then(|state| state.protocol_profile.clone()),
12733 },
12734 alias_binding.as_ref(),
12735 )?;
12736 Ok(VerifiedRemote {
12737 head: Head {
12738 brain: resolved_brain,
12739 seq,
12740 updated_at,
12741 feed_hash: advertised_hash,
12742 verified: true,
12743 },
12744 identity: Some(identity),
12745 head_entry,
12746 entries: all_entries,
12747 anchor: Some(anchor),
12748 })
12749}
12750
12751pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
12756 Ok(verified_remote_head(cfg, brain, false)?.head)
12757}
12758
12759#[cfg(test)]
12760mod tests {
12761 use super::*;
12762
12763 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
12764
12765 fn merge_fixture(
12766 base: Option<&str>,
12767 remote: Option<&str>,
12768 local: Option<&str>,
12769 keep_local: bool,
12770 ) -> V2PulledMerge<String> {
12771 let map = |value: Option<&str>| {
12772 value
12773 .map(|value| [("records/a.md".to_string(), value.to_string())])
12774 .into_iter()
12775 .flatten()
12776 .collect::<std::collections::BTreeMap<_, _>>()
12777 };
12778 merge_v2_pulled_records(
12779 &map(base),
12780 &map(remote),
12781 &map(local),
12782 |value, _| value.clone(),
12783 |value, _| value.clone(),
12784 |_| keep_local,
12785 )
12786 }
12787
12788 #[test]
12789 fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
12790 let path = "records/a.md".to_string();
12791
12792 let local_add = merge_fixture(None, None, Some("local"), false);
12793 assert_eq!(
12794 local_add.records.get(&path).map(String::as_str),
12795 Some("local")
12796 );
12797 assert!(local_add.accept_remote.is_empty());
12798 assert!(local_add.conflicts.is_empty());
12799
12800 let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
12801 assert_eq!(
12802 local_edit.records.get(&path).map(String::as_str),
12803 Some("local")
12804 );
12805 assert!(local_edit.accept_remote.is_empty());
12806 assert!(local_edit.conflicts.is_empty());
12807
12808 let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
12809 assert!(!local_delete.records.contains_key(&path));
12810 assert!(local_delete.accept_remote.is_empty());
12811 assert!(local_delete.conflicts.is_empty());
12812
12813 let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
12814 assert_eq!(
12815 remote_edit.records.get(&path).map(String::as_str),
12816 Some("remote")
12817 );
12818 assert!(remote_edit.accept_remote.contains(&path));
12819 assert!(remote_edit.conflicts.is_empty());
12820
12821 let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
12822 assert!(!remote_delete.records.contains_key(&path));
12823 assert!(remote_delete.accept_remote.contains(&path));
12824 assert!(remote_delete.conflicts.is_empty());
12825
12826 let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
12827 assert_eq!(
12828 same_edit.records.get(&path).map(String::as_str),
12829 Some("same")
12830 );
12831 assert!(same_edit.accept_remote.contains(&path));
12832 assert!(same_edit.conflicts.is_empty());
12833
12834 let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
12835 assert_eq!(conflict.conflicts, vec![path.clone()]);
12836 assert_eq!(
12837 conflict.records.get(&path).map(String::as_str),
12838 Some("local")
12839 );
12840 assert!(conflict.accept_remote.is_empty());
12841
12842 let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
12843 assert_eq!(
12844 kept_home.records.get(&path).map(String::as_str),
12845 Some("local")
12846 );
12847 assert!(kept_home.accept_remote.is_empty());
12848 assert!(kept_home.conflicts.is_empty());
12849 }
12850
12851 #[test]
12852 fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
12853 let path = "sources/report.pdf";
12854 let record = crate::AssetRecord {
12855 path: path.to_string(),
12856 sha256: "a".repeat(64),
12857 bytes: 42,
12858 media_type: "application/pdf".to_string(),
12859 wrappers: vec!["gzip".to_string()],
12860 required: true,
12861 };
12862 let mut remote = V2BaselineAsset {
12863 blob_sha256: record.sha256.clone(),
12864 bytes: record.bytes,
12865 media_type: record.media_type.clone(),
12866 wrappers: record.wrappers.clone(),
12867 required: record.required,
12868 disposition: "withheld".to_string(),
12869 leaf_hash: "b".repeat(64),
12870 };
12871
12872 assert!(v2_asset_resumes_hosting(
12873 Some(&remote),
12874 path,
12875 &record,
12876 "hosted"
12877 ));
12878 assert!(!v2_asset_resumes_hosting(
12879 Some(&remote),
12880 path,
12881 &record,
12882 "withheld"
12883 ));
12884
12885 remote.disposition = "hosted".to_string();
12886 assert!(!v2_asset_resumes_hosting(
12887 Some(&remote),
12888 path,
12889 &record,
12890 "hosted"
12891 ));
12892
12893 remote.disposition = "withheld".to_string();
12894 remote.blob_sha256 = "c".repeat(64);
12895 assert!(!v2_asset_resumes_hosting(
12896 Some(&remote),
12897 path,
12898 &record,
12899 "hosted"
12900 ));
12901 assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
12902 }
12903
12904 #[test]
12905 fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
12906 let path = "records/team/alpha.md".to_string();
12907 let deleted_path = "records/team/deleted.md".to_string();
12908 let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
12909 sha256,
12910 bytes,
12911 file: None,
12912 };
12913 let files = vec![
12914 V2ConflictFile {
12915 path: path.clone(),
12916 base: coordinate(None, None),
12917 local: coordinate(Some("b".repeat(64)), Some(7)),
12918 remote: coordinate(Some("a".repeat(64)), Some(5)),
12919 },
12920 V2ConflictFile {
12921 path: deleted_path.clone(),
12922 base: coordinate(Some("c".repeat(64)), Some(9)),
12923 local: coordinate(Some("d".repeat(64)), Some(11)),
12924 remote: coordinate(None, None),
12925 },
12926 ];
12927 let proven = V2BaselineFile {
12928 sha256: "a".repeat(64),
12929 bytes: 5,
12930 proof: None,
12931 };
12932 let current = [(path.clone(), proven.clone())]
12933 .into_iter()
12934 .collect::<std::collections::BTreeMap<_, _>>();
12935
12936 let (selected, deleted) = v2_take_remote_selection(&files, ¤t).unwrap();
12937 assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
12938 assert_eq!(deleted, vec![deleted_path.clone()]);
12939
12940 let changed = [(
12941 path.clone(),
12942 V2BaselineFile {
12943 sha256: "e".repeat(64),
12944 bytes: 5,
12945 proof: None,
12946 },
12947 )]
12948 .into_iter()
12949 .collect::<std::collections::BTreeMap<_, _>>();
12950 assert!(v2_take_remote_selection(&files, &changed).is_err());
12951
12952 let resurrected = [
12953 (path, proven),
12954 (
12955 deleted_path,
12956 V2BaselineFile {
12957 sha256: "f".repeat(64),
12958 bytes: 13,
12959 proof: None,
12960 },
12961 ),
12962 ]
12963 .into_iter()
12964 .collect::<std::collections::BTreeMap<_, _>>();
12965 assert!(v2_take_remote_selection(&files, &resurrected).is_err());
12966 }
12967
12968 #[cfg(target_os = "linux")]
12969 #[test]
12970 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
12971 use std::os::fd::AsRawFd as _;
12972
12973 let sandbox = tempfile::TempDir::new().unwrap();
12974 let parent = std::fs::File::open(sandbox.path()).unwrap();
12975 let stage = std::ffi::CString::new("stage").unwrap();
12976 let destination = std::ffi::CString::new("brain").unwrap();
12977
12978 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
12979 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
12980 install_stage_at(
12981 parent.as_raw_fd(),
12982 stage.as_c_str(),
12983 destination.as_c_str(),
12984 false,
12985 )
12986 .unwrap();
12987 assert!(!sandbox.path().join("stage").exists());
12988 assert_eq!(
12989 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
12990 b"created"
12991 );
12992
12993 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
12994 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
12995 install_stage_at(
12996 parent.as_raw_fd(),
12997 stage.as_c_str(),
12998 destination.as_c_str(),
12999 true,
13000 )
13001 .unwrap();
13002 assert_eq!(
13003 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
13004 b"replacement"
13005 );
13006 assert_eq!(
13007 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
13008 b"created",
13009 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
13010 );
13011 }
13012
13013 struct SignedRemoteFixture {
13014 card: String,
13015 feed: String,
13016 key: AgentSigningKey,
13017 identity: FeedIdentity,
13018 }
13019
13020 fn signed_remote_fixture() -> SignedRemoteFixture {
13021 let rng = ring::rand::SystemRandom::new();
13022 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13023 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13024 let (public_key, multikey) = public_identity_for(&pair);
13025 let identity = FeedIdentity {
13026 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
13027 public_key_spki: public_key.clone(),
13028 previous: Vec::new(),
13029 rotations: Vec::new(),
13030 };
13031 let mut entry = FeedEntry {
13032 v: 1,
13033 seq: 1,
13034 ts: "2026-07-30T12:00:00.000Z".to_string(),
13035 brain: multikey.clone(),
13036 public_key: public_key.clone(),
13037 kind: "push".to_string(),
13038 op: "snapshot".to_string(),
13039 pack_sha256: "a".repeat(64),
13040 files: Vec::new(),
13041 removed: Vec::new(),
13042 prev_entry_hash: None,
13043 sig: String::new(),
13044 };
13045 let unsigned = UnsignedFeedEntry {
13046 v: entry.v,
13047 seq: entry.seq,
13048 ts: &entry.ts,
13049 brain: &entry.brain,
13050 public_key: &entry.public_key,
13051 kind: &entry.kind,
13052 op: &entry.op,
13053 pack_sha256: &entry.pack_sha256,
13054 files: &entry.files,
13055 removed: &entry.removed,
13056 prev_entry_hash: &entry.prev_entry_hash,
13057 };
13058 entry.sig =
13059 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
13060 let mut exact = serde_json::to_vec(&entry).unwrap();
13061 exact.push(b'\n');
13062 let hash = content_sha256(&exact);
13063 let card = json!({
13064 "id": TEST_BRAIN_ID,
13065 "headSeq": 1,
13066 "feedHash": hash,
13067 "identity": identity.clone(),
13068 })
13069 .to_string();
13070 let feed = json!({
13071 "headSeq": 1,
13072 "feedHash": hash,
13073 "identity": identity.clone(),
13074 "entries": [{"hash": hash, "entry": entry}],
13075 "scopeLimited": false,
13076 })
13077 .to_string();
13078 SignedRemoteFixture {
13079 card,
13080 feed,
13081 key: AgentSigningKey {
13082 pkcs8: pkcs8.as_ref().to_vec(),
13083 multikey,
13084 public_key_spki: public_key,
13085 },
13086 identity,
13087 }
13088 }
13089
13090 #[test]
13091 fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
13092 let file = |path: &str, byte: char| FeedFile {
13093 path: path.to_string(),
13094 sha256: byte.to_string().repeat(64),
13095 bytes: 1,
13096 };
13097 let a0 = file("records/a.md", 'a');
13098 let a1 = file("records/a.md", 'b');
13099 let stable = file("records/stable.md", 'c');
13100 let added = file("records/added.md", 'd');
13101 let removed_file = file("records/removed.md", 'e');
13102 let previous = vec![a0, stable.clone(), removed_file.clone()];
13103 let resulting = vec![a1.clone(), stable.clone(), added.clone()];
13104 let removed = vec![removed_file.path.clone()];
13105
13106 assert_eq!(
13107 verify_v1_manifest_disclosure(
13108 "edit",
13109 &previous,
13110 &resulting,
13111 &[a1.clone(), added.clone()],
13112 &removed,
13113 ),
13114 Ok(())
13115 );
13116 assert_eq!(
13117 verify_v1_manifest_disclosure(
13118 "edit",
13119 &previous,
13120 &resulting,
13121 &[stable.clone(), added.clone(), a1.clone()],
13122 &removed,
13123 ),
13124 Ok(())
13125 );
13126 assert_eq!(
13127 verify_v1_manifest_disclosure(
13128 "edit",
13129 &previous,
13130 &resulting,
13131 std::slice::from_ref(&added),
13132 &removed,
13133 ),
13134 Err(V1DisclosureError::EditMissingChange)
13135 );
13136 assert_eq!(
13137 verify_v1_manifest_disclosure(
13138 "edit",
13139 &previous,
13140 &resulting,
13141 &[file("records/a.md", 'f'), added.clone()],
13142 &removed,
13143 ),
13144 Err(V1DisclosureError::EditFalseFile)
13145 );
13146 assert_eq!(
13147 verify_v1_manifest_disclosure(
13148 "edit",
13149 &previous,
13150 &resulting,
13151 &[a1.clone(), added.clone()],
13152 &[],
13153 ),
13154 Err(V1DisclosureError::RemovedMismatch)
13155 );
13156 assert_eq!(
13157 verify_v1_manifest_disclosure(
13158 "push",
13159 &previous,
13160 &resulting,
13161 &[added.clone(), stable, a1],
13162 &removed,
13163 ),
13164 Ok(())
13165 );
13166 assert_eq!(
13167 verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
13168 Err(V1DisclosureError::PushManifestMismatch)
13169 );
13170 }
13171
13172 #[test]
13173 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
13174 let fixture = signed_remote_fixture();
13175 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
13176 let item = feed["entries"][0].to_string();
13177 let oversized_page = format!(
13178 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
13179 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
13180 .collect::<Vec<_>>()
13181 .join(",")
13182 );
13183 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
13184
13185 let oversized_identity = format!(
13186 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
13187 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
13188 .collect::<Vec<_>>()
13189 .join(",")
13190 );
13191 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
13192
13193 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
13194 let oversized_entry = format!(
13195 "{{\"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\"}}",
13196 "a".repeat(64),
13197 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
13198 .collect::<Vec<_>>()
13199 .join(",")
13200 );
13201 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
13202 }
13203
13204 #[test]
13205 fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
13206 let id = "01arz3ndektsv4rrffq69g5fav";
13207 let digest = "a".repeat(64);
13208 assert_eq!(
13209 V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
13210 V2BulkConfirmation {
13211 id: id.to_string(),
13212 digest,
13213 }
13214 );
13215 for invalid in [
13216 "",
13217 "01arz3ndektsv4rrffq69g5fav",
13218 "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
13219 "01arz3ndektsv4rrffq69g5fav:ABCDEF",
13220 "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
13221 ] {
13222 assert!(matches!(
13223 V2BulkConfirmation::parse(invalid),
13224 Err(LinkError::InvalidPack { .. })
13225 ));
13226 }
13227 }
13228
13229 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
13230 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
13231 use std::net::TcpListener;
13232
13233 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
13234 let url = format!("http://{}", listener.local_addr().unwrap());
13235 let handle = std::thread::spawn(move || {
13236 for (status, body) in responses {
13237 let (stream, _) = listener.accept().unwrap();
13238 let mut reader = BufReader::new(stream);
13239 let mut line = String::new();
13240 reader.read_line(&mut line).unwrap();
13241 let mut content_length = 0usize;
13242 loop {
13243 line.clear();
13244 reader.read_line(&mut line).unwrap();
13245 if line == "\r\n" || line == "\n" || line.is_empty() {
13246 break;
13247 }
13248 if let Some((name, value)) = line.split_once(':') {
13249 if name.eq_ignore_ascii_case("content-length") {
13250 content_length = value.trim().parse().unwrap();
13251 }
13252 }
13253 }
13254 let mut request_body = vec![0_u8; content_length];
13255 reader.read_exact(&mut request_body).unwrap();
13256 let response = format!(
13257 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
13258 body.len()
13259 );
13260 reader.get_mut().write_all(response.as_bytes()).unwrap();
13261 }
13262 });
13263 (url, handle)
13264 }
13265
13266 fn routed_json_hub(
13267 requests: usize,
13268 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
13269 ) -> (String, std::thread::JoinHandle<()>) {
13270 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
13271 use std::net::TcpListener;
13272
13273 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
13274 let url = format!("http://{}", listener.local_addr().unwrap());
13275 let handle = std::thread::spawn(move || {
13276 for _ in 0..requests {
13277 let (stream, _) = listener.accept().unwrap();
13278 let mut reader = BufReader::new(stream);
13279 let mut line = String::new();
13280 reader.read_line(&mut line).unwrap();
13281 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
13282 let mut content_length = 0usize;
13283 loop {
13284 line.clear();
13285 reader.read_line(&mut line).unwrap();
13286 if line == "\r\n" || line == "\n" || line.is_empty() {
13287 break;
13288 }
13289 if let Some((name, value)) = line.split_once(':') {
13290 if name.eq_ignore_ascii_case("content-length") {
13291 content_length = value.trim().parse().unwrap();
13292 }
13293 }
13294 }
13295 let mut request_body = vec![0_u8; content_length];
13296 reader.read_exact(&mut request_body).unwrap();
13297 let (status, body) = respond(&path);
13298 let response = format!(
13299 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
13300 body.len()
13301 );
13302 reader.get_mut().write_all(response.as_bytes()).unwrap();
13303 }
13304 });
13305 (url, handle)
13306 }
13307
13308 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
13309 HubConfig {
13310 hub,
13311 key: Some("test-key".to_string()),
13312 agent_key: None,
13313 brain_key: None,
13314 state_dir,
13315 store_selected: false,
13316 }
13317 }
13318
13319 #[test]
13320 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
13321 use ring::signature::KeyPair as _;
13322
13323 let rng = ring::rand::SystemRandom::new();
13324 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13325 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13326 let (spki, multikey) = public_identity_for(&pair);
13327 let key = AgentSigningKey {
13328 pkcs8: pkcs8.as_ref().to_vec(),
13329 multikey,
13330 public_key_spki: spki,
13331 };
13332 let header = linkmd_sig_header(
13333 &key,
13334 "https://hub-a.example",
13335 "post",
13336 "/api/hub/brains/brain/push?mode=exact",
13337 Some("{\"ok\":true}"),
13338 )
13339 .unwrap();
13340 assert!(header.starts_with("LinkMD-Sig v2,"));
13341 let ts = header
13342 .split(",ts=")
13343 .nth(1)
13344 .unwrap()
13345 .split(',')
13346 .next()
13347 .unwrap();
13348 let signature = URL_SAFE_NO_PAD
13349 .decode(header.rsplit(",sig=").next().unwrap())
13350 .unwrap();
13351 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
13352 let accepted = format!(
13353 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
13354 );
13355 let replayed = format!(
13356 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
13357 );
13358 let public = pair.public_key().as_ref();
13359 assert!(UnparsedPublicKey::new(&ED25519, public)
13360 .verify(accepted.as_bytes(), &signature)
13361 .is_ok());
13362 assert!(
13363 UnparsedPublicKey::new(&ED25519, public)
13364 .verify(replayed.as_bytes(), &signature)
13365 .is_err(),
13366 "a proof captured at hub A must not authenticate at hub B"
13367 );
13368 }
13369
13370 #[test]
13371 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
13372 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
13373 let card = json!({
13374 "id": other,
13375 "headSeq": 0,
13376 "identity": signed_remote_fixture().identity,
13377 })
13378 .to_string();
13379 let (hub, server) = scripted_json_hub(vec![(200, card)]);
13380 let state = tempfile::tempdir().unwrap();
13381 let cfg = test_hub_config(hub, state.path().to_path_buf());
13382 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13383 assert!(
13384 error.contains("differs from the explicitly requested"),
13385 "{error}"
13386 );
13387 server.join().unwrap();
13388 }
13389
13390 #[test]
13391 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
13392 let first = signed_remote_fixture().identity;
13393 let second = signed_remote_fixture().identity;
13394 let card = |identity: FeedIdentity| {
13395 json!({
13396 "id": TEST_BRAIN_ID,
13397 "headSeq": 0,
13398 "identity": identity,
13399 })
13400 .to_string()
13401 };
13402 let (hub, server) = scripted_json_hub(vec![(200, card(first)), (200, card(second))]);
13403 let state = tempfile::tempdir().unwrap();
13404 let cfg = test_hub_config(hub, state.path().to_path_buf());
13405 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
13406 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13407 assert!(
13408 error.contains("pinned anchor") || error.contains("forked away"),
13409 "{error}"
13410 );
13411 server.join().unwrap();
13412 }
13413
13414 #[test]
13415 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
13416 let old = signed_remote_fixture();
13417 let new = signed_remote_fixture();
13418 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
13419 let unsigned = serde_json::to_string(&UnsignedRotation {
13420 v: 1,
13421 op: "rotate",
13422 brain: &old.key.multikey,
13423 public_key: &old.key.public_key_spki,
13424 new_brain: &new.key.multikey,
13425 new_public_key: &new.key.public_key_spki,
13426 prior_head_seq: 1,
13427 prior_feed_hash: Some(&"a".repeat(64)),
13428 ts: "2026-07-30T12:00:00.000Z".to_string(),
13429 })
13430 .unwrap();
13431 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
13432 let rotation = format!(
13433 "{},\"sig\":\"{}\"}}",
13434 &unsigned[..unsigned.len() - 1],
13435 signature
13436 );
13437 let identity = FeedIdentity {
13438 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
13439 public_key_spki: new.key.public_key_spki,
13440 previous: vec![PreviousIdentity {
13441 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
13442 public_key_spki: old.key.public_key_spki,
13443 }],
13444 rotations: vec![rotation],
13445 };
13446 let card = json!({
13447 "id": TEST_BRAIN_ID,
13448 "headSeq": 0,
13449 "feedHash": null,
13450 "identity": identity,
13451 })
13452 .to_string();
13453 let (hub, server) = scripted_json_hub(vec![(200, card)]);
13454 let state = tempfile::tempdir().unwrap();
13455 let cfg = test_hub_config(hub, state.path().to_path_buf());
13456 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13457 assert!(
13458 error.contains("rotation claims a feed boundary beyond the advertised head"),
13459 "{error}"
13460 );
13461 assert!(
13462 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
13463 "an inconsistent empty-head identity must not become the TOFU checkpoint"
13464 );
13465 server.join().unwrap();
13466 }
13467
13468 #[test]
13469 fn trust_checkpoint_rejects_a_later_fork() {
13470 let fixture = signed_remote_fixture();
13471 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
13472 fork["feedHash"] = Value::String("b".repeat(64));
13473 let (hub, server) = scripted_json_hub(vec![
13474 (200, fixture.card),
13475 (200, fixture.feed),
13476 (200, fork.to_string()),
13477 ]);
13478 let state = tempfile::tempdir().unwrap();
13479 let cfg = test_hub_config(hub, state.path().to_path_buf());
13480 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
13481 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
13482 server.join().unwrap();
13483 }
13484
13485 #[test]
13486 fn alias_and_canonical_id_share_one_identity_checkpoint() {
13487 let trusted = signed_remote_fixture();
13488 let attacker = signed_remote_fixture();
13489 let (hub, server) = scripted_json_hub(vec![
13490 (200, trusted.card),
13491 (200, trusted.feed),
13492 (200, attacker.card),
13493 ]);
13494 let state = tempfile::tempdir().unwrap();
13495 let cfg = test_hub_config(hub, state.path().to_path_buf());
13496 assert!(head(&cfg, "trusted-slug").unwrap().verified);
13497 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
13498 assert!(
13499 error.contains("equivocation")
13500 || error.contains("pinned")
13501 || error.contains("identity"),
13502 "{error}"
13503 );
13504 server.join().unwrap();
13505 }
13506
13507 #[test]
13508 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
13509 let alpha = signed_remote_fixture();
13510 let beta = signed_remote_fixture();
13511 let alpha_card = alpha.card.clone();
13512 let alpha_feed = alpha.feed.clone();
13513 let beta_card = beta.card.clone();
13514 let beta_feed = beta.feed.clone();
13515 let (hub, server) = routed_json_hub(3, move |path| {
13516 if path.contains("/alpha/feed?") {
13517 (200, alpha_feed.clone())
13518 } else if path.contains("/beta/feed?") {
13519 (200, beta_feed.clone())
13520 } else if path.ends_with("/alpha") {
13521 (200, alpha_card.clone())
13522 } else if path.ends_with("/beta") {
13523 (200, beta_card.clone())
13524 } else {
13525 (500, r#"{"error":"unexpected path"}"#.to_string())
13526 }
13527 });
13528 let state = tempfile::tempdir().unwrap();
13529 let cfg = test_hub_config(hub, state.path().to_path_buf());
13530 let alpha_cfg = cfg.clone();
13531 let beta_cfg = cfg;
13532 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
13533 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
13534 let results = [first.join().unwrap(), second.join().unwrap()];
13535 assert_eq!(
13536 results.iter().filter(|result| result.is_ok()).count(),
13537 1,
13538 "only one alias identity may establish canonical TOFU: {results:?}"
13539 );
13540 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
13541 server.join().unwrap();
13542 }
13543
13544 #[cfg(unix)]
13545 #[test]
13546 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
13547 use std::os::unix::fs::symlink;
13548
13549 let fixture = signed_remote_fixture();
13550 let card = json!({
13551 "id": TEST_BRAIN_ID,
13552 "headSeq": 0,
13553 "feedHash": Value::Null,
13554 "identity": fixture.identity,
13555 })
13556 .to_string();
13557 let work = tempfile::tempdir().unwrap();
13558 let outside = tempfile::tempdir().unwrap();
13559 let state = work.path().join("state");
13560 let moved = work.path().join("state-held");
13561 let swap_state = state.clone();
13562 let swap_moved = moved.clone();
13563 let outside_path = outside.path().to_path_buf();
13564 let (hub, server) = routed_json_hub(1, move |_| {
13565 std::fs::rename(&swap_state, &swap_moved).unwrap();
13567 symlink(&outside_path, &swap_state).unwrap();
13568 (200, card.clone())
13569 });
13570 let cfg = test_hub_config(hub, state);
13571
13572 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
13573 assert_eq!(verified.head.seq, 0);
13574 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
13575 assert!(std::fs::read_dir(moved.join("trust"))
13576 .unwrap()
13577 .flatten()
13578 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
13579 server.join().unwrap();
13580 }
13581
13582 #[test]
13583 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
13584 let remote = signed_remote_fixture();
13585 let unrelated = signed_remote_fixture().key;
13586 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
13587 let state = tempfile::tempdir().unwrap();
13588 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
13589 cfg.brain_key = Some(unrelated);
13590 let error = sync_push(
13591 &cfg,
13592 TEST_BRAIN_ID,
13593 &[("DB.md".to_string(), "signed local content".to_string())],
13594 )
13595 .unwrap_err()
13596 .to_string();
13597 assert!(
13598 error.contains("not the verified current brain identity"),
13599 "{error}"
13600 );
13601 server.join().unwrap();
13602 }
13603
13604 #[test]
13605 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
13606 let remote = signed_remote_fixture();
13607 let new = signed_remote_fixture().key;
13608 let state = tempfile::tempdir().unwrap();
13609 let new_file = state.path().join("new.key");
13610 std::fs::write(
13611 &new_file,
13612 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
13613 )
13614 .unwrap();
13615 #[cfg(unix)]
13616 {
13617 use std::os::unix::fs::PermissionsExt as _;
13618 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
13619 }
13620 let forged = json!({
13621 "brain": TEST_BRAIN_ID,
13622 "identity": {
13623 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
13624 "publicKeySpki": new.public_key_spki,
13625 }
13626 })
13627 .to_string();
13628 let (hub, server) = scripted_json_hub(vec![
13629 (200, remote.card.clone()),
13630 (200, remote.feed.clone()),
13631 (200, forged),
13632 (200, remote.card),
13633 (200, remote.feed),
13634 ]);
13635 let cfg = test_hub_config(hub, state.path().to_path_buf());
13636 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
13637 .unwrap_err()
13638 .to_string();
13639 assert!(
13640 error.contains("without committing the verified new identity"),
13641 "{error}"
13642 );
13643 server.join().unwrap();
13644 }
13645
13646 #[test]
13647 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
13648 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
13649 let raw = format!(
13650 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
13651 );
13652 let pack = build_store_pack(&[
13653 (
13654 "DB.md".to_string(),
13655 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
13656 ),
13657 ("records/clients/truth.md".to_string(), raw.clone()),
13658 ])
13659 .unwrap();
13660 let by_id = resolve_from_verified_pack(
13661 "01j5qc3v9k4ym8rwbn2tqe6f7d",
13662 &AddressTarget::Id(record_id.to_string()),
13663 pack.clone(),
13664 )
13665 .unwrap();
13666 assert_eq!(by_id["document"]["summary"], "Signed truth");
13667 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
13668 assert_eq!(
13669 by_id["document"]["contentSha"],
13670 content_sha256(raw.as_bytes())
13671 );
13672
13673 let by_path = resolve_from_verified_pack(
13674 "01j5qc3v9k4ym8rwbn2tqe6f7d",
13675 &AddressTarget::Path("records/clients/truth.md".to_string()),
13676 pack,
13677 )
13678 .unwrap();
13679 assert_eq!(by_path["document"]["id"], record_id);
13680 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
13681 }
13682
13683 #[test]
13684 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
13685 let unsorted = vec![
13686 ("records/a.md".to_string(), "alpha\n".to_string()),
13687 ("DB.md".to_string(), "# db\n".to_string()),
13688 ];
13689 let sorted = vec![
13690 ("DB.md".to_string(), "# db\n".to_string()),
13691 ("records/a.md".to_string(), "alpha\n".to_string()),
13692 ];
13693 let pack = build_store_pack(&unsorted).unwrap();
13694
13695 assert_eq!(pack.len(), 219);
13700 assert_eq!(
13701 content_sha256(&pack),
13702 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
13703 );
13704 assert_eq!(pack, build_store_pack(&sorted).unwrap());
13705 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
13706 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
13707 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
13708
13709 assert_eq!(
13710 parse_store_pack(pack).unwrap(),
13711 vec![
13712 ("DB.md".to_string(), b"# db\n".to_vec()),
13713 ("records/a.md".to_string(), b"alpha\n".to_vec()),
13714 ]
13715 );
13716 }
13717
13718 #[test]
13719 fn canonical_store_pack_validates_every_path_before_writing() {
13720 let duplicate = vec![
13721 ("DB.md".to_string(), "first".to_string()),
13722 ("DB.md".to_string(), "second".to_string()),
13723 ];
13724 assert!(build_store_pack(&duplicate)
13725 .unwrap_err()
13726 .to_string()
13727 .contains("duplicate path"));
13728 assert!(matches!(
13729 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
13730 Err(LinkError::UnsafePath { .. })
13731 ));
13732 }
13733
13734 #[test]
13735 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
13736 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
13737 let mut bytes = vec![0_u8];
13740 let zip64_offset = bytes.len() as u64;
13741 bytes.extend_from_slice(b"PK\x06\x06");
13742 bytes.extend_from_slice(&44_u64.to_le_bytes());
13743 bytes.extend_from_slice(&[0_u8; 12]);
13744 bytes.extend_from_slice(&COUNT.to_le_bytes());
13745 bytes.extend_from_slice(&COUNT.to_le_bytes());
13746 bytes.extend_from_slice(&1_u64.to_le_bytes());
13747 bytes.extend_from_slice(&0_u64.to_le_bytes());
13748 bytes.extend_from_slice(b"PK\x06\x07");
13749 bytes.extend_from_slice(&0_u32.to_le_bytes());
13750 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
13751 bytes.extend_from_slice(&1_u32.to_le_bytes());
13752 bytes.extend_from_slice(b"PK\x05\x06");
13753 bytes.extend_from_slice(&0_u16.to_le_bytes());
13754 bytes.extend_from_slice(&0_u16.to_le_bytes());
13755 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
13756 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
13757 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
13758 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
13759 bytes.extend_from_slice(&0_u16.to_le_bytes());
13760
13761 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
13762 .unwrap_err()
13763 .to_string();
13764 assert!(error.contains("invalid file count"), "{error}");
13765 }
13766
13767 #[test]
13768 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
13769 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
13770 let mut bytes = vec![0_u8];
13771 let zip64_offset = bytes.len() as u64;
13772 bytes.extend_from_slice(b"PK\x06\x06");
13773 bytes.extend_from_slice(&44_u64.to_le_bytes());
13774 bytes.extend_from_slice(&[0_u8; 12]);
13775 bytes.extend_from_slice(&COUNT.to_le_bytes());
13776 bytes.extend_from_slice(&COUNT.to_le_bytes());
13777 bytes.extend_from_slice(&1_u64.to_le_bytes());
13778 bytes.extend_from_slice(&0_u64.to_le_bytes());
13779 bytes.extend_from_slice(b"PK\x06\x07");
13780 bytes.extend_from_slice(&0_u32.to_le_bytes());
13781 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
13782 bytes.extend_from_slice(&1_u32.to_le_bytes());
13783 bytes.extend_from_slice(b"PK\x05\x06");
13784 bytes.extend_from_slice(&0_u16.to_le_bytes());
13785 bytes.extend_from_slice(&0_u16.to_le_bytes());
13786 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
13787 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
13788 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
13789 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
13790 bytes.extend_from_slice(&0_u16.to_le_bytes());
13791 let fake_eocd = bytes.len() as u32;
13795 bytes.extend_from_slice(b"PK\x05\x06");
13796 bytes.extend_from_slice(&0_u16.to_le_bytes());
13797 bytes.extend_from_slice(&0_u16.to_le_bytes());
13798 bytes.extend_from_slice(&1_u16.to_le_bytes());
13799 bytes.extend_from_slice(&1_u16.to_le_bytes());
13800 bytes.extend_from_slice(&0_u32.to_le_bytes());
13801 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
13802 bytes.extend_from_slice(&0_u16.to_le_bytes());
13803
13804 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
13805 .unwrap_err()
13806 .to_string();
13807 assert!(error.contains("central directory"), "{error}");
13808 }
13809
13810 #[test]
13811 fn strict_http_status_handling_rejects_redirects_without_panicking() {
13812 let error = ensure_ok(
13813 HubResponse {
13814 status: 302,
13815 body: Some(json!({"redirect": "/elsewhere"})),
13816 },
13817 "mutation",
13818 )
13819 .unwrap_err();
13820 assert!(matches!(error, LinkError::Http { status: 302, .. }));
13821
13822 let error = ensure_raw_ok(
13823 RawHubResponse {
13824 status: 302,
13825 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
13826 },
13827 "feed",
13828 )
13829 .unwrap_err();
13830 assert!(matches!(error, LinkError::Http { status: 302, .. }));
13831 }
13832
13833 #[cfg(unix)]
13834 #[test]
13835 fn collect_push_files_refuses_external_symlink_and_nested_store() {
13836 use std::os::unix::fs::symlink;
13837
13838 let root = tempfile::tempdir().unwrap();
13839 std::fs::write(
13840 root.path().join("DB.md"),
13841 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
13842 )
13843 .unwrap();
13844 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
13845
13846 let external = tempfile::tempdir().unwrap();
13847 let secret = external.path().join("secret.md");
13848 std::fs::write(&secret, "TOP SECRET").unwrap();
13849 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
13850
13851 let store = Store::open_strict(root.path()).unwrap();
13852 let err = collect_push_files(&store).unwrap_err().to_string();
13853 assert!(err.contains("cannot push"), "{err}");
13854 assert!(
13855 !err.contains("TOP SECRET"),
13856 "external bytes must never leak"
13857 );
13858
13859 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
13860 let nested = root.path().join("records/nested");
13861 std::fs::create_dir_all(&nested).unwrap();
13862 std::fs::write(
13863 nested.join("DB.md"),
13864 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
13865 )
13866 .unwrap();
13867 let err = collect_push_files(&store).unwrap_err().to_string();
13868 assert!(err.contains("nested db.md store"), "{err}");
13869 }
13870
13871 #[cfg(unix)]
13872 #[test]
13873 fn remote_push_uses_opened_root_after_path_replacement() {
13874 use std::os::unix::fs::symlink;
13875
13876 let sandbox = tempfile::tempdir().unwrap();
13877 let root = sandbox.path().join("store");
13878 std::fs::create_dir_all(root.join("records/notes")).unwrap();
13879 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
13880 std::fs::write(
13881 root.join("records/notes/owned.md"),
13882 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
13883 )
13884 .unwrap();
13885 let store = Store::open_strict(&root).unwrap();
13886 let detached = sandbox.path().join("detached");
13887 std::fs::rename(&root, &detached).unwrap();
13888
13889 let replacement = sandbox.path().join("replacement");
13890 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
13891 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
13892 std::fs::write(
13893 replacement.join("records/notes/secret.md"),
13894 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
13895 )
13896 .unwrap();
13897 symlink(&replacement, &root).unwrap();
13898
13899 let files = collect_push_files(&store).unwrap();
13900 let wire_text = files
13901 .iter()
13902 .map(|(path, content)| format!("{path}\n{content}"))
13903 .collect::<Vec<_>>()
13904 .join("\n");
13905 assert!(wire_text.contains("owned upload"));
13906 assert!(!wire_text.contains("replacement sentinel"));
13907 assert!(!wire_text.contains("records/notes/secret.md"));
13908
13909 let remote = signed_remote_fixture();
13910 let (hub, server) = scripted_json_hub(vec![
13911 (200, remote.card),
13912 (200, remote.feed),
13913 (200, json!({"ok": true}).to_string()),
13914 ]);
13915 let state = tempfile::tempdir().unwrap();
13916 let cfg = test_hub_config(hub, state.path().to_path_buf());
13917 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
13918 assert_eq!(pushed, json!({"ok": true}));
13919 server.join().unwrap();
13920 }
13921
13922 #[test]
13923 fn signed_feed_item_verifies_identity_hash_and_signature() {
13924 use ring::rand::SystemRandom;
13925 use ring::signature::{Ed25519KeyPair, KeyPair};
13926
13927 const PREFIX: &[u8] = &[
13928 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13929 ];
13930 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
13931 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13932 let mut spki = PREFIX.to_vec();
13933 spki.extend_from_slice(pair.public_key().as_ref());
13934 let public_key = URL_SAFE_NO_PAD.encode(&spki);
13935 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
13936 let mut entry = FeedEntry {
13937 v: 1,
13938 seq: 1,
13939 ts: "2026-07-14T00:00:00.000Z".to_string(),
13940 brain: format!("ed25519:{fingerprint}"),
13941 public_key: public_key.clone(),
13942 kind: "push".to_string(),
13943 op: "snapshot".to_string(),
13944 pack_sha256: "a".repeat(64),
13945 files: vec![FeedFile {
13946 path: "DB.md".to_string(),
13947 sha256: "b".repeat(64),
13948 bytes: 3,
13949 }],
13950 removed: vec![],
13951 prev_entry_hash: None,
13952 sig: String::new(),
13953 };
13954 let unsigned = UnsignedFeedEntry {
13955 v: entry.v,
13956 seq: entry.seq,
13957 ts: &entry.ts,
13958 brain: &entry.brain,
13959 public_key: &entry.public_key,
13960 kind: &entry.kind,
13961 op: &entry.op,
13962 pack_sha256: &entry.pack_sha256,
13963 files: &entry.files,
13964 removed: &entry.removed,
13965 prev_entry_hash: &entry.prev_entry_hash,
13966 };
13967 entry.sig =
13968 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
13969 let mut exact = serde_json::to_vec(&entry).unwrap();
13970 exact.push(b'\n');
13971 let item = FeedItem {
13972 hash: format!("{:x}", Sha256::digest(&exact)),
13973 entry,
13974 };
13975 let identity = FeedIdentity {
13976 fingerprint,
13977 public_key_spki: public_key,
13978 previous: Vec::new(),
13979 rotations: Vec::new(),
13980 };
13981 assert!(verify_feed_item(&item, &identity).is_ok());
13982 let mut tampered = item;
13983 tampered.entry.pack_sha256 = "c".repeat(64);
13984 assert!(verify_feed_item(&tampered, &identity).is_err());
13985 }
13986
13987 #[test]
13988 fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
13989 let rng = ring::rand::SystemRandom::new();
13990 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13991 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13992 let (spki, multikey) = public_identity_for(&pair);
13993 let identity = V2HeadIdentity {
13994 custody: "self".to_string(),
13995 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
13996 public_key_spki: spki.clone(),
13997 previous: Vec::new(),
13998 rotations: Vec::new(),
13999 };
14000 let unsigned = json!({
14001 "actor_ref": "a".repeat(64),
14002 "asset_root": Value::Null,
14003 "brain": multikey,
14004 "changes_sha256": "b".repeat(64),
14005 "control_revision": "c".repeat(64),
14006 "materializer": "dbmd-projection-v1",
14007 "op": "changeset",
14008 "parent_asset_root": Value::Null,
14009 "parent_commit": Value::Null,
14010 "parent_root": Value::Null,
14011 "prev_entry_hash": Value::Null,
14012 "public_key": spki,
14013 "seq": 1,
14014 "signer_epoch": 1,
14015 "state_root": "d".repeat(64),
14016 "ts": "2026-08-19T12:00:00.000Z",
14017 "v": 2,
14018 "v1_bridge": {
14019 "feed_hash": "e".repeat(64),
14020 "head_seq": 7,
14021 "pack_sha256": "f".repeat(64),
14022 },
14023 });
14024 let sign_value = |value: Value| {
14025 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
14026 let mut object = value.as_object().unwrap().clone();
14027 object.insert(
14028 "sig".to_string(),
14029 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14030 );
14031 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
14032 };
14033 assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
14034
14035 let mut extra = unsigned.clone();
14036 extra
14037 .as_object_mut()
14038 .unwrap()
14039 .insert("future".to_string(), Value::Bool(true));
14040 assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
14041
14042 let mut missing = unsigned.clone();
14043 missing.as_object_mut().unwrap().remove("v1_bridge");
14044 assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
14045
14046 let mut invalid_bridge = unsigned;
14047 invalid_bridge.as_object_mut().unwrap().insert(
14048 "v1_bridge".to_string(),
14049 json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
14050 );
14051 assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
14052 }
14053
14054 #[test]
14055 fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
14056 let vector: Value = serde_json::from_str(include_str!(
14057 "../tests/vectors/linkmd-v2-commit-bridge.json"
14058 ))
14059 .unwrap();
14060 let identity_value = vector.get("identity").unwrap();
14061 let identity = V2HeadIdentity {
14062 custody: "self".to_string(),
14063 fingerprint: identity_value
14064 .get("fingerprint")
14065 .and_then(Value::as_str)
14066 .unwrap()
14067 .to_string(),
14068 public_key_spki: identity_value
14069 .get("public_key_spki")
14070 .and_then(Value::as_str)
14071 .unwrap()
14072 .to_string(),
14073 previous: Vec::new(),
14074 rotations: Vec::new(),
14075 };
14076 let private = URL_SAFE_NO_PAD
14077 .decode(
14078 identity_value
14079 .get("private_key_pkcs8")
14080 .and_then(Value::as_str)
14081 .unwrap(),
14082 )
14083 .unwrap();
14084 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
14085 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
14086 .unwrap();
14087 let base = vector.get("body").unwrap().as_object().unwrap();
14088
14089 for item in vector.get("valid").unwrap().as_array().unwrap() {
14090 let mut body = base.clone();
14091 body.insert(
14092 "v1_bridge".to_string(),
14093 item.get("v1_bridge").unwrap().clone(),
14094 );
14095 body.insert(
14096 "sig".to_string(),
14097 item.get("signature_base64url").unwrap().clone(),
14098 );
14099 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
14100 assert!(verified_v2_commit_object(&signed, &identity).is_ok());
14101 assert_eq!(
14102 crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
14103 item.get("commit_hash").and_then(Value::as_str).unwrap()
14104 );
14105 assert_eq!(
14106 format!("{:x}", Sha256::digest(&signed)),
14107 item.get("feed_hash").and_then(Value::as_str).unwrap()
14108 );
14109 }
14110
14111 for item in vector.get("invalid").unwrap().as_array().unwrap() {
14112 let mut body = base.clone();
14113 if let Some(remove) = item.get("remove").and_then(Value::as_array) {
14114 for field in remove {
14115 body.remove(field.as_str().unwrap());
14116 }
14117 }
14118 if let Some(set) = item.get("set").and_then(Value::as_object) {
14119 for (field, value) in set {
14120 body.insert(field.clone(), value.clone());
14121 }
14122 }
14123 let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
14124 body.insert(
14125 "sig".to_string(),
14126 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14127 );
14128 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
14129 assert!(
14130 verified_v2_commit_object(&signed, &identity).is_err(),
14131 "accepted invalid shared vector {}",
14132 item.get("reason").and_then(Value::as_str).unwrap()
14133 );
14134 }
14135 }
14136
14137 #[test]
14138 fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
14139 let remote = signed_remote_fixture();
14140 let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
14141 let legacy_item = legacy.entries.first().unwrap();
14142 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
14143 let body = json!({
14144 "actor_ref": "a".repeat(64),
14145 "asset_root": Value::Null,
14146 "brain": remote.key.multikey,
14147 "changes_sha256": "b".repeat(64),
14148 "control_revision": "c".repeat(64),
14149 "materializer": "dbmd-projection-v1",
14150 "op": "changeset",
14151 "parent_asset_root": Value::Null,
14152 "parent_commit": Value::Null,
14153 "parent_root": Value::Null,
14154 "prev_entry_hash": Value::Null,
14155 "public_key": remote.key.public_key_spki,
14156 "seq": 1,
14157 "signer_epoch": 1,
14158 "state_root": "d".repeat(64),
14159 "ts": "2026-08-19T12:00:00.000Z",
14160 "v": 2,
14161 "v1_bridge": {
14162 "feed_hash": legacy_item.hash,
14163 "head_seq": legacy_item.entry.seq,
14164 "pack_sha256": legacy_item.entry.pack_sha256,
14165 },
14166 });
14167 let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
14168 let mut signed = body.as_object().unwrap().clone();
14169 signed.insert(
14170 "sig".to_string(),
14171 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14172 );
14173 let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
14174 let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
14175 let feed_hash = content_sha256(&raw);
14176 let pointer = V2PointerBody {
14177 v: 2,
14178 brain: TEST_BRAIN_ID.to_string(),
14179 seq: 1,
14180 commit_hash: commit_hash.clone(),
14181 feed_hash: feed_hash.clone(),
14182 content_root: Some("d".repeat(64)),
14183 asset_root: None,
14184 materializer: "dbmd-projection-v1".to_string(),
14185 signer_epoch: 1,
14186 control_revision: "c".repeat(64),
14187 backup_preparation: "e".repeat(64),
14188 prior_pointer_hash: None,
14189 signed_at: "2026-08-19T12:00:00.000Z".to_string(),
14190 };
14191 let v2_page = json!({
14192 "v": 2,
14193 "head_seq": 1,
14194 "head_commit_hash": commit_hash,
14195 "head_feed_hash": feed_hash,
14196 "entries": [{
14197 "seq": 1,
14198 "commit_hash": pointer.commit_hash,
14199 "feed_hash": pointer.feed_hash,
14200 "bytes_base64": STANDARD.encode(&raw),
14201 }],
14202 "next_after": 1,
14203 "complete": true,
14204 })
14205 .to_string();
14206 let identity = V2HeadIdentity {
14207 custody: "self".to_string(),
14208 fingerprint: remote.identity.fingerprint.clone(),
14209 public_key_spki: remote.identity.public_key_spki.clone(),
14210 previous: Vec::new(),
14211 rotations: Vec::new(),
14212 };
14213 let checkpoint = TrustState {
14214 v: 2,
14215 origin: "unused".to_string(),
14216 requested: TEST_BRAIN_ID.to_string(),
14217 brain: TEST_BRAIN_ID.to_string(),
14218 home: None,
14219 anchor: remote.key.multikey.clone(),
14220 current: remote.key.multikey,
14221 head_seq: legacy_item.entry.seq,
14222 feed_hash: Some(legacy_item.hash.clone()),
14223 rotations: Vec::new(),
14224 hub_signer: None,
14225 protocol_profile: None,
14226 };
14227 let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
14228 let state = tempfile::tempdir().unwrap();
14229 let cfg = test_hub_config(hub, state.path().to_path_buf());
14230 verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
14231 server.join().unwrap();
14232
14233 let mut wrong = checkpoint;
14234 wrong.feed_hash = Some("0".repeat(64));
14235 let (hub, server) = scripted_json_hub(vec![(
14236 200,
14237 json!({
14238 "v": 2,
14239 "head_seq": 1,
14240 "head_commit_hash": pointer.commit_hash,
14241 "head_feed_hash": pointer.feed_hash,
14242 "entries": [{
14243 "seq": 1,
14244 "commit_hash": pointer.commit_hash,
14245 "feed_hash": pointer.feed_hash,
14246 "bytes_base64": STANDARD.encode(&raw),
14247 }],
14248 "next_after": 1,
14249 "complete": true,
14250 })
14251 .to_string(),
14252 )]);
14253 let state = tempfile::tempdir().unwrap();
14254 let cfg = test_hub_config(hub, state.path().to_path_buf());
14255 assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
14256 server.join().unwrap();
14257 }
14258
14259 #[test]
14260 fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
14261 let rng = ring::rand::SystemRandom::new();
14262 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14263 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
14264 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14265 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
14266 let (old_spki, old_multikey) = public_identity_for(&old);
14267 let (new_spki, new_multikey) = public_identity_for(&new);
14268 let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
14269 v: 1,
14270 op: "rotate",
14271 brain: &old_multikey,
14272 public_key: &old_spki,
14273 new_brain: &new_multikey,
14274 new_public_key: &new_spki,
14275 prior_head_seq: 1,
14276 prior_feed_hash: Some(&"9".repeat(64)),
14277 ts: "2026-08-19T12:01:00.000Z".to_string(),
14278 })
14279 .unwrap();
14280 let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
14281 let rotation = format!(
14282 "{},\"sig\":\"{}\"}}",
14283 &rotation_unsigned[..rotation_unsigned.len() - 1],
14284 rotation_sig
14285 );
14286 let identity = V2HeadIdentity {
14287 custody: "self".to_string(),
14288 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
14289 public_key_spki: new_spki.clone(),
14290 previous: vec![V2PreviousIdentity {
14291 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
14292 public_key_spki: old_spki.clone(),
14293 }],
14294 rotations: vec![rotation],
14295 };
14296 let commit = |seq: u64,
14297 epoch: u64,
14298 multikey: &str,
14299 spki: &str,
14300 pair: &ring::signature::Ed25519KeyPair| {
14301 let value = json!({
14302 "actor_ref": "a".repeat(64),
14303 "asset_root": Value::Null,
14304 "brain": multikey,
14305 "changes_sha256": "b".repeat(64),
14306 "control_revision": "c".repeat(64),
14307 "materializer": "dbmd-projection-v1",
14308 "op": "changeset",
14309 "parent_asset_root": Value::Null,
14310 "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
14311 "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
14312 "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
14313 "public_key": spki,
14314 "seq": seq,
14315 "signer_epoch": epoch,
14316 "state_root": "1".repeat(64),
14317 "ts": "2026-08-19T12:00:00.000Z",
14318 "v": 2,
14319 "v1_bridge": Value::Null,
14320 });
14321 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
14322 let mut object = value.as_object().unwrap().clone();
14323 object.insert(
14324 "sig".to_string(),
14325 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
14326 );
14327 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
14328 };
14329
14330 assert!(verified_v2_commit_object(
14331 &commit(1, 1, &old_multikey, &old_spki, &old),
14332 &identity,
14333 )
14334 .is_ok());
14335 assert!(verified_v2_commit_object(
14336 &commit(2, 2, &new_multikey, &new_spki, &new),
14337 &identity,
14338 )
14339 .is_ok());
14340 assert!(verified_v2_commit_object(
14341 &commit(2, 1, &old_multikey, &old_spki, &old),
14342 &identity,
14343 )
14344 .is_err());
14345 assert!(verified_v2_commit_object(
14346 &commit(1, 2, &new_multikey, &new_spki, &new),
14347 &identity,
14348 )
14349 .is_err());
14350 }
14351
14352 #[test]
14353 fn a_self_custody_entry_verifies_like_any_hub_entry() {
14354 let rng = ring::rand::SystemRandom::new();
14355 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14356 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14357 let (spki, multikey) = public_identity_for(&pair);
14358 let key = AgentSigningKey {
14359 pkcs8: pkcs8.as_ref().to_vec(),
14360 multikey: multikey.clone(),
14361 public_key_spki: spki.clone(),
14362 };
14363 let files = vec![WireFeedFile {
14364 path: "DB.md".to_string(),
14365 sha256: "a".repeat(64),
14366 bytes: 3,
14367 }];
14368 let raw = self_custody_entry(
14369 &key,
14370 1,
14371 "2026-07-23T12:00:00.000Z".to_string(),
14372 &"c".repeat(64),
14373 &files,
14374 None,
14375 )
14376 .unwrap();
14377 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
14381 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
14382 let item = FeedItem { hash, entry };
14383 let identity = FeedIdentity {
14384 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
14385 public_key_spki: spki,
14386 previous: Vec::new(),
14387 rotations: Vec::new(),
14388 };
14389 assert!(verify_feed_item(&item, &identity).is_ok());
14390 }
14391
14392 #[test]
14393 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
14394 let rng = ring::rand::SystemRandom::new();
14395 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14396 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
14397 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14398 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
14399 let (old_spki, old_multikey) = public_identity_for(&old);
14400 let (new_spki, new_multikey) = public_identity_for(&new);
14401 let unsigned = serde_json::to_string(&UnsignedRotation {
14402 v: 1,
14403 op: "rotate",
14404 brain: &old_multikey,
14405 public_key: &old_spki,
14406 new_brain: &new_multikey,
14407 new_public_key: &new_spki,
14408 prior_head_seq: 1,
14409 prior_feed_hash: Some(&"a".repeat(64)),
14410 ts: "2026-07-30T12:00:00.000Z".to_string(),
14411 })
14412 .unwrap();
14413 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
14414 let rotation = format!(
14415 "{},\"sig\":\"{}\"}}",
14416 &unsigned[..unsigned.len() - 1],
14417 signature
14418 );
14419 let identity = FeedIdentity {
14420 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
14421 public_key_spki: new_spki,
14422 previous: vec![PreviousIdentity {
14423 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
14424 public_key_spki: old_spki,
14425 }],
14426 rotations: vec![rotation],
14427 };
14428 let pin = TrustState {
14429 v: 2,
14430 origin: "https://hub.example".to_string(),
14431 requested: "brain".to_string(),
14432 brain: "brain".to_string(),
14433 home: None,
14434 anchor: old_multikey.clone(),
14435 current: old_multikey.clone(),
14436 head_seq: 1,
14437 feed_hash: Some("a".repeat(64)),
14438 rotations: Vec::new(),
14439 hub_signer: None,
14440 protocol_profile: None,
14441 };
14442 assert_eq!(
14443 verify_identity_chain(&identity, Some(&pin)).unwrap(),
14444 old_multikey
14445 );
14446 let mut accepted = pin.clone();
14447 accepted.current = new_multikey.clone();
14448 accepted.rotations = identity.rotations.clone();
14449 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
14450 v: 1,
14451 op: "rotate",
14452 brain: &old_multikey,
14453 public_key: &identity.previous[0].public_key_spki,
14454 new_brain: &new_multikey,
14455 new_public_key: &identity.public_key_spki,
14456 prior_head_seq: 1,
14457 prior_feed_hash: Some(&"a".repeat(64)),
14458 ts: "2026-07-30T12:00:01.000Z".to_string(),
14459 })
14460 .unwrap();
14461 let alternate_signature =
14462 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
14463 let mut rewritten = identity.clone();
14464 rewritten.rotations[0] = format!(
14465 "{},\"sig\":\"{}\"}}",
14466 &alternate_unsigned[..alternate_unsigned.len() - 1],
14467 alternate_signature
14468 );
14469 assert!(
14470 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
14471 "an alternate valid statement must not rewrite accepted history"
14472 );
14473
14474 let mut stale_entry = FeedEntry {
14475 v: 1,
14476 seq: 2,
14477 ts: "2026-07-30T12:01:00.000Z".to_string(),
14478 brain: pin.current.clone(),
14479 public_key: identity.previous[0].public_key_spki.clone(),
14480 kind: "push".to_string(),
14481 op: "snapshot".to_string(),
14482 pack_sha256: "b".repeat(64),
14483 files: Vec::new(),
14484 removed: Vec::new(),
14485 prev_entry_hash: pin.feed_hash.clone(),
14486 sig: String::new(),
14487 };
14488 let stale_unsigned = UnsignedFeedEntry {
14489 v: stale_entry.v,
14490 seq: stale_entry.seq,
14491 ts: &stale_entry.ts,
14492 brain: &stale_entry.brain,
14493 public_key: &stale_entry.public_key,
14494 kind: &stale_entry.kind,
14495 op: &stale_entry.op,
14496 pack_sha256: &stale_entry.pack_sha256,
14497 files: &stale_entry.files,
14498 removed: &stale_entry.removed,
14499 prev_entry_hash: &stale_entry.prev_entry_hash,
14500 };
14501 stale_entry.sig = URL_SAFE_NO_PAD.encode(
14502 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
14503 .as_ref(),
14504 );
14505 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
14506 stale_exact.push(b'\n');
14507 let stale_item = FeedItem {
14508 hash: content_sha256(&stale_exact),
14509 entry: stale_entry,
14510 };
14511 assert!(
14512 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
14513 .is_err(),
14514 "a key retired before the checkpoint must never append after it"
14515 );
14516 assert!(
14517 verify_feed_item(&stale_item, &identity).is_err(),
14518 "an old key must never append after its signed rotation boundary"
14519 );
14520
14521 let mut missing = identity.clone();
14522 missing.rotations.clear();
14523 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
14524
14525 let mut tampered = identity;
14526 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
14527 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
14528 }
14529
14530 #[cfg(unix)]
14531 #[test]
14532 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
14533 use std::os::unix::fs::symlink;
14534
14535 let dir = tempfile::tempdir().unwrap();
14536 let target = dir.path().join("valuable.txt");
14537 let planted = dir.path().join("agent.key");
14538 std::fs::write(&target, "do not overwrite").unwrap();
14539 symlink(&target, &planted).unwrap();
14540
14541 assert!(matches!(
14542 generate_agent_key(&planted),
14543 Err(LinkError::BadAgentKey { .. })
14544 ));
14545 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
14546 }
14547
14548 #[cfg(unix)]
14549 #[test]
14550 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
14551 use std::os::unix::fs::symlink;
14552
14553 let root = tempfile::tempdir().unwrap();
14554 let outside = tempfile::tempdir().unwrap();
14555 symlink(outside.path(), root.path().join("redirect")).unwrap();
14556
14557 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
14558 assert!(!outside.path().join("agent.key").exists());
14559 }
14560
14561 #[test]
14564 fn address_bare_brain_with_and_without_sigil() {
14565 for raw in ["@acme-ops", "acme-ops"] {
14566 let a = Address::parse(raw).expect(raw);
14567 assert_eq!(a.brain, "acme-ops");
14568 assert_eq!(a.target, None);
14569 }
14570 }
14571
14572 #[test]
14573 fn address_ulid_target_parses_as_id() {
14574 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
14575 assert_eq!(a.brain, "acme");
14576 assert_eq!(
14577 a.target,
14578 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
14579 );
14580 }
14581
14582 #[test]
14583 fn address_md_path_target_parses_as_path() {
14584 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
14585 assert_eq!(
14586 a.target,
14587 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
14588 );
14589 }
14590
14591 #[test]
14592 fn address_rejects_malformed_forms() {
14593 for raw in [
14594 "",
14595 "@",
14596 "@/x",
14597 "@acme/",
14598 "@acme/../etc/passwd",
14599 "@acme/records/.hidden.md",
14600 "@ACME", "@acme/notes/x.txt", "@a b", ] {
14604 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
14605 }
14606 }
14607
14608 #[test]
14611 fn safe_paths_accept_store_shapes_and_reject_escapes() {
14612 for ok in [
14613 "DB.md",
14614 "assets.jsonl",
14615 "records/clients/lumio.md",
14616 "sources/emails/2026/07/x.md",
14617 ] {
14618 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
14619 }
14620 for bad in [
14621 "",
14622 "/etc/passwd",
14623 "../up.md",
14624 "records/../../up.md",
14625 "records//x.md",
14626 ".dbmd/config",
14627 "records/.hidden/x.md",
14628 "records/a b.md",
14629 "records\\win.md",
14630 ] {
14631 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
14632 }
14633 }
14634
14635 #[cfg(unix)]
14636 #[test]
14637 fn opened_destination_capability_survives_an_ancestor_path_swap() {
14638 use std::os::unix::fs::symlink;
14639
14640 let work = tempfile::tempdir().unwrap();
14641 let outside = tempfile::tempdir().unwrap();
14642 let original = work.path().join("destination");
14643 let moved = work.path().join("destination-moved");
14644 let directory = open_or_create_dir_nofollow(&original).unwrap();
14645
14646 std::fs::rename(&original, &moved).unwrap();
14647 symlink(outside.path(), &original).unwrap();
14648 write_pull_entries_beneath_dir(
14649 &directory,
14650 &[("records/note.md".to_string(), b"held inode".to_vec())],
14651 )
14652 .unwrap();
14653
14654 assert_eq!(
14655 std::fs::read(moved.join("records/note.md")).unwrap(),
14656 b"held inode"
14657 );
14658 assert!(!outside.path().join("records/note.md").exists());
14659 }
14660
14661 #[test]
14665 fn hub_config_flag_beats_file_and_requires_some_source() {
14666 let dir = tempfile::tempdir().unwrap();
14667 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
14668 std::fs::write(
14669 dir.path().join(CONFIG_REL_PATH),
14670 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
14671 )
14672 .unwrap();
14673
14674 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
14675 assert_eq!(from_flag.hub, "https://flag.example.com");
14676
14677 let from_file = hub_config(None, dir.path()).unwrap();
14678 assert_eq!(from_file.hub, "https://file.example.com");
14679
14680 let none = hub_config(None, tempfile::tempdir().unwrap().path());
14681 assert!(matches!(none, Err(LinkError::NoHub)));
14682 }
14683
14684 #[test]
14685 fn https_guard_allows_loopback_only_for_plain_http() {
14686 assert!(assert_safe_hub("https://hub.example.com").is_ok());
14687 assert!(assert_safe_hub("http://localhost:3000").is_ok());
14688 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
14689 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
14690 assert!(matches!(
14691 assert_safe_hub("http://hub.example.com"),
14692 Err(LinkError::UnsafeHub { .. })
14693 ));
14694 assert!(matches!(
14695 assert_safe_hub("hub.example.com"),
14696 Err(LinkError::UnsafeHub { .. })
14697 ));
14698 assert!(matches!(
14699 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
14700 Err(LinkError::UnsafeHub { .. })
14701 ));
14702 assert!(matches!(
14703 assert_safe_hub("https://hub.example.com@attacker.example"),
14704 Err(LinkError::UnsafeHub { .. })
14705 ));
14706 assert!(matches!(
14707 assert_safe_hub("https://hub.example.com/base"),
14708 Err(LinkError::UnsafeHub { .. })
14709 ));
14710 }
14711
14712 #[test]
14713 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
14714 for blocked in [
14715 "127.0.0.1",
14716 "10.0.0.1",
14717 "100.64.0.1",
14718 "169.254.169.254",
14719 "172.16.0.1",
14720 "192.168.0.1",
14721 "192.88.99.1",
14722 "198.18.0.1",
14723 "203.0.113.1",
14724 "::1",
14725 "fe80::1",
14726 "fd00::1",
14727 "2001:db8::1",
14728 "2001:1::1",
14729 "2002:7f00:1::",
14730 "3fff::1",
14731 ] {
14732 assert!(
14733 !is_public_registry_ip(blocked.parse().unwrap()),
14734 "must block {blocked}"
14735 );
14736 }
14737 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
14738 assert!(is_public_registry_ip(
14739 "2606:4700:4700::1111".parse().unwrap()
14740 ));
14741 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
14742 }
14743
14744 #[test]
14745 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
14746 use ureq::Resolver as _;
14747
14748 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
14749 let resolver = PinnedRegistryResolver {
14750 netloc: "home.example:443".to_string(),
14751 addresses: vec![pinned],
14752 };
14753 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
14754 assert!(resolver.resolve("127.0.0.1:443").is_err());
14755 assert_eq!(
14756 resolver.resolve("home.example:443").unwrap(),
14757 vec![pinned],
14758 "subsequent connects reuse the validated answer instead of DNS"
14759 );
14760 }
14761
14762 #[test]
14763 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
14764 let cfg = HubConfig {
14765 hub: "https://hub.example".to_string(),
14766 key: None,
14767 agent_key: None,
14768 brain_key: None,
14769 state_dir: tempfile::tempdir().unwrap().keep(),
14770 store_selected: false,
14771 };
14772 assert!(
14773 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
14774 "a production hub must not turn its presigned URL into an SSRF primitive"
14775 );
14776
14777 let store_selected = HubConfig {
14778 hub: "https://127.0.0.1".to_string(),
14779 store_selected: true,
14780 ..cfg
14781 };
14782 assert!(
14783 hub_agent(&store_selected).is_err(),
14784 "bytes in a cloned store must not select a private-network hub"
14785 );
14786 }
14787
14788 #[test]
14789 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
14790 assert_eq!(
14791 one_past_bounded_limit(MAX_PACK_BYTES),
14792 Some(MAX_PACK_BYTES + 1),
14793 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
14794 );
14795 assert_eq!(
14796 presigned_download_read_limit(),
14797 MAX_PACK_BYTES + 1,
14798 "the presigned reader is capped by the client constant, not a hub response"
14799 );
14800 assert_eq!(
14801 one_past_bounded_limit(u64::MAX),
14802 None,
14803 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
14804 );
14805 }
14806
14807 #[test]
14808 fn https_guard_matches_the_scheme_case_insensitively() {
14809 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
14812 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
14813 assert!(matches!(
14815 assert_safe_hub("HTTP://hub.example.com"),
14816 Err(LinkError::UnsafeHub { .. })
14817 ));
14818 }
14819
14820 #[test]
14821 fn clean_key_refuses_paste_artifacts_without_echoing() {
14822 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
14823 for bad in ["vc account", "vc\naccount", "ключ", ""] {
14824 let err = clean_key(bad).unwrap_err();
14825 assert!(matches!(err, LinkError::BadKey));
14826 assert!(
14827 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
14828 "error must not echo the key"
14829 );
14830 }
14831 }
14832
14833 fn dead_hub() -> HubConfig {
14839 HubConfig {
14840 hub: "http://127.0.0.1:9".to_string(),
14841 key: Some("k".to_string()),
14842 agent_key: None,
14843 brain_key: None,
14844 state_dir: PathBuf::from("."),
14845 store_selected: false,
14846 }
14847 }
14848
14849 #[test]
14850 fn request_retries_a_connection_failure_before_sending() {
14851 use std::io::{Read as _, Write as _};
14852 use std::net::TcpListener;
14853 use std::thread;
14854 use std::time::Duration;
14855
14856 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
14857 let address = probe.local_addr().unwrap();
14858 drop(probe);
14859 let server = thread::spawn(move || {
14860 thread::sleep(Duration::from_millis(40));
14861 let listener = TcpListener::bind(address).unwrap();
14862 let (mut stream, _) = listener.accept().unwrap();
14863 let mut request_bytes = [0_u8; 1024];
14864 let _ = stream.read(&mut request_bytes).unwrap();
14865 stream
14866 .write_all(
14867 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
14868 )
14869 .unwrap();
14870 });
14871 let cfg = HubConfig {
14872 hub: format!("http://{address}"),
14873 key: None,
14874 agent_key: None,
14875 brain_key: None,
14876 state_dir: tempfile::tempdir().unwrap().keep(),
14877 store_selected: false,
14878 };
14879
14880 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
14881 assert_eq!(response.status, 200);
14882 assert_eq!(response.body, Some(json!({ "ok": true })));
14883 server.join().unwrap();
14884 }
14885
14886 #[test]
14887 fn endpoint_cap_refuses_a_body_before_json_parsing() {
14888 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
14889 let cfg = HubConfig {
14890 hub,
14891 key: None,
14892 agent_key: None,
14893 brain_key: None,
14894 state_dir: tempfile::tempdir().unwrap().keep(),
14895 store_selected: false,
14896 };
14897
14898 assert!(matches!(
14899 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
14900 Err(LinkError::ResponseTooLarge { .. })
14901 ));
14902 server.join().unwrap();
14903 }
14904
14905 #[test]
14906 fn overall_deadline_stops_a_dribbled_response_body() {
14907 use std::io::{Read as _, Write as _};
14908 use std::net::TcpListener;
14909 use std::time::{Duration, Instant};
14910
14911 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
14912 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
14913 let server = std::thread::spawn(move || {
14914 let (mut stream, _) = listener.accept().unwrap();
14915 let mut request = [0_u8; 1024];
14916 let _ = stream.read(&mut request);
14917 stream
14918 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
14919 .unwrap();
14920 for byte in [b'x'; 32] {
14921 if stream.write_all(&[byte]).is_err() {
14922 break;
14923 }
14924 std::thread::sleep(Duration::from_millis(40));
14925 }
14926 });
14927 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
14928 let started = Instant::now();
14929 let response = http.get(&url).call().unwrap();
14930 let mut body = Vec::new();
14931 let error = response
14932 .into_reader()
14933 .read_to_end(&mut body)
14934 .expect_err("per-read progress must not reset the overall deadline");
14935 assert!(
14936 started.elapsed() < Duration::from_millis(700),
14937 "dribbled body exceeded the wall-clock budget: {error}"
14938 );
14939 server.join().unwrap();
14940 }
14941
14942 #[test]
14943 fn overall_deadline_stops_a_stalled_upload() {
14944 use std::net::TcpListener;
14945 use std::time::{Duration, Instant};
14946
14947 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
14948 let url = format!("http://{}/upload", listener.local_addr().unwrap());
14949 let server = std::thread::spawn(move || {
14950 let (_stream, _) = listener.accept().unwrap();
14951 std::thread::sleep(Duration::from_millis(600));
14954 });
14955 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
14956 let body = vec![0x5a; 32 * 1024 * 1024];
14957 let started = Instant::now();
14958 let error = http
14959 .put(&url)
14960 .send_bytes(&body)
14961 .expect_err("stalled request-body writes must time out");
14962 assert!(
14963 started.elapsed() < Duration::from_millis(700),
14964 "stalled upload exceeded the wall-clock budget: {error}"
14965 );
14966 server.join().unwrap();
14967 }
14968
14969 #[test]
14970 fn verb_entry_gates_accept_the_hub_ref_shapes() {
14971 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
14972 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
14973 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
14974 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
14975 }
14976 }
14977
14978 #[test]
14979 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
14980 let cfg = dead_hub();
14981 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
14982 assert!(
14983 matches!(
14984 sync_pull(&cfg, bad, None),
14985 Err(LinkError::BadAddress { .. })
14986 ),
14987 "sync_pull must refuse {bad:?}"
14988 );
14989 assert!(
14990 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
14991 "sync_push must refuse {bad:?}"
14992 );
14993 assert!(
14994 matches!(
14995 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
14996 Err(LinkError::BadAddress { .. })
14997 ),
14998 "grant_issue must refuse {bad:?}"
14999 );
15000 assert!(
15001 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
15002 "grant_list must refuse {bad:?}"
15003 );
15004 assert!(
15005 matches!(
15006 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
15007 Err(LinkError::BadAddress { .. })
15008 ),
15009 "grant_revoke must refuse brain {bad:?}"
15010 );
15011 assert!(
15012 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
15013 "head must refuse {bad:?}"
15014 );
15015 }
15016 }
15017
15018 #[test]
15019 fn grant_revoke_refuses_url_reshaping_grant_ids() {
15020 let cfg = dead_hub();
15021 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
15022 assert!(
15023 matches!(
15024 grant_revoke(&cfg, "acme", bad),
15025 Err(LinkError::BadGrantId { .. })
15026 ),
15027 "grant_revoke must refuse grant id {bad:?}"
15028 );
15029 }
15030 }
15031
15032 #[test]
15033 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
15034 let cfg = dead_hub();
15035 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
15036 assert!(
15037 matches!(
15038 propose(&cfg, bad, "intake", "hi"),
15039 Err(LinkError::BadAddress { .. })
15040 ),
15041 "propose must refuse handle {bad:?}"
15042 );
15043 }
15044 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
15045 assert!(matches!(
15046 propose(&cfg, "acme-site", "intake", &oversize),
15047 Err(LinkError::ProposeTooLarge { .. })
15048 ));
15049 assert!(matches!(
15052 propose(&cfg, "acme-site", "intake", "hi"),
15053 Err(LinkError::Transport { .. })
15054 ));
15055 }
15056
15057 #[test]
15058 fn resolve_refuses_a_hand_built_unsafe_address() {
15059 let cfg = dead_hub();
15060 for brain in ["../up", "a/b", "a?x", "a#f"] {
15061 let addr = Address {
15062 brain: brain.to_string(),
15063 target: None,
15064 };
15065 assert!(
15066 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
15067 "resolve must refuse brain {brain:?}"
15068 );
15069 }
15070 for target in [
15071 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
15072 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
15074 AddressTarget::Path("records/x.md#frag".to_string()),
15075 ] {
15076 let addr = Address {
15077 brain: "acme".to_string(),
15078 target: Some(target.clone()),
15079 };
15080 assert!(
15081 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
15082 "resolve must refuse target {target:?}"
15083 );
15084 }
15085 }
15086
15087 #[test]
15088 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
15089 let mut local = std::collections::BTreeMap::new();
15090 local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
15091 local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
15092 let mut remote = std::collections::BTreeMap::new();
15093 remote.insert(
15094 "records/a.md".to_string(),
15095 V2BaselineFile {
15096 sha256: "c".repeat(64),
15097 bytes: 1,
15098 proof: None,
15099 },
15100 );
15101 remote.insert(
15102 "records/b.md".to_string(),
15103 V2BaselineFile {
15104 sha256: "b".repeat(64),
15105 bytes: 1,
15106 proof: None,
15107 },
15108 );
15109 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
15110 }
15111
15112 #[test]
15113 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
15114 let local = std::collections::BTreeMap::new();
15115 let mut remote = std::collections::BTreeMap::new();
15116 remote.insert(
15117 "private/local.md".to_string(),
15118 V2BaselineFile {
15119 sha256: "d".repeat(64),
15120 bytes: 1,
15121 proof: None,
15122 },
15123 );
15124 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
15125 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
15126 }
15127
15128 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
15129 V2VerifiedHead {
15130 requested: TEST_BRAIN_ID.to_string(),
15131 brain_id: TEST_BRAIN_ID.to_string(),
15132 view_kind: "scoped".to_string(),
15133 view_revision: revision.to_string(),
15134 control_revision: revision.to_string(),
15135 identity: V2HeadIdentity {
15136 custody: "hub".to_string(),
15137 fingerprint: "test".to_string(),
15138 public_key_spki: "test".to_string(),
15139 previous: Vec::new(),
15140 rotations: Vec::new(),
15141 },
15142 pointer: None,
15143 trust: TrustState {
15144 v: 2,
15145 origin: "https://hub.example".to_string(),
15146 requested: TEST_BRAIN_ID.to_string(),
15147 brain: TEST_BRAIN_ID.to_string(),
15148 home: None,
15149 anchor: "ed25519:test".to_string(),
15150 current: "ed25519:test".to_string(),
15151 head_seq: 0,
15152 feed_hash: None,
15153 rotations: Vec::new(),
15154 hub_signer: None,
15155 protocol_profile: Some("link-v2".to_string()),
15156 },
15157 alias: None,
15158 }
15159 }
15160
15161 #[test]
15162 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
15163 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
15164 assert!(accepted_as_v2(&trust));
15165
15166 trust.protocol_profile = None;
15167 trust.hub_signer = Some("ed25519:hub".to_string());
15168 assert!(accepted_as_v2(&trust));
15169
15170 trust.hub_signer = None;
15171 assert!(!accepted_as_v2(&trust));
15172 }
15173
15174 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
15175 V2SyncBaseline {
15176 v: 2,
15177 origin: "https://hub.example".to_string(),
15178 brain: TEST_BRAIN_ID.to_string(),
15179 head_seq: Some(0),
15180 commit_hash: None,
15181 content_root: None,
15182 asset_root: None,
15183 assets: std::collections::BTreeMap::new(),
15184 view_kind: Some("scoped".to_string()),
15185 view_revision: Some(revision.to_string()),
15186 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
15187 files: std::collections::BTreeMap::new(),
15188 local_policy_digest: None,
15189 local_eligibility: std::collections::BTreeMap::new(),
15190 remote_copy_remains: std::collections::BTreeMap::new(),
15191 }
15192 }
15193
15194 #[test]
15195 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
15196 let directory = tempfile::tempdir().unwrap();
15197 std::fs::write(
15198 directory.path().join("DB.md"),
15199 scoped_projection_bytes(TEST_BRAIN_ID),
15200 )
15201 .unwrap();
15202 let store = Store::open_strict(directory.path()).unwrap();
15203 let head = scoped_test_head(&"a".repeat(64));
15204 let baseline = scoped_test_baseline(&"a".repeat(64));
15205 let mut view = v2_local_files(&store).unwrap();
15206 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
15207 assert!(!view.riding.contains_key("DB.md"));
15208 assert!(!view.eligibility.contains_key("DB.md"));
15209 }
15210
15211 #[test]
15212 fn scoped_projection_edit_and_scope_transition_fail_closed() {
15213 let directory = tempfile::tempdir().unwrap();
15214 std::fs::write(
15215 directory.path().join("DB.md"),
15216 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
15217 )
15218 .unwrap();
15219 let store = Store::open_strict(directory.path()).unwrap();
15220 let head = scoped_test_head(&"a".repeat(64));
15221 let baseline = scoped_test_baseline(&"a".repeat(64));
15222 let mut view = v2_local_files(&store).unwrap();
15223 assert!(matches!(
15224 remove_scoped_projection(&head, Some(&baseline), &mut view),
15225 Err(LinkError::ScopedProjectionModified)
15226 ));
15227
15228 let changed = scoped_test_head(&"b".repeat(64));
15229 assert!(matches!(
15230 ensure_v2_view_compatible(&changed, Some(&baseline)),
15231 Err(LinkError::ScopedViewChanged)
15232 ));
15233
15234 let mut same_view_new_control = head.clone();
15235 same_view_new_control.control_revision = "c".repeat(64);
15236 assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
15237 assert!(!same_v2_head(&head, &same_view_new_control));
15238 }
15239
15240 #[test]
15241 fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
15242 let scoped = scoped_test_head(&"a".repeat(64));
15243 let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
15244 assert!(matches!(
15245 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
15246 Err(LinkError::ScopedProjectionModified)
15247 ));
15248
15249 let mut full = scoped.clone();
15250 full.view_kind = "full".to_string();
15251 let mut full_baseline = scoped_baseline.clone();
15252 full_baseline.view_kind = Some("full".to_string());
15253 full_baseline.projection_sha256 = None;
15254 assert!(matches!(
15255 ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
15256 Err(LinkError::InvalidPack { .. })
15257 ));
15258
15259 assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
15260 assert!(
15261 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
15262 );
15263 }
15264
15265 #[test]
15266 fn scoped_view_metadata_is_explicitly_non_authoritative() {
15267 let head = scoped_test_head(&"a".repeat(64));
15268 let value: Value =
15269 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
15270 assert_eq!(value["kind"], "link.md-scoped-view");
15271 assert_eq!(value["authoritative"], false);
15272 assert_eq!(value["visible_files"], 7);
15273 assert_eq!(value["brain"], TEST_BRAIN_ID);
15274 }
15275
15276 #[test]
15277 fn local_scoped_marker_requires_the_exact_generated_projection() {
15278 let directory = tempfile::tempdir().unwrap();
15279 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
15280 std::fs::write(
15281 directory.path().join("DB.md"),
15282 scoped_projection_bytes(TEST_BRAIN_ID),
15283 )
15284 .unwrap();
15285 let head = scoped_test_head(&"a".repeat(64));
15286 std::fs::write(
15287 directory.path().join(".dbmd/view.json"),
15288 scoped_view_metadata(&head, 0).unwrap(),
15289 )
15290 .unwrap();
15291 let store = Store::open_strict(directory.path()).unwrap();
15292 assert!(has_verified_local_scoped_view(&store));
15293
15294 std::fs::write(
15295 directory.path().join("DB.md"),
15296 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
15297 )
15298 .unwrap();
15299 let altered = Store::open_strict(directory.path()).unwrap();
15300 assert!(!has_verified_local_scoped_view(&altered));
15301 }
15302
15303 fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
15304 use ring::signature::KeyPair as _;
15305
15306 let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
15307 let rng = ring::rand::SystemRandom::new();
15308 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15309 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15310 let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
15311 let public_key = URL_SAFE_NO_PAD.encode(&public_der);
15312 let fingerprint = format!("{:x}", Sha256::digest(&public_der));
15313 let blob = b"new";
15314 let blob_hash = content_sha256(blob);
15315 let changes = json!({
15316 "mutation_id": "sync:proposal-fixture",
15317 "operations": [{
15318 "blob": blob_hash,
15319 "bytes": blob.len(),
15320 "expected": null,
15321 "op": "put",
15322 "path": "records/new.md",
15323 }],
15324 "reason": "fixture",
15325 "v": 2,
15326 });
15327 let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
15328 let changes_base64 = STANDARD.encode(&changes_bytes);
15329 let descriptor = json!({
15330 "base": null,
15331 "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
15332 "changes_base64": changes_base64,
15333 "rebase": "strict",
15334 "v": 2,
15335 });
15336 let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
15337 let payload_hash = "b".repeat(64);
15338 let submitted_at = "2026-08-19T12:00:00.000Z";
15339 let claim = json!({
15340 "actor_root": {
15341 "actor_class": "foreign_key",
15342 "credential": "ed25519:fixture",
15343 "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
15344 "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
15345 "principal": "key:fixture",
15346 "role": null,
15347 },
15348 "brain": TEST_BRAIN_ID,
15349 "clear_sha256": clear_hash,
15350 "control_revision": "c".repeat(64),
15351 "mutation_id": "sync:proposal-fixture",
15352 "payload_sha256": payload_hash,
15353 "proposal_id": proposal_id,
15354 "submitted_at": submitted_at,
15355 "v": 2,
15356 });
15357 let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
15358 let envelope = json!({
15359 "claim": claim,
15360 "fingerprint": fingerprint,
15361 "public_key": public_key,
15362 "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
15363 });
15364 let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
15365 let submission_hash =
15366 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
15367 let mut head = scoped_test_head(&"c".repeat(64));
15368 head.view_kind = "full".to_string();
15369 head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
15370 let value = json!({
15371 "proposal": {
15372 "base": null,
15373 "blobs": [{
15374 "bytes": blob.len(),
15375 "endpoint": format!(
15376 "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
15377 ),
15378 "sha256": blob_hash,
15379 }],
15380 "changes_base64": changes_base64,
15381 "clear_sha256": clear_hash,
15382 "expires_at": "2026-08-26T12:00:00.000Z",
15383 "id": proposal_id,
15384 "payload_sha256": payload_hash,
15385 "proposer": { "class": "foreign_key" },
15386 "rebase": "strict",
15387 "state": "pending",
15388 "submission_claim_base64": STANDARD.encode(envelope_bytes),
15389 "submission_claim_sha256": submission_hash,
15390 "submitted_at": submitted_at,
15391 },
15392 "v": 2,
15393 });
15394 (head, proposal_id, value)
15395 }
15396
15397 #[test]
15398 fn v2_proposal_verifier_accepts_exact_signed_payload() {
15399 let (head, proposal_id, value) = signed_proposal_fixture();
15400 let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
15401 assert_eq!(verified.blobs.len(), 1);
15402 assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
15403 }
15404
15405 #[test]
15406 fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
15407 let (head, proposal_id, value) = signed_proposal_fixture();
15408
15409 let mut changed = value.clone();
15410 changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
15411 assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
15412
15413 let mut redirected = value.clone();
15414 redirected["proposal"]["blobs"][0]["endpoint"] =
15415 Value::String("https://attacker.example/blob".to_string());
15416 assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
15417
15418 let mut forged = value;
15419 let encoded = forged["proposal"]["submission_claim_base64"]
15420 .as_str()
15421 .unwrap();
15422 let mut envelope: Value =
15423 serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
15424 envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
15425 let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
15426 forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
15427 forged["proposal"]["submission_claim_sha256"] = Value::String(
15428 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
15429 );
15430 assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
15431 }
15432
15433 #[cfg(unix)]
15434 #[test]
15435 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
15436 let sandbox = tempfile::tempdir().unwrap();
15437 let destination = sandbox.path().join("brain");
15438 let entries = vec![
15439 (
15440 "DB.md".to_string(),
15441 scoped_projection_bytes(TEST_BRAIN_ID),
15442 ),
15443 (
15444 "records/contacts/a.md".to_string(),
15445 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
15446 .to_vec(),
15447 ),
15448 ];
15449 install_pulled_delta(&destination, &entries, &[], true).unwrap();
15450 assert!(destination.join("index.md").is_file());
15451 assert!(destination.join("records/index.md").is_file());
15452 assert!(destination.join("records/contacts/index.md").is_file());
15453 assert!(destination.join("records/contacts/index.jsonl").is_file());
15454 }
15455
15456 #[cfg(unix)]
15457 #[test]
15458 fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
15459 let sandbox = tempfile::tempdir().unwrap();
15460 let destination = sandbox.path().join("brain");
15461 let cache = sandbox.path().join("cache");
15462 std::fs::create_dir(&cache).unwrap();
15463 let db = scoped_projection_bytes(TEST_BRAIN_ID);
15464 let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
15465 let db_source = cache.join("db");
15466 let shared_source = cache.join("shared");
15467 crate::fsx::write_atomic(&db_source, &db).unwrap();
15468 crate::fsx::write_atomic(&shared_source, shared).unwrap();
15469 let mut entries = vec![V2StagedFile {
15470 path: "DB.md".to_string(),
15471 source: db_source,
15472 sha256: content_sha256(&db),
15473 bytes: db.len() as u64,
15474 }];
15475 for index in 0..512 {
15476 entries.push(V2StagedFile {
15477 path: format!("records/items/{index:05}.md"),
15478 source: shared_source.clone(),
15479 sha256: content_sha256(shared),
15480 bytes: shared.len() as u64,
15481 });
15482 }
15483 install_pulled_delta_sources(
15484 &destination,
15485 &entries,
15486 &[],
15487 false,
15488 None,
15489 &scoped_test_head(&"c".repeat(64)),
15490 )
15491 .unwrap();
15492 assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
15493 for index in 0..512 {
15494 assert_eq!(
15495 std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
15496 shared
15497 );
15498 }
15499 assert!(
15500 std::fs::read_dir(sandbox.path())
15501 .unwrap()
15502 .all(|entry| !entry
15503 .unwrap()
15504 .file_name()
15505 .to_string_lossy()
15506 .contains("pull-stage")),
15507 "the private stage must be atomically installed or removed"
15508 );
15509 }
15510
15511 #[cfg(unix)]
15512 #[test]
15513 fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
15514 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
15515
15516 let sandbox = tempfile::tempdir().unwrap();
15517 let root = sandbox.path().join("brain");
15518 std::fs::create_dir_all(root.join("records/items")).unwrap();
15519 let db = scoped_projection_bytes(TEST_BRAIN_ID);
15520 let old = b"---\ntype: note\n---\n\nold\n";
15521 let new = b"---\ntype: note\n---\n\nnew\n";
15522 let removed = b"---\ntype: note\n---\n\nremove me\n";
15523 std::fs::write(root.join("DB.md"), &db).unwrap();
15524 std::fs::write(root.join("records/items/change.md"), old).unwrap();
15525 std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
15526 for index in 0..512 {
15527 std::fs::write(
15528 root.join(format!("records/items/untouched-{index:04}.md")),
15529 old,
15530 )
15531 .unwrap();
15532 }
15533 let untouched = root.join("records/items/untouched-0256.md");
15534 let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
15535 let source = sandbox.path().join("changed-source");
15536 crate::fsx::write_atomic(&source, new).unwrap();
15537 let same_source = sandbox.path().join("unchanged-source");
15538 crate::fsx::write_atomic(&same_source, old).unwrap();
15539 let same_entry = V2StagedFile {
15540 path: "records/items/change.md".to_string(),
15541 source: same_source,
15542 sha256: content_sha256(old),
15543 bytes: old.len() as u64,
15544 };
15545 let entry = V2StagedFile {
15546 path: "records/items/change.md".to_string(),
15547 source,
15548 sha256: content_sha256(new),
15549 bytes: new.len() as u64,
15550 };
15551 let head = scoped_test_head(&"c".repeat(64));
15552
15553 install_established_v2_delta(
15557 Store::open_strict(&root).unwrap(),
15558 &[same_entry],
15559 &["records/items/already-absent.md".to_string()],
15560 true,
15561 None,
15562 &head,
15563 )
15564 .unwrap();
15565 assert_eq!(
15566 std::fs::metadata(&untouched).unwrap().ino(),
15567 untouched_inode
15568 );
15569 assert!(!root.join(V2_PULL_JOURNAL).exists());
15570
15571 install_established_v2_delta(
15572 Store::open_strict(&root).unwrap(),
15573 &[entry],
15574 &["records/items/delete.md".to_string()],
15575 false,
15576 None,
15577 &head,
15578 )
15579 .unwrap();
15580 assert_eq!(
15581 std::fs::read(root.join("records/items/change.md")).unwrap(),
15582 new
15583 );
15584 assert!(!root.join("records/items/delete.md").exists());
15585 assert_eq!(
15586 std::fs::metadata(&untouched).unwrap().ino(),
15587 untouched_inode
15588 );
15589 assert!(root.join(V2_PULL_JOURNAL).is_file());
15590 assert_eq!(
15591 std::fs::metadata(root.join(V2_PULL_JOURNAL))
15592 .unwrap()
15593 .permissions()
15594 .mode()
15595 & 0o777,
15596 0o600
15597 );
15598 let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
15599 .unwrap()
15600 .unwrap();
15601 assert_eq!(
15602 std::fs::metadata(root.join(&journal.backup_dir))
15603 .unwrap()
15604 .permissions()
15605 .mode()
15606 & 0o777,
15607 0o700
15608 );
15609 for entry in &journal.entries {
15610 if let Some(backup) = &entry.backup {
15611 assert_eq!(
15612 std::fs::metadata(root.join(&journal.backup_dir).join(backup))
15613 .unwrap()
15614 .permissions()
15615 .mode()
15616 & 0o777,
15617 0o600
15618 );
15619 }
15620 }
15621
15622 let cfg = test_hub_config(
15623 "https://example.test".to_string(),
15624 sandbox.path().join("state"),
15625 );
15626 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
15627 assert_eq!(
15628 std::fs::read(root.join("records/items/change.md")).unwrap(),
15629 old
15630 );
15631 assert_eq!(
15632 std::fs::read(root.join("records/items/delete.md")).unwrap(),
15633 removed
15634 );
15635 assert_eq!(
15636 std::fs::metadata(&untouched).unwrap().ino(),
15637 untouched_inode
15638 );
15639 assert!(!root.join(V2_PULL_JOURNAL).exists());
15640 }
15641
15642 #[test]
15643 fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
15644 let body = b"bounded bytes";
15645 let path = "records/example.md".to_string();
15646 let file = V2BaselineFile {
15647 sha256: content_sha256(body),
15648 bytes: body.len() as u64,
15649 proof: None,
15650 };
15651 let header = serde_json::to_vec(&json!({
15652 "bytes": body.len(),
15653 "path": path,
15654 "sha256": file.sha256,
15655 "v": 2,
15656 }))
15657 .unwrap();
15658 let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
15659 stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
15660 stream.extend_from_slice(&header);
15661 stream.extend_from_slice(body);
15662 stream.extend_from_slice(&0_u32.to_be_bytes());
15663 let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
15664 assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
15665
15666 let mut tampered = stream.clone();
15667 let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
15668 tampered[body_offset] ^= 1;
15669 assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
15670
15671 let mut trailing = stream;
15672 trailing.push(0);
15673 assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
15674 }
15675
15676 #[test]
15677 fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
15678 let sandbox = tempfile::TempDir::new().unwrap();
15679 let root = sandbox.path().join("brain");
15680 std::fs::create_dir_all(&root).unwrap();
15681 std::fs::write(
15682 root.join("DB.md"),
15683 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
15684 )
15685 .unwrap();
15686 let store = Store::open_strict(&root).unwrap();
15687 let incomplete = crate::ulid::mint();
15688 store
15689 .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
15690 .unwrap();
15691 let expired = crate::ulid::mint();
15692 store
15693 .create_dir_all(&v2_conflict_relative(&expired, "files"))
15694 .unwrap();
15695 let plan = V2ConflictPlan {
15696 v: 2,
15697 class: "content_resolution_required".to_string(),
15698 bundle: expired.clone(),
15699 brain: TEST_BRAIN_ID.to_string(),
15700 origin: "https://example.test".to_string(),
15701 created_unix: 0,
15702 expires_unix: 0,
15703 base_seq: None,
15704 base_commit: None,
15705 remote_seq: 0,
15706 remote_commit: None,
15707 remote_content_root: None,
15708 view_kind: "full".to_string(),
15709 view_revision: "a".repeat(64),
15710 files: vec![V2ConflictFile {
15711 path: "records/value.md".to_string(),
15712 base: V2ConflictCoordinate {
15713 sha256: None,
15714 bytes: None,
15715 file: None,
15716 },
15717 local: V2ConflictCoordinate {
15718 sha256: None,
15719 bytes: None,
15720 file: None,
15721 },
15722 remote: V2ConflictCoordinate {
15723 sha256: None,
15724 bytes: None,
15725 file: None,
15726 },
15727 }],
15728 };
15729 let mut bytes = serde_json::to_vec(&plan).unwrap();
15730 bytes.push(b'\n');
15731 store
15732 .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
15733 .unwrap();
15734
15735 let listed = sync_conflicts(&root, false, false).unwrap();
15736 assert_eq!(listed["bundles"], 2);
15737 assert_eq!(listed["pruned"], 0);
15738 let pruned = sync_conflicts(&root, true, false).unwrap();
15739 assert_eq!(pruned["bundles"], 0);
15740 assert_eq!(pruned["pruned"], 2);
15741 assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
15742 assert!(!root.join(".dbmd/conflicts").join(expired).exists());
15743 }
15744
15745 #[test]
15746 fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
15747 let sandbox = tempfile::TempDir::new().unwrap();
15748 let root = sandbox.path().join("brain");
15749 std::fs::create_dir_all(&root).unwrap();
15750 std::fs::write(
15751 root.join("DB.md"),
15752 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
15753 )
15754 .unwrap();
15755 let store = Store::open_strict(&root).unwrap();
15756 let bundle = crate::ulid::mint();
15757 store
15758 .create_dir_all(&v2_conflict_relative(&bundle, "files"))
15759 .unwrap();
15760 store
15761 .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
15762 .unwrap();
15763
15764 assert!(sync_conflicts(&root, true, false).is_err());
15765 assert!(sync_conflicts(&root, false, true).is_err());
15766 let pruned = sync_conflicts(&root, true, true).unwrap();
15767 assert_eq!(pruned["pruned"], 1);
15768 assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
15769 }
15770
15771 #[test]
15772 fn ready_pull_journal_rolls_back_exact_preimages() {
15773 let sandbox = tempfile::TempDir::new().unwrap();
15774 let root = sandbox.path().join("brain");
15775 std::fs::create_dir_all(root.join("records")).unwrap();
15776 std::fs::write(
15777 root.join("DB.md"),
15778 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
15779 )
15780 .unwrap();
15781 let path = "records/value.md";
15782 let old = b"---\ntype: note\n---\n\nold\n";
15783 let new = b"---\ntype: note\n---\n\nnew\n";
15784 std::fs::write(root.join(path), old).unwrap();
15785 let store = Store::open_strict(&root).unwrap();
15786 let bundle = crate::ulid::mint();
15787 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
15788 store
15789 .create_private_dir_all(Path::new(&backup_dir))
15790 .unwrap();
15791 store
15792 .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
15793 .unwrap();
15794 let journal = V2PullJournal {
15795 v: 1,
15796 phase: V2PullPhase::Ready,
15797 brain: TEST_BRAIN_ID.to_string(),
15798 previous: V2PullCoordinate {
15799 head_seq: None,
15800 commit_hash: None,
15801 view_kind: None,
15802 view_revision: None,
15803 },
15804 next: V2PullCoordinate {
15805 head_seq: Some(2),
15806 commit_hash: Some("c".repeat(64)),
15807 view_kind: Some("full".to_string()),
15808 view_revision: Some("d".repeat(64)),
15809 },
15810 backup_dir: backup_dir.clone(),
15811 entries: vec![V2PullJournalEntry {
15812 path: path.to_string(),
15813 old: Some(V2PullFileCoordinate {
15814 sha256: content_sha256(old),
15815 bytes: old.len() as u64,
15816 }),
15817 new: Some(V2PullFileCoordinate {
15818 sha256: content_sha256(new),
15819 bytes: new.len() as u64,
15820 }),
15821 backup: Some("00000000".to_string()),
15822 }],
15823 };
15824 validate_v2_pull_journal(&journal).unwrap();
15825 store
15826 .write_private_atomic_new(
15827 Path::new(V2_PULL_JOURNAL),
15828 &v2_pull_journal_bytes(&journal).unwrap(),
15829 )
15830 .unwrap();
15831 store.write_atomic(Path::new(path), new).unwrap();
15832
15833 let cfg = test_hub_config(
15834 "https://example.test".to_string(),
15835 sandbox.path().join("state"),
15836 );
15837 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
15838 assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
15839 assert!(!root.join(V2_PULL_JOURNAL).exists());
15840 assert!(!root.join(backup_dir).exists());
15841 }
15842
15843 #[test]
15844 fn preparing_pull_journal_discards_only_private_staging() {
15845 let sandbox = tempfile::TempDir::new().unwrap();
15846 let root = sandbox.path().join("brain");
15847 std::fs::create_dir_all(root.join(".dbmd")).unwrap();
15848 std::fs::write(
15849 root.join("DB.md"),
15850 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
15851 )
15852 .unwrap();
15853 let store = Store::open_strict(&root).unwrap();
15854 let bundle = crate::ulid::mint();
15855 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
15856 store
15857 .create_private_dir_all(Path::new(&backup_dir))
15858 .unwrap();
15859 let journal = V2PullJournal {
15860 v: 1,
15861 phase: V2PullPhase::Preparing,
15862 brain: TEST_BRAIN_ID.to_string(),
15863 previous: V2PullCoordinate {
15864 head_seq: None,
15865 commit_hash: None,
15866 view_kind: None,
15867 view_revision: None,
15868 },
15869 next: V2PullCoordinate {
15870 head_seq: Some(1),
15871 commit_hash: Some("a".repeat(64)),
15872 view_kind: Some("full".to_string()),
15873 view_revision: Some("b".repeat(64)),
15874 },
15875 backup_dir: backup_dir.clone(),
15876 entries: vec![V2PullJournalEntry {
15877 path: "records/new.md".to_string(),
15878 old: None,
15879 new: Some(V2PullFileCoordinate {
15880 sha256: "c".repeat(64),
15881 bytes: 1,
15882 }),
15883 backup: None,
15884 }],
15885 };
15886 store
15887 .write_private_atomic_new(
15888 Path::new(V2_PULL_JOURNAL),
15889 &v2_pull_journal_bytes(&journal).unwrap(),
15890 )
15891 .unwrap();
15892 let cfg = test_hub_config(
15893 "https://example.test".to_string(),
15894 sandbox.path().join("state"),
15895 );
15896
15897 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
15898
15899 assert!(root.join("DB.md").is_file());
15900 assert!(!root.join(V2_PULL_JOURNAL).exists());
15901 assert!(!root.join(backup_dir).exists());
15902 }
15903
15904 #[test]
15905 fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
15906 let sandbox = tempfile::TempDir::new().unwrap();
15907 let root = sandbox.path().join("brain");
15908 std::fs::create_dir_all(root.join("records")).unwrap();
15909 std::fs::write(
15910 root.join("DB.md"),
15911 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
15912 )
15913 .unwrap();
15914 let new = b"---\ntype: note\n---\n\nnew\n";
15915 std::fs::write(root.join("records/value.md"), new).unwrap();
15916 let store = Store::open_strict(&root).unwrap();
15917 let bundle = crate::ulid::mint();
15918 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
15919 store
15920 .create_private_dir_all(Path::new(&backup_dir))
15921 .unwrap();
15922 let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
15923 store.create_private_dir_all(Path::new(&orphan)).unwrap();
15924 let next = V2PullCoordinate {
15925 head_seq: Some(2),
15926 commit_hash: Some("c".repeat(64)),
15927 view_kind: Some("full".to_string()),
15928 view_revision: Some("d".repeat(64)),
15929 };
15930 let journal = V2PullJournal {
15931 v: 1,
15932 phase: V2PullPhase::Ready,
15933 brain: TEST_BRAIN_ID.to_string(),
15934 previous: V2PullCoordinate {
15935 head_seq: Some(1),
15936 commit_hash: Some("a".repeat(64)),
15937 view_kind: Some("full".to_string()),
15938 view_revision: Some("b".repeat(64)),
15939 },
15940 next: next.clone(),
15941 backup_dir: backup_dir.clone(),
15942 entries: vec![V2PullJournalEntry {
15943 path: "records/value.md".to_string(),
15944 old: Some(V2PullFileCoordinate {
15945 sha256: "e".repeat(64),
15946 bytes: new.len() as u64,
15947 }),
15948 new: Some(V2PullFileCoordinate {
15949 sha256: content_sha256(new),
15950 bytes: new.len() as u64,
15951 }),
15952 backup: Some("00000000".to_string()),
15953 }],
15954 };
15955 store
15956 .write_private_atomic_new(
15957 Path::new(V2_PULL_JOURNAL),
15958 &v2_pull_journal_bytes(&journal).unwrap(),
15959 )
15960 .unwrap();
15961 let cfg = test_hub_config(
15962 "https://example.test".to_string(),
15963 sandbox.path().join("state"),
15964 );
15965 save_v2_baseline(
15966 &cfg,
15967 TEST_BRAIN_ID,
15968 &root,
15969 &V2SyncBaseline {
15970 v: 2,
15971 origin: "https://example.test".to_string(),
15972 brain: TEST_BRAIN_ID.to_string(),
15973 head_seq: next.head_seq,
15974 commit_hash: next.commit_hash.clone(),
15975 content_root: Some("f".repeat(64)),
15976 asset_root: None,
15977 assets: Default::default(),
15978 view_kind: next.view_kind.clone(),
15979 view_revision: next.view_revision.clone(),
15980 projection_sha256: None,
15981 files: Default::default(),
15982 local_policy_digest: None,
15983 local_eligibility: Default::default(),
15984 remote_copy_remains: Default::default(),
15985 },
15986 )
15987 .unwrap();
15988
15989 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
15990
15991 assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
15992 assert!(!root.join(V2_PULL_JOURNAL).exists());
15993 assert!(!root.join(backup_dir).exists());
15994 assert!(!root.join(orphan).exists());
15995 }
15996}