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;
174const V2_BULK_STREAM_FILES: usize = 256;
178const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
179const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
180const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
181
182#[derive(Debug, thiserror::Error)]
186pub enum LinkError {
187 #[error(
189 "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
190 )]
191 NoHub,
192
193 #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
195 NoCredential,
196
197 #[error(
200 "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
201 )]
202 BadKey,
203
204 #[error(
210 "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}"
211 )]
212 UnboundCredential,
213
214 #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
218 BadAgentKey {
219 message: String,
221 },
222
223 #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
225 UnsafeHub {
226 hub: String,
228 },
229
230 #[error("hub unreachable at {hub}: {message}")]
232 Transport {
233 hub: String,
235 message: String,
237 },
238
239 #[error("{what} failed (HTTP {status}): {message}")]
241 Http {
242 what: &'static str,
244 status: u16,
246 message: String,
248 code: Option<String>,
250 details: Option<Value>,
252 },
253
254 #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
257 NotJson {
258 what: &'static str,
260 status: u16,
262 },
263
264 #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
266 ResponseTooLarge {
267 limit_bytes: u64,
269 },
270
271 #[error("invalid address `{given}`: {reason}")]
273 BadAddress {
274 given: String,
276 reason: String,
278 },
279
280 #[error(
282 "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
283 )]
284 BadGrantId {
285 given: String,
287 },
288
289 #[error("refusing unsafe path from the hub: `{path}`")]
293 UnsafePath {
294 path: String,
296 },
297
298 #[error(
300 "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
301 MAX_STORE_BYTES / (1024 * 1024),
302 MAX_PACK_BYTES / (1024 * 1024)
303 )]
304 PushTooLarge {
305 detail: String,
307 },
308
309 #[error(
311 "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
312 MAX_PROPOSE_BYTES / 1024
313 )]
314 ProposeTooLarge {
315 bytes: u64,
317 },
318
319 #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
321 NotUtf8 {
322 path: String,
324 },
325
326 #[error("invalid store pack: {message}")]
328 InvalidPack {
329 message: String,
331 },
332
333 #[error("invalid signed feed: {message}")]
335 InvalidFeed {
336 message: String,
338 },
339
340 #[error("sync conflict on {paths:?} — resolve the named files and retry")]
343 Conflict {
344 paths: Vec<String>,
346 },
347
348 #[error(
352 "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
353 )]
354 ConflictBundle {
355 bundle: String,
357 paths: Vec<String>,
359 },
360
361 #[error(
365 "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
366 )]
367 LocalPolicyTransition {
368 paths: Vec<String>,
370 },
371
372 #[error(
377 "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
378 )]
379 BulkPreviewRequired {
380 preview: Value,
382 },
383
384 #[error(
387 "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
388 )]
389 ScopedProjectionModified,
390
391 #[error(
395 "the checkout's permission scope changed — clone into a new directory to accept the new view"
396 )]
397 ScopedViewChanged,
398
399 #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
402 BrainUnavailable,
403
404 #[error(
407 "the remote brain advanced during sync — retry to converge from the new verified head"
408 )]
409 RemoteAdvancedDuringSync,
410
411 #[error(
414 "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
415 )]
416 UnsupportedPlatform {
417 operation: &'static str,
419 },
420
421 #[error(transparent)]
423 Io(#[from] std::io::Error),
424
425 #[error(transparent)]
427 Store(#[from] crate::StoreError),
428}
429
430pub type LinkResult<T> = std::result::Result<T, LinkError>;
432
433#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct V2BulkConfirmation {
436 pub id: String,
438 pub digest: String,
441}
442
443impl V2BulkConfirmation {
444 pub fn parse(value: &str) -> LinkResult<Self> {
447 let (id, digest) = value
448 .split_once(':')
449 .ok_or_else(|| LinkError::InvalidPack {
450 message: "bulk confirmation must be <id>:<digest>".to_string(),
451 })?;
452 if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
453 return Err(LinkError::InvalidPack {
454 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
455 .to_string(),
456 });
457 }
458 Ok(Self {
459 id: id.to_string(),
460 digest: digest.to_string(),
461 })
462 }
463}
464
465fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
470 #[cfg(any(target_os = "linux", target_os = "macos", windows))]
471 {
472 let _ = operation;
473 Ok(())
474 }
475 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
476 {
477 Err(LinkError::UnsupportedPlatform { operation })
478 }
479}
480
481#[derive(Debug, Clone, PartialEq, Eq)]
487pub enum AddressTarget {
488 Id(String),
490 Path(String),
494}
495
496const BAD_BRAIN_REASON: &str =
499 "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
500
501const BAD_TARGET_REASON: &str =
504 "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
505
506#[derive(Debug, Clone, PartialEq, Eq)]
511pub struct Address {
512 pub brain: String,
514 pub target: Option<AddressTarget>,
516}
517
518impl Address {
519 pub fn parse(raw: &str) -> LinkResult<Address> {
523 let bad = |reason: &str| LinkError::BadAddress {
524 given: raw.to_string(),
525 reason: reason.to_string(),
526 };
527
528 let trimmed = raw.trim();
529 let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
530 if body.is_empty() {
531 return Err(bad("empty address"));
532 }
533
534 let (brain, rest) = match body.split_once('/') {
535 Some((b, r)) => (b, Some(r)),
536 None => (body, None),
537 };
538
539 if brain.is_empty() {
540 return Err(bad("missing brain reference before `/`"));
541 }
542 if !is_safe_ref(brain) {
543 return Err(bad(BAD_BRAIN_REASON));
544 }
545
546 let target = match rest {
547 None => None,
548 Some("") => return Err(bad("trailing `/` with no record id or path")),
549 Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
550 Some(r) => {
551 if !safe_store_rel_path(r) || !r.ends_with(".md") {
552 return Err(bad(BAD_TARGET_REASON));
553 }
554 Some(AddressTarget::Path(r.to_string()))
555 }
556 };
557
558 Ok(Address {
559 brain: brain.to_string(),
560 target,
561 })
562 }
563}
564
565fn is_safe_ref(s: &str) -> bool {
568 !s.is_empty()
569 && s.len() <= 64
570 && s.bytes()
571 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
572}
573
574pub fn is_valid_handle(s: &str) -> bool {
577 is_safe_ref(s)
578}
579
580pub fn safe_store_rel_path(p: &str) -> bool {
586 if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
587 return false;
588 }
589 if !p
590 .bytes()
591 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
592 {
593 return false;
594 }
595 p.split('/')
596 .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
597}
598
599fn require_safe_ref(brain: &str) -> LinkResult<()> {
607 if is_safe_ref(brain) {
608 Ok(())
609 } else {
610 Err(LinkError::BadAddress {
611 given: brain.to_string(),
612 reason: BAD_BRAIN_REASON.to_string(),
613 })
614 }
615}
616
617fn require_valid_handle(handle: &str) -> LinkResult<()> {
619 if is_valid_handle(handle) {
620 Ok(())
621 } else {
622 Err(LinkError::BadAddress {
623 given: handle.to_string(),
624 reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
625 })
626 }
627}
628
629fn require_safe_grant_id(id: &str) -> LinkResult<()> {
633 if is_safe_ref(id) {
634 Ok(())
635 } else {
636 Err(LinkError::BadGrantId {
637 given: id.to_string(),
638 })
639 }
640}
641
642#[derive(Debug, Clone)]
648pub struct HubConfig {
649 pub hub: String,
651 pub key: Option<String>,
653 pub agent_key: Option<AgentSigningKey>,
656 pub brain_key: Option<AgentSigningKey>,
659 pub state_dir: PathBuf,
662 store_selected: bool,
665}
666
667#[derive(Clone)]
670pub struct AgentSigningKey {
671 pkcs8: Vec<u8>,
672 pub multikey: String,
674 pub public_key_spki: String,
676}
677
678impl std::fmt::Debug for AgentSigningKey {
679 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
680 f.debug_struct("AgentSigningKey")
681 .field("multikey", &self.multikey)
682 .field("pkcs8", &"<redacted>")
683 .finish()
684 }
685}
686
687impl HubConfig {
688 pub fn require_key(&self) -> LinkResult<&str> {
691 self.key.as_deref().ok_or(LinkError::NoCredential)
692 }
693}
694
695pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
700 let explicit_hub = flag_hub
701 .map(str::to_string)
702 .or_else(|| env_nonempty(HUB_URL_ENV));
703 let selected_by_store = explicit_hub.is_none();
704 let hub = explicit_hub
705 .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
706 .ok_or(LinkError::NoHub)?;
707 let hub = hub.trim().trim_end_matches('/').to_string();
708 assert_safe_hub(&hub)?;
709 if selected_by_store {
710 let parsed =
711 url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
712 if !parsed.scheme().eq_ignore_ascii_case("https")
716 || (parsed.path() != "/" && !parsed.path().is_empty())
717 {
718 return Err(LinkError::UnsafeHub { hub });
719 }
720 }
721
722 let key = match env_nonempty(HUB_KEY_ENV) {
723 Some(raw) => Some(clean_key(&raw)?),
724 None => None,
725 };
726
727 let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
728 Some(path) => Some(load_agent_key(Path::new(&path))?),
729 None => None,
730 };
731
732 let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
733 Some(path) => Some(load_agent_key(Path::new(&path))?),
734 None => None,
735 };
736
737 if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
744 let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
745 .and_then(|value| normalized_origin(&value).ok());
746 let selected_origin = normalized_origin(&hub)?;
747 if bound.as_deref() != Some(selected_origin.as_str()) {
748 return Err(LinkError::UnboundCredential);
749 }
750 }
751
752 Ok(HubConfig {
753 hub,
754 key,
755 agent_key,
756 brain_key,
757 state_dir: toolkit_state_dir()?,
758 store_selected: selected_by_store,
759 })
760}
761
762fn toolkit_state_dir() -> LinkResult<PathBuf> {
763 if let Some(path) = env_nonempty(STATE_DIR_ENV) {
764 let path = PathBuf::from(path);
765 if !path.is_absolute() {
766 return Err(LinkError::UnsafePath {
767 path: path.display().to_string(),
768 });
769 }
770 return Ok(path);
771 }
772 #[cfg(windows)]
773 if let Some(base) = env_nonempty("LOCALAPPDATA") {
774 let base = PathBuf::from(base);
775 if base.is_absolute() {
776 return Ok(base.join("dbmd").join("state"));
777 }
778 }
779 #[cfg(windows)]
780 {
781 Err(LinkError::Io(std::io::Error::new(
782 std::io::ErrorKind::NotFound,
783 format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
784 )))
785 }
786 #[cfg(not(windows))]
787 if let Some(base) = env_nonempty("XDG_STATE_HOME") {
788 let base = PathBuf::from(base);
789 if base.is_absolute() {
790 return Ok(base.join("dbmd"));
791 }
792 }
793 #[cfg(not(windows))]
794 let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
795 LinkError::Io(std::io::Error::new(
796 std::io::ErrorKind::NotFound,
797 format!("cannot locate user state; set {STATE_DIR_ENV}"),
798 ))
799 })?);
800 #[cfg(not(windows))]
801 if !home.is_absolute() {
802 return Err(LinkError::UnsafePath {
803 path: home.display().to_string(),
804 });
805 }
806 #[cfg(target_os = "macos")]
807 {
808 Ok(home
809 .join("Library")
810 .join("Application Support")
811 .join("dbmd")
812 .join("state"))
813 }
814 #[cfg(all(not(target_os = "macos"), not(windows)))]
815 {
816 Ok(home.join(".local").join("state").join("dbmd"))
817 }
818}
819
820fn normalized_origin(value: &str) -> LinkResult<String> {
821 let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
822 hub: value.to_string(),
823 })?;
824 if !(parsed.scheme().eq_ignore_ascii_case("https")
825 || parsed.scheme().eq_ignore_ascii_case("http"))
826 || !parsed.username().is_empty()
827 || parsed.password().is_some()
828 || (parsed.path() != "/" && !parsed.path().is_empty())
829 || parsed.query().is_some()
830 || parsed.fragment().is_some()
831 {
832 return Err(LinkError::UnsafeHub {
833 hub: value.to_string(),
834 });
835 }
836 let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
837 hub: value.to_string(),
838 })?;
839 let host = if host.contains(':') {
840 format!("[{host}]")
841 } else {
842 host.to_ascii_lowercase()
843 };
844 let port = parsed
845 .port_or_known_default()
846 .ok_or_else(|| LinkError::UnsafeHub {
847 hub: value.to_string(),
848 })?;
849 let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
850 || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
851 Ok(format!(
852 "{}://{}{}",
853 parsed.scheme().to_ascii_lowercase(),
854 host,
855 if default {
856 String::new()
857 } else {
858 format!(":{port}")
859 }
860 ))
861}
862
863const ED25519_SPKI_PREFIX: [u8; 12] = [
870 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
871];
872
873fn bad_agent_key(message: &str) -> LinkError {
874 LinkError::BadAgentKey {
875 message: message.to_string(),
876 }
877}
878
879fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
880 ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
884 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
885 .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
886}
887
888fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
890 use ring::signature::KeyPair as _;
891 let mut spki = Vec::with_capacity(44);
892 spki.extend_from_slice(&ED25519_SPKI_PREFIX);
893 spki.extend_from_slice(pair.public_key().as_ref());
894 (
895 URL_SAFE_NO_PAD.encode(&spki),
896 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
897 )
898}
899
900pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
904 load_agent_key(path)
905}
906
907fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
909 #[cfg(unix)]
910 let file = {
911 use std::os::fd::{AsRawFd as _, FromRawFd as _};
912 use std::os::unix::ffi::OsStrExt as _;
913 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
914 .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
915 let leaf = path
916 .file_name()
917 .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
918 let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
919 let fd = unsafe {
920 libc::openat(
921 parent.as_raw_fd(),
922 leaf.as_ptr(),
923 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
924 )
925 };
926 if fd < 0 {
927 return Err(bad_agent_key(
928 "the key path must be an existing regular file without symlink ancestors",
929 ));
930 }
931 unsafe { std::fs::File::from_raw_fd(fd) }
932 };
933 #[cfg(not(unix))]
934 let file = std::fs::File::open(path)
935 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
936 let metadata = file
937 .metadata()
938 .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
939 if !metadata.is_file() {
940 return Err(bad_agent_key("the key path must be a regular file"));
941 }
942 #[cfg(unix)]
943 {
944 use std::os::unix::fs::PermissionsExt as _;
945 if metadata.permissions().mode() & 0o077 != 0 {
946 return Err(bad_agent_key(
947 "the key file is accessible to group/other; set mode 0600",
948 ));
949 }
950 }
951 let mut text = String::new();
952 file.take(1024 * 1024 + 1)
953 .read_to_string(&mut text)
954 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
955 if text.len() > 1024 * 1024 {
956 return Err(bad_agent_key("the key file exceeds the size limit"));
957 }
958 let pkcs8 = URL_SAFE_NO_PAD
959 .decode(text.trim())
960 .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
961 let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
962 Ok(AgentSigningKey {
963 pkcs8,
964 multikey,
965 public_key_spki,
966 })
967}
968
969fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
975 #[cfg(unix)]
976 let (mut file, parent, leaf) = {
977 use std::os::fd::{AsRawFd as _, FromRawFd as _};
978 use std::os::unix::ffi::OsStrExt as _;
979 let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
980 let leaf_name = path
981 .file_name()
982 .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
983 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
984 let fd = unsafe {
985 libc::openat(
986 parent.as_raw_fd(),
987 leaf.as_ptr(),
988 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
989 0o600,
990 )
991 };
992 if fd < 0 {
993 let error = std::io::Error::last_os_error();
994 if error.kind() == std::io::ErrorKind::AlreadyExists {
995 return Err(bad_agent_key(
996 "the output file already exists — refusing to overwrite a key",
997 ));
998 }
999 return Err(error.into());
1000 }
1001 (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1002 };
1003 #[cfg(not(unix))]
1004 let mut file = std::fs::OpenOptions::new()
1005 .write(true)
1006 .create_new(true)
1007 .open(path)
1008 .map_err(|error| {
1009 if error.kind() == std::io::ErrorKind::AlreadyExists {
1010 bad_agent_key("the output file already exists — refusing to overwrite a key")
1011 } else {
1012 LinkError::Io(error)
1013 }
1014 })?;
1015 if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1016 drop(file);
1017 #[cfg(unix)]
1018 let _ =
1019 unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1020 #[cfg(not(unix))]
1021 let _ = std::fs::remove_file(path);
1022 return Err(LinkError::Io(error));
1023 }
1024 drop(file);
1025 #[cfg(unix)]
1026 parent.sync_all()?;
1027 Ok(())
1028}
1029
1030#[derive(Debug, Serialize)]
1033pub struct GeneratedAgentKey {
1034 pub multikey: String,
1036 #[serde(rename = "publicKeySpki")]
1038 pub public_key_spki: String,
1039 #[serde(rename = "keyFile")]
1041 pub key_file: String,
1042}
1043
1044pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1049 require_hardened_filesystem("key generation")?;
1050 let rng = ring::rand::SystemRandom::new();
1051 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1052 .map_err(|_| bad_agent_key("key generation failed"))?;
1053 let pair = agent_keypair(pkcs8.as_ref())?;
1054 let (spki_b64u, multikey) = public_identity_for(&pair);
1055
1056 write_secret_new(
1057 out,
1058 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1059 )?;
1060
1061 Ok(GeneratedAgentKey {
1062 multikey,
1063 public_key_spki: spki_b64u,
1064 key_file: out.display().to_string(),
1065 })
1066}
1067
1068fn linkmd_sig_header(
1077 key: &AgentSigningKey,
1078 origin: &str,
1079 method: &str,
1080 path: &str,
1081 body: Option<&str>,
1082) -> LinkResult<String> {
1083 let ts = std::time::SystemTime::now()
1084 .duration_since(std::time::UNIX_EPOCH)
1085 .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1086 .as_secs();
1087 let body_hash = match body {
1088 Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1089 None => "-".to_string(),
1090 };
1091 let canonical = format!(
1092 "v2\n{}\n{}\n{}\n{}\n{}",
1093 origin,
1094 method.to_uppercase(),
1095 path,
1096 ts,
1097 body_hash
1098 );
1099 let pair = agent_keypair(&key.pkcs8)?;
1100 let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1101 let fingerprint = key.multikey.trim_start_matches("ed25519:");
1102 Ok(format!(
1103 "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1104 ))
1105}
1106
1107#[derive(Serialize)]
1114struct WireFeedFile {
1115 path: String,
1116 sha256: String,
1117 bytes: u64,
1118}
1119
1120#[derive(Serialize)]
1123struct UnsignedWireEntry<'a> {
1124 v: u8,
1125 seq: u64,
1126 ts: String,
1127 brain: &'a str,
1128 public_key: &'a str,
1129 kind: &'a str,
1130 op: &'a str,
1131 pack_sha256: &'a str,
1132 files: &'a [WireFeedFile],
1133 removed: &'a [String],
1134 prev_entry_hash: Option<&'a str>,
1135}
1136
1137fn self_custody_entry(
1143 key: &AgentSigningKey,
1144 seq: u64,
1145 ts: String,
1146 pack_sha256: &str,
1147 files: &[WireFeedFile],
1148 prev_entry_hash: Option<&str>,
1149) -> LinkResult<String> {
1150 let removed: [String; 0] = [];
1151 let unsigned = serde_json::to_string(&UnsignedWireEntry {
1152 v: 1,
1153 seq,
1154 ts,
1155 brain: &key.multikey,
1156 public_key: &key.public_key_spki,
1157 kind: "push",
1158 op: "snapshot",
1159 pack_sha256,
1160 files,
1161 removed: &removed,
1162 prev_entry_hash,
1163 })
1164 .expect("serialize feed entry");
1165 let pair = agent_keypair(&key.pkcs8)?;
1166 let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1167 Ok(format!(
1168 "{},\"sig\":\"{}\"}}",
1169 &unsigned[..unsigned.len() - 1],
1170 sig
1171 ))
1172}
1173
1174fn env_nonempty(name: &str) -> Option<String> {
1177 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1178}
1179
1180fn config_file_hub(path: &Path) -> Option<String> {
1185 const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1186 #[cfg(unix)]
1187 let file = {
1188 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1189 use std::os::unix::ffi::OsStrExt as _;
1190 let parent =
1191 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1192 let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1193 let fd = unsafe {
1194 libc::openat(
1195 parent.as_raw_fd(),
1196 leaf.as_ptr(),
1197 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1198 )
1199 };
1200 if fd < 0 {
1201 return None;
1202 }
1203 unsafe { std::fs::File::from_raw_fd(fd) }
1204 };
1205 #[cfg(not(unix))]
1206 let file = std::fs::File::open(path).ok()?;
1207 let metadata = file.metadata().ok()?;
1208 if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1209 return None;
1210 }
1211 let mut bytes = Vec::with_capacity(metadata.len() as usize);
1212 file.take(MAX_CONFIG_BYTES + 1)
1213 .read_to_end(&mut bytes)
1214 .ok()?;
1215 if bytes.len() as u64 > MAX_CONFIG_BYTES {
1216 return None;
1217 }
1218 let text = String::from_utf8(bytes).ok()?;
1219 for line in text.lines() {
1220 let line = line.trim();
1221 if line.is_empty() || line.starts_with('#') {
1222 continue;
1223 }
1224 if let Some((k, v)) = line.split_once('=') {
1225 if k.trim() == "hub" {
1226 let v = v.trim();
1227 if !v.is_empty() {
1228 return Some(v.to_string());
1229 }
1230 }
1231 }
1232 }
1233 None
1234}
1235
1236fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1239 let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1240 hub: hub.to_string(),
1241 })?;
1242 if !(parsed.scheme().eq_ignore_ascii_case("https")
1243 || parsed.scheme().eq_ignore_ascii_case("http"))
1244 || !parsed.username().is_empty()
1245 || parsed.password().is_some()
1246 || (parsed.path() != "/" && !parsed.path().is_empty())
1247 || parsed.query().is_some()
1248 || parsed.fragment().is_some()
1249 {
1250 return Err(LinkError::UnsafeHub {
1251 hub: hub.to_string(),
1252 });
1253 }
1254 let loopback = match parsed.host() {
1255 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1256 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1257 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1258 None => false,
1259 };
1260 if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1261 Ok(())
1262 } else {
1263 Err(LinkError::UnsafeHub {
1264 hub: hub.to_string(),
1265 })
1266 }
1267}
1268
1269fn clean_key(raw: &str) -> LinkResult<String> {
1274 let k = raw.trim();
1275 if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1276 return Err(LinkError::BadKey);
1277 }
1278 Ok(k.to_string())
1279}
1280
1281#[derive(Debug)]
1287pub struct HubResponse {
1288 pub status: u16,
1290 pub body: Option<Value>,
1292}
1293
1294struct RawHubResponse {
1295 status: u16,
1296 body: Vec<u8>,
1297}
1298
1299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1301enum Auth {
1302 Required,
1304 None,
1306 Optional,
1310}
1311
1312fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1313 ureq::AgentBuilder::new()
1314 .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1315 .redirects(0)
1319 .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1320 .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1321 .timeout_write(overall)
1322 .timeout(overall)
1323}
1324
1325fn agent_builder() -> ureq::AgentBuilder {
1326 agent_builder_with_timeout(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS))
1327}
1328
1329fn agent() -> ureq::Agent {
1330 agent_builder().build()
1331}
1332
1333fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1334 if !cfg.store_selected {
1335 return Ok(agent());
1336 }
1337 let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1338 hub: cfg.hub.clone(),
1339 })?;
1340 pinned_public_agent(&parsed, false, "store-selected hub")
1341}
1342
1343fn request_raw(
1348 cfg: &HubConfig,
1349 method: &str,
1350 path: &str,
1351 body: Option<&Value>,
1352 auth: Auth,
1353 max_response_bytes: u64,
1354) -> LinkResult<RawHubResponse> {
1355 let http = hub_agent(cfg)?;
1356 request_raw_with_agent(cfg, &http, method, path, body, auth, max_response_bytes)
1357}
1358
1359fn request_raw_with_agent(
1360 cfg: &HubConfig,
1361 http: &ureq::Agent,
1362 method: &str,
1363 path: &str,
1364 body: Option<&Value>,
1365 auth: Auth,
1366 max_response_bytes: u64,
1367) -> LinkResult<RawHubResponse> {
1368 let url = format!("{}{}", cfg.hub, path);
1369 let encoded_body = body.map(Value::to_string);
1370 let origin = normalized_origin(&cfg.hub)?;
1371 let credential = match auth {
1374 Auth::Required => Some(match &cfg.agent_key {
1375 Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1376 None => format!("Bearer {}", cfg.require_key()?),
1377 }),
1378 Auth::Optional => match &cfg.agent_key {
1379 Some(key) => Some(linkmd_sig_header(
1380 key,
1381 &origin,
1382 method,
1383 path,
1384 encoded_body.as_deref(),
1385 )?),
1386 None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1387 },
1388 Auth::None => None,
1389 };
1390 let result = with_connect_retries(|| {
1391 let mut req = http.request(method, &url);
1392 if let Some(value) = &credential {
1393 req = req.set("authorization", value);
1394 }
1395 match &encoded_body {
1396 Some(value) => req
1397 .set("content-type", "application/json")
1398 .send_string(value)
1399 .map_err(Box::new),
1400 None => req.call().map_err(Box::new),
1401 }
1402 });
1403 let resp = match result {
1404 Ok(resp) => resp,
1405 Err(error) => match *error {
1406 ureq::Error::Status(_, resp) => resp,
1407 ureq::Error::Transport(error) => {
1408 return Err(LinkError::Transport {
1409 hub: cfg.hub.clone(),
1410 message: error.to_string(),
1411 });
1412 }
1413 },
1414 };
1415
1416 let status = resp.status();
1417 let mut buf = Vec::new();
1418 resp.into_reader()
1419 .take(max_response_bytes + 1)
1420 .read_to_end(&mut buf)?;
1421 if buf.len() as u64 > max_response_bytes {
1422 return Err(LinkError::ResponseTooLarge {
1423 limit_bytes: max_response_bytes,
1424 });
1425 }
1426 Ok(RawHubResponse { status, body: buf })
1427}
1428
1429fn request_capped(
1430 cfg: &HubConfig,
1431 method: &str,
1432 path: &str,
1433 body: Option<&Value>,
1434 auth: Auth,
1435 max_response_bytes: u64,
1436) -> LinkResult<HubResponse> {
1437 let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1438 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1439 Ok(HubResponse {
1440 status: raw.status,
1441 body: parsed,
1442 })
1443}
1444
1445fn request(
1446 cfg: &HubConfig,
1447 method: &str,
1448 path: &str,
1449 body: Option<&Value>,
1450 auth: Auth,
1451) -> LinkResult<HubResponse> {
1452 request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1453}
1454
1455fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1456 if (200..300).contains(&r.status) {
1457 return Ok(r.body);
1458 }
1459 ensure_ok(
1460 HubResponse {
1461 status: r.status,
1462 body: serde_json::from_slice(&r.body).ok(),
1463 },
1464 what,
1465 )
1466 .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1467}
1468
1469fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1474 matches!(
1475 kind,
1476 ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1477 )
1478}
1479
1480fn with_connect_retries(
1481 mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1482) -> Result<ureq::Response, Box<ureq::Error>> {
1483 let mut attempt = 0;
1484 loop {
1485 match send() {
1486 Err(error)
1487 if matches!(
1488 error.as_ref(),
1489 ureq::Error::Transport(transport)
1490 if is_pre_request_transport(transport.kind())
1491 ) && attempt + 1 < CONNECT_ATTEMPTS =>
1492 {
1493 std::thread::sleep(std::time::Duration::from_millis(
1494 CONNECT_RETRY_BACKOFF_MS[attempt],
1495 ));
1496 attempt += 1;
1497 }
1498 result => return result,
1499 }
1500 }
1501}
1502
1503fn hub_is_loopback(hub: &str) -> bool {
1504 url::Url::parse(hub).ok().is_some_and(|parsed| {
1505 parsed.host().is_some_and(|host| match host {
1506 url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1507 url::Host::Ipv4(ip) => ip.is_loopback(),
1508 url::Host::Ipv6(ip) => ip.is_loopback(),
1509 })
1510 })
1511}
1512
1513fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1514 let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1515 message: "the hub returned an invalid object-store URL".to_string(),
1516 })?;
1517 let allow_private = hub_is_loopback(&cfg.hub)
1518 || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1519 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1520 || !parsed.username().is_empty()
1521 || parsed.password().is_some()
1522 || parsed.fragment().is_some()
1523 {
1524 return Err(LinkError::InvalidPack {
1525 message: "the hub returned an unsafe object-store URL".to_string(),
1526 });
1527 }
1528 pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1529 LinkError::InvalidPack {
1530 message: "the hub returned an object-store URL with an unsafe network target"
1531 .to_string(),
1532 }
1533 })
1534}
1535
1536fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1537 let http = presigned_agent(cfg, raw)?;
1538 let result = with_connect_retries(|| {
1539 let mut req = http.put(raw);
1540 if let Some(map) = headers.as_object() {
1541 for (name, value) in map {
1542 if let Some(value) = value.as_str() {
1543 req = req.set(name, value);
1544 }
1545 }
1546 }
1547 req.send_bytes(bytes).map_err(Box::new)
1548 });
1549 match result {
1550 Ok(resp) if (200..300).contains(&resp.status()) => Ok(()),
1551 Ok(resp) => Err(LinkError::Http {
1552 what: "pack upload",
1553 status: resp.status(),
1554 message: "object store rejected the upload".to_string(),
1555 code: None,
1556 details: None,
1557 }),
1558 Err(error) => match *error {
1559 ureq::Error::Status(412, _) => Ok(()),
1564 ureq::Error::Status(_, resp) => Err(LinkError::Http {
1565 what: "pack upload",
1566 status: resp.status(),
1567 message: "object store rejected the upload".to_string(),
1568 code: None,
1569 details: None,
1570 }),
1571 ureq::Error::Transport(err) => Err(LinkError::Transport {
1572 hub: "the object store".to_string(),
1573 message: err.to_string(),
1574 }),
1575 },
1576 }
1577}
1578
1579fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1580 max_bytes.checked_add(1)
1581}
1582
1583fn presigned_download_read_limit() -> u64 {
1584 one_past_bounded_limit(MAX_PACK_BYTES)
1585 .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1586}
1587
1588fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1589 let http = presigned_agent(cfg, raw)?;
1590 let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1591 Ok(resp) => resp,
1592 Err(error) => match *error {
1593 ureq::Error::Status(_, resp) => {
1594 return Err(LinkError::Http {
1595 what: "pack download",
1596 status: resp.status(),
1597 message: "object store rejected the download".to_string(),
1598 code: None,
1599 details: None,
1600 });
1601 }
1602 ureq::Error::Transport(err) => {
1603 return Err(LinkError::Transport {
1604 hub: "the object store".to_string(),
1605 message: err.to_string(),
1606 });
1607 }
1608 },
1609 };
1610 if !(200..300).contains(&resp.status()) {
1611 return Err(LinkError::Http {
1612 what: "pack download",
1613 status: resp.status(),
1614 message: "object store rejected the download".to_string(),
1615 code: None,
1616 details: None,
1617 });
1618 }
1619 let mut bytes = Vec::new();
1620 resp.into_reader()
1621 .take(presigned_download_read_limit())
1622 .read_to_end(&mut bytes)?;
1623 if bytes.len() as u64 > MAX_PACK_BYTES {
1624 return Err(LinkError::InvalidPack {
1625 message: "download exceeds the compressed-size limit".to_string(),
1626 });
1627 }
1628 Ok(bytes)
1629}
1630
1631fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
1635 if !(200..300).contains(&r.status) {
1636 let message = r
1637 .body
1638 .as_ref()
1639 .and_then(|b| b.get("error"))
1640 .and_then(Value::as_str)
1641 .unwrap_or("unknown error")
1642 .to_string();
1643 let code = r
1644 .body
1645 .as_ref()
1646 .and_then(|b| b.get("code"))
1647 .and_then(Value::as_str)
1648 .map(str::to_string);
1649 let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
1650 return Err(LinkError::Http {
1651 what,
1652 status: r.status,
1653 message,
1654 code,
1655 details,
1656 });
1657 }
1658 r.body.ok_or(LinkError::NotJson {
1659 what,
1660 status: r.status,
1661 })
1662}
1663
1664fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
1673 match ip {
1674 std::net::IpAddr::V4(ip) => {
1675 let [a, b, c, _] = ip.octets();
1676 !(a == 0
1677 || a == 10
1678 || a == 127
1679 || (a == 100 && (64..=127).contains(&b))
1680 || (a == 169 && b == 254)
1681 || (a == 172 && (16..=31).contains(&b))
1682 || (a == 192 && b == 0 && c == 0)
1683 || (a == 192 && b == 0 && c == 2)
1684 || (a == 192 && b == 88 && c == 99)
1685 || (a == 192 && b == 168)
1686 || (a == 198 && (b == 18 || b == 19))
1687 || (a == 198 && b == 51 && c == 100)
1688 || (a == 203 && b == 0 && c == 113)
1689 || a >= 224)
1690 }
1691 std::net::IpAddr::V6(ip) => {
1692 let segments = ip.segments();
1693 (segments[0] & 0xe000) == 0x2000
1698 && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
1699 && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
1700 && segments[0] != 0x2002
1701 && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
1702 }
1703 }
1704}
1705
1706#[derive(Clone)]
1707struct PinnedRegistryResolver {
1708 netloc: String,
1709 addresses: Vec<std::net::SocketAddr>,
1710}
1711
1712impl ureq::Resolver for PinnedRegistryResolver {
1713 fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
1714 if requested == self.netloc {
1715 Ok(self.addresses.clone())
1716 } else {
1717 Err(std::io::Error::new(
1718 std::io::ErrorKind::PermissionDenied,
1719 "registry request attempted to resolve an unvalidated authority",
1720 ))
1721 }
1722 }
1723}
1724
1725fn pinned_public_agent(
1726 url: &url::Url,
1727 allow_private: bool,
1728 label: &str,
1729) -> LinkResult<ureq::Agent> {
1730 let host = url
1731 .host_str()
1732 .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
1733 let port = url
1734 .port_or_known_default()
1735 .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
1736 let addresses = resolve_addresses_with_deadline(
1737 host,
1738 port,
1739 std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
1740 )
1741 .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
1742 if addresses.is_empty() {
1743 return Err(invalid_feed(format!("{label} DNS returned no addresses")));
1744 }
1745 if !allow_private
1746 && addresses
1747 .iter()
1748 .any(|address| !is_public_registry_ip(address.ip()))
1749 {
1750 return Err(invalid_feed(format!(
1751 "{label} resolves to a non-public address"
1752 )));
1753 }
1754 let netloc = if host.contains(':') {
1755 format!("[{host}]:{port}")
1756 } else {
1757 format!("{host}:{port}")
1758 };
1759 Ok(agent_builder()
1760 .resolver(PinnedRegistryResolver { netloc, addresses })
1761 .build())
1762}
1763
1764fn resolve_addresses_with_deadline(
1769 host: &str,
1770 port: u16,
1771 timeout: std::time::Duration,
1772) -> std::io::Result<Vec<std::net::SocketAddr>> {
1773 use std::net::ToSocketAddrs as _;
1774
1775 let host = host.to_string();
1776 let (send, receive) = std::sync::mpsc::sync_channel(1);
1777 std::thread::Builder::new()
1778 .name("dbmd-dns".to_string())
1779 .spawn(move || {
1780 let result = (host.as_str(), port)
1781 .to_socket_addrs()
1782 .map(|addresses| addresses.collect());
1783 let _ = send.send(result);
1784 })
1785 .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
1786 match receive.recv_timeout(timeout) {
1787 Ok(result) => result,
1788 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
1789 std::io::ErrorKind::TimedOut,
1790 "resolution exceeded its deadline",
1791 )),
1792 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
1793 "resolver stopped without returning a result",
1794 )),
1795 }
1796}
1797
1798fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
1799 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1800 pinned_public_agent(url, allow_private, "registry home")
1801}
1802
1803fn get_json_absolute(url: &str) -> LinkResult<Value> {
1808 let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
1809 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1810 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1811 || !parsed.username().is_empty()
1812 || parsed.password().is_some()
1813 || parsed.query().is_some()
1814 || parsed.fragment().is_some()
1815 {
1816 return Err(invalid_feed("unsafe registry home URL"));
1817 }
1818 let http = registry_agent(&parsed)?;
1819 let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
1820 Ok(resp) => resp,
1821 Err(error) => match *error {
1822 ureq::Error::Status(status, resp) => {
1823 let _ = resp;
1824 return Err(LinkError::Http {
1825 what: "registry home fetch",
1826 status,
1827 message: "the home node rejected the card request".to_string(),
1828 code: None,
1829 details: None,
1830 });
1831 }
1832 ureq::Error::Transport(err) => {
1833 return Err(LinkError::Transport {
1834 hub: url.to_string(),
1835 message: err.to_string(),
1836 });
1837 }
1838 },
1839 };
1840 if !(200..300).contains(&resp.status()) {
1841 return Err(LinkError::Http {
1842 what: "registry home fetch",
1843 status: resp.status(),
1844 message: "the home node returned a redirect or error".to_string(),
1845 code: None,
1846 details: None,
1847 });
1848 }
1849 let mut buf = Vec::new();
1850 resp.into_reader()
1851 .take(MAX_REGISTRY_CARD_BYTES + 1)
1852 .read_to_end(&mut buf)?;
1853 if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
1854 return Err(LinkError::ResponseTooLarge {
1855 limit_bytes: MAX_REGISTRY_CARD_BYTES,
1856 });
1857 }
1858 serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
1859 message: "the home node returned invalid JSON".to_string(),
1860 })
1861}
1862
1863pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
1870 require_safe_ref(handle)?;
1871 let trust_directory = open_trust_dir(cfg)?;
1875 let reg = request_capped(
1876 cfg,
1877 "GET",
1878 &format!("/api/hub/registry/{handle}"),
1879 None,
1880 Auth::None,
1881 MAX_REGISTRY_CARD_BYTES,
1882 )?;
1883 if reg.status == 404 {
1884 return Ok(None);
1885 }
1886 let body = ensure_ok(reg, "registry resolve")?;
1887 let home = body
1888 .get("home")
1889 .and_then(Value::as_str)
1890 .ok_or_else(|| invalid_feed("registry entry has no home"))?;
1891 let brain = body
1892 .get("brain")
1893 .and_then(Value::as_str)
1894 .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
1895 if !crate::ulid::is_ulid(brain) {
1896 return Err(invalid_feed(
1897 "registry entry brain is not a canonical lowercase ULID",
1898 ));
1899 }
1900 let want_fp = body
1901 .get("identity")
1902 .and_then(|i| i.get("fingerprint"))
1903 .and_then(Value::as_str)
1904 .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
1905
1906 let home = home.trim_end_matches('/');
1907 let origin = normalized_origin(home)?;
1908 if origin != home {
1909 return Err(invalid_feed(
1910 "registry home must be an origin without a path, query, or fragment",
1911 ));
1912 }
1913 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
1914 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
1915 if let Some(binding) = &alias_binding {
1916 if binding
1917 .home
1918 .as_deref()
1919 .is_some_and(|pinned_home| pinned_home != home)
1920 {
1921 return Err(invalid_feed(
1922 "registry relocated a pinned handle to a different home",
1923 ));
1924 }
1925 }
1926 let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
1927 if card.get("id").and_then(Value::as_str) != Some(brain) {
1928 return Err(invalid_feed(
1929 "the home node served a card for a different brain",
1930 ));
1931 }
1932 let identity: FeedIdentity = serde_json::from_value(
1933 card.get("identity")
1934 .cloned()
1935 .ok_or_else(|| invalid_feed("the home node served no identity"))?,
1936 )
1937 .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
1938 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
1939 let got_fp = card
1940 .get("identity")
1941 .and_then(|i| i.get("fingerprint"))
1942 .and_then(Value::as_str)
1943 .unwrap_or_default();
1944 if got_fp != want_fp {
1945 return Err(invalid_feed(
1946 "the home node served an identity that does not match the registry — refusing",
1947 ));
1948 }
1949 let current = format!("ed25519:{}", identity.fingerprint);
1950 let advertised_seq = card
1951 .get("headSeq")
1952 .and_then(Value::as_u64)
1953 .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
1954 let advertised_hash = card.get("feedHash").and_then(Value::as_str);
1955 if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
1956 || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
1957 {
1958 return Err(invalid_feed(
1959 "the home node served an invalid feed head boundary",
1960 ));
1961 }
1962 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
1966 let registry_alias = AliasBinding {
1967 v: 1,
1968 origin: normalized_origin(&cfg.hub)?,
1969 requested: handle.to_string(),
1970 brain: brain.to_string(),
1971 home: Some(home.to_string()),
1972 };
1973 save_canonical_pin_and_alias(
1974 cfg,
1975 &trust_directory,
1976 handle,
1977 brain,
1978 TrustState {
1979 v: 2,
1980 origin: normalized_origin(&cfg.hub)?,
1981 requested: brain.to_string(),
1982 brain: brain.to_string(),
1983 home: None,
1984 anchor,
1985 current,
1986 head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
1987 feed_hash: pinned
1988 .as_ref()
1989 .and_then(|checkpoint| checkpoint.feed_hash.clone()),
1990 rotations: identity.rotations.clone(),
1991 hub_signer: None,
1992 protocol_profile: None,
1993 },
1994 Some(®istry_alias),
1995 )?;
1996 let mut out = card;
1997 if let Value::Object(map) = &mut out {
1998 map.insert("home".to_string(), Value::String(home.to_string()));
1999 map.insert(
2000 "resolvedVia".to_string(),
2001 Value::String("registry".to_string()),
2002 );
2003 }
2004 Ok(Some(out))
2005}
2006
2007pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2008 require_safe_ref(&addr.brain)?;
2012 if let Some(target) = &addr.target {
2013 let (given, ok) = match target {
2014 AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2015 AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2016 };
2017 if !ok {
2018 return Err(LinkError::BadAddress {
2019 given: given.clone(),
2020 reason: BAD_TARGET_REASON.to_string(),
2021 });
2022 }
2023 }
2024
2025 if let Some(target) = &addr.target {
2031 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2032 if !remote.head.verified {
2033 return Err(invalid_feed(
2034 "a path-scoped feed cannot prove a record against the full signed snapshot",
2035 ));
2036 }
2037 if remote.head.seq == 0 {
2038 return Err(LinkError::Http {
2039 what: "resolve",
2040 status: 404,
2041 message: "record not found".to_string(),
2042 code: Some("NOT_FOUND".to_string()),
2043 details: None,
2044 });
2045 }
2046 let brain = remote.head.brain.clone();
2047 let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2048 return resolve_from_verified_pack(&brain, target, pack);
2049 }
2050
2051 let path = format!("/api/hub/brains/{}", addr.brain);
2052 let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2057 if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2058 if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2059 return Ok(card);
2060 }
2061 }
2062 let resolved = ensure_ok(direct, "resolve")?;
2063 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2067 if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2068 || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2069 || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2070 {
2071 return Err(invalid_feed(
2072 "resolve card is not bound to the exact verified feed checkpoint",
2073 ));
2074 }
2075 let card_identity: FeedIdentity = serde_json::from_value(
2076 resolved
2077 .get("identity")
2078 .cloned()
2079 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2080 )
2081 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2082 if remote.identity.as_ref() != Some(&card_identity) {
2083 return Err(invalid_feed(
2084 "resolve card identity differs from the verified feed identity",
2085 ));
2086 }
2087 Ok(resolved)
2088}
2089
2090fn resolve_from_verified_pack(
2095 brain: &str,
2096 target: &AddressTarget,
2097 pack: Vec<u8>,
2098) -> LinkResult<Value> {
2099 let entries = parse_store_pack(pack)?;
2100 let mut matched: Option<(String, Vec<u8>)> = None;
2101
2102 for (path, bytes) in entries {
2103 let is_candidate = match target {
2104 AddressTarget::Path(want) => &path == want,
2105 AddressTarget::Id(_) => {
2106 path.ends_with(".md")
2107 && (path.starts_with("records/") || path.starts_with("sources/"))
2108 }
2109 };
2110 if !is_candidate {
2111 continue;
2112 }
2113 let text = std::str::from_utf8(&bytes)
2114 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2115 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2116 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2117 if let AddressTarget::Id(want) = target {
2118 let frontmatter =
2119 crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2120 .map_err(|_| {
2121 invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2122 })?;
2123 if frontmatter.id.as_deref() != Some(want) {
2124 continue;
2125 }
2126 }
2127 if matched.is_some() {
2128 return Err(invalid_feed(
2129 "signed snapshot contains more than one record for the requested target",
2130 ));
2131 }
2132 matched = Some((path, bytes));
2133 }
2134
2135 let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2136 what: "resolve",
2137 status: 404,
2138 message: "record not found".to_string(),
2139 code: Some("NOT_FOUND".to_string()),
2140 details: None,
2141 })?;
2142 let text = std::str::from_utf8(&bytes)
2143 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2144 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2145 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2146 let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2147 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2148 let Value::Object(fields) = frontmatter else {
2149 return Err(invalid_feed(format!(
2150 "signed snapshot record `{path}` frontmatter is not a mapping"
2151 )));
2152 };
2153 let mut document = serde_json::Map::new();
2154 document.insert("path".to_string(), Value::String(path));
2155 for (key, value) in fields {
2156 document.insert(key, value);
2157 }
2158 document.insert("body".to_string(), Value::String(parsed.body));
2159 document.insert(
2160 "contentSha".to_string(),
2161 Value::String(content_sha256(&bytes)),
2162 );
2163 Ok(json!({
2164 "brain": brain,
2165 "document": Value::Object(document),
2166 }))
2167}
2168
2169#[derive(Debug, serde::Serialize)]
2175pub struct PullReport {
2176 pub brain: String,
2178 pub slug: String,
2180 #[serde(rename = "headSeq")]
2182 pub head_seq: u64,
2183 pub files: usize,
2185 pub dest: String,
2187 #[serde(rename = "extraLocal")]
2190 pub extra_local: Vec<String>,
2191 #[serde(rename = "syncStatus")]
2193 pub sync_status: String,
2194}
2195
2196fn download_verified_snapshot_pack(
2197 cfg: &HubConfig,
2198 brain: &str,
2199 remote: &VerifiedRemote,
2200) -> LinkResult<Vec<u8>> {
2201 let feed_hash = remote
2202 .head
2203 .feed_hash
2204 .as_deref()
2205 .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2206 let signed_head = remote
2207 .head_entry
2208 .as_ref()
2209 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2210 let expected = &signed_head.entry.pack_sha256;
2211 if !is_sha256(expected) {
2212 return Err(invalid_feed(
2213 "signed head carries an invalid snapshot pack digest",
2214 ));
2215 }
2216 let path = format!(
2217 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2218 remote.head.seq
2219 );
2220 let body = ensure_ok(
2221 request(cfg, "GET", &path, None, Auth::Required)?,
2222 "sync pull",
2223 )?;
2224 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2225 || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2226 || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2227 || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2228 {
2229 return Err(invalid_feed(
2230 "export response is not bound to the exact verified snapshot",
2231 ));
2232 }
2233 let url = body
2234 .get("url")
2235 .and_then(Value::as_str)
2236 .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2237 let bytes = get_presigned(cfg, url)?;
2238 if content_sha256(&bytes) != *expected {
2239 return Err(LinkError::InvalidPack {
2240 message: "downloaded pack does not match the signed snapshot digest".to_string(),
2241 });
2242 }
2243 let entries = parse_store_pack(bytes.clone())?;
2244 if signed_head.entry.kind == "push" {
2245 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2246 }
2247 Ok(bytes)
2248}
2249
2250#[derive(Debug, Clone, Deserialize, Serialize)]
2251struct V2PointerBody {
2252 v: u8,
2253 brain: String,
2254 seq: u64,
2255 commit_hash: String,
2256 feed_hash: String,
2257 content_root: Option<String>,
2258 asset_root: Option<String>,
2259 materializer: String,
2260 signer_epoch: u64,
2261 control_revision: String,
2262 backup_preparation: String,
2263 prior_pointer_hash: Option<String>,
2264 signed_at: String,
2265}
2266
2267#[derive(Debug, Clone, Deserialize)]
2268struct V2SignedPointer {
2269 pointer: V2PointerBody,
2270 hub_public_key: String,
2271 hub_fingerprint: String,
2272 sig: String,
2273}
2274
2275#[derive(Debug, Clone, Deserialize)]
2276struct V2HeadIdentity {
2277 #[serde(default)]
2278 custody: String,
2279 fingerprint: String,
2280 public_key_spki: String,
2281 #[serde(default)]
2282 previous: Vec<V2PreviousIdentity>,
2283 #[serde(default)]
2284 rotations: Vec<String>,
2285}
2286
2287#[derive(Debug, Clone, Deserialize)]
2288struct V2PreviousIdentity {
2289 fingerprint: String,
2290 public_key_spki: String,
2291}
2292
2293#[derive(Debug, Deserialize)]
2294struct V2HeadResponse {
2295 v: u8,
2296 brain_id: String,
2297 profile: String,
2298 view: Option<V2HeadView>,
2299 pointer: Option<V2SignedPointer>,
2300 identity: Option<V2HeadIdentity>,
2301}
2302
2303#[derive(Debug, Clone, Deserialize)]
2304struct V2HeadView {
2305 kind: String,
2306 control_revision: String,
2307}
2308
2309#[derive(Debug, Clone)]
2310struct V2VerifiedHead {
2311 requested: String,
2312 brain_id: String,
2313 view_kind: String,
2314 view_revision: String,
2315 identity: V2HeadIdentity,
2316 pointer: Option<V2PointerBody>,
2317 trust: TrustState,
2318 alias: Option<AliasBinding>,
2319}
2320
2321fn verify_v2_spki_signature(
2322 public_key: &str,
2323 message: &[u8],
2324 signature: &str,
2325) -> LinkResult<Vec<u8>> {
2326 let der = URL_SAFE_NO_PAD
2327 .decode(public_key)
2328 .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2329 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2330 return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2331 }
2332 let sig = URL_SAFE_NO_PAD
2333 .decode(signature)
2334 .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2335 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2336 .verify(message, &sig)
2337 .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2338 Ok(der)
2339}
2340
2341fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2342 if pointer.pointer.v != 2
2343 || pointer.pointer.brain != expected_brain
2344 || pointer.pointer.seq == 0
2345 || !is_sha256(&pointer.pointer.commit_hash)
2346 || !is_sha256(&pointer.pointer.feed_hash)
2347 || pointer
2348 .pointer
2349 .content_root
2350 .as_deref()
2351 .is_some_and(|hash| !is_sha256(hash))
2352 || !is_sha256(&pointer.pointer.backup_preparation)
2353 {
2354 return Err(invalid_feed("v2 pointer fields are invalid"));
2355 }
2356 let value = serde_json::to_value(&pointer.pointer)
2357 .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2358 let message = crate::linkmd_v2::canonical_bytes(&value)
2359 .map_err(|error| invalid_feed(error.to_string()))?;
2360 let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2361 let fingerprint = format!("{:x}", Sha256::digest(&der));
2362 if fingerprint != pointer.hub_fingerprint {
2363 return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2364 }
2365 Ok(format!(
2366 "{}:{}",
2367 pointer.hub_fingerprint, pointer.hub_public_key
2368 ))
2369}
2370
2371fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2372 FeedIdentity {
2373 fingerprint: identity.fingerprint.clone(),
2374 public_key_spki: identity.public_key_spki.clone(),
2375 previous: identity
2376 .previous
2377 .iter()
2378 .map(|previous| PreviousIdentity {
2379 fingerprint: previous.fingerprint.clone(),
2380 public_key_spki: previous.public_key_spki.clone(),
2381 })
2382 .collect(),
2383 rotations: identity.rotations.clone(),
2384 }
2385}
2386
2387fn verified_v2_commit_object(
2388 raw: &[u8],
2389 identity: &V2HeadIdentity,
2390) -> LinkResult<serde_json::Map<String, Value>> {
2391 let mut value: Value =
2392 serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2393 let canonical = crate::linkmd_v2::canonical_bytes(&value)
2394 .map_err(|error| invalid_feed(error.to_string()))?;
2395 if canonical != raw {
2396 return Err(invalid_feed("v2 commit is not canonical JSON"));
2397 }
2398 let object = value
2399 .as_object_mut()
2400 .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
2401 let sig = object
2402 .remove("sig")
2403 .and_then(|value| value.as_str().map(str::to_string))
2404 .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
2405 const FIELDS: [&str; 18] = [
2406 "actor_ref",
2407 "asset_root",
2408 "brain",
2409 "changes_sha256",
2410 "control_revision",
2411 "materializer",
2412 "op",
2413 "parent_asset_root",
2414 "parent_commit",
2415 "parent_root",
2416 "prev_entry_hash",
2417 "public_key",
2418 "seq",
2419 "signer_epoch",
2420 "state_root",
2421 "ts",
2422 "v",
2423 "v1_bridge",
2424 ];
2425 if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
2426 return Err(invalid_feed("v2 commit has a non-normative field set"));
2427 }
2428 let seq = object
2429 .get("seq")
2430 .and_then(Value::as_u64)
2431 .filter(|seq| *seq > 0)
2432 .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
2433 let signer_epoch = object
2434 .get("signer_epoch")
2435 .and_then(Value::as_u64)
2436 .filter(|epoch| *epoch > 0)
2437 .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
2438 let hash_or_null = |field: &str| {
2439 object
2440 .get(field)
2441 .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
2442 };
2443 if object.get("v").and_then(Value::as_u64) != Some(2)
2444 || object.get("op").and_then(Value::as_str) != Some("changeset")
2445 || !object
2446 .get("changes_sha256")
2447 .and_then(Value::as_str)
2448 .is_some_and(is_sha256)
2449 || !object
2450 .get("actor_ref")
2451 .and_then(Value::as_str)
2452 .is_some_and(is_sha256)
2453 || !object
2454 .get("control_revision")
2455 .and_then(Value::as_str)
2456 .is_some_and(is_sha256)
2457 || !object
2458 .get("state_root")
2459 .and_then(Value::as_str)
2460 .is_some_and(is_sha256)
2461 || !hash_or_null("parent_commit")
2462 || !hash_or_null("parent_root")
2463 || !hash_or_null("parent_asset_root")
2464 || !hash_or_null("asset_root")
2465 || !hash_or_null("prev_entry_hash")
2466 || !object
2467 .get("materializer")
2468 .and_then(Value::as_str)
2469 .is_some_and(|value| !value.is_empty() && value.len() <= 128)
2470 || !object
2471 .get("ts")
2472 .and_then(Value::as_str)
2473 .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
2474 {
2475 return Err(invalid_feed("v2 commit fields are invalid"));
2476 }
2477 if (seq == 1
2478 && [
2479 "parent_commit",
2480 "parent_root",
2481 "parent_asset_root",
2482 "prev_entry_hash",
2483 ]
2484 .iter()
2485 .any(|field| !object.get(*field).is_some_and(Value::is_null)))
2486 || (seq > 1
2487 && ["parent_commit", "parent_root", "prev_entry_hash"]
2488 .iter()
2489 .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
2490 {
2491 return Err(invalid_feed("v2 commit parent shape is invalid"));
2492 }
2493 match object.get("v1_bridge") {
2494 Some(Value::Null) => {}
2495 Some(Value::Object(bridge))
2496 if seq == 1
2497 && bridge.len() == 3
2498 && bridge
2499 .get("head_seq")
2500 .and_then(Value::as_u64)
2501 .is_some_and(|v| v > 0)
2502 && bridge
2503 .get("feed_hash")
2504 .and_then(Value::as_str)
2505 .is_some_and(is_sha256)
2506 && bridge
2507 .get("pack_sha256")
2508 .and_then(Value::as_str)
2509 .is_some_and(is_sha256) => {}
2510 _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
2511 }
2512 let public_key = object
2513 .get("public_key")
2514 .and_then(Value::as_str)
2515 .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
2516 let der = URL_SAFE_NO_PAD
2517 .decode(public_key)
2518 .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
2519 let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
2520 if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
2521 return Err(invalid_feed("v2 commit brain identity mismatch"));
2522 }
2523 verify_identity_chain(&v2_identity(identity), None)?;
2525 let mut chain: Vec<(&str, &str)> = identity
2528 .previous
2529 .iter()
2530 .rev()
2531 .map(|previous| {
2532 (
2533 previous.fingerprint.as_str(),
2534 previous.public_key_spki.as_str(),
2535 )
2536 })
2537 .collect();
2538 chain.push((&identity.fingerprint, &identity.public_key_spki));
2539 let signer_index = chain.iter().position(|(fingerprint, spki)| {
2540 *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
2541 });
2542 let Some(signer_index) = signer_index else {
2543 return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
2544 };
2545 if signer_epoch != signer_index as u64 + 1 {
2546 return Err(invalid_feed("v2 commit signer epoch differs from its key"));
2547 }
2548 let lower_boundary = if signer_index == 0 {
2549 None
2550 } else {
2551 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
2552 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
2553 Some(prior.prior_head_seq)
2554 };
2555 let upper_boundary = if signer_index == identity.rotations.len() {
2556 None
2557 } else {
2558 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
2559 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
2560 Some(next.prior_head_seq)
2561 };
2562 if lower_boundary.is_some_and(|boundary| seq <= boundary)
2563 || upper_boundary.is_some_and(|boundary| seq > boundary)
2564 {
2565 return Err(invalid_feed(
2566 "v2 commit signer is outside its authenticated rotation epoch",
2567 ));
2568 }
2569 let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
2570 .map_err(|error| invalid_feed(error.to_string()))?;
2571 verify_v2_spki_signature(public_key, &unsigned, &sig)?;
2572 Ok(object.clone())
2573}
2574
2575#[derive(Debug, Deserialize)]
2576struct V2FeedWireEntry {
2577 seq: u64,
2578 commit_hash: String,
2579 feed_hash: String,
2580 bytes_base64: String,
2581}
2582
2583#[derive(Debug, Deserialize)]
2584struct V2FeedPage {
2585 v: u8,
2586 head_seq: u64,
2587 head_commit_hash: String,
2588 head_feed_hash: String,
2589 entries: Vec<V2FeedWireEntry>,
2590 next_after: u64,
2591 complete: bool,
2592}
2593
2594fn replay_v2_feed(
2595 cfg: &HubConfig,
2596 brain: &str,
2597 pointer: &V2PointerBody,
2598 identity: &V2HeadIdentity,
2599 start_after: u64,
2600 start_feed: Option<String>,
2601) -> LinkResult<()> {
2602 let mut after = start_after;
2603 let mut prior_feed = start_feed;
2604 let mut final_object = None;
2605 let mut replayed_entries = 0_u64;
2606 let mut replayed_bytes = 0_u64;
2607 while after < pointer.seq {
2608 let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
2609 let value = ensure_ok(
2610 request_capped(
2611 cfg,
2612 "GET",
2613 &path,
2614 None,
2615 Auth::Required,
2616 MAX_FEED_REPLAY_BYTES,
2617 )?,
2618 "v2 feed replay",
2619 )?;
2620 let page: V2FeedPage = serde_json::from_value(value)
2621 .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
2622 if page.v != 2
2623 || page.head_seq != pointer.seq
2624 || page.head_commit_hash != pointer.commit_hash
2625 || page.head_feed_hash != pointer.feed_hash
2626 || page.entries.is_empty()
2627 || page.entries.len() > FEED_PAGE_LIMIT
2628 {
2629 return Err(invalid_feed("v2 feed page differs from the signed head"));
2630 }
2631 for entry in page.entries {
2632 if entry.seq != after + 1
2633 || !is_sha256(&entry.commit_hash)
2634 || !is_sha256(&entry.feed_hash)
2635 {
2636 return Err(invalid_feed("v2 feed sequence is not contiguous"));
2637 }
2638 let raw = base64::engine::general_purpose::STANDARD
2639 .decode(&entry.bytes_base64)
2640 .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
2641 replayed_entries = replayed_entries
2642 .checked_add(1)
2643 .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
2644 replayed_bytes = replayed_bytes
2645 .checked_add(raw.len() as u64)
2646 .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
2647 if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
2648 {
2649 return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
2650 }
2651 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2652 .map_err(|error| invalid_feed(error.to_string()))?
2653 != entry.commit_hash
2654 || content_sha256(&raw) != entry.feed_hash
2655 {
2656 return Err(invalid_feed("v2 feed entry address mismatch"));
2657 }
2658 let object = verified_v2_commit_object(&raw, identity)?;
2659 if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
2660 || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
2661 {
2662 return Err(invalid_feed(
2663 "v2 feed entry does not extend its predecessor",
2664 ));
2665 }
2666 after = entry.seq;
2667 prior_feed = Some(entry.feed_hash);
2668 final_object = Some((entry.commit_hash, object));
2669 }
2670 if page.next_after != after || (page.complete != (after == pointer.seq)) {
2671 return Err(invalid_feed("v2 feed page cursor is inconsistent"));
2672 }
2673 }
2674 let (final_hash, object) =
2675 final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
2676 if final_hash != pointer.commit_hash
2677 || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
2678 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2679 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2680 || object.get("control_revision").and_then(Value::as_str)
2681 != Some(pointer.control_revision.as_str())
2682 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2683 {
2684 return Err(invalid_feed(
2685 "v2 replay did not converge on the signed pointer",
2686 ));
2687 }
2688 Ok(())
2689}
2690
2691fn verify_v1_to_v2_bridge(
2692 cfg: &HubConfig,
2693 brain: &str,
2694 pointer: &V2PointerBody,
2695 identity: &V2HeadIdentity,
2696 checkpoint: &TrustState,
2697) -> LinkResult<()> {
2698 let value = ensure_ok(
2699 request_capped(
2700 cfg,
2701 "GET",
2702 &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
2703 None,
2704 Auth::Required,
2705 MAX_FEED_RESPONSE_BYTES,
2706 )?,
2707 "v2 genesis bridge",
2708 )?;
2709 let page: V2FeedPage = serde_json::from_value(value)
2710 .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
2711 if page.v != 2
2712 || page.head_seq != pointer.seq
2713 || page.head_commit_hash != pointer.commit_hash
2714 || page.head_feed_hash != pointer.feed_hash
2715 || page.entries.len() != 1
2716 || page.entries[0].seq != 1
2717 || !is_sha256(&page.entries[0].commit_hash)
2718 || !is_sha256(&page.entries[0].feed_hash)
2719 {
2720 return Err(invalid_feed(
2721 "v2 genesis bridge page differs from the signed head",
2722 ));
2723 }
2724 let first = &page.entries[0];
2725 let raw = STANDARD
2726 .decode(&first.bytes_base64)
2727 .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
2728 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2729 .map_err(|error| invalid_feed(error.to_string()))?
2730 != first.commit_hash
2731 || content_sha256(&raw) != first.feed_hash
2732 {
2733 return Err(invalid_feed("v2 genesis bridge address mismatch"));
2734 }
2735 let object = verified_v2_commit_object(&raw, identity)?;
2736 if checkpoint.head_seq == 0 {
2737 if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
2738 return Err(invalid_feed(
2739 "empty v1 checkpoint did not transition through an empty v2 genesis",
2740 ));
2741 }
2742 return Ok(());
2743 }
2744 let bridge = object
2745 .get("v1_bridge")
2746 .and_then(Value::as_object)
2747 .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
2748 let checkpoint_feed = checkpoint
2749 .feed_hash
2750 .as_deref()
2751 .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
2752 if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
2753 || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
2754 {
2755 return Err(invalid_feed(
2756 "v2 genesis bridge differs from the pinned v1 checkpoint",
2757 ));
2758 }
2759 let legacy_raw = ensure_raw_ok(
2760 request_raw(
2761 cfg,
2762 "GET",
2763 &format!(
2764 "/api/hub/brains/{brain}/feed?after={}&limit=1",
2765 checkpoint.head_seq - 1
2766 ),
2767 None,
2768 Auth::Required,
2769 MAX_FEED_RESPONSE_BYTES,
2770 )?,
2771 "v1 bridge boundary",
2772 )?;
2773 let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
2774 .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
2775 let legacy_identity = legacy
2776 .identity
2777 .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
2778 let item = legacy
2779 .entries
2780 .first()
2781 .filter(|_| legacy.entries.len() == 1)
2782 .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
2783 if legacy.scope_limited
2784 || legacy.head_seq != checkpoint.head_seq
2785 || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
2786 || item.entry.seq != checkpoint.head_seq
2787 || item.hash != checkpoint_feed
2788 || legacy_identity != v2_identity(identity)
2789 || bridge.get("pack_sha256").and_then(Value::as_str)
2790 != Some(item.entry.pack_sha256.as_str())
2791 {
2792 return Err(invalid_feed(
2793 "v1 bridge boundary differs from its signed legacy head",
2794 ));
2795 }
2796 let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
2797 if anchor != checkpoint.anchor {
2798 return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
2799 }
2800 verify_feed_item(item, &legacy_identity)?;
2801 verify_rotation_feed_boundaries(
2802 &legacy_identity,
2803 Some(checkpoint),
2804 std::slice::from_ref(item),
2805 checkpoint.head_seq,
2806 )?;
2807 Ok(())
2808}
2809
2810fn verify_v2_commit(
2811 cfg: &HubConfig,
2812 brain: &str,
2813 pointer: &V2PointerBody,
2814 identity: &V2HeadIdentity,
2815 pinned: Option<&TrustState>,
2816) -> LinkResult<()> {
2817 let path = format!(
2818 "/api/hub/brains/{brain}/v2/commit?commit={}",
2819 pointer.commit_hash
2820 );
2821 let raw = ensure_raw_ok(
2822 request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
2823 "v2 commit",
2824 )?;
2825 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2826 .map_err(|error| invalid_feed(error.to_string()))?
2827 != pointer.commit_hash
2828 || content_sha256(&raw) != pointer.feed_hash
2829 {
2830 return Err(invalid_feed("v2 commit address differs from the pointer"));
2831 }
2832 let object = verified_v2_commit_object(&raw, identity)?;
2833 if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
2834 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2835 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2836 || object.get("control_revision").and_then(Value::as_str)
2837 != Some(pointer.control_revision.as_str())
2838 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2839 {
2840 return Err(invalid_feed("v2 commit fields differ from the pointer"));
2841 }
2842 if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
2843 if pointer.seq == checkpoint.head_seq + 1
2844 && object.get("prev_entry_hash").and_then(Value::as_str)
2845 != checkpoint.feed_hash.as_deref()
2846 {
2847 return Err(invalid_feed(
2848 "v2 commit does not extend the pinned feed hash",
2849 ));
2850 }
2851 if pointer.seq > checkpoint.head_seq + 1 {
2852 return replay_v2_feed(
2853 cfg,
2854 brain,
2855 pointer,
2856 identity,
2857 checkpoint.head_seq,
2858 checkpoint.feed_hash.clone(),
2859 );
2860 }
2861 } else {
2862 if let Some(checkpoint) = pinned {
2863 verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
2864 }
2865 if pointer.seq > 1 {
2866 return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
2867 }
2868 }
2869 Ok(())
2870}
2871
2872fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
2873 require_hardened_filesystem("verified link.md v2 state")?;
2874 require_safe_ref(brain)?;
2875 let path = format!("/api/hub/brains/{brain}/v2/head");
2876 let response = request(cfg, "GET", &path, None, Auth::Required)?;
2877 if response.status == 404 {
2878 if has_accepted_v2_ref(cfg, brain)? {
2879 return Err(LinkError::BrainUnavailable);
2880 }
2881 return Ok(None);
2882 }
2883 let body = ensure_ok(response, "v2 head")?;
2884 let head: V2HeadResponse = serde_json::from_value(body)
2885 .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
2886 if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
2887 return Err(invalid_feed("v2 head has no canonical brain id"));
2888 }
2889 if crate::ulid::is_ulid(brain) && head.brain_id != brain {
2890 return Err(invalid_feed("v2 head resolved a different brain id"));
2891 }
2892 if head.profile == "v1" {
2893 return Ok(None);
2894 }
2895 if head.profile != "v2" && head.profile != "v2-empty" {
2896 return Err(invalid_feed("v2 head advertised an unknown profile"));
2897 }
2898 let view = head
2899 .view
2900 .as_ref()
2901 .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
2902 if !matches!(view.kind.as_str(), "full" | "scoped") || !is_sha256(&view.control_revision) {
2903 return Err(invalid_feed("v2 head has an invalid permission view"));
2904 }
2905 let view_kind = view.kind.clone();
2906 let view_revision = view.control_revision.clone();
2907 let identity = head
2908 .identity
2909 .as_ref()
2910 .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
2911 let trust_directory = open_trust_dir(cfg)?;
2912 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
2913 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
2914 let feed_identity = v2_identity(identity);
2915 let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
2916 let (seq, feed_hash, hub_signer) = match &head.pointer {
2917 None => {
2918 if head.profile != "v2-empty" {
2919 return Err(invalid_feed("initialized v2 head has no pointer"));
2920 }
2921 (
2922 0,
2923 None,
2924 pinned.as_ref().and_then(|state| state.hub_signer.clone()),
2925 )
2926 }
2927 Some(signed) => {
2928 let signer = verify_v2_pointer(signed, &head.brain_id)?;
2929 if pinned
2930 .as_ref()
2931 .and_then(|state| state.hub_signer.as_ref())
2932 .is_some_and(|known| known != &signer)
2933 {
2934 return Err(invalid_feed(
2935 "v2 hub pointer signer changed without a trust transition",
2936 ));
2937 }
2938 if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
2939 if signed.pointer.seq < checkpoint.head_seq
2940 || (signed.pointer.seq == checkpoint.head_seq
2941 && checkpoint.feed_hash.as_deref()
2942 != Some(signed.pointer.feed_hash.as_str()))
2943 {
2944 return Err(invalid_feed("v2 pointer rolled back or equivocated"));
2945 }
2946 }
2947 verify_v2_commit(
2948 cfg,
2949 &head.brain_id,
2950 &signed.pointer,
2951 identity,
2952 pinned.as_ref(),
2953 )?;
2954 (
2955 signed.pointer.seq,
2956 Some(signed.pointer.feed_hash.clone()),
2957 Some(signer),
2958 )
2959 }
2960 };
2961 let trust = TrustState {
2962 v: 2,
2963 origin: normalized_origin(&cfg.hub)?,
2964 requested: head.brain_id.clone(),
2965 brain: head.brain_id.clone(),
2966 home: None,
2967 anchor,
2968 current: format!("ed25519:{}", identity.fingerprint),
2969 head_seq: seq,
2970 feed_hash,
2971 rotations: identity.rotations.clone(),
2972 hub_signer,
2973 protocol_profile: Some("link-v2".to_string()),
2974 };
2975 Ok(Some(V2VerifiedHead {
2976 requested: brain.to_string(),
2977 brain_id: head.brain_id,
2978 view_kind,
2979 view_revision,
2980 identity: identity.clone(),
2981 pointer: head.pointer.map(|signed| signed.pointer),
2982 trust,
2983 alias: alias_binding,
2984 }))
2985}
2986
2987fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
2988 let directory = open_trust_dir(cfg)?;
2989 let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
2990 let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
2991 if let Some(current) = current {
2992 let common_invalid = head.trust.anchor != current.anchor
2993 || !head.trust.rotations.starts_with(¤t.rotations);
2994 let profile_invalid = if accepted_as_v2(¤t) {
2995 head.trust.head_seq < current.head_seq
2996 || (head.trust.head_seq == current.head_seq
2997 && head.trust.feed_hash != current.feed_hash)
2998 || current
2999 .hub_signer
3000 .as_ref()
3001 .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3002 } else {
3003 head.trust.protocol_profile.as_deref() != Some("link-v2")
3004 || head.trust.hub_signer.is_none()
3005 };
3006 if common_invalid || profile_invalid {
3007 return Err(invalid_feed(
3008 "v2 head cannot advance the currently accepted trust checkpoint",
3009 ));
3010 }
3011 }
3012 save_canonical_pin_and_alias(
3013 cfg,
3014 &directory,
3015 &head.requested,
3016 &head.brain_id,
3017 head.trust.clone(),
3018 alias.as_ref().or(head.alias.as_ref()),
3019 )
3020}
3021
3022#[derive(Debug, Clone, Deserialize, Serialize)]
3023struct V2BaselineFile {
3024 sha256: String,
3025 bytes: u64,
3026 #[serde(skip)]
3027 proof: Option<Vec<V2ProofStep>>,
3028}
3029
3030#[derive(Debug, Clone, Deserialize, Serialize)]
3031struct V2SyncBaseline {
3032 v: u8,
3033 origin: String,
3034 brain: String,
3035 #[serde(default)]
3036 head_seq: Option<u64>,
3037 commit_hash: Option<String>,
3038 content_root: Option<String>,
3039 #[serde(default)]
3040 asset_root: Option<String>,
3041 #[serde(default)]
3042 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3043 #[serde(default)]
3044 view_kind: Option<String>,
3045 #[serde(default)]
3046 view_revision: Option<String>,
3047 #[serde(default)]
3048 projection_sha256: Option<String>,
3049 files: std::collections::BTreeMap<String, V2BaselineFile>,
3050 #[serde(default)]
3051 local_policy_digest: Option<String>,
3052 #[serde(default)]
3053 local_eligibility: std::collections::BTreeMap<String, bool>,
3054 #[serde(default)]
3055 remote_copy_remains: std::collections::BTreeMap<String, String>,
3056}
3057
3058struct V2LocalView {
3059 riding: std::collections::BTreeMap<String, (String, u64)>,
3060 eligibility: std::collections::BTreeMap<String, bool>,
3061 policy: crate::linkmd_sync_policy::SyncPolicy,
3062}
3063
3064#[derive(Debug, Clone, Deserialize, Serialize)]
3065struct V2ProofStep {
3066 directory_root: String,
3067 component: String,
3068 proof: crate::linkmd_v2::HamtProof,
3069}
3070
3071#[derive(Debug, Deserialize)]
3072struct V2ManifestFile {
3073 path: String,
3074 sha256: String,
3075 bytes: u64,
3076 proof: Vec<V2ProofStep>,
3077}
3078
3079#[derive(Debug, Deserialize)]
3080struct V2ManifestPage {
3081 v: u8,
3082 commit: String,
3083 content_root: Option<String>,
3084 files: Vec<V2ManifestFile>,
3085 next_cursor: Option<String>,
3086}
3087
3088#[derive(Debug, Clone, Deserialize, Serialize)]
3089struct V2BaselineAsset {
3090 blob_sha256: String,
3091 bytes: u64,
3092 media_type: String,
3093 wrappers: Vec<String>,
3094 required: bool,
3095 disposition: String,
3096 leaf_hash: String,
3097}
3098
3099#[derive(Debug, Deserialize)]
3100struct V2AssetManifestItem {
3101 path: String,
3102 blob_sha256: String,
3103 bytes: u64,
3104 media_type: String,
3105 wrappers: Vec<String>,
3106 required: bool,
3107 disposition: String,
3108 leaf_hash: String,
3109 proof: crate::linkmd_v2::HamtProof,
3110}
3111
3112#[derive(Debug, Deserialize)]
3113struct V2AssetManifestPage {
3114 v: u8,
3115 commit: String,
3116 asset_root: Option<String>,
3117 assets: Vec<V2AssetManifestItem>,
3118 next_cursor: Option<String>,
3119}
3120
3121#[derive(Debug, Deserialize)]
3122struct V2SigningCandidate {
3123 seq: u64,
3124 content_root: Option<String>,
3125 asset_root: Option<String>,
3126 signing_bytes_base64: String,
3127 changes_base64: String,
3128 actor_claim_base64: String,
3129}
3130
3131#[derive(Debug, Deserialize)]
3132struct V2SigningCandidatePage {
3133 v: u8,
3134 challenge_id: String,
3135 mutation_id: String,
3136 request_hash: String,
3137 parent: V2SigningParent,
3138 candidate: V2SigningCandidate,
3139 files: Vec<V2ManifestFile>,
3140 #[serde(default)]
3141 assets: Vec<V2AssetManifestItem>,
3142 next_cursor: Option<String>,
3143 expires_at: String,
3144}
3145
3146#[derive(Debug, Deserialize)]
3147struct V2SigningParent {
3148 seq: u64,
3149 commit_hash: Option<String>,
3150}
3151
3152fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3153 let normalized = crate::linkmd_v2::normalize_path(&file.path)
3154 .map_err(|error| invalid_feed(error.to_string()))?;
3155 let components = normalized.split('/').collect::<Vec<_>>();
3156 if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3157 return Err(invalid_feed("v2 file proof has the wrong shape"));
3158 }
3159 let mut directory_root = root.to_string();
3160 for (index, step) in file.proof.iter().enumerate() {
3161 if step.directory_root != directory_root || step.component != components[index] {
3162 return Err(invalid_feed(
3163 "v2 file proof path chain differs from its manifest",
3164 ));
3165 }
3166 if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3167 .map_err(|error| invalid_feed(error.to_string()))?
3168 {
3169 return Err(invalid_feed("v2 file proof failed verification"));
3170 }
3171 let entry = match &step.proof {
3172 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3173 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3174 return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3175 }
3176 };
3177 if index + 1 == components.len() {
3178 if entry.kind != crate::linkmd_v2::EntryKind::Blob
3179 || entry.child_hash != file.sha256
3180 || entry.bytes != Some(file.bytes)
3181 {
3182 return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3183 }
3184 } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3185 return Err(invalid_feed("v2 file proof traversed a non-directory"));
3186 } else {
3187 directory_root = entry.child_hash.clone();
3188 }
3189 }
3190 Ok(())
3191}
3192
3193fn v2_manifest(
3194 cfg: &HubConfig,
3195 brain: &str,
3196 pointer: Option<&V2PointerBody>,
3197) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3198 let Some(pointer) = pointer else {
3199 return Ok(std::collections::BTreeMap::new());
3200 };
3201 let Some(root) = pointer.content_root.as_deref() else {
3202 return Ok(std::collections::BTreeMap::new());
3203 };
3204 let mut files = std::collections::BTreeMap::new();
3205 let mut after = String::new();
3206 loop {
3207 let encoded_after: String =
3208 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3209 let path = format!(
3210 "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3211 pointer.commit_hash
3212 );
3213 let value = ensure_ok(
3214 request_capped(
3215 cfg,
3216 "GET",
3217 &path,
3218 None,
3219 Auth::Required,
3220 MAX_FEED_RESPONSE_BYTES,
3221 )?,
3222 "v2 file manifest",
3223 )?;
3224 let page: V2ManifestPage = serde_json::from_value(value)
3225 .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3226 if page.v != 2
3227 || page.commit != pointer.commit_hash
3228 || page.content_root.as_deref() != Some(root)
3229 || page.files.len() > 500
3230 {
3231 return Err(invalid_feed(
3232 "v2 file manifest is not bound to the verified head",
3233 ));
3234 }
3235 for file in page.files {
3236 verify_v2_file_proof(root, &file)?;
3237 if files
3238 .insert(
3239 file.path.clone(),
3240 V2BaselineFile {
3241 sha256: file.sha256,
3242 bytes: file.bytes,
3243 proof: Some(file.proof),
3244 },
3245 )
3246 .is_some()
3247 {
3248 return Err(invalid_feed("v2 file manifest repeats a path"));
3249 }
3250 if files.len() > MAX_PUSH_FILES {
3251 return Err(invalid_feed(
3252 "v2 file manifest exceeds the file-count bound",
3253 ));
3254 }
3255 }
3256 match page.next_cursor {
3257 None => break,
3258 Some(next) if next > after => after = next,
3259 Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3260 }
3261 }
3262 Ok(files)
3263}
3264
3265fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
3266 crate::linkmd_v2::normalize_path(&item.path)
3267 .map_err(|error| invalid_feed(error.to_string()))?;
3268 if !is_sha256(&item.blob_sha256)
3269 || !is_sha256(&item.leaf_hash)
3270 || item.wrappers.is_empty()
3271 || !matches!(item.disposition.as_str(), "hosted" | "withheld")
3272 || item
3273 .wrappers
3274 .iter()
3275 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
3276 {
3277 return Err(invalid_feed("v2 asset manifest item is invalid"));
3278 }
3279 let leaf = json!({
3280 "blob_sha256": item.blob_sha256,
3281 "bytes": item.bytes,
3282 "disposition": item.disposition,
3283 "media_type": item.media_type,
3284 "path": item.path,
3285 "required": item.required,
3286 "v": 2,
3287 "wrappers": item.wrappers,
3288 });
3289 if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
3290 .map_err(|error| invalid_feed(error.to_string()))?
3291 != item.leaf_hash
3292 || !crate::linkmd_v2::verify_proof_with_domain(
3293 root,
3294 &item.path,
3295 &item.proof,
3296 crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
3297 )
3298 .map_err(|error| invalid_feed(error.to_string()))?
3299 {
3300 return Err(invalid_feed("v2 asset inclusion proof failed"));
3301 }
3302 match &item.proof {
3303 crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
3304 if entry.name == item.path
3305 && entry.kind == crate::linkmd_v2::EntryKind::Blob
3306 && entry.child_hash == item.leaf_hash
3307 && entry.bytes == Some(item.bytes) =>
3308 {
3309 Ok(())
3310 }
3311 _ => Err(invalid_feed(
3312 "v2 asset proof leaf differs from its manifest",
3313 )),
3314 }
3315}
3316
3317fn v2_asset_manifest(
3318 cfg: &HubConfig,
3319 brain: &str,
3320 pointer: Option<&V2PointerBody>,
3321) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
3322 let Some(pointer) = pointer else {
3323 return Ok(std::collections::BTreeMap::new());
3324 };
3325 let Some(root) = pointer.asset_root.as_deref() else {
3326 return Ok(std::collections::BTreeMap::new());
3327 };
3328 let mut assets = std::collections::BTreeMap::new();
3329 let mut after = String::new();
3330 loop {
3331 let encoded_after: String =
3332 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3333 let path = format!(
3334 "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
3335 pointer.commit_hash
3336 );
3337 let value = ensure_ok(
3338 request_capped(
3339 cfg,
3340 "GET",
3341 &path,
3342 None,
3343 Auth::Required,
3344 MAX_FEED_RESPONSE_BYTES,
3345 )?,
3346 "v2 asset manifest",
3347 )?;
3348 let page: V2AssetManifestPage = serde_json::from_value(value)
3349 .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
3350 if page.v != 2
3351 || page.commit != pointer.commit_hash
3352 || page.asset_root.as_deref() != Some(root)
3353 || page.assets.len() > 500
3354 {
3355 return Err(invalid_feed(
3356 "v2 asset manifest is not bound to the verified head",
3357 ));
3358 }
3359 for item in page.assets {
3360 verify_v2_asset_proof(root, &item)?;
3361 let path = item.path.clone();
3362 if assets
3363 .insert(
3364 path,
3365 V2BaselineAsset {
3366 blob_sha256: item.blob_sha256,
3367 bytes: item.bytes,
3368 media_type: item.media_type,
3369 wrappers: item.wrappers,
3370 required: item.required,
3371 disposition: item.disposition,
3372 leaf_hash: item.leaf_hash,
3373 },
3374 )
3375 .is_some()
3376 {
3377 return Err(invalid_feed("v2 asset manifest repeats a path"));
3378 }
3379 if assets.len() > MAX_PUSH_FILES {
3380 return Err(invalid_feed(
3381 "v2 asset manifest exceeds the item-count bound",
3382 ));
3383 }
3384 }
3385 match page.next_cursor {
3386 None => break,
3387 Some(next) if next > after => after = next,
3388 Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
3389 }
3390 }
3391 Ok(assets)
3392}
3393
3394fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
3395 crate::AssetRecord {
3396 path: path.to_string(),
3397 sha256: asset.blob_sha256.clone(),
3398 bytes: asset.bytes,
3399 media_type: asset.media_type.clone(),
3400 wrappers: asset.wrappers.clone(),
3401 required: asset.required,
3402 }
3403}
3404
3405fn v2_asset_manifest_bytes(
3406 assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
3407) -> LinkResult<Vec<u8>> {
3408 let mut bytes = Vec::new();
3409 for (path, asset) in assets {
3410 serde_json::to_writer(&mut bytes, &v2_asset_record(asset, path))
3411 .map_err(|_| invalid_feed("could not materialize v2 assets.jsonl"))?;
3412 bytes.push(b'\n');
3413 }
3414 Ok(bytes)
3415}
3416
3417fn sign_verified_v2_candidate(
3418 cfg: &HubConfig,
3419 head: &V2VerifiedHead,
3420 expected: &std::collections::BTreeMap<String, V2BaselineFile>,
3421 expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
3422 mutation_id: &str,
3423 request_body: &Value,
3424 challenge_value: &Value,
3425) -> LinkResult<(String, String, String)> {
3426 if head.view_kind != "full" {
3427 return Err(invalid_feed(
3428 "a scoped self-custody writer must use the proposal workflow",
3429 ));
3430 }
3431 if head.identity.custody != "self" {
3432 return Err(invalid_feed(
3433 "a hub-custodied brain unexpectedly requested an external signature",
3434 ));
3435 }
3436 let key = cfg
3437 .brain_key
3438 .as_ref()
3439 .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
3440 if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
3441 || key.public_key_spki != head.identity.public_key_spki
3442 {
3443 return Err(bad_agent_key(
3444 "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
3445 ));
3446 }
3447 let challenge_id = challenge_value
3448 .get("id")
3449 .and_then(Value::as_str)
3450 .filter(|id| crate::ulid::is_ulid(id))
3451 .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
3452 let expected_endpoint = format!(
3453 "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
3454 head.brain_id
3455 );
3456 if challenge_value
3457 .get("candidate_endpoint")
3458 .and_then(Value::as_str)
3459 != Some(expected_endpoint.as_str())
3460 {
3461 return Err(invalid_feed(
3462 "self-custody challenge candidate endpoint is not origin-bound",
3463 ));
3464 }
3465
3466 let mut files = std::collections::BTreeMap::new();
3467 let mut after = String::new();
3468 type CandidateCoordinate = (
3469 String,
3470 String,
3471 String,
3472 String,
3473 Option<String>,
3474 Option<String>,
3475 u64,
3476 Option<String>,
3477 );
3478 let mut pinned: Option<CandidateCoordinate> = None;
3479 loop {
3480 let encoded_after: String =
3481 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3482 let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
3483 let value = ensure_ok(
3484 request_capped(
3485 cfg,
3486 "GET",
3487 &path,
3488 None,
3489 Auth::Required,
3490 MAX_FEED_RESPONSE_BYTES,
3491 )?,
3492 "v2 self-custody candidate",
3493 )?;
3494 let page: V2SigningCandidatePage = serde_json::from_value(value)
3495 .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
3496 if page.v != 2
3497 || page.challenge_id != challenge_id
3498 || page.mutation_id != mutation_id
3499 || page.candidate.seq != page.parent.seq + 1
3500 || page.files.len() > 500
3501 || page.expires_at.is_empty()
3502 {
3503 return Err(invalid_feed(
3504 "self-custody candidate is not bound to this mutation",
3505 ));
3506 }
3507 let coordinate = (
3508 page.request_hash.clone(),
3509 page.candidate.signing_bytes_base64.clone(),
3510 page.candidate.changes_base64.clone(),
3511 page.candidate.actor_claim_base64.clone(),
3512 page.candidate.content_root.clone(),
3513 page.candidate.asset_root.clone(),
3514 page.parent.seq,
3515 page.parent.commit_hash.clone(),
3516 );
3517 if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
3518 return Err(invalid_feed(
3519 "self-custody candidate changed between manifest pages",
3520 ));
3521 }
3522 pinned = Some(coordinate);
3523 let root = page
3524 .candidate
3525 .content_root
3526 .as_deref()
3527 .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
3528 for file in page.files {
3529 verify_v2_file_proof(root, &file)?;
3530 if files
3531 .insert(
3532 file.path.clone(),
3533 V2BaselineFile {
3534 sha256: file.sha256,
3535 bytes: file.bytes,
3536 proof: Some(file.proof),
3537 },
3538 )
3539 .is_some()
3540 {
3541 return Err(invalid_feed(
3542 "self-custody candidate repeats a manifest path",
3543 ));
3544 }
3545 if files.len() > MAX_PUSH_FILES {
3546 return Err(invalid_feed(
3547 "self-custody candidate exceeds the file-count bound",
3548 ));
3549 }
3550 }
3551 match page.next_cursor {
3552 None => break,
3553 Some(next) if next > after => after = next,
3554 Some(_) => {
3555 return Err(invalid_feed(
3556 "self-custody candidate cursor did not advance",
3557 ))
3558 }
3559 }
3560 }
3561 if files.len() != expected.len()
3562 || files.iter().any(|(path, file)| {
3563 expected.get(path).is_none_or(|expected| {
3564 expected.sha256 != file.sha256 || expected.bytes != file.bytes
3565 })
3566 })
3567 {
3568 return Err(invalid_feed(
3569 "self-custody candidate contains an unexpected file mutation",
3570 ));
3571 }
3572 let mut assets = std::collections::BTreeMap::new();
3573 after.clear();
3574 loop {
3575 let encoded_after: String =
3576 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3577 let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
3578 let value = ensure_ok(
3579 request_capped(
3580 cfg,
3581 "GET",
3582 &path,
3583 None,
3584 Auth::Required,
3585 MAX_FEED_RESPONSE_BYTES,
3586 )?,
3587 "v2 self-custody asset candidate",
3588 )?;
3589 let page: V2SigningCandidatePage = serde_json::from_value(value)
3590 .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
3591 let coordinate = (
3592 page.request_hash.clone(),
3593 page.candidate.signing_bytes_base64.clone(),
3594 page.candidate.changes_base64.clone(),
3595 page.candidate.actor_claim_base64.clone(),
3596 page.candidate.content_root.clone(),
3597 page.candidate.asset_root.clone(),
3598 page.parent.seq,
3599 page.parent.commit_hash.clone(),
3600 );
3601 if page.v != 2
3602 || page.challenge_id != challenge_id
3603 || page.mutation_id != mutation_id
3604 || page.assets.len() > 500
3605 || pinned.as_ref() != Some(&coordinate)
3606 {
3607 return Err(invalid_feed(
3608 "self-custody asset candidate changed or is not bound",
3609 ));
3610 }
3611 let root = page.candidate.asset_root.as_deref();
3612 if !page.assets.is_empty() && root.is_none() {
3613 return Err(invalid_feed("asset candidate has no asset root"));
3614 }
3615 for item in page.assets {
3616 verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
3617 if assets
3618 .insert(
3619 item.path.clone(),
3620 V2BaselineAsset {
3621 blob_sha256: item.blob_sha256,
3622 bytes: item.bytes,
3623 media_type: item.media_type,
3624 wrappers: item.wrappers,
3625 required: item.required,
3626 disposition: item.disposition,
3627 leaf_hash: item.leaf_hash,
3628 },
3629 )
3630 .is_some()
3631 {
3632 return Err(invalid_feed("self-custody candidate repeats an asset"));
3633 }
3634 }
3635 match page.next_cursor {
3636 None => break,
3637 Some(next) if next > after => after = next,
3638 Some(_) => {
3639 return Err(invalid_feed(
3640 "self-custody asset candidate cursor did not advance",
3641 ))
3642 }
3643 }
3644 }
3645 if assets.len() != expected_assets.len()
3646 || assets.iter().any(|(path, asset)| {
3647 expected_assets.get(path).is_none_or(|expected| {
3648 asset.blob_sha256 != expected.blob_sha256
3649 || asset.bytes != expected.bytes
3650 || asset.media_type != expected.media_type
3651 || asset.wrappers != expected.wrappers
3652 || asset.required != expected.required
3653 || asset.disposition != expected.disposition
3654 })
3655 })
3656 {
3657 return Err(invalid_feed(
3658 "self-custody candidate contains an unexpected asset mutation",
3659 ));
3660 }
3661 let Some((
3662 request_hash,
3663 signing_b64,
3664 changes_b64,
3665 actor_b64,
3666 root,
3667 asset_root,
3668 parent_seq,
3669 parent,
3670 )) = pinned
3671 else {
3672 return Err(invalid_feed("self-custody candidate has no manifest"));
3673 };
3674 let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
3675 let current_commit = head
3676 .pointer
3677 .as_ref()
3678 .map(|pointer| pointer.commit_hash.clone());
3679 if parent_seq != current_seq || parent != current_commit {
3680 return Err(LinkError::RemoteAdvancedDuringSync);
3681 }
3682 let changes = STANDARD
3683 .decode(changes_b64)
3684 .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
3685 let expected_changes = json!({
3686 "mutation_id": mutation_id,
3687 "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
3688 "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
3689 "v": 2,
3690 });
3691 let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
3692 .map_err(|error| invalid_feed(error.to_string()))?;
3693 if changes != expected_changes_bytes {
3694 return Err(invalid_feed(
3695 "self-custody changeset differs from the requested mutation",
3696 ));
3697 }
3698 let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
3699 .map_err(|error| invalid_feed(error.to_string()))?;
3700 let request_value = json!({
3701 "base": request_body.get("base").cloned().unwrap_or(Value::Null),
3702 "brain": head.brain_id,
3703 "changes_sha256": changes_hash,
3704 "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
3705 "v": 2,
3706 "v1_bridge": Value::Null,
3707 });
3708 let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
3709 .map_err(|error| invalid_feed(error.to_string()))?;
3710 if request_hash != expected_request_hash {
3711 return Err(invalid_feed(
3712 "self-custody request hash differs from the requested mutation",
3713 ));
3714 }
3715 let actor = STANDARD
3716 .decode(actor_b64)
3717 .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
3718 let actor_value: Value = serde_json::from_slice(&actor)
3719 .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
3720 if crate::linkmd_v2::canonical_bytes(&actor_value)
3721 .map_err(|error| invalid_feed(error.to_string()))?
3722 != actor
3723 {
3724 return Err(invalid_feed("self-custody actor claim is not canonical"));
3725 }
3726 let actor_object = actor_value
3727 .as_object()
3728 .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
3729 let actor_claim = actor_object
3730 .get("claim")
3731 .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
3732 let actor_public_key = actor_object
3733 .get("public_key")
3734 .and_then(Value::as_str)
3735 .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
3736 let actor_fingerprint = actor_object
3737 .get("fingerprint")
3738 .and_then(Value::as_str)
3739 .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
3740 let actor_signature = actor_object
3741 .get("sig")
3742 .and_then(Value::as_str)
3743 .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
3744 let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
3745 .map_err(|error| invalid_feed(error.to_string()))?;
3746 let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
3747 let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
3748 let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
3749 let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
3750 let impact = actor_claim
3751 .get("result")
3752 .and_then(|result| result.get("impact"))
3753 .and_then(Value::as_object);
3754 let impact_fields = [
3755 "creates",
3756 "updates",
3757 "deletes",
3758 "withdrawals",
3759 "renames",
3760 "restores",
3761 "asset_changes",
3762 "public_expansions",
3763 "executable_activations",
3764 ];
3765 let impact_is_valid = impact.is_some_and(|impact| {
3766 impact.len() == impact_fields.len() + 1
3767 && impact.get("v").and_then(Value::as_u64) == Some(1)
3768 && impact_fields
3769 .iter()
3770 .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
3771 });
3772 if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
3773 || head
3774 .trust
3775 .hub_signer
3776 .as_ref()
3777 .is_some_and(|known| known != &expected_actor_signer)
3778 || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
3779 || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
3780 || actor_claim
3781 .get("candidate")
3782 .and_then(|candidate| candidate.get("changes_sha256"))
3783 .and_then(Value::as_str)
3784 != Some(changes_hash.as_str())
3785 || actor_claim
3786 .get("candidate")
3787 .and_then(|candidate| candidate.get("state_root"))
3788 != Some(&expected_actor_root)
3789 || actor_claim
3790 .get("candidate")
3791 .and_then(|candidate| candidate.get("asset_root"))
3792 != Some(&expected_actor_asset_root)
3793 || actor_claim
3794 .get("candidate")
3795 .and_then(|candidate| candidate.get("control_revision"))
3796 .and_then(Value::as_str)
3797 != Some(head.view_revision.as_str())
3798 || !impact_is_valid
3799 {
3800 return Err(invalid_feed(
3801 "self-custody actor claim does not bind the verified authority",
3802 ));
3803 }
3804 let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
3805 .map_err(|error| invalid_feed(error.to_string()))?;
3806 let signing = STANDARD
3807 .decode(signing_b64)
3808 .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
3809 let signing_value: Value = serde_json::from_slice(&signing)
3810 .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
3811 if crate::linkmd_v2::canonical_bytes(&signing_value)
3812 .map_err(|error| invalid_feed(error.to_string()))?
3813 != signing
3814 {
3815 return Err(invalid_feed("self-custody signing bytes are not canonical"));
3816 }
3817 let pointer = head.pointer.as_ref();
3818 let expected_materializer = pointer
3819 .map(|value| value.materializer.as_str())
3820 .unwrap_or("dbmd-projection-v1");
3821 let expected_parent_commit = request_body
3822 .get("base")
3823 .and_then(|base| base.get("commit_hash"))
3824 .cloned()
3825 .unwrap_or(Value::Null);
3826 let expected_parent_root = request_body
3827 .get("base")
3828 .and_then(|base| base.get("content_root"))
3829 .cloned()
3830 .unwrap_or(Value::Null);
3831 let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
3832 let expected_parent_asset_root = request_body
3833 .get("base")
3834 .and_then(|base| base.get("asset_root"))
3835 .cloned()
3836 .unwrap_or(Value::Null);
3837 let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
3838 let expected_prev_entry = pointer
3839 .map(|value| Value::String(value.feed_hash.clone()))
3840 .unwrap_or(Value::Null);
3841 let expected_signer_epoch = u64::try_from(head.identity.previous.len())
3842 .map_err(|_| invalid_feed("brain identity history is too large"))?
3843 + 1;
3844 if signing_value.get("v").and_then(Value::as_u64) != Some(2)
3845 || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
3846 || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
3847 || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
3848 || signing_value.get("public_key").and_then(Value::as_str)
3849 != Some(key.public_key_spki.as_str())
3850 || signing_value.get("parent_commit") != Some(&expected_parent_commit)
3851 || signing_value.get("parent_root") != Some(&expected_parent_root)
3852 || signing_value.get("state_root") != Some(&expected_state_root)
3853 || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
3854 || signing_value.get("asset_root") != Some(&expected_asset_root)
3855 || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
3856 || signing_value.get("changes_sha256").and_then(Value::as_str)
3857 != Some(changes_hash.as_str())
3858 || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
3859 || signing_value
3860 .get("control_revision")
3861 .and_then(Value::as_str)
3862 != Some(head.view_revision.as_str())
3863 || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
3864 || signing_value.get("v1_bridge") != Some(&Value::Null)
3865 || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
3866 {
3867 return Err(invalid_feed(
3868 "self-custody signing bytes do not bind the verified candidate",
3869 ));
3870 }
3871 let pair = agent_keypair(&key.pkcs8)?;
3872 let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
3873 Ok((challenge_id.to_string(), signature, expected_actor_signer))
3874}
3875
3876fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
3877 let origin = normalized_origin(&cfg.hub)?;
3878 let absolute = if checkout.is_absolute() {
3879 checkout.to_path_buf()
3880 } else {
3881 std::env::current_dir()?.join(checkout)
3882 };
3883 Ok(format!(
3884 "sync-{}.json",
3885 content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
3886 ))
3887}
3888
3889#[cfg(any(unix, windows))]
3890fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
3891 let directory = open_trust_dir(cfg)?;
3892 let origin = normalized_origin(&cfg.hub)?;
3893 let name = format!(
3894 "operation-{}.lock",
3895 content_sha256(format!("{origin}\0{brain}").as_bytes())
3896 );
3897 lock_trust_name(&directory, &name)
3898}
3899
3900#[cfg(not(any(unix, windows)))]
3901fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
3902 Err(LinkError::UnsupportedPlatform {
3903 operation: "serialized link.md v2 sync",
3904 })
3905}
3906
3907fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
3908 left.brain_id == right.brain_id
3909 && left.view_kind == right.view_kind
3910 && left.view_revision == right.view_revision
3911 && match (&left.pointer, &right.pointer) {
3912 (None, None) => true,
3913 (Some(left), Some(right)) => {
3914 left.seq == right.seq
3915 && left.commit_hash == right.commit_hash
3916 && left.content_root == right.content_root
3917 && left.asset_root == right.asset_root
3918 && left.feed_hash == right.feed_hash
3919 }
3920 _ => false,
3921 }
3922}
3923
3924fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
3925 format!(
3926 "---\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"
3927 )
3928 .into_bytes()
3929}
3930
3931fn scoped_projection_sha256(brain: &str) -> String {
3932 content_sha256(&scoped_projection_bytes(brain))
3933}
3934
3935#[derive(Deserialize)]
3936struct LocalScopedViewMarker {
3937 v: u8,
3938 kind: String,
3939 authoritative: bool,
3940 brain: String,
3941 projection_sha256: String,
3942}
3943
3944pub fn has_verified_local_scoped_view(store: &Store) -> bool {
3948 let marker = store
3949 .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
3950 .ok()
3951 .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
3952 let Some(marker) = marker else {
3953 return false;
3954 };
3955 if marker.v != 1
3956 || marker.kind != "link.md-scoped-view"
3957 || marker.authoritative
3958 || !crate::ulid::is_ulid(&marker.brain)
3959 || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
3960 {
3961 return false;
3962 }
3963 store
3964 .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
3965 .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
3966}
3967
3968fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
3969 let mut bytes = serde_json::to_vec_pretty(&json!({
3970 "v": 1,
3971 "kind": "link.md-scoped-view",
3972 "authoritative": false,
3973 "brain": head.brain_id,
3974 "view_revision": head.view_revision,
3975 "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
3976 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
3977 "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
3978 "visible_files": files,
3979 "projection_sha256": scoped_projection_sha256(&head.brain_id),
3980 }))
3981 .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
3982 bytes.push(b'\n');
3983 Ok(bytes)
3984}
3985
3986fn refresh_scoped_view_marker(
3987 store: &Store,
3988 head: &V2VerifiedHead,
3989 files: usize,
3990) -> LinkResult<()> {
3991 if head.view_kind == "scoped" {
3992 store.write_atomic(
3993 Path::new(".dbmd/view.json"),
3994 &scoped_view_metadata(head, files)?,
3995 )?;
3996 }
3997 Ok(())
3998}
3999
4000fn ensure_v2_view_compatible(
4001 head: &V2VerifiedHead,
4002 baseline: Option<&V2SyncBaseline>,
4003) -> LinkResult<()> {
4004 let Some(baseline) = baseline else {
4005 return Ok(());
4006 };
4007 match (
4008 baseline.view_kind.as_deref(),
4009 baseline.view_revision.as_deref(),
4010 ) {
4011 (None, None) if head.view_kind == "full" => Ok(()),
4012 (Some(kind), Some(revision))
4013 if kind == head.view_kind && revision == head.view_revision =>
4014 {
4015 Ok(())
4016 }
4017 _ => Err(LinkError::ScopedViewChanged),
4018 }
4019}
4020
4021fn remove_scoped_projection(
4022 head: &V2VerifiedHead,
4023 baseline: Option<&V2SyncBaseline>,
4024 view: &mut V2LocalView,
4025) -> LinkResult<()> {
4026 if head.view_kind != "scoped" {
4027 return Ok(());
4028 }
4029 let expected = scoped_projection_sha256(&head.brain_id);
4030 if baseline
4031 .and_then(|state| state.projection_sha256.as_deref())
4032 .is_some_and(|pinned| pinned != expected)
4033 {
4034 return Err(LinkError::ScopedViewChanged);
4035 }
4036 if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4037 return Err(LinkError::ScopedProjectionModified);
4038 }
4039 view.riding.remove("DB.md");
4040 view.eligibility.remove("DB.md");
4041 Ok(())
4042}
4043
4044fn files_for_v2_view(
4045 head: &V2VerifiedHead,
4046 mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4047) -> std::collections::BTreeMap<String, V2BaselineFile> {
4048 if head.view_kind == "scoped" {
4049 files.remove("DB.md");
4053 }
4054 files
4055}
4056
4057fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4058 let baseline: V2SyncBaseline =
4059 serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
4060 if baseline.v != 2
4061 || baseline.origin != normalized_origin(&cfg.hub)?
4062 || baseline.brain != brain
4063 || baseline
4064 .commit_hash
4065 .as_deref()
4066 .is_some_and(|hash| !is_sha256(hash))
4067 || baseline
4068 .content_root
4069 .as_deref()
4070 .is_some_and(|hash| !is_sha256(hash))
4071 || baseline
4072 .asset_root
4073 .as_deref()
4074 .is_some_and(|hash| !is_sha256(hash))
4075 || baseline
4076 .local_policy_digest
4077 .as_deref()
4078 .is_some_and(|hash| !is_sha256(hash))
4079 || baseline
4080 .view_kind
4081 .as_deref()
4082 .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
4083 || baseline
4084 .view_revision
4085 .as_deref()
4086 .is_some_and(|hash| !is_sha256(hash))
4087 || baseline
4088 .projection_sha256
4089 .as_deref()
4090 .is_some_and(|hash| !is_sha256(hash))
4091 || (baseline.view_kind.as_deref() == Some("scoped")
4092 && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
4093 || baseline.files.len() > MAX_PUSH_FILES
4094 || baseline.assets.len() > MAX_PUSH_FILES
4095 || baseline.local_eligibility.len() > MAX_PUSH_FILES
4096 || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
4097 || baseline.files.iter().any(|(path, file)| {
4098 crate::linkmd_v2::normalize_path(path).is_err()
4099 || !is_sha256(&file.sha256)
4100 || file.bytes > MAX_STORE_BYTES
4101 })
4102 || baseline.assets.iter().any(|(path, asset)| {
4103 crate::linkmd_v2::normalize_path(path).is_err()
4104 || !is_sha256(&asset.blob_sha256)
4105 || !is_sha256(&asset.leaf_hash)
4106 || asset.bytes > MAX_STORE_BYTES
4107 || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
4108 || asset.wrappers.is_empty()
4109 || asset
4110 .wrappers
4111 .iter()
4112 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4113 })
4114 || baseline
4115 .local_eligibility
4116 .keys()
4117 .chain(baseline.remote_copy_remains.keys())
4118 .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
4119 || baseline
4120 .remote_copy_remains
4121 .values()
4122 .any(|hash| !is_sha256(hash))
4123 {
4124 return Err(invalid_feed("v2 sync baseline failed validation"));
4125 }
4126 Ok(baseline)
4127}
4128
4129#[cfg(unix)]
4130fn load_v2_baseline(
4131 cfg: &HubConfig,
4132 brain: &str,
4133 checkout: &Path,
4134) -> LinkResult<Option<V2SyncBaseline>> {
4135 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4136 let directory = open_trust_dir(cfg)?;
4137 let name_string = v2_baseline_name(cfg, brain, checkout)?;
4138 let _lock = lock_trust_name(&directory, &name_string)?;
4139 let name = c_name(name_string.as_bytes(), &name_string)?;
4140 let fd = unsafe {
4141 libc::openat(
4142 directory.as_raw_fd(),
4143 name.as_ptr(),
4144 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4145 )
4146 };
4147 if fd < 0 {
4148 let error = std::io::Error::last_os_error();
4149 return if error.kind() == std::io::ErrorKind::NotFound {
4150 Ok(None)
4151 } else {
4152 Err(LinkError::UnsafePath { path: name_string })
4153 };
4154 }
4155 let file = unsafe { std::fs::File::from_raw_fd(fd) };
4156 let mut bytes = Vec::new();
4157 file.take(MAX_FEED_RESPONSE_BYTES + 1)
4158 .read_to_end(&mut bytes)?;
4159 if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
4160 return Err(invalid_feed("v2 sync baseline is oversized"));
4161 }
4162 Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
4163}
4164
4165#[cfg(windows)]
4166fn load_v2_baseline(
4167 cfg: &HubConfig,
4168 brain: &str,
4169 checkout: &Path,
4170) -> LinkResult<Option<V2SyncBaseline>> {
4171 let directory = open_trust_dir(cfg)?;
4172 let name = v2_baseline_name(cfg, brain, checkout)?;
4173 let _lock = lock_trust_name(&directory, &name)?;
4174 let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
4175 match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
4176 Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
4177 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
4178 Err(_) => Err(LinkError::UnsafePath { path: name }),
4179 }
4180}
4181
4182#[cfg(not(any(unix, windows)))]
4183fn load_v2_baseline(
4184 _cfg: &HubConfig,
4185 _brain: &str,
4186 _checkout: &Path,
4187) -> LinkResult<Option<V2SyncBaseline>> {
4188 Err(LinkError::UnsupportedPlatform {
4189 operation: "verified link.md v2 baseline",
4190 })
4191}
4192
4193#[cfg(unix)]
4194fn save_v2_baseline(
4195 cfg: &HubConfig,
4196 brain: &str,
4197 checkout: &Path,
4198 baseline: &V2SyncBaseline,
4199) -> LinkResult<()> {
4200 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4201 let directory = open_trust_dir(cfg)?;
4202 let name_string = v2_baseline_name(cfg, brain, checkout)?;
4203 let _lock = lock_trust_name(&directory, &name_string)?;
4204 let name = c_name(name_string.as_bytes(), &name_string)?;
4205 let mut bytes = serde_json::to_vec(baseline)
4206 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
4207 bytes.push(b'\n');
4208 let temp_string = format!(
4209 ".{name_string}.tmp.{}-{}",
4210 std::process::id(),
4211 std::time::SystemTime::now()
4212 .duration_since(std::time::UNIX_EPOCH)
4213 .unwrap_or_default()
4214 .as_nanos()
4215 );
4216 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
4217 let fd = unsafe {
4218 libc::openat(
4219 directory.as_raw_fd(),
4220 temp.as_ptr(),
4221 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4222 0o600,
4223 )
4224 };
4225 if fd < 0 {
4226 return Err(std::io::Error::last_os_error().into());
4227 }
4228 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
4229 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
4230 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4231 return Err(error.into());
4232 }
4233 drop(file);
4234 if unsafe {
4235 libc::renameat(
4236 directory.as_raw_fd(),
4237 temp.as_ptr(),
4238 directory.as_raw_fd(),
4239 name.as_ptr(),
4240 )
4241 } != 0
4242 {
4243 let error = std::io::Error::last_os_error();
4244 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4245 return Err(error.into());
4246 }
4247 directory.sync_all()?;
4248 Ok(())
4249}
4250
4251#[cfg(windows)]
4252fn save_v2_baseline(
4253 cfg: &HubConfig,
4254 brain: &str,
4255 checkout: &Path,
4256 baseline: &V2SyncBaseline,
4257) -> LinkResult<()> {
4258 let directory = open_trust_dir(cfg)?;
4259 let name = v2_baseline_name(cfg, brain, checkout)?;
4260 let _lock = lock_trust_name(&directory, &name)?;
4261 let mut bytes = serde_json::to_vec(baseline)
4262 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
4263 bytes.push(b'\n');
4264 crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
4265 Ok(())
4266}
4267
4268#[cfg(not(any(unix, windows)))]
4269fn save_v2_baseline(
4270 _cfg: &HubConfig,
4271 _brain: &str,
4272 _checkout: &Path,
4273 _baseline: &V2SyncBaseline,
4274) -> LinkResult<()> {
4275 Err(LinkError::UnsupportedPlatform {
4276 operation: "verified link.md v2 baseline",
4277 })
4278}
4279
4280fn v2_baseline_from_head(
4281 cfg: &HubConfig,
4282 head: &V2VerifiedHead,
4283 files: std::collections::BTreeMap<String, V2BaselineFile>,
4284 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
4285 local: Option<&V2LocalView>,
4286) -> LinkResult<V2SyncBaseline> {
4287 let mut local_eligibility = local
4288 .map(|view| view.eligibility.clone())
4289 .unwrap_or_default();
4290 if let Some(view) = local {
4291 for path in files.keys() {
4292 local_eligibility
4293 .entry(path.clone())
4294 .or_insert_with(|| !view.policy.keeps_home(path));
4295 }
4296 }
4297 let remote_copy_remains = local_eligibility
4298 .iter()
4299 .filter(|(_, riding)| !**riding)
4300 .filter_map(|(path, _)| {
4301 files
4302 .get(path)
4303 .map(|file| (path.clone(), file.sha256.clone()))
4304 })
4305 .collect();
4306 Ok(V2SyncBaseline {
4307 v: 2,
4308 origin: normalized_origin(&cfg.hub)?,
4309 brain: head.brain_id.clone(),
4310 head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
4311 commit_hash: head
4312 .pointer
4313 .as_ref()
4314 .map(|pointer| pointer.commit_hash.clone()),
4315 content_root: head
4316 .pointer
4317 .as_ref()
4318 .and_then(|pointer| pointer.content_root.clone()),
4319 asset_root: head
4320 .pointer
4321 .as_ref()
4322 .and_then(|pointer| pointer.asset_root.clone()),
4323 assets,
4324 view_kind: Some(head.view_kind.clone()),
4325 view_revision: Some(head.view_revision.clone()),
4326 projection_sha256: (head.view_kind == "scoped")
4327 .then(|| scoped_projection_sha256(&head.brain_id)),
4328 files,
4329 local_policy_digest: local.map(|view| view.policy.digest.clone()),
4330 local_eligibility,
4331 remote_copy_remains,
4332 })
4333}
4334
4335fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
4336 let policy = crate::linkmd_sync_policy::load(store)
4337 .map_err(|message| LinkError::InvalidPack { message })?;
4338 let asset_paths = crate::assets::read_manifest(store)
4339 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
4340 .into_iter()
4341 .map(|asset| asset.path)
4342 .collect::<std::collections::BTreeSet<_>>();
4343 let mut result = std::collections::BTreeMap::new();
4344 let mut eligibility = std::collections::BTreeMap::new();
4345 let mut total = 0_u64;
4346 let mut paths = vec![PathBuf::from("DB.md")];
4347 paths.extend(store.walk()?);
4348 for relative in paths {
4349 let path = relative.to_string_lossy().replace('\\', "/");
4350 if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
4352 continue;
4353 }
4354 if asset_paths.contains(&path) {
4355 continue;
4356 }
4357 crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
4358 path: error.to_string(),
4359 })?;
4360 let riding = !policy.keeps_home(&path);
4361 eligibility.insert(path.clone(), riding);
4362 if !riding {
4363 continue;
4364 }
4365 let remaining = MAX_STORE_BYTES.saturating_sub(total);
4366 let bytes = store.read_bounded(&relative, remaining)?;
4367 total = total
4368 .checked_add(bytes.len() as u64)
4369 .ok_or_else(|| LinkError::PushTooLarge {
4370 detail: "v2 local byte count overflow".to_string(),
4371 })?;
4372 if total > MAX_STORE_BYTES {
4373 return Err(LinkError::PushTooLarge {
4374 detail: format!("{total} uncompressed bytes"),
4375 });
4376 }
4377 if std::str::from_utf8(&bytes).is_err() {
4378 return Err(LinkError::NotUtf8 { path });
4379 }
4380 result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
4381 }
4382 Ok(V2LocalView {
4383 riding: result,
4384 eligibility,
4385 policy,
4386 })
4387}
4388
4389#[derive(Debug, Deserialize)]
4390struct V2DownloadItem {
4391 path: String,
4392 sha256: String,
4393 bytes: u64,
4394 url: String,
4395 method: String,
4396}
4397
4398#[derive(Debug, Deserialize)]
4399struct V2DownloadWindow {
4400 v: u8,
4401 commit: String,
4402 downloads: Vec<V2DownloadItem>,
4403}
4404
4405#[derive(Debug, Deserialize)]
4406struct V2BulkStreamHeader {
4407 v: u8,
4408 path: String,
4409 sha256: String,
4410 bytes: u64,
4411}
4412
4413fn parse_v2_bulk_stream(
4414 bytes: &[u8],
4415 expected: &[(&String, &V2BaselineFile)],
4416) -> LinkResult<Vec<(String, Vec<u8>)>> {
4417 if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
4418 return Err(invalid_feed("v2 bulk stream has an invalid magic"));
4419 }
4420 let mut cursor = V2_BULK_STREAM_MAGIC.len();
4421 let mut result = Vec::with_capacity(expected.len());
4422 for (expected_path, expected_file) in expected {
4423 let length_bytes = bytes
4424 .get(cursor..cursor + 4)
4425 .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
4426 cursor += 4;
4427 let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
4428 if header_len == 0 || header_len > 4 * 1024 {
4429 return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
4430 }
4431 let header_bytes = bytes
4432 .get(cursor..cursor + header_len)
4433 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
4434 cursor += header_len;
4435 let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
4436 .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
4437 if header.v != 2
4438 || &header.path != *expected_path
4439 || header.sha256 != expected_file.sha256
4440 || header.bytes != expected_file.bytes
4441 || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
4442 {
4443 return Err(invalid_feed(
4444 "v2 bulk stream frame differs from its proven manifest entry",
4445 ));
4446 }
4447 let body_len = usize::try_from(header.bytes)
4448 .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
4449 let body = bytes
4450 .get(cursor..cursor + body_len)
4451 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
4452 cursor += body_len;
4453 if content_sha256(body) != header.sha256 {
4454 return Err(invalid_feed(
4455 "v2 bulk stream file differs from its proven manifest entry",
4456 ));
4457 }
4458 result.push((header.path, body.to_vec()));
4459 }
4460 if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
4461 return Err(invalid_feed("v2 bulk stream has no exact end marker"));
4462 }
4463 cursor += 4;
4464 if cursor != bytes.len() {
4465 return Err(invalid_feed("v2 bulk stream carries trailing data"));
4466 }
4467 Ok(result)
4468}
4469
4470fn download_v2_bulk_stream(
4471 cfg: &HubConfig,
4472 brain: &str,
4473 pointer: &V2PointerBody,
4474 pending: &[(&String, &V2BaselineFile)],
4475) -> LinkResult<Vec<(String, Vec<u8>)>> {
4476 let claims = pending
4477 .iter()
4478 .map(|(path, file)| {
4479 Ok(json!({
4480 "path": path,
4481 "sha256": file.sha256,
4482 "bytes": file.bytes,
4483 "proof": file.proof.as_ref().ok_or_else(|| {
4484 invalid_feed("v2 manifest omitted a bulk-stream proof")
4485 })?,
4486 }))
4487 })
4488 .collect::<LinkResult<Vec<_>>>()?;
4489 let raw = request_raw(
4490 cfg,
4491 "POST",
4492 &format!("/api/hub/brains/{brain}/v2/stream"),
4493 Some(&json!({
4494 "commit": pointer.commit_hash,
4495 "files": claims,
4496 })),
4497 Auth::Required,
4498 V2_BULK_STREAM_RESPONSE_BYTES,
4499 )?;
4500 let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
4501 parse_v2_bulk_stream(&body, pending)
4502}
4503
4504fn prepare_v2_downloads(
4505 cfg: &HubConfig,
4506 brain: &str,
4507 pointer: &V2PointerBody,
4508 pending: &[(&String, &V2BaselineFile)],
4509) -> LinkResult<Vec<V2DownloadItem>> {
4510 let mut result = Vec::with_capacity(pending.len());
4511 for chunk in pending.chunks(128) {
4512 let claims = chunk
4513 .iter()
4514 .map(|(path, file)| {
4515 Ok(json!({
4516 "path": path,
4517 "sha256": file.sha256,
4518 "bytes": file.bytes,
4519 "proof": file.proof.as_ref().ok_or_else(|| {
4520 invalid_feed("v2 manifest omitted a download proof")
4521 })?,
4522 }))
4523 })
4524 .collect::<LinkResult<Vec<_>>>()?;
4525 let value = ensure_ok(
4526 request_capped(
4527 cfg,
4528 "POST",
4529 &format!("/api/hub/brains/{brain}/v2/downloads"),
4530 Some(&json!({
4531 "commit": pointer.commit_hash,
4532 "files": claims,
4533 })),
4534 Auth::Required,
4535 MAX_FEED_RESPONSE_BYTES,
4536 )?,
4537 "prepare v2 blob downloads",
4538 )?;
4539 let window: V2DownloadWindow = serde_json::from_value(value)
4540 .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
4541 if window.v != 2
4542 || window.commit != pointer.commit_hash
4543 || window.downloads.len() != chunk.len()
4544 {
4545 return Err(invalid_feed(
4546 "v2 download window is not bound to the requested files",
4547 ));
4548 }
4549 let mut by_path = window
4550 .downloads
4551 .into_iter()
4552 .map(|item| (item.path.clone(), item))
4553 .collect::<std::collections::BTreeMap<_, _>>();
4554 if by_path.len() != chunk.len() {
4555 return Err(invalid_feed("v2 download window repeats a path"));
4556 }
4557 for (path, file) in chunk {
4558 let item = by_path
4559 .remove(*path)
4560 .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
4561 if item.method != "GET"
4562 || item.sha256 != file.sha256
4563 || item.bytes != file.bytes
4564 || item.url.is_empty()
4565 {
4566 return Err(invalid_feed(
4567 "v2 download capability differs from its proven file",
4568 ));
4569 }
4570 result.push(item);
4571 }
4572 }
4573 Ok(result)
4574}
4575
4576fn prepare_v2_asset_downloads(
4577 cfg: &HubConfig,
4578 brain: &str,
4579 pointer: &V2PointerBody,
4580 pending: &[(&String, &V2BaselineAsset)],
4581) -> LinkResult<Vec<V2DownloadItem>> {
4582 let mut result = Vec::with_capacity(pending.len());
4583 for chunk in pending.chunks(128) {
4584 let claims = chunk
4585 .iter()
4586 .map(|(path, asset)| {
4587 json!({
4588 "path": path,
4589 "sha256": asset.blob_sha256,
4590 "bytes": asset.bytes,
4591 "leaf_hash": asset.leaf_hash,
4592 })
4593 })
4594 .collect::<Vec<_>>();
4595 let value = ensure_ok(
4596 request_capped(
4597 cfg,
4598 "POST",
4599 &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
4600 Some(&json!({
4601 "commit": pointer.commit_hash,
4602 "assets": claims,
4603 })),
4604 Auth::Required,
4605 MAX_FEED_RESPONSE_BYTES,
4606 )?,
4607 "prepare v2 asset downloads",
4608 )?;
4609 let window: V2DownloadWindow = serde_json::from_value(value)
4610 .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
4611 if window.v != 2
4612 || window.commit != pointer.commit_hash
4613 || window.downloads.len() != chunk.len()
4614 {
4615 return Err(invalid_feed(
4616 "v2 asset download window is not bound to the requested assets",
4617 ));
4618 }
4619 let mut by_path = window
4620 .downloads
4621 .into_iter()
4622 .map(|item| (item.path.clone(), item))
4623 .collect::<std::collections::BTreeMap<_, _>>();
4624 if by_path.len() != chunk.len() {
4625 return Err(invalid_feed("v2 asset download window repeats a path"));
4626 }
4627 for (path, asset) in chunk {
4628 let item = by_path
4629 .remove(*path)
4630 .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
4631 if item.method != "GET"
4632 || item.sha256 != asset.blob_sha256
4633 || item.bytes != asset.bytes
4634 || item.url.is_empty()
4635 {
4636 return Err(invalid_feed(
4637 "v2 asset download capability differs from its signed leaf",
4638 ));
4639 }
4640 result.push(item);
4641 }
4642 }
4643 Ok(result)
4644}
4645
4646fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
4647 let bytes = get_presigned(cfg, &item.url)?;
4648 if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
4649 return Err(invalid_feed("v2 blob differs from its proven path entry"));
4650 }
4651 Ok(bytes)
4652}
4653
4654#[derive(Debug, Clone)]
4655struct V2StagedFile {
4656 path: String,
4657 source: PathBuf,
4658 sha256: String,
4659 bytes: u64,
4660}
4661
4662#[cfg(unix)]
4663fn v2_download_cache_dir(
4664 cfg: &HubConfig,
4665 brain: &str,
4666 pointer: &V2PointerBody,
4667) -> LinkResult<PathBuf> {
4668 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
4669}
4670
4671#[cfg(unix)]
4672fn v2_download_cache_dir_for(
4673 cfg: &HubConfig,
4674 brain: &str,
4675 transaction: &str,
4676) -> LinkResult<PathBuf> {
4677 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4678 return Err(invalid_feed("v2 download cache address is invalid"));
4679 }
4680 let path = cfg
4681 .state_dir
4682 .join("downloads")
4683 .join(brain)
4684 .join(transaction);
4685 let directory = open_or_create_dir_nofollow(&path)?;
4686 use std::os::fd::AsRawFd as _;
4687 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
4688 return Err(std::io::Error::last_os_error().into());
4689 }
4690 directory.sync_all()?;
4691 Ok(path)
4692}
4693
4694#[cfg(windows)]
4695fn v2_download_cache_dir(
4696 cfg: &HubConfig,
4697 brain: &str,
4698 pointer: &V2PointerBody,
4699) -> LinkResult<PathBuf> {
4700 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
4701}
4702
4703#[cfg(windows)]
4704fn v2_download_cache_dir_for(
4705 cfg: &HubConfig,
4706 brain: &str,
4707 transaction: &str,
4708) -> LinkResult<PathBuf> {
4709 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4710 return Err(invalid_feed("v2 download cache address is invalid"));
4711 }
4712 let path = cfg
4713 .state_dir
4714 .join("downloads")
4715 .join(brain)
4716 .join(transaction);
4717 crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
4718 crate::fsx::open_directory_nofollow(&path)?;
4719 Ok(path)
4720}
4721
4722#[cfg(unix)]
4723fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
4724 use std::os::fd::AsRawFd as _;
4725 let parent = cfg.state_dir.join("downloads").join(brain);
4726 let Ok(directory) = open_existing_dir_nofollow(&parent) else {
4727 return;
4728 };
4729 let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
4730 return;
4731 };
4732 let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
4733 let _ = directory.sync_all();
4734}
4735
4736#[cfg(windows)]
4737fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
4738 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
4739 return;
4740 }
4741 let parent = cfg.state_dir.join("downloads").join(brain);
4742 let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
4743 return;
4744 };
4745 let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
4746}
4747
4748#[cfg(not(any(unix, windows)))]
4749fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
4750
4751#[cfg(not(any(unix, windows)))]
4752fn v2_download_cache_dir_for(
4753 _cfg: &HubConfig,
4754 _brain: &str,
4755 _transaction: &str,
4756) -> LinkResult<PathBuf> {
4757 Err(LinkError::UnsupportedPlatform {
4758 operation: "resumable v2 download staging",
4759 })
4760}
4761
4762#[cfg(any(unix, windows))]
4763fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
4764 let file = match crate::fsx::open_regular_nofollow(path) {
4765 Ok(file) => file,
4766 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
4767 Err(error) => return Err(error.into()),
4768 };
4769 if file.metadata()?.len() != bytes {
4770 return Ok(false);
4771 }
4772 Ok(content_sha256_reader(file)? == sha256)
4773}
4774
4775#[cfg(any(unix, windows))]
4776fn cache_v2_blob_bytes(
4777 cache_dir: &Path,
4778 sha256: &str,
4779 expected_bytes: u64,
4780 bytes: &[u8],
4781) -> LinkResult<PathBuf> {
4782 if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
4783 return Err(invalid_feed("v2 cached blob differs from its declaration"));
4784 }
4785 let path = cache_dir.join(sha256);
4786 if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
4787 crate::fsx::write_atomic(&path, bytes)?;
4788 }
4789 Ok(path)
4790}
4791
4792#[cfg(not(any(unix, windows)))]
4793fn cache_v2_blob_bytes(
4794 _cache_dir: &Path,
4795 _sha256: &str,
4796 _expected_bytes: u64,
4797 _bytes: &[u8],
4798) -> LinkResult<PathBuf> {
4799 Err(LinkError::UnsupportedPlatform {
4800 operation: "resumable v2 download staging",
4801 })
4802}
4803
4804#[cfg(unix)]
4805fn download_presigned_to_cache(
4806 cfg: &HubConfig,
4807 url: &str,
4808 cache_dir: &Path,
4809 sha256: &str,
4810 expected_bytes: u64,
4811) -> LinkResult<PathBuf> {
4812 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4813
4814 let target = cache_dir.join(sha256);
4815 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
4816 return Ok(target);
4817 }
4818 let directory = open_existing_dir_nofollow(cache_dir)?;
4819 let mut nonce = [0_u8; 16];
4820 ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
4821 .map_err(|_| invalid_feed("could not mint a download cache name"))?;
4822 let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
4823 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
4824 let fd = unsafe {
4825 libc::openat(
4826 directory.as_raw_fd(),
4827 temp.as_ptr(),
4828 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4829 0o600,
4830 )
4831 };
4832 if fd < 0 {
4833 return Err(std::io::Error::last_os_error().into());
4834 }
4835 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
4836 let response = match presigned_agent(cfg, url)?.get(url).call() {
4837 Ok(response) => response,
4838 Err(ureq::Error::Status(_, response)) => {
4839 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4840 return Err(LinkError::Http {
4841 what: "v2 direct download",
4842 status: response.status(),
4843 message: "object store rejected the download".to_string(),
4844 code: None,
4845 details: None,
4846 });
4847 }
4848 Err(ureq::Error::Transport(error)) => {
4849 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4850 return Err(LinkError::Transport {
4851 hub: cfg.hub.clone(),
4852 message: error.to_string(),
4853 });
4854 }
4855 };
4856 let mut reader = response
4857 .into_reader()
4858 .take(expected_bytes.saturating_add(1));
4859 let mut digest = Sha256::new();
4860 let mut total = 0_u64;
4861 let mut buffer = [0_u8; 64 * 1024];
4862 let write_result = (|| -> std::io::Result<()> {
4863 loop {
4864 let read = reader.read(&mut buffer)?;
4865 if read == 0 {
4866 break;
4867 }
4868 total = total.saturating_add(read as u64);
4869 digest.update(&buffer[..read]);
4870 output.write_all(&buffer[..read])?;
4871 }
4872 output.sync_all()
4873 })();
4874 if let Err(error) = write_result {
4875 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4876 return Err(error.into());
4877 }
4878 drop(output);
4879 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
4880 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4881 return Err(invalid_feed(
4882 "v2 direct download failed integrity verification",
4883 ));
4884 }
4885 let target_name = c_name(sha256.as_bytes(), sha256)?;
4886 if unsafe {
4889 libc::renameat(
4890 directory.as_raw_fd(),
4891 temp.as_ptr(),
4892 directory.as_raw_fd(),
4893 target_name.as_ptr(),
4894 )
4895 } != 0
4896 {
4897 let error = std::io::Error::last_os_error();
4898 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4899 return Err(error.into());
4900 }
4901 directory.sync_all()?;
4902 Ok(target)
4903}
4904
4905#[cfg(windows)]
4906fn download_presigned_to_cache(
4907 cfg: &HubConfig,
4908 url: &str,
4909 cache_dir: &Path,
4910 sha256: &str,
4911 expected_bytes: u64,
4912) -> LinkResult<PathBuf> {
4913 use std::fs::OpenOptions;
4914
4915 let target = cache_dir.join(sha256);
4916 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
4917 return Ok(target);
4918 }
4919 let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
4923 let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
4924 let mut output = OpenOptions::new()
4925 .write(true)
4926 .create_new(true)
4927 .open(&temp)?;
4928 let response = match presigned_agent(cfg, url)?.get(url).call() {
4929 Ok(response) => response,
4930 Err(ureq::Error::Status(_, response)) => {
4931 let _ = std::fs::remove_file(&temp);
4932 return Err(LinkError::Http {
4933 what: "v2 direct download",
4934 status: response.status(),
4935 message: "object store rejected the download".to_string(),
4936 code: None,
4937 details: None,
4938 });
4939 }
4940 Err(ureq::Error::Transport(error)) => {
4941 let _ = std::fs::remove_file(&temp);
4942 return Err(LinkError::Transport {
4943 hub: cfg.hub.clone(),
4944 message: error.to_string(),
4945 });
4946 }
4947 };
4948 let mut reader = response
4949 .into_reader()
4950 .take(expected_bytes.saturating_add(1));
4951 let mut digest = Sha256::new();
4952 let mut total = 0_u64;
4953 let mut buffer = [0_u8; 64 * 1024];
4954 let copied = (|| -> std::io::Result<()> {
4955 loop {
4956 let read = reader.read(&mut buffer)?;
4957 if read == 0 {
4958 break;
4959 }
4960 total = total.saturating_add(read as u64);
4961 digest.update(&buffer[..read]);
4962 output.write_all(&buffer[..read])?;
4963 }
4964 output.sync_all()
4965 })();
4966 if let Err(error) = copied {
4967 let _ = std::fs::remove_file(&temp);
4968 return Err(error.into());
4969 }
4970 drop(output);
4971 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
4972 let _ = std::fs::remove_file(&temp);
4973 return Err(invalid_feed(
4974 "v2 direct download failed integrity verification",
4975 ));
4976 }
4977 if target.exists() {
4978 std::fs::remove_file(&target)?;
4979 }
4980 if let Err(error) = std::fs::rename(&temp, &target) {
4981 let _ = std::fs::remove_file(&temp);
4982 return Err(error.into());
4983 }
4984 Ok(target)
4985}
4986
4987#[cfg(not(any(unix, windows)))]
4988fn download_presigned_to_cache(
4989 _cfg: &HubConfig,
4990 _url: &str,
4991 _cache_dir: &Path,
4992 _sha256: &str,
4993 _expected_bytes: u64,
4994) -> LinkResult<PathBuf> {
4995 Err(LinkError::UnsupportedPlatform {
4996 operation: "resumable v2 download staging",
4997 })
4998}
4999
5000fn download_v2_blobs(
5001 cfg: &HubConfig,
5002 brain: &str,
5003 pointer: &V2PointerBody,
5004 pending: Vec<(&String, &V2BaselineFile)>,
5005) -> LinkResult<Vec<(String, Vec<u8>)>> {
5006 if pending.is_empty() {
5007 return Ok(Vec::new());
5008 }
5009 let expected_order = pending
5010 .iter()
5011 .map(|(path, _)| (*path).clone())
5012 .collect::<Vec<_>>();
5013 let mut streamed = std::collections::BTreeMap::new();
5014 let mut direct = Vec::new();
5015 let mut window = Vec::new();
5016 let mut window_bytes = 0_u64;
5017 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5018 window_bytes: &mut u64,
5019 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
5020 -> LinkResult<()> {
5021 if window.is_empty() {
5022 return Ok(());
5023 }
5024 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
5025 if streamed.insert(path, bytes).is_some() {
5026 return Err(invalid_feed("v2 bulk streams repeated a path"));
5027 }
5028 }
5029 window.clear();
5030 *window_bytes = 0;
5031 Ok(())
5032 };
5033 for &(path, file) in &pending {
5034 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5035 flush(&mut window, &mut window_bytes, &mut streamed)?;
5036 direct.push((path, file));
5037 continue;
5038 }
5039 if window.len() == V2_BULK_STREAM_FILES
5040 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5041 {
5042 flush(&mut window, &mut window_bytes, &mut streamed)?;
5043 }
5044 window.push((path, file));
5045 window_bytes += file.bytes;
5046 }
5047 flush(&mut window, &mut window_bytes, &mut streamed)?;
5048
5049 let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
5050 let next = std::sync::atomic::AtomicUsize::new(0);
5051 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5052 let mut results = std::iter::repeat_with(|| None)
5053 .take(downloads.len())
5054 .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
5055 std::thread::scope(|scope| {
5056 let (sender, receiver) = std::sync::mpsc::channel();
5057 for _ in 0..worker_count {
5058 let sender = sender.clone();
5059 let downloads = &downloads;
5060 let next = &next;
5061 scope.spawn(move || loop {
5062 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5063 let Some(item) = downloads.get(index) else {
5064 break;
5065 };
5066 let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
5067 if sender.send((index, result)).is_err() {
5068 break;
5069 }
5070 });
5071 }
5072 drop(sender);
5073 for (index, result) in receiver {
5074 results[index] = Some(result);
5075 }
5076 });
5077 for result in results.into_iter().map(|result| {
5078 result.ok_or_else(|| LinkError::Transport {
5079 hub: cfg.hub.clone(),
5080 message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
5081 })?
5082 }) {
5083 let (path, bytes) = result?;
5084 if streamed.insert(path, bytes).is_some() {
5085 return Err(invalid_feed("v2 download lanes repeated a path"));
5086 }
5087 }
5088 expected_order
5089 .into_iter()
5090 .map(|path| {
5091 streamed
5092 .remove(&path)
5093 .map(|bytes| (path, bytes))
5094 .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
5095 })
5096 .collect()
5097}
5098
5099#[cfg(any(unix, windows))]
5103fn stage_v2_blobs(
5104 cfg: &HubConfig,
5105 brain: &str,
5106 pointer: &V2PointerBody,
5107 pending: Vec<(&String, &V2BaselineFile)>,
5108) -> LinkResult<Vec<V2StagedFile>> {
5109 let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
5110 let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
5111 let mut direct = Vec::new();
5112 let mut window = Vec::new();
5113 let mut window_bytes = 0_u64;
5114 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5115 window_bytes: &mut u64,
5116 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
5117 -> LinkResult<()> {
5118 if window.is_empty() {
5119 return Ok(());
5120 }
5121 let missing = window
5122 .iter()
5123 .filter_map(|(path, file)| {
5124 let target = cache_dir.join(&file.sha256);
5125 match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
5126 Ok(true) => {
5127 staged.insert(
5128 (*path).clone(),
5129 V2StagedFile {
5130 path: (*path).clone(),
5131 source: target,
5132 sha256: file.sha256.clone(),
5133 bytes: file.bytes,
5134 },
5135 );
5136 None
5137 }
5138 Ok(false) => Some(Ok((*path, *file))),
5139 Err(error) => Some(Err(error)),
5140 }
5141 })
5142 .collect::<LinkResult<Vec<_>>>()?;
5143 if !missing.is_empty() {
5144 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
5145 let file = missing
5146 .iter()
5147 .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
5148 .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
5149 let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
5150 staged.insert(
5151 path.clone(),
5152 V2StagedFile {
5153 path,
5154 source,
5155 sha256: file.sha256.clone(),
5156 bytes: file.bytes,
5157 },
5158 );
5159 }
5160 }
5161 window.clear();
5162 *window_bytes = 0;
5163 Ok(())
5164 };
5165 for &(path, file) in &pending {
5166 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5167 flush(&mut window, &mut window_bytes, &mut staged)?;
5168 direct.push((path, file));
5169 continue;
5170 }
5171 if window.len() == V2_BULK_STREAM_FILES
5172 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5173 {
5174 flush(&mut window, &mut window_bytes, &mut staged)?;
5175 }
5176 window.push((path, file));
5177 window_bytes += file.bytes;
5178 }
5179 flush(&mut window, &mut window_bytes, &mut staged)?;
5180 for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
5181 let source =
5182 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
5183 staged.insert(
5184 item.path.clone(),
5185 V2StagedFile {
5186 path: item.path,
5187 source,
5188 sha256: item.sha256,
5189 bytes: item.bytes,
5190 },
5191 );
5192 }
5193 pending
5194 .into_iter()
5195 .map(|(path, _)| {
5196 staged
5197 .remove(path)
5198 .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
5199 })
5200 .collect()
5201}
5202
5203#[cfg(not(any(unix, windows)))]
5204fn stage_v2_blobs(
5205 _cfg: &HubConfig,
5206 _brain: &str,
5207 _pointer: &V2PointerBody,
5208 _pending: Vec<(&String, &V2BaselineFile)>,
5209) -> LinkResult<Vec<V2StagedFile>> {
5210 Err(LinkError::UnsupportedPlatform {
5211 operation: "resumable v2 download staging",
5212 })
5213}
5214
5215const V2_CONFLICT_BUNDLE_MAX: usize = 32;
5216const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
5217const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
5218
5219#[derive(Debug, Clone, Deserialize, Serialize)]
5220struct V2ConflictCoordinate {
5221 sha256: Option<String>,
5222 bytes: Option<u64>,
5223 file: Option<String>,
5224}
5225
5226#[derive(Debug, Clone, Deserialize, Serialize)]
5227struct V2ConflictFile {
5228 path: String,
5229 base: V2ConflictCoordinate,
5230 local: V2ConflictCoordinate,
5231 remote: V2ConflictCoordinate,
5232}
5233
5234#[derive(Debug, Clone, Deserialize, Serialize)]
5235struct V2ConflictPlan {
5236 v: u8,
5237 class: String,
5238 bundle: String,
5239 brain: String,
5240 origin: String,
5241 created_unix: u64,
5242 expires_unix: u64,
5243 base_seq: Option<u64>,
5244 base_commit: Option<String>,
5245 remote_seq: u64,
5246 remote_commit: Option<String>,
5247 remote_content_root: Option<String>,
5248 view_kind: String,
5249 view_revision: String,
5250 files: Vec<V2ConflictFile>,
5251}
5252
5253fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
5254 PathBuf::from(".dbmd")
5255 .join("conflicts")
5256 .join(bundle)
5257 .join(suffix)
5258}
5259
5260fn read_historical_conflict_blob(
5261 cfg: &HubConfig,
5262 brain: &str,
5263 baseline: &V2SyncBaseline,
5264 path: &str,
5265 file: &V2BaselineFile,
5266) -> LinkResult<Option<Vec<u8>>> {
5267 let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
5268 return Ok(None);
5269 };
5270 if seq == 0 {
5271 return Ok(None);
5272 }
5273 let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
5274 let endpoint = format!(
5275 "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
5276 file.sha256
5277 );
5278 let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
5279 if raw.status == 404 || raw.status == 403 {
5280 return Ok(None);
5281 }
5282 let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
5283 if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
5284 return Err(invalid_feed(
5285 "v2 conflict base failed integrity verification",
5286 ));
5287 }
5288 Ok(Some(bytes))
5289}
5290
5291fn create_v2_conflict_bundle(
5294 cfg: &HubConfig,
5295 store: &Store,
5296 head: &V2VerifiedHead,
5297 baseline: Option<&V2SyncBaseline>,
5298 local: &std::collections::BTreeMap<String, (String, u64)>,
5299 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
5300 paths: &[String],
5301) -> LinkResult<(String, Vec<String>)> {
5302 let conflicts_root = Path::new(".dbmd/conflicts");
5303 store.create_dir_all(conflicts_root)?;
5304 let completed = store
5305 .directory_names(conflicts_root)?
5306 .into_iter()
5307 .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
5308 .count();
5309 if completed >= V2_CONFLICT_BUNDLE_MAX {
5310 return Err(LinkError::InvalidPack {
5311 message: format!(
5312 "private conflict cache has {completed} bundles; resolve or prune one before syncing"
5313 ),
5314 });
5315 }
5316
5317 let mut selected_paths = Vec::new();
5321 let mut selected_remote_bytes = 0_u64;
5322 for path in paths {
5323 let bytes = remote.get(path).map_or(0, |file| file.bytes);
5324 if !selected_paths.is_empty()
5325 && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
5326 {
5327 break;
5328 }
5329 selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
5330 selected_paths.push(path.clone());
5331 if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
5332 break;
5333 }
5334 }
5335 if selected_paths.is_empty() {
5336 return Err(invalid_feed("content conflict set is empty"));
5337 }
5338 let bundle = crate::ulid::mint();
5339 let bundle_root = v2_conflict_relative(&bundle, "");
5340 store.create_dir_all(&bundle_root.join("files"))?;
5341 let pointer = head.pointer.as_ref();
5342 let remote_bytes = match pointer {
5343 Some(pointer) => download_v2_blobs(
5344 cfg,
5345 &head.brain_id,
5346 pointer,
5347 selected_paths
5348 .iter()
5349 .filter_map(|path| {
5350 remote
5351 .get(path)
5352 .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
5353 .map(|file| (path, file))
5354 })
5355 .collect(),
5356 )?
5357 .into_iter()
5358 .collect::<std::collections::BTreeMap<_, _>>(),
5359 None => std::collections::BTreeMap::new(),
5360 };
5361
5362 let mut files = Vec::with_capacity(selected_paths.len());
5363 for (index, path) in selected_paths.iter().enumerate() {
5364 let base_file = baseline.and_then(|state| state.files.get(path));
5365 let base_bytes = match (baseline, base_file) {
5366 (Some(state), Some(file)) => {
5367 read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
5368 }
5369 _ => None,
5370 };
5371 let local_file = local.get(path);
5372 let remote_file = remote.get(path);
5373 let remote_content = remote_bytes.get(path);
5374 let prefix = format!("files/{index:04}");
5375 let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
5376 let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
5377 let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
5378 if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
5379 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
5380 }
5381 if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
5382 let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
5383 if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
5384 return Err(LinkError::InvalidPack {
5385 message: format!("local conflict path `{path}` changed while bundling"),
5386 });
5387 }
5388 store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
5389 }
5390 if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
5391 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
5392 }
5393 files.push(V2ConflictFile {
5394 path: path.clone(),
5395 base: V2ConflictCoordinate {
5396 sha256: base_file.map(|file| file.sha256.clone()),
5397 bytes: base_file.map(|file| file.bytes),
5398 file: base_name,
5399 },
5400 local: V2ConflictCoordinate {
5401 sha256: local_file.map(|(sha256, _)| sha256.clone()),
5402 bytes: local_file.map(|(_, bytes)| *bytes),
5403 file: local_name,
5404 },
5405 remote: V2ConflictCoordinate {
5406 sha256: remote_file.map(|file| file.sha256.clone()),
5407 bytes: remote_file.map(|file| file.bytes),
5408 file: remote_name,
5409 },
5410 });
5411 }
5412 let now = SystemTime::now()
5413 .duration_since(UNIX_EPOCH)
5414 .unwrap_or_default()
5415 .as_secs();
5416 let plan = V2ConflictPlan {
5417 v: 2,
5418 class: "content_resolution_required".to_string(),
5419 bundle: bundle.clone(),
5420 brain: head.brain_id.clone(),
5421 origin: normalized_origin(&cfg.hub)?,
5422 created_unix: now,
5423 expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
5424 base_seq: baseline.and_then(|state| state.head_seq),
5425 base_commit: baseline.and_then(|state| state.commit_hash.clone()),
5426 remote_seq: pointer.map_or(0, |value| value.seq),
5427 remote_commit: pointer.map(|value| value.commit_hash.clone()),
5428 remote_content_root: pointer.and_then(|value| value.content_root.clone()),
5429 view_kind: head.view_kind.clone(),
5430 view_revision: head.view_revision.clone(),
5431 files,
5432 };
5433 let mut bytes = serde_json::to_vec_pretty(&plan)
5434 .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
5435 bytes.push(b'\n');
5436 store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
5437 Ok((bundle, selected_paths))
5438}
5439
5440fn v2_sync_pull(
5441 cfg: &HubConfig,
5442 requested_brain: &str,
5443 head: V2VerifiedHead,
5444 out: Option<&Path>,
5445) -> LinkResult<PullReport> {
5446 let dest = out
5447 .map(Path::to_path_buf)
5448 .unwrap_or_else(|| PathBuf::from(requested_brain));
5449 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
5450 recover_windows_v2_pull(cfg, &head.brain_id, &dest)?;
5451 let head = v2_verified_head(cfg, requested_brain)?
5452 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
5453 let remote = files_for_v2_view(
5454 &head,
5455 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
5456 );
5457 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
5458 let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
5459 ensure_v2_view_compatible(&head, baseline.as_ref())?;
5460 let local_store = Store::open_strict(&dest).ok();
5461 let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
5462 if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
5463 return Err(LinkError::ScopedViewChanged);
5464 }
5465 if let Some(view) = local_view.as_mut() {
5466 remove_scoped_projection(&head, baseline.as_ref(), view)?;
5467 }
5468 let local = local_view
5469 .as_ref()
5470 .map(|view| &view.riding)
5471 .cloned()
5472 .unwrap_or_default();
5473 let kept_home = |path: &str| {
5474 local_view
5475 .as_ref()
5476 .is_some_and(|view| view.policy.keeps_home(path))
5477 };
5478 let base = baseline
5479 .as_ref()
5480 .map(|state| &state.files)
5481 .cloned()
5482 .unwrap_or_default();
5483 let base_assets = baseline
5484 .as_ref()
5485 .map(|state| state.assets.clone())
5486 .unwrap_or_default();
5487 let local_assets = local_store
5488 .as_ref()
5489 .map(|store| {
5490 crate::assets::read_manifest(store)
5491 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))
5492 })
5493 .transpose()?
5494 .unwrap_or_default()
5495 .into_iter()
5496 .map(|asset| (asset.path.clone(), asset))
5497 .collect::<std::collections::BTreeMap<_, _>>();
5498 let all_paths = base
5499 .keys()
5500 .chain(remote.keys())
5501 .chain(local.keys())
5502 .cloned()
5503 .collect::<std::collections::BTreeSet<_>>();
5504 let mut conflicts = Vec::new();
5505 for path in &all_paths {
5506 if kept_home(path) {
5507 continue;
5508 }
5509 let base_hash = base.get(path).map(|file| file.sha256.as_str());
5510 let remote_hash = remote.get(path).map(|file| file.sha256.as_str());
5511 let local_hash = local.get(path).map(|file| file.0.as_str());
5512 if local_hash != base_hash && remote_hash != base_hash && local_hash != remote_hash {
5513 conflicts.push(path.clone());
5514 }
5515 }
5516 if !conflicts.is_empty() {
5517 conflicts.truncate(100);
5518 if let Some(store) = local_store.as_ref() {
5519 let (bundle, paths) = create_v2_conflict_bundle(
5520 cfg,
5521 store,
5522 &head,
5523 baseline.as_ref(),
5524 &local,
5525 &remote,
5526 &conflicts,
5527 )?;
5528 return Err(LinkError::ConflictBundle { bundle, paths });
5529 }
5530 return Err(LinkError::Conflict { paths: conflicts });
5531 }
5532 let asset_paths = base_assets
5533 .keys()
5534 .chain(remote_assets.keys())
5535 .chain(local_assets.keys())
5536 .cloned()
5537 .collect::<std::collections::BTreeSet<_>>();
5538 for path in &asset_paths {
5539 let base_record = base_assets
5540 .get(path)
5541 .map(|asset| v2_asset_record(asset, path));
5542 let remote_record = remote_assets
5543 .get(path)
5544 .map(|asset| v2_asset_record(asset, path));
5545 let local_record = local_assets.get(path).cloned();
5546 if local_record != base_record
5547 && remote_record != base_record
5548 && local_record != remote_record
5549 {
5550 conflicts.push(path.clone());
5551 }
5552 }
5553 if !conflicts.is_empty() {
5554 conflicts.truncate(100);
5555 return Err(LinkError::Conflict { paths: conflicts });
5556 }
5557 let pointer = head.pointer.as_ref();
5558 let cache_transaction = pointer.map_or_else(
5559 || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
5560 |value| value.commit_hash.clone(),
5561 );
5562 let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
5563 let mut changed = match pointer {
5564 Some(pointer) => stage_v2_blobs(
5565 cfg,
5566 &head.brain_id,
5567 pointer,
5568 remote
5569 .iter()
5570 .filter(|(path, file)| {
5571 !kept_home(path)
5572 && local.get(*path).map(|value| value.0.as_str())
5573 != Some(file.sha256.as_str())
5574 })
5575 .collect(),
5576 )?,
5577 None => Vec::new(),
5578 };
5579 let mut deleted = base
5580 .iter()
5581 .filter(|(path, file)| {
5582 !remote.contains_key(*path)
5583 && !kept_home(path)
5584 && local.get(*path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
5585 })
5586 .map(|(path, _)| path.clone())
5587 .collect::<Vec<_>>();
5588 if local_assets
5589 != remote_assets
5590 .iter()
5591 .map(|(path, asset)| (path.clone(), v2_asset_record(asset, path)))
5592 .collect()
5593 {
5594 if remote_assets.is_empty() {
5595 deleted.push("assets.jsonl".to_string());
5596 } else {
5597 let bytes = v2_asset_manifest_bytes(&remote_assets)?;
5598 let sha256 = content_sha256(&bytes);
5599 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
5600 changed.push(V2StagedFile {
5601 path: "assets.jsonl".to_string(),
5602 source,
5603 sha256,
5604 bytes: bytes.len() as u64,
5605 });
5606 }
5607 }
5608 if let Some(pointer) = pointer {
5609 let mut pending_assets = Vec::new();
5610 for (path, asset) in &remote_assets {
5611 if asset.disposition != "hosted" || kept_home(path) {
5612 continue;
5613 }
5614 let already_current = local_store.as_ref().is_some_and(|store| {
5615 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
5616 && store
5617 .read_bounded(Path::new(path), asset.bytes)
5618 .ok()
5619 .is_some_and(|bytes| {
5620 bytes.len() as u64 == asset.bytes
5621 && content_sha256(&bytes) == asset.blob_sha256
5622 })
5623 });
5624 if !already_current {
5625 pending_assets.push((path, asset));
5626 }
5627 }
5628 for item in prepare_v2_asset_downloads(cfg, &head.brain_id, pointer, &pending_assets)? {
5629 let source =
5630 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
5631 changed.push(V2StagedFile {
5632 path: item.path,
5633 source,
5634 sha256: item.sha256,
5635 bytes: item.bytes,
5636 });
5637 }
5638 }
5639 for (path, prior) in &base_assets {
5640 if remote_assets.contains_key(path) || kept_home(path) {
5641 continue;
5642 }
5643 let unchanged = local_store.as_ref().is_some_and(|store| {
5644 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
5645 && store
5646 .read_bounded(Path::new(path), prior.bytes)
5647 .ok()
5648 .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
5649 });
5650 if unchanged {
5651 deleted.push(path.clone());
5652 }
5653 }
5654 let extra_local = local
5655 .keys()
5656 .filter(|path| !remote.contains_key(*path) && !deleted.contains(path))
5657 .cloned()
5658 .collect::<Vec<_>>();
5659 if head.view_kind == "scoped" {
5660 for (path, bytes) in [
5661 ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
5662 (
5663 ".dbmd/view.json".to_string(),
5664 scoped_view_metadata(&head, remote.len())?,
5665 ),
5666 ] {
5667 let sha256 = content_sha256(&bytes);
5668 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
5669 changed.push(V2StagedFile {
5670 path,
5671 source,
5672 sha256,
5673 bytes: bytes.len() as u64,
5674 });
5675 }
5676 }
5677 install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
5678 let finalized = (|| -> LinkResult<bool> {
5679 let installed_store =
5680 Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
5681 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
5682 })?;
5683 let mut installed_local = v2_local_files(&installed_store)?;
5684 remove_scoped_projection(&head, baseline.as_ref(), &mut installed_local)?;
5685 let mut expected_local = local.clone();
5686 for (path, file) in &remote {
5687 if !kept_home(path) {
5688 expected_local.insert(path.clone(), (file.sha256.clone(), file.bytes));
5689 }
5690 }
5691 for path in &deleted {
5692 expected_local.remove(path);
5693 }
5694 let local_dirty = installed_local.riding.iter().any(|(path, (hash, _))| {
5695 expected_local.get(path).map(|expected| &expected.0) != Some(hash)
5696 }) || expected_local.iter().any(|(path, (hash, _))| {
5697 installed_local.riding.get(path).map(|actual| &actual.0) != Some(hash)
5698 });
5699 let final_head = v2_verified_head(cfg, requested_brain)?
5700 .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
5701 if !same_v2_head(&head, &final_head) {
5702 return Err(LinkError::RemoteAdvancedDuringSync);
5703 }
5704 accept_v2_head(cfg, &final_head)?;
5705 save_v2_baseline(
5706 cfg,
5707 &head.brain_id,
5708 &dest,
5709 &v2_baseline_from_head(
5710 cfg,
5711 &head,
5712 remote.clone(),
5713 remote_assets.clone(),
5714 Some(&installed_local),
5715 )?,
5716 )?;
5717 complete_windows_v2_pull(&dest)?;
5718 Ok(local_dirty)
5719 })();
5720 let local_dirty = match finalized {
5721 Ok(value) => value,
5722 Err(error) => {
5723 if let Err(recovery) = recover_windows_v2_pull(cfg, &head.brain_id, &dest) {
5724 return Err(LinkError::InvalidPack {
5725 message: format!("{error}; durable pull recovery also failed: {recovery}"),
5726 });
5727 }
5728 return Err(error);
5729 }
5730 };
5731 cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
5732 Ok(PullReport {
5733 brain: head.brain_id,
5734 slug: requested_brain.to_string(),
5735 head_seq: pointer.map_or(0, |value| value.seq),
5736 files: remote.len() + remote_assets.len(),
5737 dest: dest.to_string_lossy().into_owned(),
5738 extra_local,
5739 sync_status: if local_dirty {
5740 "local_dirty_after_install".to_string()
5741 } else {
5742 "synced".to_string()
5743 },
5744 })
5745}
5746
5747fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
5748 match remote {
5749 Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
5750 None => json!({ "kind": "absent" }),
5751 }
5752}
5753
5754fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
5755 match remote {
5756 Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
5757 None => json!({ "kind": "absent" }),
5758 }
5759}
5760
5761fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
5762 json!({
5763 "blob_sha256": record.sha256,
5764 "bytes": record.bytes,
5765 "media_type": record.media_type,
5766 "wrappers": record.wrappers,
5767 "required": record.required,
5768 "disposition": disposition,
5769 })
5770}
5771
5772fn v2_riding_matches_remote(
5773 local: &std::collections::BTreeMap<String, (String, u64)>,
5774 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
5775 keeps_home: impl Fn(&str) -> bool,
5776) -> bool {
5777 remote.iter().all(|(path, file)| {
5778 keeps_home(path)
5779 || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
5780 }) && local.iter().all(|(path, (hash, _))| {
5781 remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
5782 })
5783}
5784
5785#[derive(Debug, Clone)]
5786struct V2ResolutionOverride {
5787 expected_remote: Option<String>,
5788 selected_local: Option<String>,
5789}
5790
5791#[derive(Debug, Clone)]
5792struct V2UploadSource {
5793 path: String,
5794 bytes: u64,
5795}
5796
5797fn verify_v2_upload_source(
5798 store: &Store,
5799 path: &str,
5800 sha256: &str,
5801 expected_bytes: u64,
5802) -> LinkResult<()> {
5803 let file = store.open_regular(Path::new(path))?;
5804 if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
5805 return Err(LinkError::InvalidPack {
5806 message: format!("local path `{path}` changed during sync planning"),
5807 });
5808 }
5809 Ok(())
5810}
5811
5812fn put_presigned_source(
5813 cfg: &HubConfig,
5814 raw: &str,
5815 headers: &Value,
5816 store: &Store,
5817 source: &V2UploadSource,
5818) -> LinkResult<()> {
5819 let http = presigned_agent(cfg, raw)?;
5820 let mut attempt = 0;
5821 let result = loop {
5822 let file = store.open_regular(Path::new(&source.path))?;
5823 if file.metadata()?.len() != source.bytes {
5824 return Err(LinkError::InvalidPack {
5825 message: format!("local path `{}` changed before upload", source.path),
5826 });
5827 }
5828 let mut req = http
5829 .put(raw)
5830 .set("Content-Length", &source.bytes.to_string());
5831 if let Some(map) = headers.as_object() {
5832 for (name, value) in map {
5833 if let Some(value) = value.as_str() {
5834 req = req.set(name, value);
5835 }
5836 }
5837 }
5838 match req.send(file) {
5839 Err(ureq::Error::Transport(error))
5840 if is_pre_request_transport(error.kind()) && attempt + 1 < CONNECT_ATTEMPTS =>
5841 {
5842 std::thread::sleep(std::time::Duration::from_millis(
5843 CONNECT_RETRY_BACKOFF_MS[attempt],
5844 ));
5845 attempt += 1;
5846 }
5847 result => break result,
5848 }
5849 };
5850 match result {
5851 Ok(response) if (200..300).contains(&response.status()) => Ok(()),
5852 Ok(response) => Err(LinkError::Http {
5853 what: "v2 changed-byte upload",
5854 status: response.status(),
5855 message: "object store rejected the upload".to_string(),
5856 code: None,
5857 details: None,
5858 }),
5859 Err(error) => match error {
5860 ureq::Error::Status(412, _) => Ok(()),
5861 ureq::Error::Status(_, response) => Err(LinkError::Http {
5862 what: "v2 changed-byte upload",
5863 status: response.status(),
5864 message: "object store rejected the upload".to_string(),
5865 code: None,
5866 details: None,
5867 }),
5868 ureq::Error::Transport(error) => Err(LinkError::Transport {
5869 hub: "the object store".to_string(),
5870 message: error.to_string(),
5871 }),
5872 },
5873 }
5874}
5875
5876fn v2_sync_push(
5877 cfg: &HubConfig,
5878 requested_brain: &str,
5879 store: &Store,
5880 head: V2VerifiedHead,
5881 resume_local_policy: bool,
5882 bulk_confirmation: Option<&V2BulkConfirmation>,
5883 resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
5884) -> LinkResult<Value> {
5885 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
5886 let head = v2_verified_head(cfg, requested_brain)?
5887 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
5888 let remote = files_for_v2_view(
5889 &head,
5890 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
5891 );
5892 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
5893 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
5894 ensure_v2_view_compatible(&head, baseline.as_ref())?;
5895 if head.view_kind == "scoped" && baseline.is_none() {
5896 return Err(LinkError::ScopedViewChanged);
5897 }
5898 let mut local_view = v2_local_files(store)?;
5899 remove_scoped_projection(&head, baseline.as_ref(), &mut local_view)?;
5900 let local = &local_view.riding;
5901 let local_assets = crate::assets::read_manifest(store)
5902 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5903 .into_iter()
5904 .map(|asset| (asset.path.clone(), asset))
5905 .collect::<std::collections::BTreeMap<_, _>>();
5906 if let Some(previous) = baseline.as_ref() {
5907 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
5908 && !resume_local_policy
5909 {
5910 let mut newly_eligible = previous
5911 .local_eligibility
5912 .iter()
5913 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
5914 .map(|(path, _)| path.clone())
5915 .collect::<Vec<_>>();
5916 if !newly_eligible.is_empty() {
5917 newly_eligible.truncate(100);
5918 return Err(LinkError::LocalPolicyTransition {
5919 paths: newly_eligible,
5920 });
5921 }
5922 }
5923 }
5924 let base = match baseline {
5925 Some(ref state) => state.files.clone(),
5926 None if remote.is_empty() => std::collections::BTreeMap::new(),
5927 None => {
5928 let mut conflicts = remote
5929 .iter()
5930 .filter(|(path, file)| {
5931 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
5932 })
5933 .map(|(path, _)| path.clone())
5934 .collect::<Vec<_>>();
5935 if !conflicts.is_empty() {
5936 conflicts.truncate(100);
5937 let (bundle, paths) =
5938 create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
5939 return Err(LinkError::ConflictBundle { bundle, paths });
5940 }
5941 remote.clone()
5942 }
5943 };
5944 let all_paths = base
5945 .keys()
5946 .chain(remote.keys())
5947 .chain(local.keys())
5948 .cloned()
5949 .collect::<std::collections::BTreeSet<_>>();
5950 let mut conflicts = Vec::new();
5951 let mut operations = Vec::new();
5952 let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
5953 for path in all_paths {
5954 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
5955 let remote_file = remote.get(&path);
5956 let remote_hash = remote_file.map(|file| file.sha256.as_str());
5957 let local_file = local.get(&path);
5958 let local_hash = local_file.map(|file| file.0.as_str());
5959 if local_hash == base_hash {
5960 continue;
5961 }
5962 if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
5963 continue;
5964 }
5965 if local_view.policy.keeps_home(&path) {
5966 continue;
5969 }
5970 if remote_hash != base_hash && local_hash != remote_hash {
5971 let explicitly_resolved = resolution
5972 .and_then(|allowed| allowed.get(&path))
5973 .is_some_and(|selected| {
5974 selected.expected_remote.as_deref() == remote_hash
5975 && selected.selected_local.as_deref() == local_hash
5976 });
5977 if !explicitly_resolved {
5978 conflicts.push(path);
5979 continue;
5980 }
5981 }
5982 match local_file {
5983 Some((sha256, byte_count)) => {
5984 verify_v2_upload_source(store, &path, sha256, *byte_count)?;
5985 operations.push(json!({
5986 "op": "put",
5987 "path": path,
5988 "expected": v2_expected(remote_file),
5989 "blob": sha256,
5990 "bytes": byte_count,
5991 }));
5992 upload_sources
5993 .entry(sha256.clone())
5994 .or_insert_with(|| V2UploadSource {
5995 path: path.clone(),
5996 bytes: *byte_count,
5997 });
5998 }
5999 None => {
6000 let Some(current) = remote_file else {
6001 continue;
6002 };
6003 operations.push(json!({
6004 "op": "delete",
6005 "path": path,
6006 "expected": { "kind": "blob", "hash": current.sha256 },
6007 }));
6008 }
6009 }
6010 }
6011 if !conflicts.is_empty() {
6012 conflicts.truncate(100);
6013 let (bundle, paths) = create_v2_conflict_bundle(
6014 cfg,
6015 store,
6016 &head,
6017 baseline.as_ref(),
6018 local,
6019 &remote,
6020 &conflicts,
6021 )?;
6022 return Err(LinkError::ConflictBundle { bundle, paths });
6023 }
6024 let base_assets = match baseline.as_ref() {
6025 Some(state) => state.assets.clone(),
6026 None if remote_assets.is_empty() => std::collections::BTreeMap::new(),
6027 None => {
6028 let mismatched = remote_assets.iter().any(|(path, remote)| {
6029 local_assets.get(path) != Some(&v2_asset_record(remote, path))
6030 }) || local_assets.len() != remote_assets.len();
6031 if mismatched {
6032 return Err(LinkError::Conflict {
6033 paths: vec!["assets.jsonl".to_string()],
6034 });
6035 }
6036 remote_assets.clone()
6037 }
6038 };
6039 let asset_paths = base_assets
6040 .keys()
6041 .chain(remote_assets.keys())
6042 .chain(local_assets.keys())
6043 .cloned()
6044 .collect::<std::collections::BTreeSet<_>>();
6045 for path in asset_paths {
6046 let base_record = base_assets
6047 .get(&path)
6048 .map(|asset| v2_asset_record(asset, &path));
6049 let remote = remote_assets.get(&path);
6050 let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
6051 let local_record = local_assets.get(&path);
6052 if local_record == base_record.as_ref() {
6053 continue;
6054 }
6055 if remote_record != base_record && local_record != remote_record.as_ref() {
6056 conflicts.push(path);
6057 continue;
6058 }
6059 let Some(record) = local_record else {
6060 if let Some(remote) = remote {
6061 operations.push(json!({
6062 "op": "asset_delete",
6063 "path": path,
6064 "expected": v2_asset_expected(Some(remote)),
6065 }));
6066 }
6067 continue;
6068 };
6069 crate::linkmd_v2::normalize_path(&record.path)
6070 .map_err(|error| invalid_feed(error.to_string()))?;
6071 let kept_home = local_view.policy.keeps_home(&path);
6072 let raw = if matches!(store.regular_file_exists(Path::new(&path)), Ok(true)) {
6073 verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
6074 Some(())
6075 } else {
6076 None
6077 };
6078 let disposition = if kept_home || raw.is_none() {
6079 "withheld"
6080 } else {
6081 "hosted"
6082 };
6083 if raw.is_none() && record.required && !kept_home {
6084 return Err(LinkError::InvalidPack {
6085 message: format!("required asset {path} is missing"),
6086 });
6087 }
6088 let op = if remote.is_some_and(|asset| {
6089 asset.disposition == "withheld"
6090 && disposition == "hosted"
6091 && v2_asset_record(asset, &path) == *record
6092 }) {
6093 if !resume_local_policy {
6094 continue;
6095 }
6096 "asset_resume"
6097 } else {
6098 "asset_put"
6099 };
6100 operations.push(json!({
6101 "op": op,
6102 "path": path,
6103 "expected": v2_asset_expected(remote),
6104 "asset": v2_asset_value(record, disposition),
6105 }));
6106 if disposition == "hosted" {
6107 raw.expect("hosted asset was checked present");
6108 upload_sources
6109 .entry(record.sha256.clone())
6110 .or_insert_with(|| V2UploadSource {
6111 path: path.clone(),
6112 bytes: record.bytes,
6113 });
6114 }
6115 }
6116 if !conflicts.is_empty() {
6117 conflicts.truncate(100);
6118 return Err(LinkError::Conflict { paths: conflicts });
6119 }
6120 if operations.is_empty() {
6121 let final_head = v2_verified_head(cfg, requested_brain)?
6122 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
6123 if !same_v2_head(&head, &final_head) {
6124 return Err(LinkError::RemoteAdvancedDuringSync);
6125 }
6126 let mut final_local = v2_local_files(store)?;
6127 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
6128 let local_changed = final_local.riding != local_view.riding;
6129 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
6130 final_local.policy.keeps_home(path)
6131 });
6132 let next = v2_baseline_from_head(cfg, &head, remote, remote_assets, Some(&final_local))?;
6133 let split_count = next.remote_copy_remains.len();
6134 accept_v2_head(cfg, &final_head)?;
6135 if !local_changed && !remote_ahead {
6136 refresh_scoped_view_marker(store, &head, next.files.len())?;
6137 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
6138 }
6139 return Ok(json!({
6140 "v": 2,
6141 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
6142 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
6143 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
6144 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
6145 "local_policy": {
6146 "remote_copy_remains": split_count,
6147 },
6148 }));
6149 }
6150 let includes_contract = operations
6151 .iter()
6152 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
6153 let rebase = if head.pointer.is_none() || includes_contract {
6154 "strict"
6155 } else {
6156 "disjoint"
6157 };
6158 let base_value = head.pointer.as_ref().map(|pointer| {
6159 json!({
6160 "seq": pointer.seq,
6161 "commit_hash": pointer.commit_hash,
6162 "content_root": pointer.content_root,
6163 "asset_root": pointer.asset_root,
6164 })
6165 });
6166 let entropy = format!(
6170 "{}\0{}\0{}\0{}",
6171 normalized_origin(&cfg.hub)?,
6172 head.brain_id,
6173 serde_json::to_string(&base_value).unwrap_or_default(),
6174 serde_json::to_string(&operations).unwrap_or_default()
6175 );
6176 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
6177 let mut expected_candidate = remote.clone();
6178 let mut expected_candidate_assets = remote_assets.clone();
6179 for operation in &operations {
6180 match operation.get("op").and_then(Value::as_str) {
6181 Some("put") => {
6182 let path = operation
6183 .get("path")
6184 .and_then(Value::as_str)
6185 .ok_or_else(|| invalid_feed("v2 put has no path"))?;
6186 let sha256 = operation
6187 .get("blob")
6188 .and_then(Value::as_str)
6189 .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
6190 let bytes = operation
6191 .get("bytes")
6192 .and_then(Value::as_u64)
6193 .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
6194 expected_candidate.insert(
6195 path.to_string(),
6196 V2BaselineFile {
6197 sha256: sha256.to_string(),
6198 bytes,
6199 proof: None,
6200 },
6201 );
6202 }
6203 Some("delete") => {
6204 let path = operation
6205 .get("path")
6206 .and_then(Value::as_str)
6207 .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
6208 expected_candidate.remove(path);
6209 }
6210 Some("asset_delete") => {
6211 let path = operation
6212 .get("path")
6213 .and_then(Value::as_str)
6214 .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
6215 expected_candidate_assets.remove(path);
6216 }
6217 Some("asset_put" | "asset_resume") => {
6218 let path = operation
6219 .get("path")
6220 .and_then(Value::as_str)
6221 .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
6222 let record = local_assets
6223 .get(path)
6224 .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
6225 let disposition = operation
6226 .get("asset")
6227 .and_then(|asset| asset.get("disposition"))
6228 .and_then(Value::as_str)
6229 .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?;
6230 expected_candidate_assets.insert(
6231 path.to_string(),
6232 V2BaselineAsset {
6233 blob_sha256: record.sha256.clone(),
6234 bytes: record.bytes,
6235 media_type: record.media_type.clone(),
6236 wrappers: record.wrappers.clone(),
6237 required: record.required,
6238 disposition: disposition.to_string(),
6239 leaf_hash: String::new(),
6240 },
6241 );
6242 }
6243 _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
6244 }
6245 }
6246 let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
6247 total
6248 .checked_add(source.bytes)
6249 .ok_or_else(|| LinkError::PushTooLarge {
6250 detail: "v2 changed-byte total overflow".to_string(),
6251 })
6252 })?;
6253 let inline = changed_bytes <= 3 * 1024 * 1024;
6254 let inline_blobs = if inline {
6255 upload_sources
6256 .iter()
6257 .map(|(sha256, source)| {
6258 let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
6259 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
6260 return Err(LinkError::InvalidPack {
6261 message: format!("local path `{}` changed before upload", source.path),
6262 });
6263 }
6264 Ok(json!({
6265 "sha256": sha256,
6266 "bytes": source.bytes,
6267 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
6268 }))
6269 })
6270 .collect::<LinkResult<Vec<_>>>()?
6271 } else {
6272 Vec::new()
6273 };
6274 let mut body = json!({
6275 "mutation_id": mutation_id,
6276 "base": base_value,
6277 "rebase": rebase,
6278 "reason": "dbmd sync",
6279 "operations": operations,
6280 "blobs": inline_blobs,
6281 });
6282 if let Some(confirmation) = bulk_confirmation {
6283 if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
6284 return Err(LinkError::InvalidPack {
6285 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
6286 .to_string(),
6287 });
6288 }
6289 body["rebase"] = Value::String("strict".to_string());
6293 body["bulk_preview_id"] = Value::String(confirmation.id.clone());
6294 body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
6295 }
6296 if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
6297 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6298 for operation in &operations {
6299 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
6300 return Err(invalid_feed("v2 upload operation has no kind"));
6301 };
6302 let hash = match kind {
6303 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
6304 "asset_put" | "asset_resume" => operation
6305 .get("asset")
6306 .and_then(|asset| asset.get("blob_sha256"))
6307 .and_then(Value::as_str),
6308 _ => None,
6309 };
6310 let Some(hash) = hash else { continue };
6311 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
6312 if kind == "rename" {
6313 for field in ["from", "to"] {
6314 coordinates.insert(
6315 operation
6316 .get(field)
6317 .and_then(Value::as_str)
6318 .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
6319 .to_string(),
6320 );
6321 }
6322 } else {
6323 let path = operation
6324 .get("path")
6325 .and_then(Value::as_str)
6326 .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
6327 coordinates.insert(if kind.starts_with("asset_") {
6328 format!("assets/{path}")
6329 } else {
6330 path.to_string()
6331 });
6332 }
6333 }
6334 let declarations = upload_sources
6335 .iter()
6336 .map(|(sha256, source)| {
6337 json!({
6338 "sha256": sha256,
6339 "bytes": source.bytes,
6340 "coordinates": coordinates_by_hash
6341 .get(sha256)
6342 .into_iter()
6343 .flatten()
6344 .collect::<Vec<_>>(),
6345 })
6346 })
6347 .collect::<Vec<_>>();
6348 let reserved = ensure_ok(
6349 request(
6350 cfg,
6351 "POST",
6352 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
6353 Some(&json!({ "blobs": declarations })),
6354 Auth::Required,
6355 )?,
6356 "prepare v2 changed-byte uploads",
6357 )?;
6358 let items = reserved
6359 .get("uploads")
6360 .and_then(Value::as_array)
6361 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
6362 if items.len() != upload_sources.len() {
6363 return Err(invalid_feed(
6364 "v2 upload reservation response changed the requested set",
6365 ));
6366 }
6367 let mut references = Vec::with_capacity(items.len());
6368 let mut seen = std::collections::BTreeSet::new();
6369 for item in items {
6370 let sha256 = item
6371 .get("sha256")
6372 .and_then(Value::as_str)
6373 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
6374 let source = upload_sources
6375 .get(sha256)
6376 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
6377 let declared_bytes = item
6378 .get("bytes")
6379 .and_then(Value::as_u64)
6380 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
6381 let reservation_id = item
6382 .get("reservation_id")
6383 .and_then(Value::as_str)
6384 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
6385 let expected_coordinates = coordinates_by_hash
6386 .get(sha256)
6387 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinate binding"))?;
6388 let returned_coordinates = item
6389 .get("coordinates")
6390 .and_then(Value::as_array)
6391 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
6392 if declared_bytes != source.bytes
6393 || !crate::ulid::is_ulid(reservation_id)
6394 || !seen.insert(sha256.to_string())
6395 || returned_coordinates.len() != expected_coordinates.len()
6396 || returned_coordinates
6397 .iter()
6398 .zip(expected_coordinates)
6399 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
6400 {
6401 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
6402 }
6403 match item.get("status").and_then(Value::as_str) {
6404 Some("upload") => {
6405 let url = item
6406 .get("url")
6407 .and_then(Value::as_str)
6408 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
6409 put_presigned_source(
6410 cfg,
6411 url,
6412 item.get("headers").unwrap_or(&Value::Null),
6413 store,
6414 source,
6415 )?;
6416 verify_v2_upload_source(store, &source.path, sha256, source.bytes)?;
6417 }
6418 Some("already_present") => {}
6419 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
6420 }
6421 references.push(json!({
6422 "sha256": sha256,
6423 "bytes": source.bytes,
6424 "reservation_id": reservation_id,
6425 }));
6426 }
6427 body["blobs"] = Value::Array(references);
6428 }
6429 if body.to_string().len() > MAX_PUSH_BYTES {
6430 return Err(LinkError::PushTooLarge {
6431 detail: "v2 operation metadata exceeds the bounded commit request".to_string(),
6432 });
6433 }
6434 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
6435 let mut candidate_hub_signer: Option<String> = None;
6436 let mut response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
6437 let bulk_preview_required = !(200..300).contains(&response.status)
6438 && response.body.as_ref().is_some_and(|value| {
6439 value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
6440 || value
6441 .get("details")
6442 .and_then(|details| details.get("code"))
6443 .and_then(Value::as_str)
6444 == Some("bulk_preview_required")
6445 });
6446 if bulk_preview_required && bulk_confirmation.is_none() {
6447 body["rebase"] = Value::String("strict".to_string());
6448 body["preview_only"] = Value::Bool(true);
6449 let preview = ensure_ok(
6450 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
6451 "v2 bulk preview",
6452 )?;
6453 let preview_code = preview.get("code").and_then(Value::as_str);
6454 let required = preview.get("required").and_then(Value::as_bool);
6455 if preview.get("v").and_then(Value::as_u64) != Some(2)
6456 || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
6457 || !matches!(
6458 preview_code,
6459 Some("bulk_preview_created" | "bulk_preview_not_required")
6460 )
6461 || required.is_none()
6462 {
6463 return Err(invalid_feed(
6464 "bulk preview response is not bound to the requested mutation",
6465 ));
6466 }
6467 if required == Some(true) {
6468 let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
6469 let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
6470 if preview_code != Some("bulk_preview_created")
6471 || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
6472 || preview_digest.is_none_or(|value| !is_sha256(value))
6473 || preview.get("expires_at").and_then(Value::as_str).is_none()
6474 || !preview.get("impact").is_some_and(Value::is_object)
6475 {
6476 return Err(invalid_feed("bulk preview receipt is malformed"));
6477 }
6478 return Err(LinkError::BulkPreviewRequired { preview });
6479 }
6480 if preview_code != Some("bulk_preview_not_required") {
6481 return Err(invalid_feed("bulk preview requirement is inconsistent"));
6482 }
6483 body.as_object_mut()
6486 .expect("v2 commit request is an object")
6487 .remove("preview_only");
6488 response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
6489 }
6490 let mut result = ensure_ok(response, "v2 sync push")?;
6491 if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
6492 if let Some(object) = result.as_object_mut() {
6493 object.insert(
6494 "sync_status".to_string(),
6495 Value::String("proposal_pending".to_string()),
6496 );
6497 }
6498 return Ok(result);
6499 }
6500 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
6501 let challenge = result
6502 .get("signing_challenge")
6503 .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
6504 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
6505 cfg,
6506 &head,
6507 &expected_candidate,
6508 &expected_candidate_assets,
6509 &mutation_id,
6510 &body,
6511 challenge,
6512 )?;
6513 body["signing_challenge_id"] = Value::String(challenge_id);
6514 body["signature_base64url"] = Value::String(signature);
6515 candidate_hub_signer = Some(actor_signer);
6516 result = ensure_ok(
6517 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
6518 "v2 self-custody commit",
6519 )?;
6520 }
6521 let refreshed = v2_verified_head(cfg, requested_brain)?
6522 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
6523 if candidate_hub_signer
6524 .as_ref()
6525 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
6526 {
6527 return Err(invalid_feed(
6528 "self-custody actor signer differs from the committed hub pointer signer",
6529 ));
6530 }
6531 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
6532 if refreshed
6533 .pointer
6534 .as_ref()
6535 .map(|pointer| pointer.commit_hash.as_str())
6536 != accepted_hash
6537 {
6538 return Err(LinkError::RemoteAdvancedDuringSync);
6539 }
6540 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
6541 let refreshed_files = files_for_v2_view(
6542 &refreshed,
6543 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
6544 );
6545 let refreshed_assets = v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?;
6546 let mut final_local = v2_local_files(store)?;
6547 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
6548 let local_dirty = final_local.riding != local_view.riding
6549 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
6550 final_local.policy.keeps_home(path)
6551 });
6552 let next = v2_baseline_from_head(
6553 cfg,
6554 &refreshed,
6555 refreshed_files,
6556 refreshed_assets,
6557 Some(&final_local),
6558 )?;
6559 let split_count = next.remote_copy_remains.len();
6560 accept_v2_head(cfg, &refreshed)?;
6561 if !local_dirty {
6562 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
6563 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
6564 }
6565 if let Some(object) = result.as_object_mut() {
6566 object.insert(
6567 "local_policy".to_string(),
6568 json!({ "remote_copy_remains": split_count }),
6569 );
6570 object.insert(
6571 "sync_status".to_string(),
6572 Value::String(if local_dirty {
6573 "remote_committed_local_dirty".to_string()
6574 } else {
6575 "synced".to_string()
6576 }),
6577 );
6578 }
6579 Ok(result)
6580}
6581
6582pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
6585 sync_push_incremental_with_policy(cfg, brain, store, false)
6586}
6587
6588pub fn sync_push_incremental_with_policy(
6591 cfg: &HubConfig,
6592 brain: &str,
6593 store: &Store,
6594 resume_local_policy: bool,
6595) -> LinkResult<Value> {
6596 sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
6597}
6598
6599pub fn sync_push_incremental_with_options(
6602 cfg: &HubConfig,
6603 brain: &str,
6604 store: &Store,
6605 resume_local_policy: bool,
6606 bulk_confirmation: Option<&V2BulkConfirmation>,
6607) -> LinkResult<Value> {
6608 require_safe_ref(brain)?;
6609 if let Some(head) = v2_verified_head(cfg, brain)? {
6610 return v2_sync_push(
6611 cfg,
6612 brain,
6613 store,
6614 head,
6615 resume_local_policy,
6616 bulk_confirmation,
6617 None,
6618 );
6619 }
6620 legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
6621}
6622
6623#[cfg(windows)]
6624fn legacy_sync_push_incremental(
6625 _cfg: &HubConfig,
6626 _brain: &str,
6627 _store: &Store,
6628 _resume_local_policy: bool,
6629 _bulk_confirmation: Option<&V2BulkConfirmation>,
6630) -> LinkResult<Value> {
6631 Err(LinkError::UnsupportedPlatform {
6632 operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
6633 })
6634}
6635
6636#[cfg(not(windows))]
6637fn legacy_sync_push_incremental(
6638 cfg: &HubConfig,
6639 brain: &str,
6640 store: &Store,
6641 resume_local_policy: bool,
6642 bulk_confirmation: Option<&V2BulkConfirmation>,
6643) -> LinkResult<Value> {
6644 if resume_local_policy || bulk_confirmation.is_some() {
6645 return Err(LinkError::InvalidPack {
6646 message: "v2 sync options require a link.md v2 brain".to_string(),
6647 });
6648 }
6649 let files = collect_push_files(store)?;
6650 sync_push(cfg, brain, &files)
6651}
6652
6653#[derive(Debug, Clone)]
6655pub enum V2ConflictChoice {
6656 KeepLocal,
6657 TakeRemote,
6658 From(PathBuf),
6659}
6660
6661fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
6662 if !crate::ulid::is_ulid(bundle) {
6663 return Err(LinkError::InvalidPack {
6664 message: "conflict bundle must be a lowercase ULID".to_string(),
6665 });
6666 }
6667 let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
6668 let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
6669 .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
6670 if plan.v != 2
6671 || plan.class != "content_resolution_required"
6672 || plan.bundle != bundle
6673 || !crate::ulid::is_ulid(&plan.brain)
6674 || plan.files.is_empty()
6675 || plan.files.len() > 100
6676 || plan.files.iter().any(|file| {
6677 crate::linkmd_v2::normalize_path(&file.path).is_err()
6678 || [&file.base, &file.local, &file.remote]
6679 .into_iter()
6680 .any(|coordinate| {
6681 coordinate
6682 .sha256
6683 .as_deref()
6684 .is_some_and(|hash| !is_sha256(hash))
6685 || coordinate.file.as_deref().is_some_and(|name| {
6686 name.starts_with('/')
6687 || name
6688 .split('/')
6689 .any(|part| part.is_empty() || part == "." || part == "..")
6690 })
6691 })
6692 })
6693 {
6694 return Err(invalid_feed("private conflict plan failed validation"));
6695 }
6696 Ok(plan)
6697}
6698
6699pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
6704 require_hardened_filesystem("private conflict maintenance")?;
6705 if all && !prune {
6706 return Err(LinkError::InvalidPack {
6707 message: "discarding all conflict bundles requires prune=true".to_string(),
6708 });
6709 }
6710 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
6711 message: format!("conflict checkout is not a valid db.md store: {error}"),
6712 })?;
6713 let _transaction = store.transaction()?;
6714 let root = Path::new(".dbmd/conflicts");
6715 let names = match store.directory_names(root) {
6716 Ok(names) => names,
6717 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
6718 Err(error) => return Err(error.into()),
6719 };
6720 let now = SystemTime::now()
6721 .duration_since(UNIX_EPOCH)
6722 .unwrap_or_default()
6723 .as_secs();
6724 let mut bundles = Vec::new();
6725 let mut pruned = 0_u64;
6726 for name in names {
6727 let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
6728 continue;
6729 };
6730 let plan_path = v2_conflict_relative(bundle, "plan.json");
6731 let plan_exists = store.regular_file_exists(&plan_path)?;
6732 let expired = if plan_exists {
6733 match load_v2_conflict_plan(&store, bundle) {
6734 Ok(plan) => plan.expires_unix < now,
6735 Err(error) if all => {
6736 let _ = error;
6737 true
6738 }
6739 Err(error) => return Err(error),
6740 }
6741 } else {
6742 true
6743 };
6744 if prune && (all || expired) {
6745 store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
6746 pruned += 1;
6747 continue;
6748 }
6749 bundles.push(json!({
6750 "bundle": bundle,
6751 "complete": plan_exists,
6752 "expired": expired,
6753 }));
6754 }
6755 Ok(json!({
6756 "v": 2,
6757 "class": "private_conflict_state",
6758 "bundles": bundles.len(),
6759 "pruned": pruned,
6760 "items": bundles,
6761 }))
6762}
6763
6764pub fn sync_resolve_conflict(
6768 cfg: &HubConfig,
6769 checkout: &Path,
6770 bundle: &str,
6771 choice: V2ConflictChoice,
6772 bulk_confirmation: Option<&V2BulkConfirmation>,
6773) -> LinkResult<Value> {
6774 require_hardened_filesystem("conflict resolution")?;
6775 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
6776 message: format!("conflict checkout is not a valid db.md store: {error}"),
6777 })?;
6778 let plan = load_v2_conflict_plan(&store, bundle)?;
6779 if plan.origin != normalized_origin(&cfg.hub)? {
6780 return Err(invalid_feed(
6781 "conflict bundle belongs to another hub origin",
6782 ));
6783 }
6784 let now = SystemTime::now()
6785 .duration_since(UNIX_EPOCH)
6786 .unwrap_or_default()
6787 .as_secs();
6788 if now > plan.expires_unix {
6789 return Err(LinkError::InvalidPack {
6790 message: "conflict bundle expired; rerun sync to obtain current coordinates"
6791 .to_string(),
6792 });
6793 }
6794 let head = v2_verified_head(cfg, &plan.brain)?
6795 .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
6796 let pointer = head.pointer.as_ref();
6797 if pointer.map_or(0, |value| value.seq) != plan.remote_seq
6798 || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
6799 || pointer.and_then(|value| value.content_root.as_deref())
6800 != plan.remote_content_root.as_deref()
6801 || head.view_kind != plan.view_kind
6802 || head.view_revision != plan.view_revision
6803 {
6804 return Err(LinkError::RemoteAdvancedDuringSync);
6805 }
6806
6807 for file in &plan.files {
6809 let actual = match store.regular_file_exists(Path::new(&file.path))? {
6810 true => Some(content_sha256(&store.read_bounded(
6811 Path::new(&file.path),
6812 file.local.bytes.unwrap_or(MAX_STORE_BYTES),
6813 )?)),
6814 false => None,
6815 };
6816 if actual.as_deref() != file.local.sha256.as_deref() {
6817 return Err(LinkError::InvalidPack {
6818 message: format!(
6819 "local conflict path `{}` changed after the bundle was created",
6820 file.path
6821 ),
6822 });
6823 }
6824 }
6825
6826 let from_source = match &choice {
6827 V2ConflictChoice::From(source) => Some(source.clone()),
6828 _ => None,
6829 };
6830 let result = match choice {
6831 V2ConflictChoice::TakeRemote => {
6832 if bulk_confirmation.is_some() {
6833 return Err(LinkError::InvalidPack {
6834 message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
6835 });
6836 }
6837 let mut remote_files = std::collections::BTreeMap::new();
6838 let mut deleted = Vec::new();
6839 for file in &plan.files {
6840 match (&file.remote.sha256, file.remote.bytes) {
6841 (Some(sha256), Some(bytes)) => {
6842 remote_files.insert(
6843 file.path.clone(),
6844 V2BaselineFile {
6845 sha256: sha256.clone(),
6846 bytes,
6847 proof: None,
6848 },
6849 );
6850 }
6851 (None, None) => deleted.push(file.path.clone()),
6852 _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6853 }
6854 }
6855 let staged = match head.pointer.as_ref() {
6856 Some(pointer) => {
6857 stage_v2_blobs(cfg, &plan.brain, pointer, remote_files.iter().collect())?
6858 }
6859 None if remote_files.is_empty() => Vec::new(),
6860 None => return Err(invalid_feed("conflict head has no content pointer")),
6861 };
6862 let baseline = load_v2_baseline(cfg, &plan.brain, checkout)?;
6863 install_pulled_delta_sources(
6864 checkout,
6865 &staged,
6866 &deleted,
6867 true,
6868 baseline.as_ref(),
6869 &head,
6870 )?;
6871 complete_windows_v2_pull(checkout)?;
6872 let refreshed = v2_verified_head(cfg, &plan.brain)?
6873 .ok_or_else(|| invalid_feed("conflict brain disappeared during resolution"))?;
6874 serde_json::to_value(v2_sync_pull(cfg, &plan.brain, refreshed, Some(checkout))?)
6875 .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
6876 }
6877 V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
6878 if let Some(source) = from_source.as_ref() {
6879 if plan.files.len() != 1 {
6880 return Err(LinkError::InvalidPack {
6881 message: "--from requires a bundle with exactly one conflict".to_string(),
6882 });
6883 }
6884 let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
6885 if std::str::from_utf8(&candidate).is_err() {
6886 return Err(LinkError::NotUtf8 {
6887 path: source.display().to_string(),
6888 });
6889 }
6890 store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
6891 }
6892 let refreshed_store =
6893 Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
6894 message: format!("resolved checkout is not a valid db.md store: {error}"),
6895 })?;
6896 let mut overrides = std::collections::BTreeMap::new();
6897 for file in &plan.files {
6898 let selected_local = match refreshed_store
6899 .regular_file_exists(Path::new(&file.path))?
6900 {
6901 true => Some(content_sha256(
6902 &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
6903 )),
6904 false => None,
6905 };
6906 overrides.insert(
6907 file.path.clone(),
6908 V2ResolutionOverride {
6909 expected_remote: file.remote.sha256.clone(),
6910 selected_local,
6911 },
6912 );
6913 }
6914 v2_sync_push(
6915 cfg,
6916 &plan.brain,
6917 &refreshed_store,
6918 head,
6919 true,
6920 bulk_confirmation,
6921 Some(&overrides),
6922 )?
6923 }
6924 };
6925
6926 if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
6927 let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
6928 message: format!("resolved checkout is not a valid db.md store: {error}"),
6929 })?;
6930 installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
6931 }
6932 Ok(json!({
6933 "v": 2,
6934 "class": "auto_converged",
6935 "bundle": bundle,
6936 "receipt": result,
6937 }))
6938}
6939
6940pub fn sync_converge(
6951 cfg: &HubConfig,
6952 brain: &str,
6953 checkout: &Path,
6954 resume_local_policy: bool,
6955) -> LinkResult<Value> {
6956 sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
6957}
6958
6959pub fn sync_converge_with_options(
6961 cfg: &HubConfig,
6962 brain: &str,
6963 checkout: &Path,
6964 resume_local_policy: bool,
6965 bulk_confirmation: Option<&V2BulkConfirmation>,
6966) -> LinkResult<Value> {
6967 require_hardened_filesystem("bidirectional sync")?;
6968 require_safe_ref(brain)?;
6969 let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
6970 message:
6971 "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
6972 .to_string(),
6973 })?;
6974 let pulled = v2_sync_pull(cfg, brain, head, Some(checkout))?;
6975 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
6976 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6977 })?;
6978 let _transaction = store.transaction()?;
6979 let fresh = v2_verified_head(cfg, brain)?
6980 .ok_or_else(|| invalid_feed("v2 head disappeared between sync phases"))?;
6981 let mut result = v2_sync_push(
6982 cfg,
6983 brain,
6984 &store,
6985 fresh,
6986 resume_local_policy,
6987 bulk_confirmation,
6988 None,
6989 )?;
6990 if let Some(object) = result.as_object_mut() {
6991 object.insert("pulled_files".to_string(), json!(pulled.files));
6992 object.insert("checkout".to_string(), Value::String(pulled.dest));
6993 object.insert(
6994 "mode".to_string(),
6995 Value::String("bidirectional".to_string()),
6996 );
6997 }
6998 Ok(result)
6999}
7000
7001pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7007 require_hardened_filesystem("sync pull")?;
7008 require_safe_ref(brain)?;
7009 if let Some(head) = v2_verified_head(cfg, brain)? {
7010 return v2_sync_pull(cfg, brain, head, out);
7011 }
7012 legacy_sync_pull(cfg, brain, out)
7013}
7014
7015#[cfg(windows)]
7016fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
7017 Err(LinkError::UnsupportedPlatform {
7018 operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
7019 })
7020}
7021
7022#[cfg(not(windows))]
7023fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
7024 let remote = verified_remote_head(cfg, brain, false)?;
7025 if !remote.head.verified {
7026 return Err(invalid_feed(
7027 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
7028 ));
7029 }
7030 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
7031 let path = format!(
7032 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
7033 remote.head.seq
7034 );
7035 let body = ensure_ok(
7036 request(cfg, "GET", &path, None, Auth::Required)?,
7037 "sync pull",
7038 )?;
7039 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
7040 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
7041 {
7042 return Err(invalid_feed(
7043 "export response is not bound to the verified snapshot token",
7044 ));
7045 }
7046
7047 let remote_slug = body
7048 .get("slug")
7049 .and_then(Value::as_str)
7050 .filter(|slug| is_safe_slug(slug));
7051 let slug = remote_slug
7052 .or_else(|| is_safe_slug(brain).then_some(brain))
7053 .unwrap_or("brain")
7054 .to_string();
7055 let brain_id = body
7056 .get("brain")
7057 .and_then(Value::as_str)
7058 .unwrap_or(&remote.head.brain)
7059 .to_string();
7060 if brain_id != remote.head.brain {
7061 return Err(invalid_feed(
7062 "export response names a different brain than the verified head",
7063 ));
7064 }
7065 let head_seq = remote.head.seq;
7066 let dest: PathBuf = match out {
7067 Some(p) => p.to_path_buf(),
7068 None => PathBuf::from(&slug),
7069 };
7070 let entries = if head_seq == 0 {
7071 let files = body
7072 .get("files")
7073 .and_then(Value::as_array)
7074 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
7075 if !files.is_empty() || body.get("url").is_some() {
7076 return Err(invalid_feed(
7077 "empty signed feed cannot authorize non-empty exported content",
7078 ));
7079 }
7080 Vec::new()
7081 } else {
7082 let signed_head = remote
7083 .head_entry
7084 .as_ref()
7085 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
7086 let expected = &signed_head.entry.pack_sha256;
7087 if !is_sha256(expected) {
7088 return Err(invalid_feed(
7089 "signed head carries an invalid snapshot pack digest",
7090 ));
7091 }
7092 if let Some(url) = body.get("url").and_then(Value::as_str) {
7093 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
7094 return Err(invalid_feed(
7095 "export pack digest does not match the signed head entry",
7096 ));
7097 }
7098 let bytes = get_presigned(cfg, url)?;
7099 let actual = format!("{:x}", Sha256::digest(&bytes));
7100 if actual != *expected {
7101 return Err(LinkError::InvalidPack {
7102 message: "downloaded pack does not match the signed snapshot digest"
7103 .to_string(),
7104 });
7105 }
7106 let entries = parse_store_pack(bytes)?;
7107 if signed_head.entry.kind == "push" {
7108 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7109 }
7110 entries
7111 } else {
7112 if signed_head.entry.kind != "push" {
7113 return Err(invalid_feed(
7114 "delta snapshots must export the exact signed pack",
7115 ));
7116 }
7117 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
7118 invalid_feed("verified snapshot export carried neither a pack nor files")
7119 })?;
7120 let mut entries = Vec::with_capacity(files.len());
7121 for file in files {
7122 let path = file
7123 .get("path")
7124 .and_then(Value::as_str)
7125 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
7126 let content = file
7127 .get("content")
7128 .and_then(Value::as_str)
7129 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
7130 entries.push((path.to_string(), content.as_bytes().to_vec()));
7131 }
7132 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
7133 entries
7134 }
7135 };
7136
7137 let mut seen = std::collections::HashSet::new();
7139 for (path, _) in &entries {
7140 if !safe_store_rel_path(path) {
7141 return Err(LinkError::UnsafePath { path: path.clone() });
7142 }
7143 if !seen.insert(path) {
7144 return Err(LinkError::InvalidPack {
7145 message: format!("duplicate path `{path}`"),
7146 });
7147 }
7148 }
7149 let pulled: std::collections::BTreeSet<&str> =
7152 entries.iter().map(|(p, _)| p.as_str()).collect();
7153 let mut extra_local = Vec::new();
7154 if let Ok(store) = Store::open(&dest) {
7155 if let Ok(walked) = store.walk() {
7156 for rel in walked {
7157 let rel_str = rel.to_string_lossy().replace('\\', "/");
7158 if !pulled.contains(rel_str.as_str()) {
7159 extra_local.push(rel_str);
7160 }
7161 }
7162 }
7163 }
7164 #[cfg(unix)]
7165 install_pulled_snapshot(&dest, &entries)?;
7166
7167 Ok(PullReport {
7168 brain: brain_id,
7169 slug,
7170 head_seq,
7171 files: entries.len(),
7172 dest: dest.to_string_lossy().into_owned(),
7173 extra_local,
7174 sync_status: "synced".to_string(),
7175 })
7176}
7177
7178#[cfg(unix)]
7179fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
7180 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
7181 path: display.to_string(),
7182 })
7183}
7184
7185#[cfg(unix)]
7186fn open_dir_at(
7187 parent: std::os::fd::RawFd,
7188 name: &std::ffi::CStr,
7189 display: &str,
7190) -> LinkResult<std::fs::File> {
7191 use std::os::fd::FromRawFd as _;
7192 let fd = unsafe {
7193 libc::openat(
7194 parent,
7195 name.as_ptr(),
7196 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7197 )
7198 };
7199 if fd < 0 {
7200 return Err(LinkError::UnsafePath {
7201 path: display.to_string(),
7202 });
7203 }
7204 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
7205}
7206
7207#[cfg(unix)]
7211fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
7212 use std::os::fd::AsRawFd as _;
7213
7214 #[cfg(target_os = "macos")]
7218 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
7219 .into_iter()
7220 .find_map(|(alias, real)| {
7221 path.strip_prefix(alias)
7222 .ok()
7223 .map(|rest| Path::new(real).join(rest))
7224 })
7225 .unwrap_or_else(|| path.to_path_buf());
7226 #[cfg(not(target_os = "macos"))]
7227 let normalized = path.to_path_buf();
7228
7229 let start = if normalized.is_absolute() {
7230 std::fs::File::open("/")?
7231 } else {
7232 std::fs::File::open(".")?
7233 };
7234 let mut directory = start;
7235 for component in normalized.components() {
7236 use std::path::Component;
7237 let name = match component {
7238 Component::RootDir | Component::CurDir => continue,
7239 Component::Normal(name) => name,
7240 Component::ParentDir | Component::Prefix(_) => {
7241 return Err(LinkError::UnsafePath {
7242 path: path.display().to_string(),
7243 });
7244 }
7245 };
7246 use std::os::unix::ffi::OsStrExt as _;
7247 let name = c_name(name.as_bytes(), &path.display().to_string())?;
7248 if create {
7249 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
7250 if made != 0 {
7251 let error = std::io::Error::last_os_error();
7252 if error.raw_os_error() != Some(libc::EEXIST) {
7253 return Err(error.into());
7254 }
7255 }
7256 }
7257 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
7258 }
7259 Ok(directory)
7260}
7261
7262#[cfg(unix)]
7263fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
7264 open_dir_path_nofollow(path, true)
7265}
7266
7267#[cfg(unix)]
7268fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
7269 open_dir_path_nofollow(path, false)
7270}
7271
7272#[cfg(unix)]
7273fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
7274 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
7275 let result =
7276 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
7277 if result == 0 {
7278 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
7279 }
7280 let error = std::io::Error::last_os_error();
7281 if error.kind() == std::io::ErrorKind::NotFound {
7282 Ok(None)
7283 } else {
7284 Err(error.into())
7285 }
7286}
7287
7288#[cfg(unix)]
7289fn create_dir_exclusive_at(
7290 parent: std::os::fd::RawFd,
7291 name: &std::ffi::CStr,
7292 display: &str,
7293) -> LinkResult<std::fs::File> {
7294 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
7295 if made != 0 {
7296 return Err(LinkError::UnsafePath {
7297 path: display.to_string(),
7298 });
7299 }
7300 open_dir_at(parent, name, display)
7301}
7302
7303#[cfg(unix)]
7304fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
7305 use std::os::fd::AsRawFd as _;
7306
7307 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
7308 if duplicate < 0 {
7309 return Err(std::io::Error::last_os_error().into());
7310 }
7311 let stream = unsafe { libc::fdopendir(duplicate) };
7312 if stream.is_null() {
7313 let error = std::io::Error::last_os_error();
7314 unsafe {
7315 libc::close(duplicate);
7316 }
7317 return Err(error.into());
7318 }
7319 let mut names = Vec::new();
7320 loop {
7321 let entry = unsafe { libc::readdir(stream) };
7322 if entry.is_null() {
7323 break;
7324 }
7325 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
7326 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
7327 names.push(raw.to_owned());
7328 }
7329 }
7330 if unsafe { libc::closedir(stream) } != 0 {
7331 return Err(std::io::Error::last_os_error().into());
7332 }
7333 Ok(names)
7334}
7335
7336#[cfg(unix)]
7339fn remove_tree_at(
7340 parent: std::os::fd::RawFd,
7341 name: &std::ffi::CStr,
7342 display: &str,
7343) -> LinkResult<()> {
7344 use std::os::fd::AsRawFd as _;
7345
7346 match entry_is_dir_at(parent, name)? {
7347 None => return Ok(()),
7348 Some(false) => {
7349 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
7350 return Err(std::io::Error::last_os_error().into());
7351 }
7352 }
7353 Some(true) => {
7354 let directory = open_dir_at(parent, name, display)?;
7355 for child in directory_entry_names(&directory)? {
7356 let child_display =
7357 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
7358 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
7359 }
7360 drop(directory);
7361 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
7362 return Err(std::io::Error::last_os_error().into());
7363 }
7364 }
7365 }
7366 Ok(())
7367}
7368
7369#[cfg(unix)]
7373fn clone_tree_contents(
7374 source: &std::fs::File,
7375 destination: &std::fs::File,
7376 display: &str,
7377) -> LinkResult<()> {
7378 use std::os::fd::{AsRawFd as _, FromRawFd as _};
7379
7380 for name in directory_entry_names(source)? {
7381 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
7382 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
7383 if unsafe {
7384 libc::fstatat(
7385 source.as_raw_fd(),
7386 name.as_ptr(),
7387 &mut stat,
7388 libc::AT_SYMLINK_NOFOLLOW,
7389 )
7390 } != 0
7391 {
7392 return Err(std::io::Error::last_os_error().into());
7393 }
7394 match stat.st_mode & libc::S_IFMT {
7395 libc::S_IFDIR => {
7396 if unsafe {
7397 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
7398 } != 0
7399 {
7400 return Err(std::io::Error::last_os_error().into());
7401 }
7402 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
7403 let destination_child =
7404 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
7405 clone_tree_contents(&source_child, &destination_child, &child_display)?;
7406 destination_child.sync_all()?;
7407 }
7408 libc::S_IFREG => {
7409 let source_fd = unsafe {
7410 libc::openat(
7411 source.as_raw_fd(),
7412 name.as_ptr(),
7413 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7414 )
7415 };
7416 if source_fd < 0 {
7417 return Err(std::io::Error::last_os_error().into());
7418 }
7419 let destination_fd = unsafe {
7420 libc::openat(
7421 destination.as_raw_fd(),
7422 name.as_ptr(),
7423 libc::O_WRONLY
7424 | libc::O_CREAT
7425 | libc::O_EXCL
7426 | libc::O_CLOEXEC
7427 | libc::O_NOFOLLOW,
7428 (stat.st_mode & 0o777) as libc::c_uint,
7429 )
7430 };
7431 if destination_fd < 0 {
7432 unsafe {
7433 libc::close(source_fd);
7434 }
7435 return Err(std::io::Error::last_os_error().into());
7436 }
7437 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
7438 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
7439 std::io::copy(&mut input, &mut output)?;
7440 output.sync_all()?;
7441 }
7442 libc::S_IFLNK => {
7443 let mut target = vec![0_u8; 4097];
7444 let length = unsafe {
7445 libc::readlinkat(
7446 source.as_raw_fd(),
7447 name.as_ptr(),
7448 target.as_mut_ptr().cast(),
7449 target.len(),
7450 )
7451 };
7452 if length < 0 || length as usize >= target.len() {
7453 return Err(LinkError::UnsafePath {
7454 path: child_display,
7455 });
7456 }
7457 target.truncate(length as usize);
7458 let target = c_name(&target, &child_display)?;
7459 if unsafe {
7460 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
7461 } != 0
7462 {
7463 return Err(std::io::Error::last_os_error().into());
7464 }
7465 }
7466 _ => {
7467 return Err(LinkError::UnsafePath {
7468 path: child_display,
7469 });
7470 }
7471 }
7472 }
7473 destination.sync_all()?;
7474 Ok(())
7475}
7476
7477#[cfg(target_os = "linux")]
7478fn install_stage_at(
7479 parent: std::os::fd::RawFd,
7480 stage: &std::ffi::CStr,
7481 dest: &std::ffi::CStr,
7482 dest_exists: bool,
7483) -> LinkResult<()> {
7484 let flags = if dest_exists {
7485 libc::RENAME_EXCHANGE
7486 } else {
7487 libc::RENAME_NOREPLACE
7488 };
7489 let result = unsafe {
7493 libc::syscall(
7494 libc::SYS_renameat2,
7495 parent,
7496 stage.as_ptr(),
7497 parent,
7498 dest.as_ptr(),
7499 flags,
7500 )
7501 };
7502 if result == 0 {
7503 Ok(())
7504 } else {
7505 Err(std::io::Error::last_os_error().into())
7506 }
7507}
7508
7509#[cfg(target_os = "macos")]
7510fn install_stage_at(
7511 parent: std::os::fd::RawFd,
7512 stage: &std::ffi::CStr,
7513 dest: &std::ffi::CStr,
7514 dest_exists: bool,
7515) -> LinkResult<()> {
7516 let flags = if dest_exists {
7517 libc::RENAME_SWAP
7518 } else {
7519 libc::RENAME_EXCL
7520 };
7521 let result =
7522 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
7523 if result == 0 {
7524 Ok(())
7525 } else {
7526 Err(std::io::Error::last_os_error().into())
7527 }
7528}
7529
7530#[cfg(unix)]
7531fn write_pull_entries_beneath_dir(
7532 root: &std::fs::File,
7533 entries: &[(String, Vec<u8>)],
7534) -> LinkResult<()> {
7535 use std::os::fd::{AsRawFd as _, FromRawFd as _};
7536
7537 for (path, content) in entries {
7538 let components: Vec<&str> = path.split('/').collect();
7539 let (leaf, parents) = components
7540 .split_last()
7541 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
7542 let mut directory = root.try_clone()?;
7543 for component in parents {
7544 let name = c_name(component.as_bytes(), path)?;
7545 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
7546 if made != 0 {
7547 let error = std::io::Error::last_os_error();
7548 if error.raw_os_error() != Some(libc::EEXIST) {
7549 return Err(error.into());
7550 }
7551 }
7552 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
7553 }
7554
7555 let leaf_name = c_name(leaf.as_bytes(), path)?;
7556 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
7557 let inspected = unsafe {
7558 libc::fstatat(
7559 directory.as_raw_fd(),
7560 leaf_name.as_ptr(),
7561 &mut existing,
7562 libc::AT_SYMLINK_NOFOLLOW,
7563 )
7564 };
7565 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
7566 return Err(LinkError::UnsafePath { path: path.clone() });
7567 }
7568
7569 let nonce = std::time::SystemTime::now()
7570 .duration_since(std::time::UNIX_EPOCH)
7571 .unwrap_or_default()
7572 .as_nanos();
7573 let temp_name = format!(
7574 ".dbmd-pull-{}-{nonce}-{}",
7575 std::process::id(),
7576 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
7577 );
7578 let temp = c_name(temp_name.as_bytes(), path)?;
7579 let fd = unsafe {
7580 libc::openat(
7581 directory.as_raw_fd(),
7582 temp.as_ptr(),
7583 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7584 0o600,
7585 )
7586 };
7587 if fd < 0 {
7588 return Err(std::io::Error::last_os_error().into());
7589 }
7590 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
7591 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
7592 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
7593 return Err(error.into());
7594 }
7595 drop(file);
7596 let renamed = unsafe {
7597 libc::renameat(
7598 directory.as_raw_fd(),
7599 temp.as_ptr(),
7600 directory.as_raw_fd(),
7601 leaf_name.as_ptr(),
7602 )
7603 };
7604 if renamed != 0 {
7605 let error = std::io::Error::last_os_error();
7606 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
7607 return Err(error.into());
7608 }
7609 directory.sync_all()?;
7610 }
7611 root.sync_all()?;
7612 Ok(())
7613}
7614
7615#[cfg(unix)]
7616fn write_pull_sources_beneath_dir(
7617 root: &std::fs::File,
7618 entries: &[V2StagedFile],
7619) -> LinkResult<()> {
7620 use std::os::fd::{AsRawFd as _, FromRawFd as _};
7621
7622 for entry in entries {
7623 let path = &entry.path;
7624 let components: Vec<&str> = path.split('/').collect();
7625 let (leaf, parents) = components
7626 .split_last()
7627 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
7628 let mut directory = root.try_clone()?;
7629 for component in parents {
7630 let name = c_name(component.as_bytes(), path)?;
7631 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
7632 if made != 0 {
7633 let error = std::io::Error::last_os_error();
7634 if error.raw_os_error() != Some(libc::EEXIST) {
7635 return Err(error.into());
7636 }
7637 }
7638 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
7639 }
7640 let leaf_name = c_name(leaf.as_bytes(), path)?;
7641 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
7642 if unsafe {
7643 libc::fstatat(
7644 directory.as_raw_fd(),
7645 leaf_name.as_ptr(),
7646 &mut existing,
7647 libc::AT_SYMLINK_NOFOLLOW,
7648 )
7649 } == 0
7650 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
7651 {
7652 return Err(LinkError::UnsafePath { path: path.clone() });
7653 }
7654 let nonce = SystemTime::now()
7655 .duration_since(UNIX_EPOCH)
7656 .unwrap_or_default()
7657 .as_nanos();
7658 let temp_name = format!(".dbmd-pull-{}-{nonce}", std::process::id());
7659 let temp = c_name(temp_name.as_bytes(), path)?;
7660 let fd = unsafe {
7661 libc::openat(
7662 directory.as_raw_fd(),
7663 temp.as_ptr(),
7664 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
7665 0o600,
7666 )
7667 };
7668 if fd < 0 {
7669 return Err(std::io::Error::last_os_error().into());
7670 }
7671 let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
7672 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
7673 let mut digest = Sha256::new();
7674 let mut total = 0_u64;
7675 let mut buffer = [0_u8; 64 * 1024];
7676 let copied = (|| -> std::io::Result<()> {
7677 loop {
7678 let read = input.read(&mut buffer)?;
7679 if read == 0 {
7680 break;
7681 }
7682 total = total.saturating_add(read as u64);
7683 if total > entry.bytes {
7684 return Err(std::io::Error::new(
7685 std::io::ErrorKind::InvalidData,
7686 "staged sync source grew beyond its verified length",
7687 ));
7688 }
7689 digest.update(&buffer[..read]);
7690 output.write_all(&buffer[..read])?;
7691 }
7692 output.sync_all()
7693 })();
7694 if let Err(error) = copied {
7695 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
7696 return Err(error.into());
7697 }
7698 drop(output);
7699 if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
7700 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
7701 return Err(invalid_feed(
7702 "private staged sync source failed final integrity verification",
7703 ));
7704 }
7705 if unsafe {
7706 libc::renameat(
7707 directory.as_raw_fd(),
7708 temp.as_ptr(),
7709 directory.as_raw_fd(),
7710 leaf_name.as_ptr(),
7711 )
7712 } != 0
7713 {
7714 let error = std::io::Error::last_os_error();
7715 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
7716 return Err(error.into());
7717 }
7718 directory.sync_all()?;
7719 }
7720 root.sync_all()?;
7721 Ok(())
7722}
7723
7724#[cfg(unix)]
7725fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
7726 use std::os::fd::AsRawFd as _;
7727 for path in paths {
7728 if !safe_store_rel_path(path) {
7729 return Err(LinkError::UnsafePath { path: path.clone() });
7730 }
7731 let components = path.split('/').collect::<Vec<_>>();
7732 let Some((leaf, parents)) = components.split_last() else {
7733 return Err(LinkError::UnsafePath { path: path.clone() });
7734 };
7735 let mut directory = root.try_clone()?;
7736 let mut missing = false;
7737 for component in parents {
7738 let name = c_name(component.as_bytes(), path)?;
7739 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
7740 None => {
7741 missing = true;
7742 break;
7743 }
7744 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
7745 Some(true) => {
7746 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
7747 }
7748 }
7749 }
7750 if missing {
7751 continue;
7752 }
7753 let leaf = c_name(leaf.as_bytes(), path)?;
7754 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
7755 None => {}
7756 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
7757 Some(false) => {
7758 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
7759 return Err(std::io::Error::last_os_error().into());
7760 }
7761 directory.sync_all()?;
7762 }
7763 }
7764 }
7765 Ok(())
7766}
7767
7768#[cfg(unix)]
7769fn install_pulled_delta(
7770 dest: &Path,
7771 entries: &[(String, Vec<u8>)],
7772 deleted: &[String],
7773 rebuild_indexes: bool,
7774) -> LinkResult<()> {
7775 use ring::rand::SecureRandom as _;
7776 use std::os::fd::AsRawFd as _;
7777 use std::os::unix::ffi::OsStrExt as _;
7778
7779 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
7780 let name = dest
7781 .file_name()
7782 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
7783 .ok_or_else(|| LinkError::UnsafePath {
7784 path: dest.display().to_string(),
7785 })?;
7786 let parent_dir = open_or_create_dir_nofollow(parent)?;
7787 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
7788 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
7789 None => false,
7790 Some(true) => true,
7791 Some(false) => {
7792 return Err(LinkError::UnsafePath {
7793 path: dest.display().to_string(),
7794 });
7795 }
7796 };
7797
7798 let mut nonce = [0_u8; 16];
7799 ring::rand::SystemRandom::new()
7800 .fill(&mut nonce)
7801 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
7802 let stage_label = format!(
7803 ".{}.dbmd-pull-stage-{}",
7804 name.to_string_lossy(),
7805 URL_SAFE_NO_PAD.encode(nonce)
7806 );
7807 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
7808 let stage_dir = create_dir_exclusive_at(
7809 parent_dir.as_raw_fd(),
7810 &stage_name,
7811 &dest.display().to_string(),
7812 )?;
7813
7814 let prepared = (|| -> LinkResult<()> {
7815 if dest_exists {
7816 let live = open_dir_at(
7817 parent_dir.as_raw_fd(),
7818 &dest_name,
7819 &dest.display().to_string(),
7820 )?;
7821 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
7822 }
7823 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
7824 write_pull_entries_beneath_dir(&stage_dir, entries)?;
7825 if rebuild_indexes {
7826 let stage_store =
7827 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
7828 .map_err(|error| LinkError::InvalidPack {
7829 message: format!("v2 staging tree is not a valid db.md store: {error}"),
7830 })?;
7831 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
7832 LinkError::InvalidPack {
7833 message: format!("could not materialize v2 local catalogs: {error}"),
7834 }
7835 })?;
7836 }
7837 stage_dir.sync_all()?;
7838 Ok(())
7839 })();
7840 if let Err(error) = prepared {
7841 let _ = remove_tree_at(
7842 parent_dir.as_raw_fd(),
7843 &stage_name,
7844 &dest.display().to_string(),
7845 );
7846 return Err(error);
7847 }
7848
7849 if let Err(error) =
7850 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
7851 {
7852 let _ = remove_tree_at(
7853 parent_dir.as_raw_fd(),
7854 &stage_name,
7855 &dest.display().to_string(),
7856 );
7857 return Err(error);
7858 }
7859 parent_dir.sync_all()?;
7860 if dest_exists {
7861 let _ = remove_tree_at(
7865 parent_dir.as_raw_fd(),
7866 &stage_name,
7867 &dest.display().to_string(),
7868 );
7869 let _ = parent_dir.sync_all();
7870 }
7871 Ok(())
7872}
7873
7874#[cfg(unix)]
7875fn install_pulled_delta_sources(
7876 dest: &Path,
7877 entries: &[V2StagedFile],
7878 deleted: &[String],
7879 rebuild_indexes: bool,
7880 _previous: Option<&V2SyncBaseline>,
7881 _next: &V2VerifiedHead,
7882) -> LinkResult<()> {
7883 use ring::rand::SecureRandom as _;
7884 use std::os::fd::AsRawFd as _;
7885 use std::os::unix::ffi::OsStrExt as _;
7886
7887 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
7888 let name = dest
7889 .file_name()
7890 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
7891 .ok_or_else(|| LinkError::UnsafePath {
7892 path: dest.display().to_string(),
7893 })?;
7894 let parent_dir = open_or_create_dir_nofollow(parent)?;
7895 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
7896 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
7897 None => false,
7898 Some(true) => true,
7899 Some(false) => {
7900 return Err(LinkError::UnsafePath {
7901 path: dest.display().to_string(),
7902 })
7903 }
7904 };
7905 let mut nonce = [0_u8; 16];
7906 ring::rand::SystemRandom::new()
7907 .fill(&mut nonce)
7908 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
7909 let stage_label = format!(
7910 ".{}.dbmd-pull-stage-{}",
7911 name.to_string_lossy(),
7912 URL_SAFE_NO_PAD.encode(nonce)
7913 );
7914 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
7915 let stage_dir = create_dir_exclusive_at(
7916 parent_dir.as_raw_fd(),
7917 &stage_name,
7918 &dest.display().to_string(),
7919 )?;
7920 let prepared = (|| -> LinkResult<()> {
7921 if dest_exists {
7922 let live = open_dir_at(
7923 parent_dir.as_raw_fd(),
7924 &dest_name,
7925 &dest.display().to_string(),
7926 )?;
7927 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
7928 }
7929 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
7930 write_pull_sources_beneath_dir(&stage_dir, entries)?;
7931 if rebuild_indexes {
7932 let stage_store =
7933 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
7934 .map_err(|error| LinkError::InvalidPack {
7935 message: format!("v2 staging tree is not a valid db.md store: {error}"),
7936 })?;
7937 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
7938 LinkError::InvalidPack {
7939 message: format!("could not materialize v2 local catalogs: {error}"),
7940 }
7941 })?;
7942 }
7943 stage_dir.sync_all()?;
7944 Ok(())
7945 })();
7946 if let Err(error) = prepared {
7947 let _ = remove_tree_at(
7948 parent_dir.as_raw_fd(),
7949 &stage_name,
7950 &dest.display().to_string(),
7951 );
7952 return Err(error);
7953 }
7954 if let Err(error) =
7955 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
7956 {
7957 let _ = remove_tree_at(
7958 parent_dir.as_raw_fd(),
7959 &stage_name,
7960 &dest.display().to_string(),
7961 );
7962 return Err(error);
7963 }
7964 parent_dir.sync_all()?;
7965 if dest_exists {
7966 let _ = remove_tree_at(
7967 parent_dir.as_raw_fd(),
7968 &stage_name,
7969 &dest.display().to_string(),
7970 );
7971 let _ = parent_dir.sync_all();
7972 }
7973 Ok(())
7974}
7975
7976#[cfg(windows)]
7977#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
7978struct WindowsPullCoordinate {
7979 head_seq: Option<u64>,
7980 commit_hash: Option<String>,
7981 view_kind: Option<String>,
7982 view_revision: Option<String>,
7983}
7984
7985#[cfg(windows)]
7986#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
7987struct WindowsPullFileCoordinate {
7988 sha256: String,
7989 bytes: u64,
7990}
7991
7992#[cfg(windows)]
7993#[derive(Debug, Clone, Deserialize, Serialize)]
7994struct WindowsPullJournalEntry {
7995 path: String,
7996 old: Option<WindowsPullFileCoordinate>,
7997 new: Option<WindowsPullFileCoordinate>,
7998 backup: Option<String>,
7999}
8000
8001#[cfg(windows)]
8002#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
8003#[serde(rename_all = "snake_case")]
8004enum WindowsPullPhase {
8005 Preparing,
8006 Ready,
8007}
8008
8009#[cfg(windows)]
8010#[derive(Debug, Clone, Deserialize, Serialize)]
8011struct WindowsPullJournal {
8012 v: u8,
8013 phase: WindowsPullPhase,
8014 brain: String,
8015 previous: WindowsPullCoordinate,
8016 next: WindowsPullCoordinate,
8017 backup_dir: String,
8018 entries: Vec<WindowsPullJournalEntry>,
8019}
8020
8021#[cfg(windows)]
8022const WINDOWS_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
8023
8024#[cfg(windows)]
8025fn windows_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> WindowsPullCoordinate {
8026 WindowsPullCoordinate {
8027 head_seq: baseline.and_then(|value| value.head_seq),
8028 commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
8029 view_kind: baseline.and_then(|value| value.view_kind.clone()),
8030 view_revision: baseline.and_then(|value| value.view_revision.clone()),
8031 }
8032}
8033
8034#[cfg(windows)]
8035fn windows_head_coordinate(head: &V2VerifiedHead) -> WindowsPullCoordinate {
8036 WindowsPullCoordinate {
8037 head_seq: head.pointer.as_ref().map(|value| value.seq),
8038 commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
8039 view_kind: Some(head.view_kind.clone()),
8040 view_revision: Some(head.view_revision.clone()),
8041 }
8042}
8043
8044#[cfg(windows)]
8045fn windows_pull_state(
8046 store: &Store,
8047 path: &str,
8048 limit: u64,
8049) -> LinkResult<Option<WindowsPullFileCoordinate>> {
8050 let file = match store.open_regular(Path::new(path)) {
8051 Ok(file) => file,
8052 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8053 Err(error) => return Err(error.into()),
8054 };
8055 let bytes = file.metadata()?.len();
8056 if bytes > limit || bytes > MAX_STORE_BYTES {
8057 return Err(invalid_feed(
8058 "pull transaction file exceeds its declared bound",
8059 ));
8060 }
8061 Ok(Some(WindowsPullFileCoordinate {
8062 sha256: content_sha256_reader(file)?,
8063 bytes,
8064 }))
8065}
8066
8067#[cfg(windows)]
8068fn windows_pull_journal_bytes(journal: &WindowsPullJournal) -> LinkResult<Vec<u8>> {
8069 let mut bytes = serde_json::to_vec_pretty(journal)
8070 .map_err(|_| invalid_feed("could not serialize Windows pull journal"))?;
8071 bytes.push(b'\n');
8072 Ok(bytes)
8073}
8074
8075#[cfg(windows)]
8076fn validate_windows_pull_journal(journal: &WindowsPullJournal) -> LinkResult<()> {
8077 let backup_prefix = ".dbmd/pull-backup-";
8078 let suffix = journal
8079 .backup_dir
8080 .strip_prefix(backup_prefix)
8081 .ok_or_else(|| invalid_feed("Windows pull journal backup address is invalid"))?;
8082 let mut paths = std::collections::BTreeSet::new();
8083 if journal.v != 1
8084 || !crate::ulid::is_ulid(&journal.brain)
8085 || !crate::ulid::is_ulid(suffix)
8086 || journal.entries.is_empty()
8087 || journal.entries.len() > MAX_PUSH_FILES + 4
8088 || journal.previous == journal.next
8089 {
8090 return Err(invalid_feed("Windows pull journal failed validation"));
8091 }
8092 for (index, entry) in journal.entries.iter().enumerate() {
8093 if !safe_store_rel_path(&entry.path)
8094 || entry.path == WINDOWS_PULL_JOURNAL
8095 || entry.path.starts_with(backup_prefix)
8096 || !paths.insert(entry.path.clone())
8097 || (entry.old.is_none() && entry.new.is_none())
8098 || entry
8099 .old
8100 .iter()
8101 .chain(entry.new.iter())
8102 .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
8103 || entry.backup.as_deref()
8104 != entry
8105 .old
8106 .as_ref()
8107 .map(|_| format!("{index:08x}"))
8108 .as_deref()
8109 {
8110 return Err(invalid_feed("Windows pull journal entry failed validation"));
8111 }
8112 }
8113 Ok(())
8114}
8115
8116#[cfg(windows)]
8117fn load_windows_pull_journal(store: &Store) -> LinkResult<Option<WindowsPullJournal>> {
8118 let bytes = match store.read_bounded(Path::new(WINDOWS_PULL_JOURNAL), 64 * 1024 * 1024) {
8119 Ok(bytes) => bytes,
8120 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
8121 Err(error) => return Err(error.into()),
8122 };
8123 let journal: WindowsPullJournal = serde_json::from_slice(&bytes)
8124 .map_err(|_| invalid_feed("Windows pull journal is corrupt"))?;
8125 validate_windows_pull_journal(&journal)?;
8126 Ok(Some(journal))
8127}
8128
8129#[cfg(windows)]
8130fn cleanup_windows_pull_journal(store: &Store, journal: &WindowsPullJournal) -> LinkResult<()> {
8131 match store.remove_file(Path::new(WINDOWS_PULL_JOURNAL)) {
8135 Ok(()) => {}
8136 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
8137 Err(error) => return Err(error.into()),
8138 }
8139 match store.remove_private_tree(Path::new(&journal.backup_dir)) {
8140 Ok(()) => Ok(()),
8141 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
8142 Err(error) => Err(error.into()),
8143 }
8144}
8145
8146#[cfg(windows)]
8147fn prune_orphan_windows_pull_backups(store: &Store) -> LinkResult<()> {
8148 let names = match store.directory_names(Path::new(".dbmd")) {
8149 Ok(names) => names,
8150 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
8151 Err(error) => return Err(error.into()),
8152 };
8153 for name in names {
8154 let Some(name) = name.to_str() else {
8155 continue;
8156 };
8157 let Some(suffix) = name.strip_prefix("pull-backup-") else {
8158 continue;
8159 };
8160 if crate::ulid::is_ulid(suffix) {
8161 store.remove_private_tree(&Path::new(".dbmd").join(name))?;
8162 }
8163 }
8164 Ok(())
8165}
8166
8167#[cfg(windows)]
8168fn rollback_windows_pull(store: &Store, journal: &WindowsPullJournal) -> LinkResult<()> {
8169 for entry in &journal.entries {
8171 let limit = entry
8172 .old
8173 .as_ref()
8174 .into_iter()
8175 .chain(entry.new.iter())
8176 .map(|value| value.bytes)
8177 .max()
8178 .unwrap_or(0);
8179 let current = windows_pull_state(store, &entry.path, limit)?;
8180 if current != entry.old && current != entry.new {
8181 return Err(LinkError::InvalidPack {
8182 message: format!(
8183 "cannot recover interrupted pull because `{}` changed afterward",
8184 entry.path
8185 ),
8186 });
8187 }
8188 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
8189 let path = Path::new(&journal.backup_dir).join(backup);
8190 let file = store.open_regular(&path)?;
8191 if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
8192 return Err(invalid_feed(
8193 "Windows pull recovery backup failed verification",
8194 ));
8195 }
8196 }
8197 }
8198 for entry in journal.entries.iter().rev() {
8199 match (&entry.old, &entry.backup) {
8200 (Some(old), Some(backup)) => {
8201 let bytes =
8202 store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
8203 store.write_atomic(Path::new(&entry.path), &bytes)?;
8204 }
8205 (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
8206 store.remove_file(Path::new(&entry.path))?;
8207 }
8208 (None, None) => {}
8209 _ => return Err(invalid_feed("Windows pull recovery entry is inconsistent")),
8210 }
8211 }
8212 crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
8213 message: format!("could not rebuild catalogs after pull recovery: {error}"),
8214 })?;
8215 cleanup_windows_pull_journal(store, journal)
8216}
8217
8218#[cfg(windows)]
8219fn recover_windows_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
8220 let Ok(store) = Store::open_strict(dest) else {
8221 return Ok(());
8222 };
8223 if let Some(journal) = load_windows_pull_journal(&store)? {
8224 if journal.brain != brain {
8225 return Err(invalid_feed(
8226 "Windows pull journal belongs to another brain",
8227 ));
8228 }
8229 if journal.phase == WindowsPullPhase::Preparing {
8230 cleanup_windows_pull_journal(&store, &journal)?;
8231 } else {
8232 let baseline = load_v2_baseline(cfg, brain, dest)?;
8233 let current = windows_baseline_coordinate(baseline.as_ref());
8234 if current == journal.next {
8235 cleanup_windows_pull_journal(&store, &journal)?;
8236 } else {
8237 if current != journal.previous {
8238 return Err(invalid_feed(
8239 "cannot recover interrupted pull because its baseline changed afterward",
8240 ));
8241 }
8242 rollback_windows_pull(&store, &journal)?;
8243 }
8244 }
8245 }
8246 prune_orphan_windows_pull_backups(&store)
8251}
8252
8253#[cfg(windows)]
8254fn complete_windows_v2_pull(dest: &Path) -> LinkResult<()> {
8255 let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
8256 message: format!("installed Windows checkout is not a valid db.md store: {error}"),
8257 })?;
8258 if let Some(journal) = load_windows_pull_journal(&store)? {
8259 cleanup_windows_pull_journal(&store, &journal)?;
8260 }
8261 Ok(())
8262}
8263
8264#[cfg(not(windows))]
8265fn recover_windows_v2_pull(_cfg: &HubConfig, _brain: &str, _dest: &Path) -> LinkResult<()> {
8266 Ok(())
8267}
8268
8269#[cfg(not(windows))]
8270fn complete_windows_v2_pull(_dest: &Path) -> LinkResult<()> {
8271 Ok(())
8272}
8273
8274#[cfg(windows)]
8275fn install_windows_initial_sources(
8276 dest: &Path,
8277 entries: &[V2StagedFile],
8278 rebuild_indexes: bool,
8279) -> LinkResult<()> {
8280 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
8281 let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
8282 path: dest.display().to_string(),
8283 })?;
8284 let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
8285 if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
8286 return Err(LinkError::UnsafePath {
8287 path: dest.display().to_string(),
8288 });
8289 }
8290 let stage_name = format!(
8291 ".{}.dbmd-pull-stage-{}",
8292 name.to_string_lossy(),
8293 crate::ulid::mint()
8294 );
8295 let stage_path = parent.join(&stage_name);
8296 let stage_capability =
8297 crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
8298 let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
8299 let prepared = (|| -> LinkResult<()> {
8300 for entry in entries {
8301 let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
8302 if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
8303 return Err(invalid_feed(
8304 "private staged sync source failed final integrity verification",
8305 ));
8306 }
8307 stage.write_atomic(Path::new(&entry.path), &bytes)?;
8308 }
8309 let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
8310 .map_err(|error| LinkError::InvalidPack {
8311 message: format!("v2 staging tree is not a valid db.md store: {error}"),
8312 })?;
8313 if rebuild_indexes {
8314 crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
8315 message: format!("could not materialize v2 local catalogs: {error}"),
8316 })?;
8317 }
8318 Ok(())
8319 })();
8320 if let Err(error) = prepared {
8321 let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
8322 return Err(error);
8323 }
8324 crate::fsx::rename_directory_beneath(
8325 &parent_capability,
8326 Path::new(&stage_name),
8327 Path::new(name),
8328 )?;
8329 Ok(())
8330}
8331
8332#[cfg(windows)]
8333fn install_pulled_delta_sources(
8334 dest: &Path,
8335 entries: &[V2StagedFile],
8336 deleted: &[String],
8337 rebuild_indexes: bool,
8338 previous: Option<&V2SyncBaseline>,
8339 next: &V2VerifiedHead,
8340) -> LinkResult<()> {
8341 let store = match Store::open_strict(dest) {
8342 Ok(store) => store,
8343 Err(_) => return install_windows_initial_sources(dest, entries, rebuild_indexes),
8344 };
8345 if load_windows_pull_journal(&store)?.is_some() {
8346 return Err(invalid_feed(
8347 "an interrupted Windows pull must be recovered before installing",
8348 ));
8349 }
8350 let mut sources = std::collections::BTreeMap::new();
8351 for entry in entries {
8352 if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
8353 return Err(invalid_feed("Windows pull mutation repeats a path"));
8354 }
8355 }
8356 let mut paths = sources.keys().cloned().collect::<Vec<_>>();
8357 paths.extend(deleted.iter().cloned());
8358 paths.sort();
8359 paths.dedup();
8360 if paths.is_empty() {
8361 return Ok(());
8362 }
8363 let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
8364 let mut journal = WindowsPullJournal {
8365 v: 1,
8366 phase: WindowsPullPhase::Preparing,
8367 brain: next.brain_id.clone(),
8368 previous: windows_baseline_coordinate(previous),
8369 next: windows_head_coordinate(next),
8370 backup_dir: backup_dir.clone(),
8371 entries: Vec::with_capacity(paths.len()),
8372 };
8373 for (index, path) in paths.iter().enumerate() {
8374 let old = windows_pull_state(&store, path, MAX_STORE_BYTES)?;
8375 let new = sources.get(path).map(|entry| WindowsPullFileCoordinate {
8376 sha256: entry.sha256.clone(),
8377 bytes: entry.bytes,
8378 });
8379 journal.entries.push(WindowsPullJournalEntry {
8380 path: path.clone(),
8381 backup: old.as_ref().map(|_| format!("{index:08x}")),
8382 old,
8383 new,
8384 });
8385 }
8386 validate_windows_pull_journal(&journal)?;
8387 store.write_atomic_new(
8388 Path::new(WINDOWS_PULL_JOURNAL),
8389 &windows_pull_journal_bytes(&journal)?,
8390 )?;
8391 let prepared = (|| -> LinkResult<()> {
8392 store.create_dir_all(Path::new(&backup_dir))?;
8393 for entry in &journal.entries {
8394 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
8395 let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
8396 if content_sha256(&bytes) != old.sha256 {
8397 return Err(invalid_feed("live pull source changed during backup"));
8398 }
8399 store.write_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
8400 }
8401 }
8402 journal.phase = WindowsPullPhase::Ready;
8403 store.write_atomic(
8404 Path::new(WINDOWS_PULL_JOURNAL),
8405 &windows_pull_journal_bytes(&journal)?,
8406 )?;
8407 Ok(())
8408 })();
8409 if let Err(error) = prepared {
8410 let cleanup = cleanup_windows_pull_journal(&store, &journal);
8411 return match cleanup {
8412 Ok(()) => Err(error),
8413 Err(cleanup) => Err(LinkError::InvalidPack {
8414 message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
8415 }),
8416 };
8417 }
8418 let installed = (|| -> LinkResult<()> {
8419 for entry in &journal.entries {
8420 if windows_pull_state(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
8421 return Err(LinkError::InvalidPack {
8422 message: format!("local path `{}` changed during pull", entry.path),
8423 });
8424 }
8425 if let Some(source) = sources.get(&entry.path) {
8426 let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
8427 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
8428 return Err(invalid_feed(
8429 "private staged sync source failed final integrity verification",
8430 ));
8431 }
8432 store.write_atomic(Path::new(&entry.path), &bytes)?;
8433 } else if entry.old.is_some() {
8434 store.remove_file(Path::new(&entry.path))?;
8435 }
8436 }
8437 if rebuild_indexes {
8438 crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
8439 message: format!("could not materialize v2 local catalogs: {error}"),
8440 })?;
8441 }
8442 Ok(())
8443 })();
8444 if let Err(error) = installed {
8445 return match rollback_windows_pull(&store, &journal) {
8446 Ok(()) => Err(error),
8447 Err(rollback) => Err(LinkError::InvalidPack {
8448 message: format!("{error}; durable pull rollback also failed: {rollback}"),
8449 }),
8450 };
8451 }
8452 Ok(())
8453}
8454
8455#[cfg(not(any(unix, windows)))]
8456fn install_pulled_delta_sources(
8457 _dest: &Path,
8458 _entries: &[V2StagedFile],
8459 _deleted: &[String],
8460 _rebuild_indexes: bool,
8461 _previous: Option<&V2SyncBaseline>,
8462 _next: &V2VerifiedHead,
8463) -> LinkResult<()> {
8464 Err(LinkError::UnsupportedPlatform {
8465 operation: "atomic v2 pull install",
8466 })
8467}
8468
8469#[cfg(unix)]
8470fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
8471 install_pulled_delta(dest, entries, &[], false)
8472}
8473
8474#[cfg(not(windows))]
8475fn is_safe_slug(slug: &str) -> bool {
8476 !slug.is_empty()
8477 && slug.len() <= 63
8478 && !slug.starts_with('-')
8479 && !slug.ends_with('-')
8480 && slug
8481 .bytes()
8482 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
8483}
8484
8485fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
8486 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
8487}
8488
8489fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
8490 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
8491}
8492
8493fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
8494 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
8495}
8496
8497fn preflight_zip_central_directory(
8498 bytes: &[u8],
8499 offset: usize,
8500 size: usize,
8501 count: u64,
8502) -> LinkResult<()> {
8503 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
8504 let end = offset
8505 .checked_add(size)
8506 .filter(|end| *end <= bytes.len())
8507 .ok_or_else(|| LinkError::InvalidPack {
8508 message: "ZIP central directory is out of bounds".to_string(),
8509 })?;
8510 let mut cursor = offset;
8511 for _ in 0..count {
8512 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
8513 return Err(LinkError::InvalidPack {
8514 message: "ZIP central directory entry count is inconsistent".to_string(),
8515 });
8516 }
8517 if le_u16(bytes, cursor + 34) != Some(0) {
8518 return Err(LinkError::InvalidPack {
8519 message: "multi-disk ZIP archives are not supported".to_string(),
8520 });
8521 }
8522 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
8523 total.checked_add(le_u16(bytes, cursor + at)? as usize)
8524 });
8525 cursor = cursor
8526 .checked_add(46)
8527 .and_then(|fixed| fixed.checked_add(variable?))
8528 .filter(|cursor| *cursor <= end)
8529 .ok_or_else(|| LinkError::InvalidPack {
8530 message: "ZIP central directory entry is truncated".to_string(),
8531 })?;
8532 }
8533 if cursor != end {
8534 return Err(LinkError::InvalidPack {
8535 message: "ZIP central directory size is inconsistent".to_string(),
8536 });
8537 }
8538 Ok(())
8539}
8540
8541fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
8545 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
8546 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
8547 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
8548 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
8549 let eocd = bytes[search_start..]
8550 .windows(4)
8551 .rposition(|window| window == EOCD_SIG)
8552 .map(|offset| search_start + offset)
8553 .ok_or_else(|| LinkError::InvalidPack {
8554 message: "ZIP has no end-of-central-directory record".to_string(),
8555 })?;
8556 let invalid_end = || LinkError::InvalidPack {
8557 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
8558 };
8559 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
8560 if eocd
8561 .checked_add(22)
8562 .and_then(|end| end.checked_add(comment_len))
8563 != Some(bytes.len())
8564 {
8565 return Err(invalid_end());
8569 }
8570 let disk = le_u16(bytes, eocd + 4);
8571 let central_disk = le_u16(bytes, eocd + 6);
8572 if disk != Some(0) || central_disk != Some(0) {
8573 return Err(LinkError::InvalidPack {
8574 message: "multi-disk ZIP archives are not supported".to_string(),
8575 });
8576 }
8577 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
8578 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
8579 if entries_on_disk != ordinary {
8580 return Err(LinkError::InvalidPack {
8581 message: "multi-disk ZIP archives are not supported".to_string(),
8582 });
8583 }
8584 let zip64_locator = eocd
8585 .checked_sub(20)
8586 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
8587 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
8588 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
8589 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
8590 if central_offset
8591 .checked_add(central_size)
8592 .filter(|end| *end == eocd)
8593 .is_none()
8594 {
8595 return Err(invalid_end());
8596 }
8597 (ordinary as u64, central_offset, central_size)
8598 } else {
8599 let Some(locator) = zip64_locator else {
8600 return Err(invalid_end());
8601 };
8602 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
8603 return Err(LinkError::InvalidPack {
8604 message: "multi-disk ZIP64 archives are not supported".to_string(),
8605 });
8606 }
8607 let record = le_u64(bytes, locator + 8)
8608 .and_then(|offset| usize::try_from(offset).ok())
8609 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
8610 .ok_or_else(|| LinkError::InvalidPack {
8611 message: "ZIP64 archive has an invalid end record".to_string(),
8612 })?;
8613 let record_size = le_u64(bytes, record + 4)
8614 .and_then(|size| usize::try_from(size).ok())
8615 .filter(|size| *size >= 44)
8616 .ok_or_else(invalid_end)?;
8617 if record
8618 .checked_add(12)
8619 .and_then(|end| end.checked_add(record_size))
8620 != Some(locator)
8621 || le_u32(bytes, record + 16) != Some(0)
8622 || le_u32(bytes, record + 20) != Some(0)
8623 {
8624 return Err(invalid_end());
8625 }
8626 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
8627 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
8628 let central_size = le_u64(bytes, record + 40)
8629 .and_then(|size| usize::try_from(size).ok())
8630 .ok_or_else(invalid_end)?;
8631 let central_offset = le_u64(bytes, record + 48)
8632 .and_then(|offset| usize::try_from(offset).ok())
8633 .ok_or_else(invalid_end)?;
8634 if zip64_on_disk != zip64_total
8635 || central_offset
8636 .checked_add(central_size)
8637 .filter(|end| *end == record)
8638 .is_none()
8639 {
8640 return Err(invalid_end());
8641 }
8642 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
8643 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
8644 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
8645 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
8646 {
8647 return Err(invalid_end());
8648 }
8649 (zip64_total, central_offset, central_size)
8650 };
8651 if count == 0 || count > max_entries as u64 {
8652 return Err(LinkError::InvalidPack {
8653 message: format!("invalid file count {count}"),
8654 });
8655 }
8656 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
8657 Ok(())
8658}
8659
8660fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
8661 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
8662 let mut archive =
8663 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
8664 message: format!("ZIP parse failed: {err}"),
8665 })?;
8666 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
8667 return Err(LinkError::InvalidPack {
8668 message: format!("invalid file count {}", archive.len()),
8669 });
8670 }
8671 let mut total = 0u64;
8672 let mut seen = std::collections::HashSet::new();
8673 let mut entries = Vec::with_capacity(archive.len());
8674 for index in 0..archive.len() {
8675 let mut file = archive
8676 .by_index(index)
8677 .map_err(|err| LinkError::InvalidPack {
8678 message: format!("ZIP entry failed: {err}"),
8679 })?;
8680 if file.is_dir() {
8681 continue;
8682 }
8683 let path = file.name().to_string();
8684 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
8685 return Err(LinkError::UnsafePath { path });
8686 }
8687 if file
8688 .unix_mode()
8689 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
8690 {
8691 return Err(LinkError::InvalidPack {
8692 message: format!("non-file entry `{path}`"),
8693 });
8694 }
8695 if !seen.insert(path.clone()) {
8696 return Err(LinkError::InvalidPack {
8697 message: format!("duplicate path `{path}`"),
8698 });
8699 }
8700 let remaining = MAX_STORE_BYTES.saturating_sub(total);
8701 if file.size() > remaining {
8702 return Err(LinkError::InvalidPack {
8703 message: "expanded content exceeds the 512 MB limit".to_string(),
8704 });
8705 }
8706 let mut content = Vec::new();
8707 (&mut file)
8708 .take(remaining + 1)
8709 .read_to_end(&mut content)
8710 .map_err(|err| LinkError::InvalidPack {
8711 message: format!("could not decompress `{path}`: {err}"),
8712 })?;
8713 if content.len() as u64 > remaining {
8714 return Err(LinkError::InvalidPack {
8715 message: "expanded content exceeds the 512 MB limit".to_string(),
8716 });
8717 }
8718 if content.len() as u64 != file.size() {
8719 return Err(LinkError::InvalidPack {
8720 message: format!("length mismatch for `{path}`"),
8721 });
8722 }
8723 total += content.len() as u64;
8724 entries.push((path, content));
8725 }
8726 if entries.is_empty() {
8727 return Err(LinkError::InvalidPack {
8728 message: "pack contains no files".to_string(),
8729 });
8730 }
8731 Ok(entries)
8732}
8733
8734fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
8735 let mut expected = std::collections::BTreeMap::new();
8736 for file in signed {
8737 if !safe_store_rel_path(&file.path) {
8738 return Err(LinkError::UnsafePath {
8739 path: file.path.clone(),
8740 });
8741 }
8742 if !is_sha256(&file.sha256)
8743 || expected
8744 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
8745 .is_some()
8746 {
8747 return Err(invalid_feed(
8748 "signed snapshot manifest contains an invalid or duplicate file",
8749 ));
8750 }
8751 }
8752 if expected.len() != entries.len() {
8753 return Err(invalid_feed(
8754 "downloaded pack file set differs from the signed snapshot manifest",
8755 ));
8756 }
8757 for (path, bytes) in entries {
8758 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
8759 return Err(invalid_feed(format!(
8760 "downloaded pack contains unsigned path `{path}`"
8761 )));
8762 };
8763 if *declared_bytes != bytes.len() as u64
8764 || *sha256 != format!("{:x}", Sha256::digest(bytes))
8765 {
8766 return Err(invalid_feed(format!(
8767 "downloaded file `{path}` differs from its signed manifest"
8768 )));
8769 }
8770 }
8771 Ok(())
8772}
8773
8774pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
8781 require_hardened_filesystem("sync push")?;
8782 preflight_push_ownership(store)?;
8783 let mut out: Vec<(String, String)> = Vec::new();
8784 let mut total = 0u64;
8785
8786 let mut read_text = |rel: &str| -> LinkResult<String> {
8787 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
8788 total = total
8789 .checked_add(bytes.len() as u64)
8790 .ok_or_else(|| LinkError::PushTooLarge {
8791 detail: "uncompressed byte count overflow".to_string(),
8792 })?;
8793 if total > MAX_STORE_BYTES {
8794 return Err(LinkError::PushTooLarge {
8795 detail: format!("{total} uncompressed bytes"),
8796 });
8797 }
8798 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
8799 path: rel.to_string(),
8800 })
8801 };
8802
8803 out.push(("DB.md".to_string(), read_text("DB.md")?));
8804 if store
8805 .regular_file_exists(Path::new("assets.jsonl"))
8806 .unwrap_or(false)
8807 {
8808 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
8809 }
8810
8811 for rel in store.walk()? {
8812 let rel_str = rel.to_string_lossy().replace('\\', "/");
8813 if !safe_store_rel_path(&rel_str) {
8814 return Err(LinkError::UnsafePath { path: rel_str });
8817 }
8818 let content = read_text(&rel_str)?;
8819 out.push((rel_str, content));
8820 }
8821
8822 out.sort_by(|a, b| a.0.cmp(&b.0));
8823 Ok(out)
8824}
8825
8826fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
8830 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
8831 return Err(LinkError::from(std::io::Error::new(
8832 std::io::ErrorKind::PermissionDenied,
8833 format!("cannot push: nested db.md store at {}", nested.display()),
8834 )));
8835 }
8836
8837 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
8838 return Err(LinkError::from(std::io::Error::new(
8839 std::io::ErrorKind::PermissionDenied,
8840 format!(
8841 "cannot push: {} is a symlink outside the store ownership model",
8842 symlink.display()
8843 ),
8844 )));
8845 }
8846 Ok(())
8847}
8848
8849pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
8855 require_safe_ref(brain)?;
8856 let remote = verified_remote_head(cfg, brain, false)?;
8857 if files.len() > MAX_PUSH_FILES {
8858 return Err(LinkError::PushTooLarge {
8859 detail: format!("{} files", files.len()),
8860 });
8861 }
8862 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
8863 if raw_total > MAX_STORE_BYTES {
8864 return Err(LinkError::PushTooLarge {
8865 detail: format!("{raw_total} uncompressed bytes"),
8866 });
8867 }
8868
8869 if cfg.brain_key.is_none() {
8873 let body = json!({
8874 "files": files
8875 .iter()
8876 .map(|(p, c)| json!({ "path": p, "content": c }))
8877 .collect::<Vec<_>>(),
8878 });
8879 if body.to_string().len() <= MAX_PUSH_BYTES {
8880 let path = format!("/api/hub/brains/{brain}/push");
8881 let pushed = ensure_ok(
8882 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
8883 "sync push",
8884 )?;
8885 return Ok(pushed);
8886 }
8887 }
8888
8889 let pack = build_store_pack(files)?;
8890 if pack.len() as u64 > MAX_PACK_BYTES {
8891 return Err(LinkError::PushTooLarge {
8892 detail: format!("{} pack bytes", pack.len()),
8893 });
8894 }
8895 let sha256 = format!("{:x}", Sha256::digest(&pack));
8896 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
8897 if let Some(key) = &cfg.brain_key {
8898 if !remote.head.verified {
8899 return Err(invalid_feed(
8900 "self-custody push requires a fully verified, unscoped feed head",
8901 ));
8902 }
8903 let identity = remote
8904 .identity
8905 .as_ref()
8906 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
8907 let current_multikey = format!("ed25519:{}", identity.fingerprint);
8908 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
8909 return Err(invalid_feed(
8910 "configured brain key is not the verified current brain identity",
8911 ));
8912 }
8913 let next_seq = remote
8916 .head
8917 .seq
8918 .checked_add(1)
8919 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
8920 let mut manifest: Vec<WireFeedFile> = files
8921 .iter()
8922 .map(|(path, content)| WireFeedFile {
8923 path: path.clone(),
8924 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
8925 bytes: content.len() as u64,
8926 })
8927 .collect();
8928 manifest.sort_by(|a, b| a.path.cmp(&b.path));
8929 let ts = crate::now()
8930 .with_timezone(&chrono::Utc)
8931 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
8932 .to_string();
8933 let entry = self_custody_entry(
8934 key,
8935 next_seq,
8936 ts,
8937 &sha256,
8938 &manifest,
8939 remote.head.feed_hash.as_deref(),
8940 )?;
8941 meta["entry"] = Value::String(entry);
8942 }
8943 let presigned = ensure_ok(
8944 request(
8945 cfg,
8946 "POST",
8947 &format!("/api/hub/brains/{brain}/packs/presign"),
8948 Some(&meta),
8949 Auth::Required,
8950 )?,
8951 "prepare pack upload",
8952 )?;
8953 let url = presigned
8954 .get("url")
8955 .and_then(Value::as_str)
8956 .ok_or_else(|| LinkError::InvalidPack {
8957 message: "the hub returned no upload URL".to_string(),
8958 })?;
8959 put_presigned(
8960 cfg,
8961 url,
8962 presigned.get("headers").unwrap_or(&Value::Null),
8963 &pack,
8964 )?;
8965 let committed = ensure_ok(
8966 request(
8967 cfg,
8968 "POST",
8969 &format!("/api/hub/brains/{brain}/packs/commit"),
8970 Some(&meta),
8971 Auth::Required,
8972 )?,
8973 "commit pack",
8974 )?;
8975 Ok(committed)
8976}
8977
8978fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
8979 const LOCAL_HEADER: u32 = 0x0403_4b50;
8980 const CENTRAL_HEADER: u32 = 0x0201_4b50;
8981 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
8982 const VERSION_20: u16 = 20;
8983 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
8984 const UTF8_FLAG: u16 = 1 << 11;
8985 const STORED: u16 = 0;
8986 const DOS_TIME_MIDNIGHT: u16 = 0;
8987 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
8988 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
8989
8990 struct CentralEntry<'a> {
8991 name: &'a [u8],
8992 crc32: u32,
8993 size: u32,
8994 local_offset: u32,
8995 }
8996
8997 fn push_u16(out: &mut Vec<u8>, value: u16) {
8998 out.extend_from_slice(&value.to_le_bytes());
8999 }
9000
9001 fn push_u32(out: &mut Vec<u8>, value: u32) {
9002 out.extend_from_slice(&value.to_le_bytes());
9003 }
9004
9005 if files.is_empty() {
9006 return Err(LinkError::InvalidPack {
9007 message: "cannot create an empty snapshot pack".to_string(),
9008 });
9009 }
9010 if files.len() > u16::MAX as usize {
9011 return Err(LinkError::PushTooLarge {
9012 detail: format!(
9013 "{} files (canonical ZIP32 packs cap at {})",
9014 files.len(),
9015 u16::MAX
9016 ),
9017 });
9018 }
9019
9020 let mut sorted: Vec<_> = files.iter().collect();
9021 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
9022 let mut previous: Option<&str> = None;
9023 for (path, content) in &sorted {
9024 if !safe_store_rel_path(path) {
9025 return Err(LinkError::UnsafePath {
9026 path: (*path).clone(),
9027 });
9028 }
9029 if previous == Some(path.as_str()) {
9030 return Err(LinkError::InvalidPack {
9031 message: format!("duplicate path `{path}`"),
9032 });
9033 }
9034 previous = Some(path.as_str());
9035 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
9036 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9037 })?;
9038 }
9039
9040 let mut out = Vec::new();
9041 let mut central = Vec::with_capacity(sorted.len());
9042 for (path, content) in sorted {
9043 let name = path.as_bytes();
9044 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
9045 message: format!("ZIP entry name is too long: `{path}`"),
9046 })?;
9047 let bytes = content.as_bytes();
9048 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
9049 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
9050 })?;
9051 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
9052 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
9053 })?;
9054 let crc32 = crc32fast::hash(bytes);
9055
9056 push_u32(&mut out, LOCAL_HEADER);
9059 push_u16(&mut out, VERSION_20);
9060 push_u16(&mut out, UTF8_FLAG);
9061 push_u16(&mut out, STORED);
9062 push_u16(&mut out, DOS_TIME_MIDNIGHT);
9063 push_u16(&mut out, DOS_DATE_1980_01_01);
9064 push_u32(&mut out, crc32);
9065 push_u32(&mut out, size);
9066 push_u32(&mut out, size);
9067 push_u16(&mut out, name_len);
9068 push_u16(&mut out, 0); out.extend_from_slice(name);
9070 out.extend_from_slice(bytes);
9071
9072 central.push(CentralEntry {
9073 name,
9074 crc32,
9075 size,
9076 local_offset,
9077 });
9078 }
9079
9080 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
9081 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
9082 })?;
9083 for entry in ¢ral {
9084 push_u32(&mut out, CENTRAL_HEADER);
9085 push_u16(&mut out, MADE_BY_UNIX_20);
9086 push_u16(&mut out, VERSION_20);
9087 push_u16(&mut out, UTF8_FLAG);
9088 push_u16(&mut out, STORED);
9089 push_u16(&mut out, DOS_TIME_MIDNIGHT);
9090 push_u16(&mut out, DOS_DATE_1980_01_01);
9091 push_u32(&mut out, entry.crc32);
9092 push_u32(&mut out, entry.size);
9093 push_u32(&mut out, entry.size);
9094 push_u16(&mut out, entry.name.len() as u16);
9095 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);
9100 push_u32(&mut out, entry.local_offset);
9101 out.extend_from_slice(entry.name);
9102 }
9103 let central_size = u32::try_from(out.len())
9104 .ok()
9105 .and_then(|end| end.checked_sub(central_offset))
9106 .ok_or_else(|| LinkError::PushTooLarge {
9107 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
9108 })?;
9109 let entry_count = central.len() as u16;
9110
9111 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
9112 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
9115 push_u16(&mut out, entry_count);
9116 push_u32(&mut out, central_size);
9117 push_u32(&mut out, central_offset);
9118 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
9121 return Err(LinkError::PushTooLarge {
9122 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
9123 });
9124 }
9125 Ok(out)
9126}
9127
9128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9134pub enum Capability {
9135 Read,
9137 Write,
9139}
9140
9141impl Capability {
9142 pub fn as_str(self) -> &'static str {
9144 match self {
9145 Capability::Read => "read",
9146 Capability::Write => "write",
9147 }
9148 }
9149}
9150
9151pub fn grant_issue(
9157 cfg: &HubConfig,
9158 brain: &str,
9159 grantee: &str,
9160 can: Capability,
9161 scope: Option<&str>,
9162 until: Option<&str>,
9163) -> LinkResult<Value> {
9164 require_safe_ref(brain)?;
9165 let _ = verified_remote_head(cfg, brain, false)?;
9166 let is_key_grantee = URL_SAFE_NO_PAD
9171 .decode(grantee)
9172 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
9173 .unwrap_or(false);
9174 let mut body = if is_key_grantee {
9175 json!({ "keySpki": grantee, "capability": can.as_str() })
9176 } else {
9177 json!({ "email": grantee, "capability": can.as_str() })
9178 };
9179 if let Some(s) = scope {
9180 body["scopePrefix"] = json!(s);
9181 }
9182 if let Some(u) = until {
9183 body["expiresAt"] = json!(u);
9184 }
9185 let path = format!("/api/hub/brains/{brain}/grants");
9186 ensure_ok(
9187 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9188 "grant issue",
9189 )
9190}
9191
9192pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
9194 require_safe_ref(brain)?;
9195 let _ = verified_remote_head(cfg, brain, false)?;
9196 let path = format!("/api/hub/brains/{brain}/grants");
9197 ensure_ok(
9198 request(cfg, "GET", &path, None, Auth::Required)?,
9199 "grant list",
9200 )
9201}
9202
9203pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
9206 require_safe_ref(brain)?;
9207 require_safe_grant_id(grant_id)?;
9208 let _ = verified_remote_head(cfg, brain, false)?;
9209 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
9210 ensure_ok(
9211 request(cfg, "DELETE", &path, None, Auth::Required)?,
9212 "grant revoke",
9213 )
9214}
9215
9216#[derive(Debug)]
9221struct VerifiedV2Proposal {
9222 value: Value,
9223 changes: Value,
9224 blobs: Vec<(String, u64, String)>,
9225}
9226
9227fn require_proposal_id(id: &str) -> LinkResult<()> {
9228 if crate::ulid::is_ulid(id) {
9229 Ok(())
9230 } else {
9231 Err(invalid_feed("proposal id is not a lowercase ULID"))
9232 }
9233}
9234
9235fn verified_v2_proposal(
9236 cfg: &HubConfig,
9237 head: &V2VerifiedHead,
9238 proposal_id: &str,
9239) -> LinkResult<VerifiedV2Proposal> {
9240 require_proposal_id(proposal_id)?;
9241 if head.view_kind != "full" {
9242 return Err(invalid_feed(
9243 "proposal review requires a full readable view",
9244 ));
9245 }
9246 let path = format!(
9247 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
9248 head.brain_id
9249 );
9250 let value = ensure_ok(
9251 request_capped(
9252 cfg,
9253 "GET",
9254 &path,
9255 None,
9256 Auth::Required,
9257 MAX_FEED_RESPONSE_BYTES,
9258 )?,
9259 "v2 proposal",
9260 )?;
9261 verify_v2_proposal_value(head, proposal_id, value)
9262}
9263
9264fn verify_v2_proposal_value(
9265 head: &V2VerifiedHead,
9266 proposal_id: &str,
9267 value: Value,
9268) -> LinkResult<VerifiedV2Proposal> {
9269 if value.get("v").and_then(Value::as_u64) != Some(2) {
9270 return Err(invalid_feed("proposal response has an invalid version"));
9271 }
9272 let proposal = value
9273 .get("proposal")
9274 .and_then(Value::as_object)
9275 .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
9276 if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
9277 return Err(invalid_feed("proposal response changed its id"));
9278 }
9279 let payload_hash = proposal
9280 .get("payload_sha256")
9281 .and_then(Value::as_str)
9282 .filter(|hash| is_sha256(hash))
9283 .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
9284 let clear_hash = proposal
9285 .get("clear_sha256")
9286 .and_then(Value::as_str)
9287 .filter(|hash| is_sha256(hash))
9288 .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
9289 let submission_hash = proposal
9290 .get("submission_claim_sha256")
9291 .and_then(Value::as_str)
9292 .filter(|hash| is_sha256(hash))
9293 .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
9294 let submission = STANDARD
9295 .decode(
9296 proposal
9297 .get("submission_claim_base64")
9298 .and_then(Value::as_str)
9299 .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
9300 )
9301 .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
9302 let submission_value: Value = serde_json::from_slice(&submission)
9303 .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
9304 if crate::linkmd_v2::canonical_bytes(&submission_value)
9305 .map_err(|error| invalid_feed(error.to_string()))?
9306 != submission
9307 || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
9308 .map_err(|error| invalid_feed(error.to_string()))?
9309 != submission_hash
9310 {
9311 return Err(invalid_feed(
9312 "proposal submission claim is not canonical or addressed",
9313 ));
9314 }
9315 let envelope = submission_value
9316 .as_object()
9317 .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
9318 let claim = envelope
9319 .get("claim")
9320 .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
9321 let claim_object = claim
9322 .as_object()
9323 .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
9324 let actor_root = claim_object
9325 .get("actor_root")
9326 .and_then(Value::as_object)
9327 .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
9328 let public_key = envelope
9329 .get("public_key")
9330 .and_then(Value::as_str)
9331 .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
9332 let fingerprint = envelope
9333 .get("fingerprint")
9334 .and_then(Value::as_str)
9335 .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
9336 let signature = envelope
9337 .get("sig")
9338 .and_then(Value::as_str)
9339 .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
9340 let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
9341 .map_err(|error| invalid_feed(error.to_string()))?;
9342 let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
9343 let signer = format!("{fingerprint}:{public_key}");
9344 let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
9345 let grants = actor_root.get("grants").and_then(Value::as_array);
9346 let grants_are_canonical = grants.is_some_and(|items| {
9347 let mut prior: Option<&str> = None;
9348 items.iter().all(|item| {
9349 let Some(grant) = item.as_str() else {
9350 return false;
9351 };
9352 if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
9353 return false;
9354 }
9355 prior = Some(grant);
9356 true
9357 })
9358 });
9359 let optional_actor_field = |name: &str| {
9360 actor_root.get(name).is_some_and(|value| {
9361 value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
9362 })
9363 };
9364 let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
9365 if claim_object.get("v").and_then(Value::as_u64) != Some(2)
9366 || format!("{:x}", Sha256::digest(&der)) != fingerprint
9367 || head
9368 .trust
9369 .hub_signer
9370 .as_ref()
9371 .is_some_and(|known| known != &signer)
9372 || !matches!(
9373 actor_class,
9374 Some(
9375 "user"
9376 | "owned_agent"
9377 | "foreign_key"
9378 | "curation"
9379 | "inbox"
9380 | "restore"
9381 | "migration"
9382 | "operator_recovery"
9383 )
9384 )
9385 || actor_root
9386 .get("principal")
9387 .and_then(Value::as_str)
9388 .is_none_or(|value| value.is_empty())
9389 || actor_root
9390 .get("credential")
9391 .and_then(Value::as_str)
9392 .is_none_or(|value| value.is_empty())
9393 || !optional_actor_field("organization")
9394 || !optional_actor_field("role")
9395 || !grants_are_canonical
9396 || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
9397 || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
9398 || !claim_object
9399 .get("mutation_id")
9400 .and_then(Value::as_str)
9401 .is_some_and(|value| {
9402 !value.is_empty()
9403 && value.len() <= 128
9404 && value.chars().enumerate().all(|(index, char)| {
9405 char.is_ascii_alphanumeric()
9406 || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
9407 })
9408 })
9409 || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
9410 || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
9411 || !claim_object
9412 .get("control_revision")
9413 .and_then(Value::as_str)
9414 .is_some_and(is_sha256)
9415 || submitted_at.is_none_or(|value| {
9416 chrono::DateTime::parse_from_rfc3339(value).is_err()
9417 || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
9418 })
9419 || !proposal
9420 .get("state")
9421 .and_then(Value::as_str)
9422 .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
9423 || proposal
9424 .get("expires_at")
9425 .and_then(Value::as_str)
9426 .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
9427 || proposal
9428 .get("proposer")
9429 .and_then(Value::as_object)
9430 .and_then(|value| value.get("class"))
9431 .and_then(Value::as_str)
9432 != actor_class
9433 {
9434 return Err(invalid_feed(
9435 "proposal submission claim does not bind the verified proposal",
9436 ));
9437 }
9438 let changes_b64 = proposal
9439 .get("changes_base64")
9440 .and_then(Value::as_str)
9441 .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
9442 let changes_bytes = STANDARD
9443 .decode(changes_b64)
9444 .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
9445 let changes: Value = serde_json::from_slice(&changes_bytes)
9446 .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
9447 if crate::linkmd_v2::canonical_bytes(&changes)
9448 .map_err(|error| invalid_feed(error.to_string()))?
9449 != changes_bytes
9450 || changes.get("v").and_then(Value::as_u64) != Some(2)
9451 || !changes.get("operations").is_some_and(Value::is_array)
9452 {
9453 return Err(invalid_feed("proposal changeset is not canonical v2"));
9454 }
9455 let blob_values = proposal
9456 .get("blobs")
9457 .and_then(Value::as_array)
9458 .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
9459 let mut blobs = Vec::with_capacity(blob_values.len());
9460 let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
9461 let mut prior_hash: Option<String> = None;
9462 for item in blob_values {
9463 let hash = item
9464 .get("sha256")
9465 .and_then(Value::as_str)
9466 .filter(|hash| is_sha256(hash))
9467 .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
9468 let bytes = item
9469 .get("bytes")
9470 .and_then(Value::as_u64)
9471 .filter(|bytes| *bytes <= MAX_STORE_BYTES)
9472 .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
9473 if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
9474 return Err(invalid_feed(
9475 "proposal blob declarations are not unique and sorted",
9476 ));
9477 }
9478 prior_hash = Some(hash.to_string());
9479 let endpoint = item
9480 .get("endpoint")
9481 .and_then(Value::as_str)
9482 .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
9483 let expected_endpoint = format!(
9484 "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
9485 head.brain_id
9486 );
9487 if endpoint != expected_endpoint {
9488 return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
9489 }
9490 descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
9491 blobs.push((hash.to_string(), bytes, endpoint.to_string()));
9492 }
9493 let descriptor = json!({
9494 "base": proposal.get("base").cloned().unwrap_or(Value::Null),
9495 "blobs": descriptor_blobs,
9496 "changes_base64": changes_b64,
9497 "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
9498 "v": 2,
9499 });
9500 let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
9501 .map_err(|error| invalid_feed(error.to_string()))?;
9502 if content_sha256(&descriptor_bytes) != clear_hash {
9503 return Err(invalid_feed(
9504 "proposal clear payload differs from its signed submission claim",
9505 ));
9506 }
9507 Ok(VerifiedV2Proposal {
9508 value,
9509 changes,
9510 blobs,
9511 })
9512}
9513
9514pub fn proposal_list(
9515 cfg: &HubConfig,
9516 brain: &str,
9517 state: &str,
9518 after: Option<&str>,
9519 limit: usize,
9520) -> LinkResult<Value> {
9521 require_safe_ref(brain)?;
9522 if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
9523 return Err(invalid_feed("proposal state is invalid"));
9524 }
9525 if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
9526 return Err(invalid_feed("proposal cursor is invalid"));
9527 }
9528 let head = v2_verified_head(cfg, brain)?
9529 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
9530 let path = format!(
9531 "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
9532 head.brain_id,
9533 limit.clamp(1, 100),
9534 after.map_or_else(String::new, |value| format!("&after={value}"))
9535 );
9536 ensure_ok(
9537 request_capped(
9538 cfg,
9539 "GET",
9540 &path,
9541 None,
9542 Auth::Required,
9543 MAX_FEED_RESPONSE_BYTES,
9544 )?,
9545 "v2 proposal list",
9546 )
9547}
9548
9549pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
9550 require_safe_ref(brain)?;
9551 let head = v2_verified_head(cfg, brain)?
9552 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
9553 Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
9554}
9555
9556pub fn proposal_reject(
9557 cfg: &HubConfig,
9558 brain: &str,
9559 proposal_id: &str,
9560 mutation_id: &str,
9561 reason: &str,
9562) -> LinkResult<Value> {
9563 require_safe_ref(brain)?;
9564 require_proposal_id(proposal_id)?;
9565 let head = v2_verified_head(cfg, brain)?
9566 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
9567 let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
9568 let body = json!({
9569 "mutation_id": mutation_id,
9570 "control_revision": head.view_revision,
9571 "reason": reason,
9572 });
9573 let path = format!(
9574 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
9575 head.brain_id
9576 );
9577 ensure_ok(
9578 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
9579 "v2 proposal rejection",
9580 )
9581}
9582
9583pub fn proposal_accept_exact(
9584 cfg: &HubConfig,
9585 brain: &str,
9586 proposal_id: &str,
9587 mutation_id: &str,
9588 reason: &str,
9589) -> LinkResult<Value> {
9590 require_safe_ref(brain)?;
9591 require_proposal_id(proposal_id)?;
9592 let head = v2_verified_head(cfg, brain)?
9593 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
9594 let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
9595 let operations = proposal
9596 .changes
9597 .get("operations")
9598 .and_then(Value::as_array)
9599 .cloned()
9600 .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
9601 if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
9602 return Err(invalid_feed("proposal operation count is invalid"));
9603 }
9604 let mut downloaded = std::collections::BTreeMap::new();
9605 for (hash, bytes, endpoint) in &proposal.blobs {
9606 let body = ensure_raw_ok(
9607 request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
9608 "v2 proposal blob",
9609 )?;
9610 if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
9611 return Err(invalid_feed("proposal blob does not match its declaration"));
9612 }
9613 downloaded.insert(hash.clone(), body);
9614 }
9615 let remote = files_for_v2_view(
9616 &head,
9617 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
9618 );
9619 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
9620 let mut expected_candidate = remote.clone();
9621 let mut expected_candidate_assets = remote_assets;
9622 for operation in &operations {
9623 let op = operation
9624 .get("op")
9625 .and_then(Value::as_str)
9626 .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
9627 match op {
9628 "put" | "restore" => {
9629 let path = operation
9630 .get("path")
9631 .and_then(Value::as_str)
9632 .ok_or_else(|| invalid_feed("proposal write has no path"))?;
9633 crate::linkmd_v2::normalize_path(path)
9634 .map_err(|error| invalid_feed(error.to_string()))?;
9635 let hash = operation
9636 .get("blob")
9637 .and_then(Value::as_str)
9638 .filter(|hash| is_sha256(hash))
9639 .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
9640 let bytes = operation
9641 .get("bytes")
9642 .and_then(Value::as_u64)
9643 .ok_or_else(|| invalid_feed("proposal write has no size"))?;
9644 expected_candidate.insert(
9645 path.to_string(),
9646 V2BaselineFile {
9647 sha256: hash.to_string(),
9648 bytes,
9649 proof: None,
9650 },
9651 );
9652 }
9653 "delete" | "withdraw_from_hosting" => {
9654 let path = operation
9655 .get("path")
9656 .and_then(Value::as_str)
9657 .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
9658 crate::linkmd_v2::normalize_path(path)
9659 .map_err(|error| invalid_feed(error.to_string()))?;
9660 expected_candidate.remove(path);
9661 }
9662 "rename" => {
9663 let from = operation
9664 .get("from")
9665 .and_then(Value::as_str)
9666 .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
9667 let to = operation
9668 .get("to")
9669 .and_then(Value::as_str)
9670 .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
9671 crate::linkmd_v2::normalize_path(from)
9672 .and_then(|_| crate::linkmd_v2::normalize_path(to))
9673 .map_err(|error| invalid_feed(error.to_string()))?;
9674 let hash = operation
9675 .get("blob")
9676 .and_then(Value::as_str)
9677 .filter(|hash| is_sha256(hash))
9678 .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
9679 let bytes = operation
9680 .get("bytes")
9681 .and_then(Value::as_u64)
9682 .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
9683 expected_candidate.remove(from);
9684 expected_candidate.insert(
9685 to.to_string(),
9686 V2BaselineFile {
9687 sha256: hash.to_string(),
9688 bytes,
9689 proof: None,
9690 },
9691 );
9692 }
9693 "asset_delete" => {
9694 let path = operation
9695 .get("path")
9696 .and_then(Value::as_str)
9697 .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
9698 expected_candidate_assets.remove(path);
9699 }
9700 "asset_withdraw" => {
9701 let path = operation
9702 .get("path")
9703 .and_then(Value::as_str)
9704 .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
9705 let asset = expected_candidate_assets
9706 .get_mut(path)
9707 .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
9708 asset.disposition = "withheld".to_string();
9709 asset.leaf_hash.clear();
9710 }
9711 "asset_put" | "asset_resume" => {
9712 let path = operation
9713 .get("path")
9714 .and_then(Value::as_str)
9715 .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
9716 let asset = operation
9717 .get("asset")
9718 .and_then(Value::as_object)
9719 .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
9720 let blob_sha256 = asset
9721 .get("blob_sha256")
9722 .and_then(Value::as_str)
9723 .filter(|hash| is_sha256(hash))
9724 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
9725 let bytes = asset
9726 .get("bytes")
9727 .and_then(Value::as_u64)
9728 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
9729 let media_type = asset
9730 .get("media_type")
9731 .and_then(Value::as_str)
9732 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
9733 let wrappers = asset
9734 .get("wrappers")
9735 .and_then(Value::as_array)
9736 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
9737 .iter()
9738 .map(|wrapper| {
9739 wrapper
9740 .as_str()
9741 .map(str::to_string)
9742 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
9743 })
9744 .collect::<LinkResult<Vec<_>>>()?;
9745 let required = asset
9746 .get("required")
9747 .and_then(Value::as_bool)
9748 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
9749 let disposition = asset
9750 .get("disposition")
9751 .and_then(Value::as_str)
9752 .filter(|value| matches!(*value, "hosted" | "withheld"))
9753 .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
9754 expected_candidate_assets.insert(
9755 path.to_string(),
9756 V2BaselineAsset {
9757 blob_sha256: blob_sha256.to_string(),
9758 bytes,
9759 media_type: media_type.to_string(),
9760 wrappers,
9761 required,
9762 disposition: disposition.to_string(),
9763 leaf_hash: String::new(),
9764 },
9765 );
9766 }
9767 _ => return Err(invalid_feed("proposal operation kind is unsupported")),
9768 }
9769 }
9770 let base = head.pointer.as_ref().map(|pointer| {
9771 json!({
9772 "seq": pointer.seq,
9773 "commit_hash": pointer.commit_hash,
9774 "content_root": pointer.content_root,
9775 "asset_root": pointer.asset_root,
9776 })
9777 });
9778 let mut body = json!({
9779 "mutation_id": mutation_id,
9780 "base": base,
9781 "rebase": "strict",
9782 "reason": reason,
9783 "operations": operations,
9784 "blobs": downloaded
9785 .iter()
9786 .map(|(sha256, bytes)| json!({
9787 "sha256": sha256,
9788 "bytes": bytes.len(),
9789 "content_base64": STANDARD.encode(bytes),
9790 }))
9791 .collect::<Vec<_>>(),
9792 "proposal_id": proposal_id,
9793 "proposal_mode": "exact",
9794 });
9795 let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
9796 total
9797 .checked_add(bytes.len())
9798 .ok_or_else(|| LinkError::PushTooLarge {
9799 detail: "proposal changed-byte total overflow".to_string(),
9800 })
9801 })?;
9802 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
9803 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9804 for operation in &operations {
9805 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
9806 return Err(invalid_feed("proposal upload operation has no kind"));
9807 };
9808 let hash = match kind {
9809 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
9810 "asset_put" | "asset_resume" => operation
9811 .get("asset")
9812 .and_then(|asset| asset.get("blob_sha256"))
9813 .and_then(Value::as_str),
9814 _ => None,
9815 };
9816 let Some(hash) = hash else { continue };
9817 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
9818 if kind == "rename" {
9819 for field in ["from", "to"] {
9820 coordinates.insert(
9821 operation
9822 .get(field)
9823 .and_then(Value::as_str)
9824 .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
9825 .to_string(),
9826 );
9827 }
9828 } else {
9829 let path = operation
9830 .get("path")
9831 .and_then(Value::as_str)
9832 .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
9833 coordinates.insert(if kind.starts_with("asset_") {
9834 format!("assets/{path}")
9835 } else {
9836 path.to_string()
9837 });
9838 }
9839 }
9840 let declarations = downloaded
9841 .iter()
9842 .map(|(sha256, bytes)| {
9843 json!({
9844 "sha256": sha256,
9845 "bytes": bytes.len(),
9846 "coordinates": coordinates_by_hash
9847 .get(sha256)
9848 .into_iter()
9849 .flatten()
9850 .collect::<Vec<_>>(),
9851 })
9852 })
9853 .collect::<Vec<_>>();
9854 let reserved = ensure_ok(
9855 request(
9856 cfg,
9857 "POST",
9858 &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
9859 Some(&json!({ "blobs": declarations })),
9860 Auth::Required,
9861 )?,
9862 "prepare proposal blob transport",
9863 )?;
9864 let items = reserved
9865 .get("uploads")
9866 .and_then(Value::as_array)
9867 .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
9868 if items.len() != downloaded.len() {
9869 return Err(invalid_feed("proposal upload reservation changed the set"));
9870 }
9871 let mut references = Vec::with_capacity(items.len());
9872 for item in items {
9873 let hash = item
9874 .get("sha256")
9875 .and_then(Value::as_str)
9876 .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
9877 let bytes = downloaded
9878 .get(hash)
9879 .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
9880 let reservation_id = item
9881 .get("reservation_id")
9882 .and_then(Value::as_str)
9883 .filter(|id| crate::ulid::is_ulid(id))
9884 .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
9885 let expected_coordinates = coordinates_by_hash
9886 .get(hash)
9887 .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
9888 let returned_coordinates = item
9889 .get("coordinates")
9890 .and_then(Value::as_array)
9891 .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
9892 if returned_coordinates.len() != expected_coordinates.len()
9893 || returned_coordinates
9894 .iter()
9895 .zip(expected_coordinates)
9896 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
9897 {
9898 return Err(invalid_feed(
9899 "proposal upload reservation changed its coordinates",
9900 ));
9901 }
9902 match item.get("status").and_then(Value::as_str) {
9903 Some("upload") => put_presigned(
9904 cfg,
9905 item.get("url")
9906 .and_then(Value::as_str)
9907 .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
9908 item.get("headers").unwrap_or(&Value::Null),
9909 bytes,
9910 )?,
9911 Some("already_present") => {}
9912 _ => return Err(invalid_feed("proposal upload status is invalid")),
9913 }
9914 references.push(json!({
9915 "sha256": hash,
9916 "bytes": bytes.len(),
9917 "reservation_id": reservation_id,
9918 }));
9919 }
9920 body["blobs"] = Value::Array(references);
9921 }
9922 if body.to_string().len() > MAX_PUSH_BYTES {
9923 return Err(LinkError::PushTooLarge {
9924 detail: "proposal operation metadata exceeds the commit request cap".to_string(),
9925 });
9926 }
9927 let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
9928 let mut result = ensure_ok(
9929 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9930 "exact proposal acceptance",
9931 )?;
9932 let mut candidate_hub_signer = None;
9933 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
9934 let challenge = result
9935 .get("signing_challenge")
9936 .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
9937 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
9938 cfg,
9939 &head,
9940 &expected_candidate,
9941 &expected_candidate_assets,
9942 mutation_id,
9943 &body,
9944 challenge,
9945 )?;
9946 body["signing_challenge_id"] = Value::String(challenge_id);
9947 body["signature_base64url"] = Value::String(signature);
9948 candidate_hub_signer = Some(actor_signer);
9949 result = ensure_ok(
9950 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
9951 "signed exact proposal acceptance",
9952 )?;
9953 }
9954 let refreshed = v2_verified_head(cfg, brain)?
9955 .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
9956 if candidate_hub_signer
9957 .as_ref()
9958 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
9959 || refreshed
9960 .pointer
9961 .as_ref()
9962 .map(|pointer| pointer.commit_hash.as_str())
9963 != result.get("commit_hash").and_then(Value::as_str)
9964 || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
9965 || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
9966 {
9967 return Err(LinkError::RemoteAdvancedDuringSync);
9968 }
9969 accept_v2_head(cfg, &refreshed)?;
9970 Ok(result)
9971}
9972
9973pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
9984 require_valid_handle(handle)?;
9985 if body.len() as u64 > MAX_PROPOSE_BYTES {
9986 return Err(LinkError::ProposeTooLarge {
9987 bytes: body.len() as u64,
9988 });
9989 }
9990 let payload = json!({ "app": app, "body": body });
9991 let (path, auth) = if crate::ulid::is_ulid(handle) {
9996 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
9997 } else {
9998 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
9999 };
10000 ensure_ok(
10001 request(cfg, "POST", &path, Some(&payload), auth)?,
10002 "propose",
10003 )
10004}
10005
10006#[derive(Debug, serde::Serialize)]
10012pub struct Head {
10013 pub brain: String,
10015 pub seq: u64,
10017 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
10019 pub updated_at: Option<String>,
10020 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
10022 pub feed_hash: Option<String>,
10023 pub verified: bool,
10026}
10027
10028struct BoundedVecVisitor<T, const MAX: usize> {
10029 label: &'static str,
10030 marker: std::marker::PhantomData<T>,
10031}
10032
10033impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
10034where
10035 T: Deserialize<'de>,
10036{
10037 type Value = Vec<T>;
10038
10039 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10040 write!(formatter, "at most {MAX} {}", self.label)
10041 }
10042
10043 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
10044 where
10045 A: serde::de::SeqAccess<'de>,
10046 {
10047 if sequence.size_hint().is_some_and(|size| size > MAX) {
10048 return Err(serde::de::Error::custom(format!(
10049 "{} exceeds the {MAX}-item limit",
10050 self.label
10051 )));
10052 }
10053 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
10054 while let Some(value) = sequence.next_element()? {
10055 if values.len() == MAX {
10056 return Err(serde::de::Error::custom(format!(
10057 "{} exceeds the {MAX}-item limit",
10058 self.label
10059 )));
10060 }
10061 values.push(value);
10062 }
10063 Ok(values)
10064 }
10065}
10066
10067fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
10068 deserializer: D,
10069 label: &'static str,
10070) -> Result<Vec<T>, D::Error>
10071where
10072 D: serde::Deserializer<'de>,
10073 T: Deserialize<'de>,
10074{
10075 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
10076 label,
10077 marker: std::marker::PhantomData,
10078 })
10079}
10080
10081fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
10082where
10083 D: serde::Deserializer<'de>,
10084{
10085 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
10086}
10087
10088fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
10089where
10090 D: serde::Deserializer<'de>,
10091{
10092 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
10093}
10094
10095fn deserialize_previous_identities<'de, D>(
10096 deserializer: D,
10097) -> Result<Vec<PreviousIdentity>, D::Error>
10098where
10099 D: serde::Deserializer<'de>,
10100{
10101 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
10102 deserializer,
10103 "previous identities",
10104 )
10105}
10106
10107fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
10108where
10109 D: serde::Deserializer<'de>,
10110{
10111 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
10112 deserializer,
10113 "rotation statements",
10114 )
10115}
10116
10117fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
10118where
10119 D: serde::Deserializer<'de>,
10120{
10121 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
10122}
10123
10124#[derive(Debug, Clone, Deserialize, Serialize)]
10125struct FeedFile {
10126 path: String,
10127 sha256: String,
10128 bytes: u64,
10129}
10130
10131#[cfg(test)]
10132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10133enum V1DisclosureError {
10134 DuplicateFile,
10135 DuplicateRemoved,
10136 PushManifestMismatch,
10137 EditMissingChange,
10138 EditFalseFile,
10139 RemovedMismatch,
10140}
10141
10142#[cfg(test)]
10146fn verify_v1_manifest_disclosure(
10147 kind: &str,
10148 previous: &[FeedFile],
10149 resulting: &[FeedFile],
10150 files: &[FeedFile],
10151 removed: &[String],
10152) -> Result<(), V1DisclosureError> {
10153 fn as_map(
10154 files: &[FeedFile],
10155 ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
10156 let mut result = std::collections::BTreeMap::new();
10157 for file in files {
10158 if result
10159 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10160 .is_some()
10161 {
10162 return Err(V1DisclosureError::DuplicateFile);
10163 }
10164 }
10165 Ok(result)
10166 }
10167 let previous = as_map(previous)?;
10168 let resulting = as_map(resulting)?;
10169 let disclosed = as_map(files)?;
10170 let removed_set: std::collections::BTreeSet<&str> =
10171 removed.iter().map(String::as_str).collect();
10172 if removed_set.len() != removed.len() {
10173 return Err(V1DisclosureError::DuplicateRemoved);
10174 }
10175 let expected_removed: std::collections::BTreeSet<&str> = previous
10176 .keys()
10177 .copied()
10178 .filter(|path| !resulting.contains_key(path))
10179 .collect();
10180 if removed_set != expected_removed {
10181 return Err(V1DisclosureError::RemovedMismatch);
10182 }
10183 if kind == "push" {
10184 return if disclosed == resulting {
10185 Ok(())
10186 } else {
10187 Err(V1DisclosureError::PushManifestMismatch)
10188 };
10189 }
10190 if kind != "edit" {
10191 return Err(V1DisclosureError::EditFalseFile);
10192 }
10193 if disclosed
10194 .iter()
10195 .any(|(path, value)| resulting.get(path) != Some(value))
10196 {
10197 return Err(V1DisclosureError::EditFalseFile);
10198 }
10199 for (path, value) in &resulting {
10200 if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
10201 return Err(V1DisclosureError::EditMissingChange);
10202 }
10203 }
10204 Ok(())
10205}
10206
10207#[derive(Debug, Clone, Deserialize, Serialize)]
10208struct FeedEntry {
10209 v: u8,
10210 seq: u64,
10211 ts: String,
10212 brain: String,
10213 public_key: String,
10214 kind: String,
10215 op: String,
10216 pack_sha256: String,
10217 #[serde(deserialize_with = "deserialize_feed_files")]
10218 files: Vec<FeedFile>,
10219 #[serde(deserialize_with = "deserialize_removed_paths")]
10220 removed: Vec<String>,
10221 prev_entry_hash: Option<String>,
10222 sig: String,
10223}
10224
10225#[derive(Serialize)]
10226struct UnsignedFeedEntry<'a> {
10227 v: u8,
10228 seq: u64,
10229 ts: &'a str,
10230 brain: &'a str,
10231 public_key: &'a str,
10232 kind: &'a str,
10233 op: &'a str,
10234 pack_sha256: &'a str,
10235 files: &'a [FeedFile],
10236 removed: &'a [String],
10237 prev_entry_hash: &'a Option<String>,
10238}
10239
10240#[derive(Debug, Clone, Deserialize, Serialize)]
10241struct FeedItem {
10242 hash: String,
10243 entry: FeedEntry,
10244}
10245
10246#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
10247struct FeedIdentity {
10248 fingerprint: String,
10249 #[serde(rename = "publicKeySpki")]
10250 public_key_spki: String,
10251 #[serde(default, deserialize_with = "deserialize_previous_identities")]
10255 previous: Vec<PreviousIdentity>,
10256 #[serde(default, deserialize_with = "deserialize_rotations")]
10259 rotations: Vec<String>,
10260}
10261
10262#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
10263struct PreviousIdentity {
10264 fingerprint: String,
10265 #[serde(rename = "publicKeySpki")]
10266 public_key_spki: String,
10267}
10268
10269#[derive(Debug, Deserialize)]
10270struct FeedResponse {
10271 #[serde(rename = "headSeq")]
10272 head_seq: u64,
10273 #[serde(rename = "feedHash")]
10274 feed_hash: Option<String>,
10275 identity: Option<FeedIdentity>,
10276 #[serde(deserialize_with = "deserialize_feed_items")]
10277 entries: Vec<FeedItem>,
10278 #[serde(rename = "scopeLimited")]
10279 scope_limited: bool,
10280}
10281
10282#[derive(Debug, Deserialize, Serialize)]
10283#[serde(deny_unknown_fields)]
10284struct RotationStatement {
10285 v: u8,
10286 op: String,
10287 brain: String,
10288 public_key: String,
10289 new_brain: String,
10290 new_public_key: String,
10291 prior_head_seq: u64,
10292 prior_feed_hash: Option<String>,
10293 ts: String,
10294 sig: String,
10295}
10296
10297#[derive(Debug, Clone, Deserialize, Serialize)]
10298struct TrustState {
10299 v: u8,
10300 origin: String,
10301 #[serde(default)]
10305 requested: String,
10306 brain: String,
10308 #[serde(default, skip_serializing_if = "Option::is_none")]
10311 home: Option<String>,
10312 anchor: String,
10313 current: String,
10314 #[serde(rename = "headSeq")]
10315 head_seq: u64,
10316 #[serde(rename = "feedHash")]
10317 feed_hash: Option<String>,
10318 #[serde(default)]
10322 rotations: Vec<String>,
10323 #[serde(default, skip_serializing_if = "Option::is_none")]
10326 hub_signer: Option<String>,
10327 #[serde(default, skip_serializing_if = "Option::is_none")]
10330 protocol_profile: Option<String>,
10331}
10332
10333fn accepted_as_v2(state: &TrustState) -> bool {
10334 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
10335}
10336
10337fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
10338 let directory = open_trust_dir(cfg)?;
10339 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
10340 return Ok(true);
10341 }
10342 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
10343 return Ok(false);
10344 };
10345 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
10346}
10347
10348#[derive(Debug, Clone, Deserialize, Serialize)]
10349struct AliasBinding {
10350 v: u8,
10351 origin: String,
10352 requested: String,
10353 brain: String,
10354 #[serde(default, skip_serializing_if = "Option::is_none")]
10355 home: Option<String>,
10356}
10357
10358struct VerifiedRemote {
10359 head: Head,
10360 identity: Option<FeedIdentity>,
10361 head_entry: Option<FeedItem>,
10362 entries: Vec<FeedItem>,
10364 anchor: Option<String>,
10365}
10366
10367fn invalid_feed(message: impl Into<String>) -> LinkError {
10368 LinkError::InvalidFeed {
10369 message: message.into(),
10370 }
10371}
10372
10373fn is_sha256(value: &str) -> bool {
10374 value.len() == 64
10375 && value
10376 .bytes()
10377 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
10378}
10379
10380fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
10381 let der = URL_SAFE_NO_PAD
10382 .decode(public_key_spki)
10383 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
10384 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
10385 return Err(invalid_feed(
10386 "identity public key is not a valid Ed25519 SPKI",
10387 ));
10388 }
10389 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
10390}
10391
10392fn verify_identity_chain(
10396 identity: &FeedIdentity,
10397 pinned: Option<&TrustState>,
10398) -> LinkResult<String> {
10399 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
10400 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
10401 {
10402 return Err(invalid_feed(
10403 "identity rotation history exceeds the client cap",
10404 ));
10405 }
10406 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
10407 return Err(invalid_feed(
10408 "current identity fingerprint does not match its public key",
10409 ));
10410 }
10411 for previous in &identity.previous {
10412 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
10413 return Err(invalid_feed(
10414 "previous identity fingerprint does not match its public key",
10415 ));
10416 }
10417 }
10418 if identity.rotations.len() != identity.previous.len() {
10419 return Err(invalid_feed(
10420 "identity history is missing an old-key-signed rotation statement",
10421 ));
10422 }
10423
10424 let mut chain: Vec<(&str, &str)> = identity
10428 .previous
10429 .iter()
10430 .rev()
10431 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
10432 .collect();
10433 chain.push((&identity.fingerprint, &identity.public_key_spki));
10434
10435 for (index, raw) in identity.rotations.iter().enumerate() {
10436 let statement: RotationStatement = serde_json::from_str(raw)
10437 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
10438 let (old_fingerprint, old_spki) = chain[index];
10439 let (new_fingerprint, new_spki) = chain[index + 1];
10440 if statement.v != 1
10441 || statement.op != "rotate"
10442 || statement.brain != format!("ed25519:{old_fingerprint}")
10443 || statement.public_key != old_spki
10444 || statement.new_brain != format!("ed25519:{new_fingerprint}")
10445 || statement.new_public_key != new_spki
10446 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
10447 || (statement.prior_head_seq > 0
10448 && statement
10449 .prior_feed_hash
10450 .as_deref()
10451 .is_none_or(|hash| !is_sha256(hash)))
10452 {
10453 return Err(invalid_feed(
10454 "rotation statement does not connect adjacent identities",
10455 ));
10456 }
10457 let unsigned = serde_json::to_string(&UnsignedRotation {
10458 v: statement.v,
10459 op: &statement.op,
10460 brain: &statement.brain,
10461 public_key: &statement.public_key,
10462 new_brain: &statement.new_brain,
10463 new_public_key: &statement.new_public_key,
10464 prior_head_seq: statement.prior_head_seq,
10465 prior_feed_hash: statement.prior_feed_hash.as_deref(),
10466 ts: statement.ts.clone(),
10467 })
10468 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
10469 let exact = format!(
10470 "{},\"sig\":\"{}\"}}",
10471 &unsigned[..unsigned.len() - 1],
10472 statement.sig
10473 );
10474 if exact != *raw {
10475 return Err(invalid_feed(
10476 "rotation statement is not in normative serialization",
10477 ));
10478 }
10479 let der = URL_SAFE_NO_PAD
10480 .decode(old_spki)
10481 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
10482 let signature = URL_SAFE_NO_PAD
10483 .decode(&statement.sig)
10484 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
10485 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
10486 .verify(unsigned.as_bytes(), &signature)
10487 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
10488 if index > 0 {
10489 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
10490 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
10491 if statement.prior_head_seq < prior.prior_head_seq {
10492 return Err(invalid_feed("rotation feed boundaries move backward"));
10493 }
10494 }
10495 }
10496
10497 let anchor = format!("ed25519:{}", chain[0].0);
10498 let current = format!("ed25519:{}", identity.fingerprint);
10499 if let Some(pin) = pinned {
10500 if pin.anchor != anchor {
10501 return Err(invalid_feed(
10502 "served identity chain does not descend from the pinned anchor",
10503 ));
10504 }
10505 if !chain
10506 .iter()
10507 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
10508 {
10509 return Err(invalid_feed(
10510 "served identity chain forked away from the last pinned identity",
10511 ));
10512 }
10513 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
10514 return Err(invalid_feed("served identity discarded its rotation chain"));
10515 }
10516 if pin.v >= 2
10517 && (identity.rotations.len() < pin.rotations.len()
10518 || identity.rotations[..pin.rotations.len()] != pin.rotations)
10519 {
10520 return Err(invalid_feed(
10521 "served identity rewrote the locally accepted rotation history",
10522 ));
10523 }
10524 }
10525 Ok(anchor)
10526}
10527
10528fn verify_rotation_feed_boundaries(
10529 identity: &FeedIdentity,
10530 pinned: Option<&TrustState>,
10531 observed: &[FeedItem],
10532 advertised_seq: u64,
10533) -> LinkResult<()> {
10534 let mut chain: Vec<String> = identity
10535 .previous
10536 .iter()
10537 .rev()
10538 .map(|previous| format!("ed25519:{}", previous.fingerprint))
10539 .collect();
10540 chain.push(format!("ed25519:{}", identity.fingerprint));
10541 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
10542
10543 for (index, raw) in identity.rotations.iter().enumerate() {
10544 let rotation: RotationStatement = serde_json::from_str(raw)
10545 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
10546 if rotation.prior_head_seq > advertised_seq {
10547 return Err(invalid_feed(
10548 "rotation claims a feed boundary beyond the advertised head",
10549 ));
10550 }
10551 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
10552 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
10553 return Err(invalid_feed(
10554 "newly disclosed rotation predates the local feed checkpoint",
10555 ));
10556 }
10557 }
10558 let actual = if rotation.prior_head_seq == 0 {
10559 None
10560 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
10561 pinned.and_then(|pin| pin.feed_hash.as_deref())
10562 } else {
10563 observed
10564 .iter()
10565 .find(|item| item.entry.seq == rotation.prior_head_seq)
10566 .map(|item| item.hash.as_str())
10567 };
10568 if let Some(actual) = actual {
10569 if rotation.prior_feed_hash.as_deref() != Some(actual) {
10570 return Err(invalid_feed(
10571 "rotation statement does not commit the verified feed boundary",
10572 ));
10573 }
10574 } else if rotation.prior_head_seq == 0 {
10575 } else if pinned.is_some_and(|pin| {
10578 pinned_index.is_some_and(|pin_index| index >= pin_index)
10579 || rotation.prior_head_seq >= pin.head_seq
10580 }) {
10581 return Err(invalid_feed(
10582 "rotation feed boundary was not present in the verified chain",
10583 ));
10584 }
10585 }
10586 Ok(())
10587}
10588
10589fn reject_retired_signer_after_checkpoint(
10594 identity: &FeedIdentity,
10595 pinned: Option<&TrustState>,
10596 item: &FeedItem,
10597) -> LinkResult<()> {
10598 let Some(pin) = pinned else {
10599 return Ok(());
10600 };
10601 if item.entry.seq <= pin.head_seq {
10602 return Ok(());
10603 }
10604 let mut chain: Vec<String> = identity
10605 .previous
10606 .iter()
10607 .rev()
10608 .map(|previous| format!("ed25519:{}", previous.fingerprint))
10609 .collect();
10610 chain.push(format!("ed25519:{}", identity.fingerprint));
10611 let pinned_index = chain
10612 .iter()
10613 .position(|key| key == &pin.current)
10614 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
10615 let signer_index = chain
10616 .iter()
10617 .position(|key| key == &item.entry.brain)
10618 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
10619 if signer_index < pinned_index {
10620 return Err(invalid_feed(
10621 "a retired identity attempted to sign after the local checkpoint",
10622 ));
10623 }
10624 Ok(())
10625}
10626
10627fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
10628 let origin = normalized_origin(&cfg.hub)?;
10629 let key = format!(
10630 "{:x}",
10631 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
10632 );
10633 Ok(format!("{key}.json"))
10634}
10635
10636fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
10637 let origin = normalized_origin(&cfg.hub)?;
10638 let key = format!(
10639 "{:x}",
10640 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
10641 );
10642 Ok(format!("alias-{key}.json"))
10643}
10644
10645#[cfg(any(unix, windows))]
10646struct TrustLock {
10647 _file: std::fs::File,
10648}
10649
10650#[cfg(unix)]
10651fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
10652 use std::os::fd::{AsRawFd as _, FromRawFd as _};
10653
10654 let lock_string = format!(".{state_name}.lock");
10655 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
10656 let fd = unsafe {
10657 libc::openat(
10658 directory.as_raw_fd(),
10659 lock_name.as_ptr(),
10660 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10661 0o600,
10662 )
10663 };
10664 if fd < 0 {
10665 return Err(std::io::Error::last_os_error().into());
10666 }
10667 let file = unsafe { std::fs::File::from_raw_fd(fd) };
10668 if !file.metadata()?.is_file() {
10669 return Err(LinkError::UnsafePath { path: lock_string });
10670 }
10671 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
10672 return Err(std::io::Error::last_os_error().into());
10673 }
10674 Ok(TrustLock { _file: file })
10675}
10676
10677#[cfg(windows)]
10678fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
10679 let lock_name = format!(".{state_name}.lock");
10680 let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
10681 Ok(TrustLock { _file: file })
10682}
10683
10684#[cfg(any(unix, windows))]
10685fn lock_trust_many(
10686 cfg: &HubConfig,
10687 directory: &std::fs::File,
10688 refs: &[&str],
10689) -> LinkResult<Vec<TrustLock>> {
10690 let mut names = refs
10691 .iter()
10692 .map(|reference| trust_file_name(cfg, reference))
10693 .collect::<LinkResult<Vec<_>>>()?;
10694 names.sort();
10695 names.dedup();
10696 names
10697 .iter()
10698 .map(|name| lock_trust_name(directory, name))
10699 .collect()
10700}
10701
10702#[cfg(not(any(unix, windows)))]
10703fn lock_trust_many(
10704 _cfg: &HubConfig,
10705 _directory: &TrustDirectory,
10706 _refs: &[&str],
10707) -> LinkResult<Vec<()>> {
10708 Err(LinkError::UnsupportedPlatform {
10709 operation: "verified link.md state",
10710 })
10711}
10712
10713#[cfg(any(unix, windows))]
10714type TrustDirectory = std::fs::File;
10715
10716#[cfg(not(any(unix, windows)))]
10717struct TrustDirectory;
10718
10719#[cfg(unix)]
10720fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
10721 use std::os::fd::AsRawFd as _;
10722
10723 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
10724 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
10725 return Err(std::io::Error::last_os_error().into());
10726 }
10727 directory.sync_all()?;
10728 Ok(directory)
10729}
10730
10731#[cfg(windows)]
10732fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
10733 let marker = cfg.state_dir.join("trust").join(".directory");
10734 crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
10735 Ok(crate::fsx::open_directory_nofollow(
10736 marker.parent().expect("trust marker has a parent"),
10737 )?)
10738}
10739
10740#[cfg(not(any(unix, windows)))]
10741fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
10742 Err(LinkError::UnsupportedPlatform {
10743 operation: "verified link.md state",
10744 })
10745}
10746
10747#[cfg(unix)]
10748fn load_trust_in(
10749 cfg: &HubConfig,
10750 directory: &TrustDirectory,
10751 requested: &str,
10752) -> LinkResult<Option<TrustState>> {
10753 use std::os::fd::{AsRawFd as _, FromRawFd as _};
10754
10755 let name_string = trust_file_name(cfg, requested)?;
10756 let name = c_name(name_string.as_bytes(), &name_string)?;
10757 let fd = unsafe {
10758 libc::openat(
10759 directory.as_raw_fd(),
10760 name.as_ptr(),
10761 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10762 )
10763 };
10764 if fd < 0 {
10765 let error = std::io::Error::last_os_error();
10766 if error.kind() == std::io::ErrorKind::NotFound {
10767 return Ok(None);
10768 }
10769 return Err(LinkError::UnsafePath { path: name_string });
10770 }
10771 let file = unsafe { std::fs::File::from_raw_fd(fd) };
10772 if !file.metadata()?.is_file() {
10773 return Err(LinkError::UnsafePath { path: name_string });
10774 }
10775 let mut bytes = Vec::new();
10776 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
10777 if bytes.len() > 1024 * 1024 {
10778 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
10779 }
10780 let mut state: TrustState = serde_json::from_slice(&bytes)
10781 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
10782 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
10783 return Err(invalid_feed(
10784 "local identity/feed checkpoint does not match this hub and brain",
10785 ));
10786 }
10787 if state.v == 1 {
10788 if state.brain != requested {
10792 return Err(invalid_feed(
10793 "legacy checkpoint is not bound to the requested brain id",
10794 ));
10795 }
10796 state.requested = requested.to_string();
10797 } else if state.requested != requested {
10798 return Err(invalid_feed(
10799 "local identity/feed checkpoint is bound to a different requested ref",
10800 ));
10801 }
10802 Ok(Some(state))
10803}
10804
10805#[cfg(windows)]
10806fn load_trust_in(
10807 cfg: &HubConfig,
10808 directory: &TrustDirectory,
10809 requested: &str,
10810) -> LinkResult<Option<TrustState>> {
10811 let name = trust_file_name(cfg, requested)?;
10812 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
10813 let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
10814 Ok(bytes) => bytes,
10815 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10816 Err(_) => return Err(LinkError::UnsafePath { path: name }),
10817 };
10818 let mut state: TrustState = serde_json::from_slice(&bytes)
10819 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
10820 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
10821 return Err(invalid_feed(
10822 "local identity/feed checkpoint does not match this hub and brain",
10823 ));
10824 }
10825 if state.v == 1 {
10826 if state.brain != requested {
10827 return Err(invalid_feed(
10828 "legacy checkpoint is not bound to the requested brain id",
10829 ));
10830 }
10831 state.requested = requested.to_string();
10832 } else if state.requested != requested {
10833 return Err(invalid_feed(
10834 "local identity/feed checkpoint is bound to a different requested ref",
10835 ));
10836 }
10837 Ok(Some(state))
10838}
10839
10840#[cfg(not(any(unix, windows)))]
10841fn load_trust_in(
10842 _cfg: &HubConfig,
10843 _directory: &TrustDirectory,
10844 _brain: &str,
10845) -> LinkResult<Option<TrustState>> {
10846 Err(LinkError::UnsupportedPlatform {
10847 operation: "verified link.md state",
10848 })
10849}
10850
10851#[cfg(all(test, any(unix, windows)))]
10852fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
10853 let directory = open_trust_dir(cfg)?;
10854 load_trust_in(cfg, &directory, requested)
10855}
10856
10857#[cfg(unix)]
10858fn save_trust_in(
10859 cfg: &HubConfig,
10860 directory: &TrustDirectory,
10861 state: &TrustState,
10862) -> LinkResult<()> {
10863 use std::os::fd::{AsRawFd as _, FromRawFd as _};
10864
10865 let name_string = trust_file_name(cfg, &state.requested)?;
10866 let name = c_name(name_string.as_bytes(), &name_string)?;
10867 let mut bytes = serde_json::to_vec(state)
10868 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
10869 bytes.push(b'\n');
10870
10871 let nonce = std::time::SystemTime::now()
10872 .duration_since(std::time::UNIX_EPOCH)
10873 .unwrap_or_default()
10874 .as_nanos();
10875 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
10876 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
10877 let fd = unsafe {
10878 libc::openat(
10879 directory.as_raw_fd(),
10880 temp.as_ptr(),
10881 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10882 0o600,
10883 )
10884 };
10885 if fd < 0 {
10886 return Err(std::io::Error::last_os_error().into());
10887 }
10888 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
10889 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
10890 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10891 return Err(error.into());
10892 }
10893 drop(file);
10894 if unsafe {
10895 libc::renameat(
10896 directory.as_raw_fd(),
10897 temp.as_ptr(),
10898 directory.as_raw_fd(),
10899 name.as_ptr(),
10900 )
10901 } != 0
10902 {
10903 let error = std::io::Error::last_os_error();
10904 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10905 return Err(error.into());
10906 }
10907 directory.sync_all()?;
10908 Ok(())
10909}
10910
10911#[cfg(windows)]
10912fn save_trust_in(
10913 cfg: &HubConfig,
10914 directory: &TrustDirectory,
10915 state: &TrustState,
10916) -> LinkResult<()> {
10917 let name = trust_file_name(cfg, &state.requested)?;
10918 let mut bytes = serde_json::to_vec(state)
10919 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
10920 bytes.push(b'\n');
10921 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
10922 Ok(())
10923}
10924
10925#[cfg(not(any(unix, windows)))]
10926fn save_trust_in(
10927 _cfg: &HubConfig,
10928 _directory: &TrustDirectory,
10929 _state: &TrustState,
10930) -> LinkResult<()> {
10931 Err(LinkError::UnsupportedPlatform {
10932 operation: "verified link.md state",
10933 })
10934}
10935
10936#[cfg(unix)]
10937fn load_alias_in(
10938 cfg: &HubConfig,
10939 directory: &TrustDirectory,
10940 requested: &str,
10941) -> LinkResult<Option<AliasBinding>> {
10942 use std::os::fd::{AsRawFd as _, FromRawFd as _};
10943
10944 let name_string = alias_file_name(cfg, requested)?;
10945 let name = c_name(name_string.as_bytes(), &name_string)?;
10946 let fd = unsafe {
10947 libc::openat(
10948 directory.as_raw_fd(),
10949 name.as_ptr(),
10950 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10951 )
10952 };
10953 if fd < 0 {
10954 let error = std::io::Error::last_os_error();
10955 if error.kind() == std::io::ErrorKind::NotFound {
10956 return Ok(None);
10957 }
10958 return Err(LinkError::UnsafePath { path: name_string });
10959 }
10960 let file = unsafe { std::fs::File::from_raw_fd(fd) };
10961 if !file.metadata()?.is_file() {
10962 return Err(LinkError::UnsafePath { path: name_string });
10963 }
10964 let mut bytes = Vec::new();
10965 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
10966 if bytes.len() > 64 * 1024 {
10967 return Err(invalid_feed("local alias binding is oversized"));
10968 }
10969 let alias: AliasBinding = serde_json::from_slice(&bytes)
10970 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
10971 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
10972 {
10973 return Err(invalid_feed(
10974 "local alias binding does not match this hub and requested ref",
10975 ));
10976 }
10977 Ok(Some(alias))
10978}
10979
10980#[cfg(windows)]
10981fn load_alias_in(
10982 cfg: &HubConfig,
10983 directory: &TrustDirectory,
10984 requested: &str,
10985) -> LinkResult<Option<AliasBinding>> {
10986 let name = alias_file_name(cfg, requested)?;
10987 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
10988 let bytes = match reader.read(Path::new(&name), 64 * 1024) {
10989 Ok(bytes) => bytes,
10990 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10991 Err(_) => return Err(LinkError::UnsafePath { path: name }),
10992 };
10993 let alias: AliasBinding = serde_json::from_slice(&bytes)
10994 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
10995 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
10996 {
10997 return Err(invalid_feed(
10998 "local alias binding does not match this hub and requested ref",
10999 ));
11000 }
11001 Ok(Some(alias))
11002}
11003
11004#[cfg(not(any(unix, windows)))]
11005fn load_alias_in(
11006 _cfg: &HubConfig,
11007 _directory: &TrustDirectory,
11008 _requested: &str,
11009) -> LinkResult<Option<AliasBinding>> {
11010 Err(LinkError::UnsupportedPlatform {
11011 operation: "verified link.md state",
11012 })
11013}
11014
11015#[cfg(unix)]
11016fn save_alias_in(
11017 cfg: &HubConfig,
11018 directory: &TrustDirectory,
11019 alias: &AliasBinding,
11020) -> LinkResult<()> {
11021 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11022
11023 let name_string = alias_file_name(cfg, &alias.requested)?;
11024 let name = c_name(name_string.as_bytes(), &name_string)?;
11025 let mut bytes = serde_json::to_vec(alias)
11026 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
11027 bytes.push(b'\n');
11028 let nonce = std::time::SystemTime::now()
11029 .duration_since(std::time::UNIX_EPOCH)
11030 .unwrap_or_default()
11031 .as_nanos();
11032 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
11033 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
11034 let fd = unsafe {
11035 libc::openat(
11036 directory.as_raw_fd(),
11037 temp.as_ptr(),
11038 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11039 0o600,
11040 )
11041 };
11042 if fd < 0 {
11043 return Err(std::io::Error::last_os_error().into());
11044 }
11045 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
11046 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
11047 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11048 return Err(error.into());
11049 }
11050 drop(file);
11051 if unsafe {
11052 libc::renameat(
11053 directory.as_raw_fd(),
11054 temp.as_ptr(),
11055 directory.as_raw_fd(),
11056 name.as_ptr(),
11057 )
11058 } != 0
11059 {
11060 let error = std::io::Error::last_os_error();
11061 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
11062 return Err(error.into());
11063 }
11064 directory.sync_all()?;
11065 Ok(())
11066}
11067
11068#[cfg(windows)]
11069fn save_alias_in(
11070 cfg: &HubConfig,
11071 directory: &TrustDirectory,
11072 alias: &AliasBinding,
11073) -> LinkResult<()> {
11074 let name = alias_file_name(cfg, &alias.requested)?;
11075 let mut bytes = serde_json::to_vec(alias)
11076 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
11077 bytes.push(b'\n');
11078 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
11079 Ok(())
11080}
11081
11082#[cfg(not(any(unix, windows)))]
11083fn save_alias_in(
11084 _cfg: &HubConfig,
11085 _directory: &TrustDirectory,
11086 _alias: &AliasBinding,
11087) -> LinkResult<()> {
11088 Err(LinkError::UnsupportedPlatform {
11089 operation: "verified link.md state",
11090 })
11091}
11092
11093fn load_canonical_pin(
11098 cfg: &HubConfig,
11099 directory: &TrustDirectory,
11100 requested: &str,
11101 resolved_brain: &str,
11102) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
11103 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
11104 if requested == resolved_brain {
11105 return Ok((canonical, None));
11106 }
11107
11108 let mut alias = load_alias_in(cfg, directory, requested)?;
11109 if let Some(binding) = &alias {
11110 if binding.brain != resolved_brain {
11111 return Err(invalid_feed(
11112 "requested brain alias now resolves to a different canonical brain",
11113 ));
11114 }
11115 return Ok((canonical, alias));
11116 }
11117
11118 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
11122 if legacy.brain != resolved_brain {
11123 return Err(invalid_feed(
11124 "legacy alias checkpoint names a different canonical brain",
11125 ));
11126 }
11127 if let Some(existing) = &canonical {
11128 if existing.brain != legacy.brain
11129 || existing.anchor != legacy.anchor
11130 || existing.current != legacy.current
11131 || existing.head_seq != legacy.head_seq
11132 || existing.feed_hash != legacy.feed_hash
11133 || existing.rotations != legacy.rotations
11134 {
11135 return Err(invalid_feed(
11136 "legacy alias checkpoint conflicts with the canonical checkpoint",
11137 ));
11138 }
11139 } else {
11140 let mut promoted = legacy.clone();
11141 promoted.requested = resolved_brain.to_string();
11142 promoted.home = None;
11143 save_trust_in(cfg, directory, &promoted)?;
11144 canonical = Some(promoted);
11145 }
11146 alias = Some(AliasBinding {
11147 v: 1,
11148 origin: normalized_origin(&cfg.hub)?,
11149 requested: requested.to_string(),
11150 brain: resolved_brain.to_string(),
11151 home: legacy.home,
11152 });
11153 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
11154 }
11155 Ok((canonical, alias))
11156}
11157
11158fn save_canonical_pin_and_alias(
11159 cfg: &HubConfig,
11160 directory: &TrustDirectory,
11161 requested: &str,
11162 resolved_brain: &str,
11163 mut state: TrustState,
11164 existing_alias: Option<&AliasBinding>,
11165) -> LinkResult<()> {
11166 state.requested = resolved_brain.to_string();
11167 state.brain = resolved_brain.to_string();
11168 state.home = None;
11169 save_trust_in(cfg, directory, &state)?;
11170 if requested != resolved_brain {
11171 save_alias_in(
11172 cfg,
11173 directory,
11174 &AliasBinding {
11175 v: 1,
11176 origin: normalized_origin(&cfg.hub)?,
11177 requested: requested.to_string(),
11178 brain: resolved_brain.to_string(),
11179 home: existing_alias.and_then(|alias| alias.home.clone()),
11180 },
11181 )?;
11182 }
11183 Ok(())
11184}
11185
11186fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
11187 const ED25519_SPKI_PREFIX: &[u8] = &[
11188 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
11189 ];
11190 let entry = &item.entry;
11191 let public_der = URL_SAFE_NO_PAD
11192 .decode(&entry.public_key)
11193 .map_err(|_| invalid_feed("public key is not base64url"))?;
11194 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
11195 || !public_der.starts_with(ED25519_SPKI_PREFIX)
11196 {
11197 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
11198 }
11199 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
11200 if entry.brain != format!("ed25519:{fingerprint}") {
11201 return Err(invalid_feed(
11202 "brain fingerprint does not match its public key",
11203 ));
11204 }
11205 let _ = verify_identity_chain(identity, None)?;
11207 let mut chain: Vec<(&str, &str)> = identity
11208 .previous
11209 .iter()
11210 .rev()
11211 .map(|previous| {
11212 (
11213 previous.fingerprint.as_str(),
11214 previous.public_key_spki.as_str(),
11215 )
11216 })
11217 .collect();
11218 chain.push((&identity.fingerprint, &identity.public_key_spki));
11219 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
11220 *known_fingerprint == fingerprint && *spki == entry.public_key
11221 });
11222 let Some(signer_index) = signer_index else {
11223 return Err(invalid_feed(
11224 "entry signer is not this brain's identity (current or rotated-from)",
11225 ));
11226 };
11227 let lower_boundary = if signer_index == 0 {
11228 None
11229 } else {
11230 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
11231 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
11232 Some(prior.prior_head_seq)
11233 };
11234 let upper_boundary = if signer_index == identity.rotations.len() {
11235 None
11236 } else {
11237 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
11238 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
11239 Some(next.prior_head_seq)
11240 };
11241 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
11242 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
11243 {
11244 return Err(invalid_feed(
11245 "entry signer is outside its authenticated rotation epoch",
11246 ));
11247 }
11248 let unsigned = UnsignedFeedEntry {
11249 v: entry.v,
11250 seq: entry.seq,
11251 ts: &entry.ts,
11252 brain: &entry.brain,
11253 public_key: &entry.public_key,
11254 kind: &entry.kind,
11255 op: &entry.op,
11256 pack_sha256: &entry.pack_sha256,
11257 files: &entry.files,
11258 removed: &entry.removed,
11259 prev_entry_hash: &entry.prev_entry_hash,
11260 };
11261 let message =
11262 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
11263 let signature = URL_SAFE_NO_PAD
11264 .decode(&entry.sig)
11265 .map_err(|_| invalid_feed("signature is not base64url"))?;
11266 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
11267 .verify(&message, &signature)
11268 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
11269
11270 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
11271 exact.push(b'\n');
11272 let actual_hash = format!("{:x}", Sha256::digest(&exact));
11273 if actual_hash != item.hash {
11274 return Err(invalid_feed("entry SHA-256 does not match"));
11275 }
11276 Ok(())
11277}
11278
11279#[derive(Serialize)]
11285struct UnsignedRotation<'a> {
11286 v: u8,
11287 op: &'a str,
11288 brain: &'a str,
11289 public_key: &'a str,
11290 new_brain: &'a str,
11291 new_public_key: &'a str,
11292 prior_head_seq: u64,
11293 prior_feed_hash: Option<&'a str>,
11294 ts: String,
11295}
11296
11297#[derive(Debug, Deserialize, Serialize)]
11302#[serde(deny_unknown_fields)]
11303struct RotationJournal {
11304 v: u8,
11305 origin: String,
11306 brain: String,
11307 old_brain: String,
11308 new_brain: String,
11309 prior_head_seq: u64,
11310 prior_feed_hash: Option<String>,
11311 statement: String,
11312}
11313
11314fn rotation_journal_path(key_path: &Path) -> PathBuf {
11315 let mut path = key_path.as_os_str().to_os_string();
11316 path.push(".rotation.json");
11317 PathBuf::from(path)
11318}
11319
11320fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
11321 #[cfg(unix)]
11322 let file = {
11323 use std::os::fd::{AsRawFd as _, FromRawFd as _};
11324 use std::os::unix::ffi::OsStrExt as _;
11325 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
11326 .map_err(|error| {
11327 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
11328 })?;
11329 let leaf_name = path
11330 .file_name()
11331 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
11332 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
11333 let fd = unsafe {
11334 libc::openat(
11335 parent.as_raw_fd(),
11336 leaf.as_ptr(),
11337 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
11338 )
11339 };
11340 if fd < 0 {
11341 return Err(bad_agent_key(
11342 "the rotation journal must be an existing regular file without symlink ancestors",
11343 ));
11344 }
11345 unsafe { std::fs::File::from_raw_fd(fd) }
11346 };
11347 #[cfg(not(unix))]
11348 let file = std::fs::File::open(path)
11349 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
11350 let metadata = file
11351 .metadata()
11352 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
11353 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
11354 return Err(bad_agent_key(
11355 "the rotation journal must be a bounded regular file",
11356 ));
11357 }
11358 #[cfg(unix)]
11359 {
11360 use std::os::unix::fs::PermissionsExt as _;
11361 if metadata.permissions().mode() & 0o077 != 0 {
11362 return Err(bad_agent_key(
11363 "the rotation journal is accessible to group/other; set mode 0600",
11364 ));
11365 }
11366 }
11367 serde_json::from_reader(file)
11368 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
11369}
11370
11371fn remove_rotation_journal(path: &Path) {
11372 #[cfg(unix)]
11373 {
11374 use std::os::fd::AsRawFd as _;
11375 use std::os::unix::ffi::OsStrExt as _;
11376 let Ok(parent) =
11377 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
11378 else {
11379 return;
11380 };
11381 let Some(leaf_name) = path.file_name() else {
11382 return;
11383 };
11384 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
11385 return;
11386 };
11387 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
11388 let _ = parent.sync_all();
11389 }
11390 }
11391 #[cfg(not(unix))]
11392 {
11393 let _ = std::fs::remove_file(path);
11394 }
11395}
11396
11397fn validate_rotation_journal(
11398 journal: &RotationJournal,
11399 cfg: &HubConfig,
11400 canonical_brain: &str,
11401 old_key: &AgentSigningKey,
11402 new_key: &AgentSigningKey,
11403 head: &Head,
11404) -> LinkResult<()> {
11405 if journal.v != 1
11406 || journal.origin != normalized_origin(&cfg.hub)?
11407 || journal.brain != canonical_brain
11408 || journal.old_brain != old_key.multikey
11409 || journal.new_brain != new_key.multikey
11410 || journal.prior_head_seq != head.seq
11411 || journal.prior_feed_hash != head.feed_hash
11412 {
11413 return Err(invalid_feed(
11414 "rotation journal does not match the verified key and feed boundary",
11415 ));
11416 }
11417 let statement: RotationStatement = serde_json::from_str(&journal.statement)
11418 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
11419 if statement.prior_head_seq != journal.prior_head_seq
11420 || statement.prior_feed_hash != journal.prior_feed_hash
11421 || statement.brain != old_key.multikey
11422 || statement.public_key != old_key.public_key_spki
11423 || statement.new_brain != new_key.multikey
11424 || statement.new_public_key != new_key.public_key_spki
11425 {
11426 return Err(invalid_feed(
11427 "rotation journal statement does not match its durable intent",
11428 ));
11429 }
11430 let identity = FeedIdentity {
11431 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
11432 public_key_spki: new_key.public_key_spki.clone(),
11433 previous: vec![PreviousIdentity {
11434 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
11435 public_key_spki: old_key.public_key_spki.clone(),
11436 }],
11437 rotations: vec![journal.statement.clone()],
11438 };
11439 verify_identity_chain(&identity, None)?;
11440 Ok(())
11441}
11442
11443#[derive(Debug, Serialize)]
11445pub struct RotationReport {
11446 pub brain: String,
11448 pub multikey: String,
11450 #[serde(rename = "keyFile")]
11452 pub key_file: String,
11453 pub previous: Vec<String>,
11455}
11456
11457pub fn rotate_brain_key(
11463 cfg: &HubConfig,
11464 brain: &str,
11465 old_key: &AgentSigningKey,
11466 out: &Path,
11467) -> LinkResult<RotationReport> {
11468 require_hardened_filesystem("key rotation")?;
11469 require_safe_ref(brain)?;
11470 let new_key = if out.exists() {
11474 load_signing_key(out)?
11475 } else {
11476 let rng = ring::rand::SystemRandom::new();
11477 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
11478 .map_err(|_| bad_agent_key("key generation failed"))?;
11479 let pair = agent_keypair(pkcs8.as_ref())?;
11480 let (public_key_spki, multikey) = public_identity_for(&pair);
11481 write_secret_new(
11482 out,
11483 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
11484 )?;
11485 AgentSigningKey {
11486 pkcs8: pkcs8.as_ref().to_vec(),
11487 multikey,
11488 public_key_spki,
11489 }
11490 };
11491 let new_spki = new_key.public_key_spki.clone();
11492 let new_multikey = new_key.multikey.clone();
11493 let journal_path = rotation_journal_path(out);
11494 let before = verified_remote_head(cfg, brain, false)?;
11495 let served_identity = before
11496 .identity
11497 .as_ref()
11498 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
11499 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
11500 if served_multikey == new_multikey {
11501 remove_rotation_journal(&journal_path);
11502 return Ok(RotationReport {
11503 brain: brain.to_string(),
11504 multikey: new_multikey,
11505 key_file: out.display().to_string(),
11506 previous: served_identity
11507 .previous
11508 .iter()
11509 .map(|identity| format!("ed25519:{}", identity.fingerprint))
11510 .collect(),
11511 });
11512 }
11513 if served_multikey != old_key.multikey {
11514 return Err(invalid_feed(
11515 "the supplied old key is not the brain's verified current identity",
11516 ));
11517 }
11518
11519 let journal = if journal_path.exists() {
11520 read_rotation_journal(&journal_path)?
11521 } else {
11522 let ts = crate::now()
11523 .with_timezone(&chrono::Utc)
11524 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
11525 .to_string();
11526 let unsigned = serde_json::to_string(&UnsignedRotation {
11527 v: 1,
11528 op: "rotate",
11529 brain: &old_key.multikey,
11530 public_key: &old_key.public_key_spki,
11531 new_brain: &new_multikey,
11532 new_public_key: &new_spki,
11533 prior_head_seq: before.head.seq,
11534 prior_feed_hash: before.head.feed_hash.as_deref(),
11535 ts,
11536 })
11537 .expect("serialize rotation");
11538 let old_pair = agent_keypair(&old_key.pkcs8)?;
11539 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
11540 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
11541 let journal = RotationJournal {
11542 v: 1,
11543 origin: normalized_origin(&cfg.hub)?,
11544 brain: before.head.brain.clone(),
11545 old_brain: old_key.multikey.clone(),
11546 new_brain: new_multikey.clone(),
11547 prior_head_seq: before.head.seq,
11548 prior_feed_hash: before.head.feed_hash.clone(),
11549 statement,
11550 };
11551 let mut exact = serde_json::to_vec(&journal)
11552 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
11553 exact.push(b'\n');
11554 if write_secret_new(&journal_path, &exact).is_err() {
11555 read_rotation_journal(&journal_path)?
11558 } else {
11559 journal
11560 }
11561 };
11562 validate_rotation_journal(
11563 &journal,
11564 cfg,
11565 &before.head.brain,
11566 old_key,
11567 &new_key,
11568 &before.head,
11569 )?;
11570
11571 let body = json!({ "statement": journal.statement });
11572 let path = format!("/api/hub/brains/{brain}/rotate");
11573 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
11574 let attempted_failure = match attempted {
11575 Ok(response) if (200..300).contains(&response.status) => None,
11576 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
11577 Err(error) => Some(error),
11578 };
11579
11580 let after = match verified_remote_head(cfg, brain, false) {
11584 Ok(after) => after,
11585 Err(error) => return Err(attempted_failure.unwrap_or(error)),
11586 };
11587 let identity = after
11588 .identity
11589 .as_ref()
11590 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?;
11591 if format!("ed25519:{}", identity.fingerprint) != new_multikey
11592 || identity.public_key_spki != new_spki
11593 {
11594 return Err(attempted_failure.unwrap_or_else(|| {
11595 invalid_feed("hub acknowledged rotation without committing the verified new identity")
11596 }));
11597 }
11598 let previous = identity
11599 .previous
11600 .iter()
11601 .map(|prior| format!("ed25519:{}", prior.fingerprint))
11602 .collect();
11603 remove_rotation_journal(&journal_path);
11604
11605 Ok(RotationReport {
11606 brain: brain.to_string(),
11607 multikey: new_multikey,
11608 key_file: out.display().to_string(),
11609 previous,
11610 })
11611}
11612
11613#[derive(Debug, Serialize)]
11619pub struct MirrorReport {
11620 pub brain: String,
11622 #[serde(rename = "headSeq")]
11624 pub head_seq: u64,
11625 #[serde(rename = "feedHash")]
11627 pub feed_hash: Option<String>,
11628 pub entries: u64,
11630 pub pinned: String,
11632 pub files: usize,
11634}
11635
11636pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
11638
11639#[derive(Debug)]
11641pub struct VerifiedMirrorMaterial {
11642 pub brain: String,
11643 pub head_seq: u64,
11644 pub feed_hash: Option<String>,
11645 pub identity: serde_json::Value,
11646 pub entries: Vec<(u64, String, String)>,
11648 pub pack_sha256: Option<String>,
11649}
11650
11651#[derive(Deserialize)]
11652#[serde(deny_unknown_fields)]
11653struct StoredMirrorHead {
11654 brain: String,
11655 #[serde(rename = "headSeq")]
11656 head_seq: u64,
11657 #[serde(rename = "feedHash")]
11658 feed_hash: Option<String>,
11659}
11660
11661pub fn verify_mirror_material(
11664 head_bytes: &[u8],
11665 identity_bytes: &[u8],
11666 feed_bytes: &[Vec<u8>],
11667 snapshot_pack: Option<&[u8]>,
11668 expected_anchor: &str,
11669) -> LinkResult<VerifiedMirrorMaterial> {
11670 let snapshot_hash = snapshot_pack
11671 .filter(|pack| !pack.is_empty())
11672 .map(content_sha256);
11673 verify_mirror_material_with_pack_hash(
11674 head_bytes,
11675 identity_bytes,
11676 feed_bytes,
11677 snapshot_hash.as_deref(),
11678 expected_anchor,
11679 )
11680}
11681
11682pub fn verify_mirror_material_with_pack_hash(
11686 head_bytes: &[u8],
11687 identity_bytes: &[u8],
11688 feed_bytes: &[Vec<u8>],
11689 snapshot_pack_sha256: Option<&str>,
11690 expected_anchor: &str,
11691) -> LinkResult<VerifiedMirrorMaterial> {
11692 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
11693 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
11694 require_safe_ref(&head.brain)?;
11695 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
11696 return Err(invalid_feed(
11697 "stored mirror feed count does not match its bounded head sequence",
11698 ));
11699 }
11700 let aggregate = feed_bytes
11701 .iter()
11702 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
11703 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
11704 if aggregate > MAX_FEED_REPLAY_BYTES {
11705 return Err(invalid_feed(
11706 "stored mirror feed metadata exceeds the aggregate limit",
11707 ));
11708 }
11709 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
11710 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
11711 let anchor = verify_identity_chain(&identity, None)?;
11712 if anchor != expected_anchor {
11713 return Err(invalid_feed(
11714 "stored mirror identity does not descend from the explicitly trusted anchor",
11715 ));
11716 }
11717
11718 let mut entries = Vec::with_capacity(feed_bytes.len());
11719 let mut items = Vec::with_capacity(feed_bytes.len());
11720 let mut previous_hash = None;
11721 let mut pack_sha256 = None;
11722 for (index, bytes) in feed_bytes.iter().enumerate() {
11723 let exact = bytes
11724 .strip_suffix(b"\n")
11725 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
11726 if exact.ends_with(b"\n") {
11727 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
11728 }
11729 let entry: FeedEntry = serde_json::from_slice(exact)
11730 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
11731 let expected_seq = index as u64 + 1;
11732 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
11733 return Err(invalid_feed(
11734 "stored mirror feed is not contiguous and hash-chained",
11735 ));
11736 }
11737 let canonical = serde_json::to_vec(&entry)
11738 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
11739 if canonical != exact {
11740 return Err(invalid_feed(
11741 "stored feed entry is not in normative serialization",
11742 ));
11743 }
11744 let hash = content_sha256(bytes);
11745 let item = FeedItem {
11746 hash: hash.clone(),
11747 entry,
11748 };
11749 verify_feed_item(&item, &identity)?;
11750 previous_hash = Some(hash.clone());
11751 if expected_seq == head.head_seq {
11752 pack_sha256 = Some(item.entry.pack_sha256.clone());
11753 }
11754 entries.push((
11755 expected_seq,
11756 std::str::from_utf8(exact)
11757 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
11758 .to_string(),
11759 hash,
11760 ));
11761 items.push(item);
11762 }
11763 if previous_hash != head.feed_hash {
11764 return Err(invalid_feed(
11765 "stored mirror feed does not converge on its advertised head",
11766 ));
11767 }
11768 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
11769 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
11770 (0, None, None) => {}
11771 (_, Some(actual), Some(expected)) if actual == expected => {}
11772 _ => {
11773 return Err(LinkError::InvalidPack {
11774 message: "stored snapshot pack does not match the signed head digest".to_string(),
11775 });
11776 }
11777 }
11778 let identity_value = serde_json::to_value(&identity)
11779 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
11780 Ok(VerifiedMirrorMaterial {
11781 brain: head.brain,
11782 head_seq: head.head_seq,
11783 feed_hash: head.feed_hash,
11784 identity: identity_value,
11785 entries,
11786 pack_sha256,
11787 })
11788}
11789
11790pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
11793 format!(
11794 "{:x}",
11795 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
11796 )
11797}
11798
11799pub fn content_sha256(bytes: &[u8]) -> String {
11802 format!("{:x}", Sha256::digest(bytes))
11803}
11804
11805pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
11807 let mut digest = Sha256::new();
11808 let mut buffer = [0u8; 64 * 1024];
11809 loop {
11810 let read = reader.read(&mut buffer)?;
11811 if read == 0 {
11812 break;
11813 }
11814 digest.update(&buffer[..read]);
11815 }
11816 Ok(format!("{:x}", digest.finalize()))
11817}
11818
11819#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
11827pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
11828 require_hardened_filesystem("mirror")?;
11829 require_safe_ref(brain)?;
11830 #[cfg(windows)]
11831 {
11832 let _ = (cfg, dest);
11833 return Err(LinkError::UnsupportedPlatform {
11834 operation: "atomic whole-mirror replacement on Windows",
11835 });
11836 }
11837 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
11838 let name = dest
11839 .file_name()
11840 .and_then(|name| name.to_str())
11841 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
11842 .ok_or_else(|| LinkError::UnsafePath {
11843 path: dest.display().to_string(),
11844 })?;
11845 #[cfg(unix)]
11846 let parent_dir = open_or_create_dir_nofollow(parent)?;
11847 #[cfg(unix)]
11848 use std::os::fd::AsRawFd as _;
11849 #[cfg(unix)]
11850 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
11851 #[cfg(unix)]
11852 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
11853 None => false,
11854 Some(true) => true,
11855 Some(false) => {
11856 return Err(LinkError::UnsafePath {
11857 path: dest.display().to_string(),
11858 });
11859 }
11860 };
11861
11862 #[cfg(unix)]
11865 let legacy_backup_name = c_name(
11866 format!(".{name}.dbmd-backup").as_bytes(),
11867 &dest.display().to_string(),
11868 )?;
11869 #[cfg(unix)]
11870 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
11871 return Err(LinkError::UnsafePath {
11872 path: parent
11873 .join(format!(".{name}.dbmd-backup"))
11874 .display()
11875 .to_string(),
11876 });
11877 }
11878
11879 let nonce = std::time::SystemTime::now()
11880 .duration_since(std::time::UNIX_EPOCH)
11881 .unwrap_or_default()
11882 .as_nanos();
11883 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
11884 #[cfg(unix)]
11885 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
11886 #[cfg(unix)]
11887 let stage_dir = create_dir_exclusive_at(
11888 parent_dir.as_raw_fd(),
11889 &stage_name,
11890 &dest.display().to_string(),
11891 )?;
11892
11893 let assembled = (|| -> LinkResult<MirrorReport> {
11894 let remote = verified_remote_head(cfg, brain, true)?;
11895 let brain_id = remote.head.brain.clone();
11896 let identity = remote
11897 .identity
11898 .as_ref()
11899 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
11900 let anchor = remote
11901 .anchor
11902 .clone()
11903 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
11904 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
11905 let snapshot_entries = parse_store_pack(pack.clone())?;
11906 let snapshot_count = snapshot_entries.len();
11907 let mut staged_entries = snapshot_entries;
11908 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
11909 for item in &remote.entries {
11910 let mut exact = serde_json::to_vec(&item.entry)
11911 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
11912 exact.push(b'\n');
11913 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
11914 return Err(invalid_feed(
11915 "serialized mirror entry differs from its verified hash",
11916 ));
11917 }
11918 staged_entries.push((
11919 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
11920 exact,
11921 ));
11922 }
11923 let mut identity_bytes = serde_json::to_vec(identity)
11924 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
11925 identity_bytes.push(b'\n');
11926 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
11927 let mut head_bytes = serde_json::to_vec(&json!({
11928 "brain": brain_id,
11929 "headSeq": remote.head.seq,
11930 "feedHash": remote.head.feed_hash,
11931 }))
11932 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
11933 head_bytes.push(b'\n');
11934 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
11935 staged_entries.push((
11936 CONFIG_REL_PATH.to_string(),
11937 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
11938 ));
11939 #[cfg(unix)]
11940 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
11941
11942 Ok(MirrorReport {
11943 brain: brain_id,
11944 head_seq: remote.head.seq,
11945 feed_hash: remote.head.feed_hash,
11946 entries: remote.entries.len() as u64,
11947 pinned: anchor,
11948 files: snapshot_count,
11949 })
11950 })();
11951
11952 let report = match assembled {
11953 Ok(report) => report,
11954 Err(error) => {
11955 #[cfg(unix)]
11956 let _ = remove_tree_at(
11957 parent_dir.as_raw_fd(),
11958 &stage_name,
11959 &dest.display().to_string(),
11960 );
11961 return Err(error);
11962 }
11963 };
11964
11965 #[cfg(unix)]
11966 if let Err(error) =
11967 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
11968 {
11969 let _ = remove_tree_at(
11970 parent_dir.as_raw_fd(),
11971 &stage_name,
11972 &dest.display().to_string(),
11973 );
11974 return Err(error);
11975 }
11976 #[cfg(unix)]
11979 if dest_exists {
11980 remove_tree_at(
11981 parent_dir.as_raw_fd(),
11982 &stage_name,
11983 &dest.display().to_string(),
11984 )?;
11985 }
11986 #[cfg(unix)]
11987 parent_dir.sync_all()?;
11988 Ok(report)
11989}
11990
11991fn verified_remote_head(
11992 cfg: &HubConfig,
11993 brain: &str,
11994 require_full_chain: bool,
11995) -> LinkResult<VerifiedRemote> {
11996 require_hardened_filesystem("verified link.md state")?;
11997 require_safe_ref(brain)?;
11998 let trust_directory = open_trust_dir(cfg)?;
12002 let path = format!("/api/hub/brains/{brain}");
12003 let body = ensure_ok(
12004 request(cfg, "GET", &path, None, Auth::Required)?,
12005 "subscribe",
12006 )?;
12007 let resolved_brain = body
12008 .get("id")
12009 .and_then(Value::as_str)
12010 .filter(|id| crate::ulid::is_ulid(id))
12011 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
12012 .to_string();
12013 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
12014 return Err(invalid_feed(
12015 "brain card id differs from the explicitly requested brain id",
12016 ));
12017 }
12018 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
12023 let (pinned, alias_binding) =
12024 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
12025 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
12026 let advertised_hash = body
12027 .get("feedHash")
12028 .and_then(Value::as_str)
12029 .map(str::to_string);
12030 let updated_at = body
12031 .get("updatedAt")
12032 .and_then(Value::as_str)
12033 .map(str::to_string);
12034 if let Some(pin) = &pinned {
12035 if seq < pin.head_seq {
12036 return Err(invalid_feed(format!(
12037 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
12038 pin.head_seq
12039 )));
12040 }
12041 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
12042 return Err(invalid_feed(
12043 "feed equivocation: the checkpoint sequence now has a different hash",
12044 ));
12045 }
12046 }
12047 if seq == 0 {
12048 if advertised_hash.is_some() {
12049 return Err(invalid_feed("an empty feed advertised a head hash"));
12050 }
12051 let identity: FeedIdentity = serde_json::from_value(
12052 body.get("identity")
12053 .cloned()
12054 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
12055 )
12056 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
12057 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
12058 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
12063 save_canonical_pin_and_alias(
12064 cfg,
12065 &trust_directory,
12066 brain,
12067 &resolved_brain,
12068 TrustState {
12069 v: 2,
12070 origin: normalized_origin(&cfg.hub)?,
12071 requested: resolved_brain.clone(),
12072 brain: resolved_brain.clone(),
12073 home: None,
12074 anchor: anchor.clone(),
12075 current: format!("ed25519:{}", identity.fingerprint),
12076 head_seq: 0,
12077 feed_hash: None,
12078 rotations: identity.rotations.clone(),
12079 hub_signer: None,
12080 protocol_profile: None,
12081 },
12082 alias_binding.as_ref(),
12083 )?;
12084 return Ok(VerifiedRemote {
12085 head: Head {
12086 brain: resolved_brain,
12087 seq,
12088 updated_at,
12089 feed_hash: None,
12090 verified: true,
12091 },
12092 identity: Some(identity),
12093 head_entry: None,
12094 entries: Vec::new(),
12095 anchor: Some(anchor),
12096 });
12097 }
12098 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
12099 return Err(invalid_feed(
12100 "non-empty feed did not advertise a valid SHA-256 head",
12101 ));
12102 }
12103
12104 let replay_head_only = !require_full_chain
12108 && pinned
12109 .as_ref()
12110 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
12111 let mut after = if replay_head_only {
12112 seq - 1
12113 } else if require_full_chain || pinned.is_none() {
12114 0
12115 } else {
12116 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
12117 };
12118 let mut expected_seq = after + 1;
12119 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
12120 None
12121 } else {
12122 pinned
12123 .as_ref()
12124 .and_then(|checkpoint| checkpoint.feed_hash.clone())
12125 };
12126 let mut identity: Option<FeedIdentity> = None;
12127 let mut anchor: Option<String> = None;
12128 let mut head_entry: Option<FeedItem> = None;
12129 let mut all_entries = Vec::new();
12130 let mut observed_entries = Vec::new();
12131 let replay_count = seq
12132 .checked_sub(after)
12133 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
12134 if replay_count > MAX_FEED_REPLAY_ENTRIES {
12135 return Err(invalid_feed(format!(
12136 "feed replay requires {replay_count} entries, over the client cap"
12137 )));
12138 }
12139 let mut replay_bytes = 0u64;
12140
12141 loop {
12142 let feed_bytes = ensure_raw_ok(
12143 request_raw(
12144 cfg,
12145 "GET",
12146 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
12147 None,
12148 Auth::Required,
12149 MAX_FEED_RESPONSE_BYTES,
12150 )?,
12151 "subscribe feed",
12152 )?;
12153 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
12154 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
12155 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
12156 return Err(invalid_feed("brain card and feed head disagree"));
12157 }
12158 if feed.entries.len() > FEED_PAGE_LIMIT {
12159 return Err(invalid_feed("feed page exceeds the requested entry limit"));
12160 }
12161 if feed.scope_limited {
12162 if require_full_chain {
12163 return Err(invalid_feed(
12164 "path-scoped grants cannot verify a full snapshot chain",
12165 ));
12166 }
12167 return Ok(VerifiedRemote {
12168 head: Head {
12169 brain: resolved_brain,
12170 seq,
12171 updated_at,
12172 feed_hash: advertised_hash,
12173 verified: false,
12174 },
12175 identity: None,
12176 head_entry: None,
12177 entries: Vec::new(),
12178 anchor: None,
12179 });
12180 }
12181 let page_identity = feed
12182 .identity
12183 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
12184 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
12185 if identity
12186 .as_ref()
12187 .is_some_and(|existing| existing != &page_identity)
12188 {
12189 return Err(invalid_feed("identity changed while reading the feed"));
12190 }
12191 if anchor
12192 .as_ref()
12193 .is_some_and(|existing| existing != &page_anchor)
12194 {
12195 return Err(invalid_feed(
12196 "identity anchor changed while reading the feed",
12197 ));
12198 }
12199 identity = Some(page_identity.clone());
12200 if anchor.is_none() {
12201 anchor = Some(page_anchor);
12202 }
12203 if feed.entries.is_empty() {
12204 return Err(invalid_feed("feed page was empty before the signed head"));
12205 }
12206
12207 for item in feed.entries {
12208 if item.entry.seq != expected_seq {
12209 return Err(invalid_feed(format!(
12210 "expected entry {expected_seq}, feed served {}",
12211 item.entry.seq
12212 )));
12213 }
12214 if item.entry.seq > seq {
12215 return Err(invalid_feed("feed advanced past the card snapshot"));
12216 }
12217 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
12218 return Err(invalid_feed(format!(
12219 "entry {} does not chain to the local checkpoint",
12220 item.entry.seq
12221 )));
12222 }
12223 verify_feed_item(&item, &page_identity)?;
12224 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
12225 replay_bytes = replay_bytes.saturating_add(
12226 serde_json::to_vec(&item)
12227 .map_err(|_| invalid_feed("could not size feed entry"))?
12228 .len() as u64,
12229 );
12230 if replay_bytes > MAX_FEED_REPLAY_BYTES {
12231 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
12232 }
12233 previous_hash = Some(item.hash.clone());
12234 after = item.entry.seq;
12235 expected_seq = expected_seq
12236 .checked_add(1)
12237 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
12238 if require_full_chain {
12239 all_entries.push(item.clone());
12240 }
12241 observed_entries.push(item.clone());
12242 head_entry = Some(item);
12243 }
12244 if after == seq {
12245 break;
12246 }
12247 }
12248
12249 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
12250 return Err(invalid_feed(
12251 "verified chain does not converge on the advertised head",
12252 ));
12253 }
12254 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
12255 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
12256 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
12257 save_canonical_pin_and_alias(
12258 cfg,
12259 &trust_directory,
12260 brain,
12261 &resolved_brain,
12262 TrustState {
12263 v: 2,
12264 origin: normalized_origin(&cfg.hub)?,
12265 requested: resolved_brain.clone(),
12266 brain: resolved_brain.clone(),
12267 home: None,
12268 anchor: anchor.clone(),
12269 current: format!("ed25519:{}", identity.fingerprint),
12270 head_seq: seq,
12271 feed_hash: advertised_hash.clone(),
12272 rotations: identity.rotations.clone(),
12273 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
12274 protocol_profile: pinned
12275 .as_ref()
12276 .and_then(|state| state.protocol_profile.clone()),
12277 },
12278 alias_binding.as_ref(),
12279 )?;
12280 Ok(VerifiedRemote {
12281 head: Head {
12282 brain: resolved_brain,
12283 seq,
12284 updated_at,
12285 feed_hash: advertised_hash,
12286 verified: true,
12287 },
12288 identity: Some(identity),
12289 head_entry,
12290 entries: all_entries,
12291 anchor: Some(anchor),
12292 })
12293}
12294
12295pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
12300 Ok(verified_remote_head(cfg, brain, false)?.head)
12301}
12302
12303#[cfg(test)]
12304mod tests {
12305 use super::*;
12306
12307 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
12308
12309 #[cfg(target_os = "linux")]
12310 #[test]
12311 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
12312 use std::os::fd::AsRawFd as _;
12313
12314 let sandbox = tempfile::TempDir::new().unwrap();
12315 let parent = std::fs::File::open(sandbox.path()).unwrap();
12316 let stage = std::ffi::CString::new("stage").unwrap();
12317 let destination = std::ffi::CString::new("brain").unwrap();
12318
12319 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
12320 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
12321 install_stage_at(
12322 parent.as_raw_fd(),
12323 stage.as_c_str(),
12324 destination.as_c_str(),
12325 false,
12326 )
12327 .unwrap();
12328 assert!(!sandbox.path().join("stage").exists());
12329 assert_eq!(
12330 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
12331 b"created"
12332 );
12333
12334 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
12335 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
12336 install_stage_at(
12337 parent.as_raw_fd(),
12338 stage.as_c_str(),
12339 destination.as_c_str(),
12340 true,
12341 )
12342 .unwrap();
12343 assert_eq!(
12344 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
12345 b"replacement"
12346 );
12347 assert_eq!(
12348 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
12349 b"created",
12350 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
12351 );
12352 }
12353
12354 struct SignedRemoteFixture {
12355 card: String,
12356 feed: String,
12357 key: AgentSigningKey,
12358 identity: FeedIdentity,
12359 }
12360
12361 fn signed_remote_fixture() -> SignedRemoteFixture {
12362 let rng = ring::rand::SystemRandom::new();
12363 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
12364 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
12365 let (public_key, multikey) = public_identity_for(&pair);
12366 let identity = FeedIdentity {
12367 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
12368 public_key_spki: public_key.clone(),
12369 previous: Vec::new(),
12370 rotations: Vec::new(),
12371 };
12372 let mut entry = FeedEntry {
12373 v: 1,
12374 seq: 1,
12375 ts: "2026-07-30T12:00:00.000Z".to_string(),
12376 brain: multikey.clone(),
12377 public_key: public_key.clone(),
12378 kind: "push".to_string(),
12379 op: "snapshot".to_string(),
12380 pack_sha256: "a".repeat(64),
12381 files: Vec::new(),
12382 removed: Vec::new(),
12383 prev_entry_hash: None,
12384 sig: String::new(),
12385 };
12386 let unsigned = UnsignedFeedEntry {
12387 v: entry.v,
12388 seq: entry.seq,
12389 ts: &entry.ts,
12390 brain: &entry.brain,
12391 public_key: &entry.public_key,
12392 kind: &entry.kind,
12393 op: &entry.op,
12394 pack_sha256: &entry.pack_sha256,
12395 files: &entry.files,
12396 removed: &entry.removed,
12397 prev_entry_hash: &entry.prev_entry_hash,
12398 };
12399 entry.sig =
12400 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
12401 let mut exact = serde_json::to_vec(&entry).unwrap();
12402 exact.push(b'\n');
12403 let hash = content_sha256(&exact);
12404 let card = json!({
12405 "id": TEST_BRAIN_ID,
12406 "headSeq": 1,
12407 "feedHash": hash,
12408 "identity": identity.clone(),
12409 })
12410 .to_string();
12411 let feed = json!({
12412 "headSeq": 1,
12413 "feedHash": hash,
12414 "identity": identity.clone(),
12415 "entries": [{"hash": hash, "entry": entry}],
12416 "scopeLimited": false,
12417 })
12418 .to_string();
12419 SignedRemoteFixture {
12420 card,
12421 feed,
12422 key: AgentSigningKey {
12423 pkcs8: pkcs8.as_ref().to_vec(),
12424 multikey,
12425 public_key_spki: public_key,
12426 },
12427 identity,
12428 }
12429 }
12430
12431 #[test]
12432 fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
12433 let file = |path: &str, byte: char| FeedFile {
12434 path: path.to_string(),
12435 sha256: byte.to_string().repeat(64),
12436 bytes: 1,
12437 };
12438 let a0 = file("records/a.md", 'a');
12439 let a1 = file("records/a.md", 'b');
12440 let stable = file("records/stable.md", 'c');
12441 let added = file("records/added.md", 'd');
12442 let removed_file = file("records/removed.md", 'e');
12443 let previous = vec![a0, stable.clone(), removed_file.clone()];
12444 let resulting = vec![a1.clone(), stable.clone(), added.clone()];
12445 let removed = vec![removed_file.path.clone()];
12446
12447 assert_eq!(
12448 verify_v1_manifest_disclosure(
12449 "edit",
12450 &previous,
12451 &resulting,
12452 &[a1.clone(), added.clone()],
12453 &removed,
12454 ),
12455 Ok(())
12456 );
12457 assert_eq!(
12458 verify_v1_manifest_disclosure(
12459 "edit",
12460 &previous,
12461 &resulting,
12462 &[stable.clone(), added.clone(), a1.clone()],
12463 &removed,
12464 ),
12465 Ok(())
12466 );
12467 assert_eq!(
12468 verify_v1_manifest_disclosure(
12469 "edit",
12470 &previous,
12471 &resulting,
12472 std::slice::from_ref(&added),
12473 &removed,
12474 ),
12475 Err(V1DisclosureError::EditMissingChange)
12476 );
12477 assert_eq!(
12478 verify_v1_manifest_disclosure(
12479 "edit",
12480 &previous,
12481 &resulting,
12482 &[file("records/a.md", 'f'), added.clone()],
12483 &removed,
12484 ),
12485 Err(V1DisclosureError::EditFalseFile)
12486 );
12487 assert_eq!(
12488 verify_v1_manifest_disclosure(
12489 "edit",
12490 &previous,
12491 &resulting,
12492 &[a1.clone(), added.clone()],
12493 &[],
12494 ),
12495 Err(V1DisclosureError::RemovedMismatch)
12496 );
12497 assert_eq!(
12498 verify_v1_manifest_disclosure(
12499 "push",
12500 &previous,
12501 &resulting,
12502 &[added.clone(), stable, a1],
12503 &removed,
12504 ),
12505 Ok(())
12506 );
12507 assert_eq!(
12508 verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
12509 Err(V1DisclosureError::PushManifestMismatch)
12510 );
12511 }
12512
12513 #[test]
12514 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
12515 let fixture = signed_remote_fixture();
12516 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
12517 let item = feed["entries"][0].to_string();
12518 let oversized_page = format!(
12519 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
12520 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
12521 .collect::<Vec<_>>()
12522 .join(",")
12523 );
12524 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
12525
12526 let oversized_identity = format!(
12527 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
12528 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
12529 .collect::<Vec<_>>()
12530 .join(",")
12531 );
12532 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
12533
12534 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
12535 let oversized_entry = format!(
12536 "{{\"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\"}}",
12537 "a".repeat(64),
12538 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
12539 .collect::<Vec<_>>()
12540 .join(",")
12541 );
12542 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
12543 }
12544
12545 #[test]
12546 fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
12547 let id = "01arz3ndektsv4rrffq69g5fav";
12548 let digest = "a".repeat(64);
12549 assert_eq!(
12550 V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
12551 V2BulkConfirmation {
12552 id: id.to_string(),
12553 digest,
12554 }
12555 );
12556 for invalid in [
12557 "",
12558 "01arz3ndektsv4rrffq69g5fav",
12559 "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
12560 "01arz3ndektsv4rrffq69g5fav:ABCDEF",
12561 "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
12562 ] {
12563 assert!(matches!(
12564 V2BulkConfirmation::parse(invalid),
12565 Err(LinkError::InvalidPack { .. })
12566 ));
12567 }
12568 }
12569
12570 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
12571 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
12572 use std::net::TcpListener;
12573
12574 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
12575 let url = format!("http://{}", listener.local_addr().unwrap());
12576 let handle = std::thread::spawn(move || {
12577 for (status, body) in responses {
12578 let (stream, _) = listener.accept().unwrap();
12579 let mut reader = BufReader::new(stream);
12580 let mut line = String::new();
12581 reader.read_line(&mut line).unwrap();
12582 let mut content_length = 0usize;
12583 loop {
12584 line.clear();
12585 reader.read_line(&mut line).unwrap();
12586 if line == "\r\n" || line == "\n" || line.is_empty() {
12587 break;
12588 }
12589 if let Some((name, value)) = line.split_once(':') {
12590 if name.eq_ignore_ascii_case("content-length") {
12591 content_length = value.trim().parse().unwrap();
12592 }
12593 }
12594 }
12595 let mut request_body = vec![0_u8; content_length];
12596 reader.read_exact(&mut request_body).unwrap();
12597 let response = format!(
12598 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
12599 body.len()
12600 );
12601 reader.get_mut().write_all(response.as_bytes()).unwrap();
12602 }
12603 });
12604 (url, handle)
12605 }
12606
12607 fn routed_json_hub(
12608 requests: usize,
12609 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
12610 ) -> (String, std::thread::JoinHandle<()>) {
12611 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
12612 use std::net::TcpListener;
12613
12614 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
12615 let url = format!("http://{}", listener.local_addr().unwrap());
12616 let handle = std::thread::spawn(move || {
12617 for _ in 0..requests {
12618 let (stream, _) = listener.accept().unwrap();
12619 let mut reader = BufReader::new(stream);
12620 let mut line = String::new();
12621 reader.read_line(&mut line).unwrap();
12622 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
12623 let mut content_length = 0usize;
12624 loop {
12625 line.clear();
12626 reader.read_line(&mut line).unwrap();
12627 if line == "\r\n" || line == "\n" || line.is_empty() {
12628 break;
12629 }
12630 if let Some((name, value)) = line.split_once(':') {
12631 if name.eq_ignore_ascii_case("content-length") {
12632 content_length = value.trim().parse().unwrap();
12633 }
12634 }
12635 }
12636 let mut request_body = vec![0_u8; content_length];
12637 reader.read_exact(&mut request_body).unwrap();
12638 let (status, body) = respond(&path);
12639 let response = format!(
12640 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
12641 body.len()
12642 );
12643 reader.get_mut().write_all(response.as_bytes()).unwrap();
12644 }
12645 });
12646 (url, handle)
12647 }
12648
12649 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
12650 HubConfig {
12651 hub,
12652 key: Some("test-key".to_string()),
12653 agent_key: None,
12654 brain_key: None,
12655 state_dir,
12656 store_selected: false,
12657 }
12658 }
12659
12660 #[test]
12661 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
12662 use ring::signature::KeyPair as _;
12663
12664 let rng = ring::rand::SystemRandom::new();
12665 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
12666 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
12667 let (spki, multikey) = public_identity_for(&pair);
12668 let key = AgentSigningKey {
12669 pkcs8: pkcs8.as_ref().to_vec(),
12670 multikey,
12671 public_key_spki: spki,
12672 };
12673 let header = linkmd_sig_header(
12674 &key,
12675 "https://hub-a.example",
12676 "post",
12677 "/api/hub/brains/brain/push?mode=exact",
12678 Some("{\"ok\":true}"),
12679 )
12680 .unwrap();
12681 assert!(header.starts_with("LinkMD-Sig v2,"));
12682 let ts = header
12683 .split(",ts=")
12684 .nth(1)
12685 .unwrap()
12686 .split(',')
12687 .next()
12688 .unwrap();
12689 let signature = URL_SAFE_NO_PAD
12690 .decode(header.rsplit(",sig=").next().unwrap())
12691 .unwrap();
12692 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
12693 let accepted = format!(
12694 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
12695 );
12696 let replayed = format!(
12697 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
12698 );
12699 let public = pair.public_key().as_ref();
12700 assert!(UnparsedPublicKey::new(&ED25519, public)
12701 .verify(accepted.as_bytes(), &signature)
12702 .is_ok());
12703 assert!(
12704 UnparsedPublicKey::new(&ED25519, public)
12705 .verify(replayed.as_bytes(), &signature)
12706 .is_err(),
12707 "a proof captured at hub A must not authenticate at hub B"
12708 );
12709 }
12710
12711 #[test]
12712 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
12713 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
12714 let card = json!({
12715 "id": other,
12716 "headSeq": 0,
12717 "identity": signed_remote_fixture().identity,
12718 })
12719 .to_string();
12720 let (hub, server) = scripted_json_hub(vec![(200, card)]);
12721 let state = tempfile::tempdir().unwrap();
12722 let cfg = test_hub_config(hub, state.path().to_path_buf());
12723 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
12724 assert!(
12725 error.contains("differs from the explicitly requested"),
12726 "{error}"
12727 );
12728 server.join().unwrap();
12729 }
12730
12731 #[test]
12732 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
12733 let first = signed_remote_fixture().identity;
12734 let second = signed_remote_fixture().identity;
12735 let card = |identity: FeedIdentity| {
12736 json!({
12737 "id": TEST_BRAIN_ID,
12738 "headSeq": 0,
12739 "identity": identity,
12740 })
12741 .to_string()
12742 };
12743 let (hub, server) = scripted_json_hub(vec![(200, card(first)), (200, card(second))]);
12744 let state = tempfile::tempdir().unwrap();
12745 let cfg = test_hub_config(hub, state.path().to_path_buf());
12746 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
12747 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
12748 assert!(
12749 error.contains("pinned anchor") || error.contains("forked away"),
12750 "{error}"
12751 );
12752 server.join().unwrap();
12753 }
12754
12755 #[test]
12756 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
12757 let old = signed_remote_fixture();
12758 let new = signed_remote_fixture();
12759 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
12760 let unsigned = serde_json::to_string(&UnsignedRotation {
12761 v: 1,
12762 op: "rotate",
12763 brain: &old.key.multikey,
12764 public_key: &old.key.public_key_spki,
12765 new_brain: &new.key.multikey,
12766 new_public_key: &new.key.public_key_spki,
12767 prior_head_seq: 1,
12768 prior_feed_hash: Some(&"a".repeat(64)),
12769 ts: "2026-07-30T12:00:00.000Z".to_string(),
12770 })
12771 .unwrap();
12772 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
12773 let rotation = format!(
12774 "{},\"sig\":\"{}\"}}",
12775 &unsigned[..unsigned.len() - 1],
12776 signature
12777 );
12778 let identity = FeedIdentity {
12779 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
12780 public_key_spki: new.key.public_key_spki,
12781 previous: vec![PreviousIdentity {
12782 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
12783 public_key_spki: old.key.public_key_spki,
12784 }],
12785 rotations: vec![rotation],
12786 };
12787 let card = json!({
12788 "id": TEST_BRAIN_ID,
12789 "headSeq": 0,
12790 "feedHash": null,
12791 "identity": identity,
12792 })
12793 .to_string();
12794 let (hub, server) = scripted_json_hub(vec![(200, card)]);
12795 let state = tempfile::tempdir().unwrap();
12796 let cfg = test_hub_config(hub, state.path().to_path_buf());
12797 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
12798 assert!(
12799 error.contains("rotation claims a feed boundary beyond the advertised head"),
12800 "{error}"
12801 );
12802 assert!(
12803 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
12804 "an inconsistent empty-head identity must not become the TOFU checkpoint"
12805 );
12806 server.join().unwrap();
12807 }
12808
12809 #[test]
12810 fn trust_checkpoint_rejects_a_later_fork() {
12811 let fixture = signed_remote_fixture();
12812 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
12813 fork["feedHash"] = Value::String("b".repeat(64));
12814 let (hub, server) = scripted_json_hub(vec![
12815 (200, fixture.card),
12816 (200, fixture.feed),
12817 (200, fork.to_string()),
12818 ]);
12819 let state = tempfile::tempdir().unwrap();
12820 let cfg = test_hub_config(hub, state.path().to_path_buf());
12821 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
12822 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
12823 server.join().unwrap();
12824 }
12825
12826 #[test]
12827 fn alias_and_canonical_id_share_one_identity_checkpoint() {
12828 let trusted = signed_remote_fixture();
12829 let attacker = signed_remote_fixture();
12830 let (hub, server) = scripted_json_hub(vec![
12831 (200, trusted.card),
12832 (200, trusted.feed),
12833 (200, attacker.card),
12834 ]);
12835 let state = tempfile::tempdir().unwrap();
12836 let cfg = test_hub_config(hub, state.path().to_path_buf());
12837 assert!(head(&cfg, "trusted-slug").unwrap().verified);
12838 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
12839 assert!(
12840 error.contains("equivocation")
12841 || error.contains("pinned")
12842 || error.contains("identity"),
12843 "{error}"
12844 );
12845 server.join().unwrap();
12846 }
12847
12848 #[test]
12849 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
12850 let alpha = signed_remote_fixture();
12851 let beta = signed_remote_fixture();
12852 let alpha_card = alpha.card.clone();
12853 let alpha_feed = alpha.feed.clone();
12854 let beta_card = beta.card.clone();
12855 let beta_feed = beta.feed.clone();
12856 let (hub, server) = routed_json_hub(3, move |path| {
12857 if path.contains("/alpha/feed?") {
12858 (200, alpha_feed.clone())
12859 } else if path.contains("/beta/feed?") {
12860 (200, beta_feed.clone())
12861 } else if path.ends_with("/alpha") {
12862 (200, alpha_card.clone())
12863 } else if path.ends_with("/beta") {
12864 (200, beta_card.clone())
12865 } else {
12866 (500, r#"{"error":"unexpected path"}"#.to_string())
12867 }
12868 });
12869 let state = tempfile::tempdir().unwrap();
12870 let cfg = test_hub_config(hub, state.path().to_path_buf());
12871 let alpha_cfg = cfg.clone();
12872 let beta_cfg = cfg;
12873 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
12874 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
12875 let results = [first.join().unwrap(), second.join().unwrap()];
12876 assert_eq!(
12877 results.iter().filter(|result| result.is_ok()).count(),
12878 1,
12879 "only one alias identity may establish canonical TOFU: {results:?}"
12880 );
12881 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
12882 server.join().unwrap();
12883 }
12884
12885 #[cfg(unix)]
12886 #[test]
12887 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
12888 use std::os::unix::fs::symlink;
12889
12890 let fixture = signed_remote_fixture();
12891 let card = json!({
12892 "id": TEST_BRAIN_ID,
12893 "headSeq": 0,
12894 "feedHash": Value::Null,
12895 "identity": fixture.identity,
12896 })
12897 .to_string();
12898 let work = tempfile::tempdir().unwrap();
12899 let outside = tempfile::tempdir().unwrap();
12900 let state = work.path().join("state");
12901 let moved = work.path().join("state-held");
12902 let swap_state = state.clone();
12903 let swap_moved = moved.clone();
12904 let outside_path = outside.path().to_path_buf();
12905 let (hub, server) = routed_json_hub(1, move |_| {
12906 std::fs::rename(&swap_state, &swap_moved).unwrap();
12908 symlink(&outside_path, &swap_state).unwrap();
12909 (200, card.clone())
12910 });
12911 let cfg = test_hub_config(hub, state);
12912
12913 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
12914 assert_eq!(verified.head.seq, 0);
12915 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
12916 assert!(std::fs::read_dir(moved.join("trust"))
12917 .unwrap()
12918 .flatten()
12919 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
12920 server.join().unwrap();
12921 }
12922
12923 #[test]
12924 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
12925 let remote = signed_remote_fixture();
12926 let unrelated = signed_remote_fixture().key;
12927 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
12928 let state = tempfile::tempdir().unwrap();
12929 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
12930 cfg.brain_key = Some(unrelated);
12931 let error = sync_push(
12932 &cfg,
12933 TEST_BRAIN_ID,
12934 &[("DB.md".to_string(), "signed local content".to_string())],
12935 )
12936 .unwrap_err()
12937 .to_string();
12938 assert!(
12939 error.contains("not the verified current brain identity"),
12940 "{error}"
12941 );
12942 server.join().unwrap();
12943 }
12944
12945 #[test]
12946 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
12947 let remote = signed_remote_fixture();
12948 let new = signed_remote_fixture().key;
12949 let state = tempfile::tempdir().unwrap();
12950 let new_file = state.path().join("new.key");
12951 std::fs::write(
12952 &new_file,
12953 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
12954 )
12955 .unwrap();
12956 #[cfg(unix)]
12957 {
12958 use std::os::unix::fs::PermissionsExt as _;
12959 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
12960 }
12961 let forged = json!({
12962 "brain": TEST_BRAIN_ID,
12963 "identity": {
12964 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
12965 "publicKeySpki": new.public_key_spki,
12966 }
12967 })
12968 .to_string();
12969 let (hub, server) = scripted_json_hub(vec![
12970 (200, remote.card.clone()),
12971 (200, remote.feed.clone()),
12972 (200, forged),
12973 (200, remote.card),
12974 (200, remote.feed),
12975 ]);
12976 let cfg = test_hub_config(hub, state.path().to_path_buf());
12977 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
12978 .unwrap_err()
12979 .to_string();
12980 assert!(
12981 error.contains("without committing the verified new identity"),
12982 "{error}"
12983 );
12984 server.join().unwrap();
12985 }
12986
12987 #[test]
12988 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
12989 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
12990 let raw = format!(
12991 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
12992 );
12993 let pack = build_store_pack(&[
12994 (
12995 "DB.md".to_string(),
12996 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
12997 ),
12998 ("records/clients/truth.md".to_string(), raw.clone()),
12999 ])
13000 .unwrap();
13001 let by_id = resolve_from_verified_pack(
13002 "01j5qc3v9k4ym8rwbn2tqe6f7d",
13003 &AddressTarget::Id(record_id.to_string()),
13004 pack.clone(),
13005 )
13006 .unwrap();
13007 assert_eq!(by_id["document"]["summary"], "Signed truth");
13008 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
13009 assert_eq!(
13010 by_id["document"]["contentSha"],
13011 content_sha256(raw.as_bytes())
13012 );
13013
13014 let by_path = resolve_from_verified_pack(
13015 "01j5qc3v9k4ym8rwbn2tqe6f7d",
13016 &AddressTarget::Path("records/clients/truth.md".to_string()),
13017 pack,
13018 )
13019 .unwrap();
13020 assert_eq!(by_path["document"]["id"], record_id);
13021 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
13022 }
13023
13024 #[test]
13025 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
13026 let unsorted = vec![
13027 ("records/a.md".to_string(), "alpha\n".to_string()),
13028 ("DB.md".to_string(), "# db\n".to_string()),
13029 ];
13030 let sorted = vec![
13031 ("DB.md".to_string(), "# db\n".to_string()),
13032 ("records/a.md".to_string(), "alpha\n".to_string()),
13033 ];
13034 let pack = build_store_pack(&unsorted).unwrap();
13035
13036 assert_eq!(pack.len(), 219);
13041 assert_eq!(
13042 content_sha256(&pack),
13043 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
13044 );
13045 assert_eq!(pack, build_store_pack(&sorted).unwrap());
13046 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
13047 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
13048 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
13049
13050 assert_eq!(
13051 parse_store_pack(pack).unwrap(),
13052 vec![
13053 ("DB.md".to_string(), b"# db\n".to_vec()),
13054 ("records/a.md".to_string(), b"alpha\n".to_vec()),
13055 ]
13056 );
13057 }
13058
13059 #[test]
13060 fn canonical_store_pack_validates_every_path_before_writing() {
13061 let duplicate = vec![
13062 ("DB.md".to_string(), "first".to_string()),
13063 ("DB.md".to_string(), "second".to_string()),
13064 ];
13065 assert!(build_store_pack(&duplicate)
13066 .unwrap_err()
13067 .to_string()
13068 .contains("duplicate path"));
13069 assert!(matches!(
13070 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
13071 Err(LinkError::UnsafePath { .. })
13072 ));
13073 }
13074
13075 #[test]
13076 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
13077 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
13078 let mut bytes = vec![0_u8];
13081 let zip64_offset = bytes.len() as u64;
13082 bytes.extend_from_slice(b"PK\x06\x06");
13083 bytes.extend_from_slice(&44_u64.to_le_bytes());
13084 bytes.extend_from_slice(&[0_u8; 12]);
13085 bytes.extend_from_slice(&COUNT.to_le_bytes());
13086 bytes.extend_from_slice(&COUNT.to_le_bytes());
13087 bytes.extend_from_slice(&1_u64.to_le_bytes());
13088 bytes.extend_from_slice(&0_u64.to_le_bytes());
13089 bytes.extend_from_slice(b"PK\x06\x07");
13090 bytes.extend_from_slice(&0_u32.to_le_bytes());
13091 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
13092 bytes.extend_from_slice(&1_u32.to_le_bytes());
13093 bytes.extend_from_slice(b"PK\x05\x06");
13094 bytes.extend_from_slice(&0_u16.to_le_bytes());
13095 bytes.extend_from_slice(&0_u16.to_le_bytes());
13096 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
13097 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
13098 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
13099 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
13100 bytes.extend_from_slice(&0_u16.to_le_bytes());
13101
13102 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
13103 .unwrap_err()
13104 .to_string();
13105 assert!(error.contains("invalid file count"), "{error}");
13106 }
13107
13108 #[test]
13109 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
13110 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
13111 let mut bytes = vec![0_u8];
13112 let zip64_offset = bytes.len() as u64;
13113 bytes.extend_from_slice(b"PK\x06\x06");
13114 bytes.extend_from_slice(&44_u64.to_le_bytes());
13115 bytes.extend_from_slice(&[0_u8; 12]);
13116 bytes.extend_from_slice(&COUNT.to_le_bytes());
13117 bytes.extend_from_slice(&COUNT.to_le_bytes());
13118 bytes.extend_from_slice(&1_u64.to_le_bytes());
13119 bytes.extend_from_slice(&0_u64.to_le_bytes());
13120 bytes.extend_from_slice(b"PK\x06\x07");
13121 bytes.extend_from_slice(&0_u32.to_le_bytes());
13122 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
13123 bytes.extend_from_slice(&1_u32.to_le_bytes());
13124 bytes.extend_from_slice(b"PK\x05\x06");
13125 bytes.extend_from_slice(&0_u16.to_le_bytes());
13126 bytes.extend_from_slice(&0_u16.to_le_bytes());
13127 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
13128 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
13129 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
13130 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
13131 bytes.extend_from_slice(&0_u16.to_le_bytes());
13132 let fake_eocd = bytes.len() as u32;
13136 bytes.extend_from_slice(b"PK\x05\x06");
13137 bytes.extend_from_slice(&0_u16.to_le_bytes());
13138 bytes.extend_from_slice(&0_u16.to_le_bytes());
13139 bytes.extend_from_slice(&1_u16.to_le_bytes());
13140 bytes.extend_from_slice(&1_u16.to_le_bytes());
13141 bytes.extend_from_slice(&0_u32.to_le_bytes());
13142 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
13143 bytes.extend_from_slice(&0_u16.to_le_bytes());
13144
13145 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
13146 .unwrap_err()
13147 .to_string();
13148 assert!(error.contains("central directory"), "{error}");
13149 }
13150
13151 #[test]
13152 fn strict_http_status_handling_rejects_redirects_without_panicking() {
13153 let error = ensure_ok(
13154 HubResponse {
13155 status: 302,
13156 body: Some(json!({"redirect": "/elsewhere"})),
13157 },
13158 "mutation",
13159 )
13160 .unwrap_err();
13161 assert!(matches!(error, LinkError::Http { status: 302, .. }));
13162
13163 let error = ensure_raw_ok(
13164 RawHubResponse {
13165 status: 302,
13166 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
13167 },
13168 "feed",
13169 )
13170 .unwrap_err();
13171 assert!(matches!(error, LinkError::Http { status: 302, .. }));
13172 }
13173
13174 #[cfg(unix)]
13175 #[test]
13176 fn collect_push_files_refuses_external_symlink_and_nested_store() {
13177 use std::os::unix::fs::symlink;
13178
13179 let root = tempfile::tempdir().unwrap();
13180 std::fs::write(
13181 root.path().join("DB.md"),
13182 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
13183 )
13184 .unwrap();
13185 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
13186
13187 let external = tempfile::tempdir().unwrap();
13188 let secret = external.path().join("secret.md");
13189 std::fs::write(&secret, "TOP SECRET").unwrap();
13190 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
13191
13192 let store = Store::open_strict(root.path()).unwrap();
13193 let err = collect_push_files(&store).unwrap_err().to_string();
13194 assert!(err.contains("cannot push"), "{err}");
13195 assert!(
13196 !err.contains("TOP SECRET"),
13197 "external bytes must never leak"
13198 );
13199
13200 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
13201 let nested = root.path().join("records/nested");
13202 std::fs::create_dir_all(&nested).unwrap();
13203 std::fs::write(
13204 nested.join("DB.md"),
13205 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
13206 )
13207 .unwrap();
13208 let err = collect_push_files(&store).unwrap_err().to_string();
13209 assert!(err.contains("nested db.md store"), "{err}");
13210 }
13211
13212 #[cfg(unix)]
13213 #[test]
13214 fn remote_push_uses_opened_root_after_path_replacement() {
13215 use std::os::unix::fs::symlink;
13216
13217 let sandbox = tempfile::tempdir().unwrap();
13218 let root = sandbox.path().join("store");
13219 std::fs::create_dir_all(root.join("records/notes")).unwrap();
13220 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
13221 std::fs::write(
13222 root.join("records/notes/owned.md"),
13223 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
13224 )
13225 .unwrap();
13226 let store = Store::open_strict(&root).unwrap();
13227 let detached = sandbox.path().join("detached");
13228 std::fs::rename(&root, &detached).unwrap();
13229
13230 let replacement = sandbox.path().join("replacement");
13231 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
13232 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
13233 std::fs::write(
13234 replacement.join("records/notes/secret.md"),
13235 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
13236 )
13237 .unwrap();
13238 symlink(&replacement, &root).unwrap();
13239
13240 let files = collect_push_files(&store).unwrap();
13241 let wire_text = files
13242 .iter()
13243 .map(|(path, content)| format!("{path}\n{content}"))
13244 .collect::<Vec<_>>()
13245 .join("\n");
13246 assert!(wire_text.contains("owned upload"));
13247 assert!(!wire_text.contains("replacement sentinel"));
13248 assert!(!wire_text.contains("records/notes/secret.md"));
13249
13250 let remote = signed_remote_fixture();
13251 let (hub, server) = scripted_json_hub(vec![
13252 (200, remote.card),
13253 (200, remote.feed),
13254 (200, json!({"ok": true}).to_string()),
13255 ]);
13256 let state = tempfile::tempdir().unwrap();
13257 let cfg = test_hub_config(hub, state.path().to_path_buf());
13258 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
13259 assert_eq!(pushed, json!({"ok": true}));
13260 server.join().unwrap();
13261 }
13262
13263 #[test]
13264 fn signed_feed_item_verifies_identity_hash_and_signature() {
13265 use ring::rand::SystemRandom;
13266 use ring::signature::{Ed25519KeyPair, KeyPair};
13267
13268 const PREFIX: &[u8] = &[
13269 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13270 ];
13271 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
13272 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13273 let mut spki = PREFIX.to_vec();
13274 spki.extend_from_slice(pair.public_key().as_ref());
13275 let public_key = URL_SAFE_NO_PAD.encode(&spki);
13276 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
13277 let mut entry = FeedEntry {
13278 v: 1,
13279 seq: 1,
13280 ts: "2026-07-14T00:00:00.000Z".to_string(),
13281 brain: format!("ed25519:{fingerprint}"),
13282 public_key: public_key.clone(),
13283 kind: "push".to_string(),
13284 op: "snapshot".to_string(),
13285 pack_sha256: "a".repeat(64),
13286 files: vec![FeedFile {
13287 path: "DB.md".to_string(),
13288 sha256: "b".repeat(64),
13289 bytes: 3,
13290 }],
13291 removed: vec![],
13292 prev_entry_hash: None,
13293 sig: String::new(),
13294 };
13295 let unsigned = UnsignedFeedEntry {
13296 v: entry.v,
13297 seq: entry.seq,
13298 ts: &entry.ts,
13299 brain: &entry.brain,
13300 public_key: &entry.public_key,
13301 kind: &entry.kind,
13302 op: &entry.op,
13303 pack_sha256: &entry.pack_sha256,
13304 files: &entry.files,
13305 removed: &entry.removed,
13306 prev_entry_hash: &entry.prev_entry_hash,
13307 };
13308 entry.sig =
13309 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
13310 let mut exact = serde_json::to_vec(&entry).unwrap();
13311 exact.push(b'\n');
13312 let item = FeedItem {
13313 hash: format!("{:x}", Sha256::digest(&exact)),
13314 entry,
13315 };
13316 let identity = FeedIdentity {
13317 fingerprint,
13318 public_key_spki: public_key,
13319 previous: Vec::new(),
13320 rotations: Vec::new(),
13321 };
13322 assert!(verify_feed_item(&item, &identity).is_ok());
13323 let mut tampered = item;
13324 tampered.entry.pack_sha256 = "c".repeat(64);
13325 assert!(verify_feed_item(&tampered, &identity).is_err());
13326 }
13327
13328 #[test]
13329 fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
13330 let rng = ring::rand::SystemRandom::new();
13331 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13332 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13333 let (spki, multikey) = public_identity_for(&pair);
13334 let identity = V2HeadIdentity {
13335 custody: "self".to_string(),
13336 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
13337 public_key_spki: spki.clone(),
13338 previous: Vec::new(),
13339 rotations: Vec::new(),
13340 };
13341 let unsigned = json!({
13342 "actor_ref": "a".repeat(64),
13343 "asset_root": Value::Null,
13344 "brain": multikey,
13345 "changes_sha256": "b".repeat(64),
13346 "control_revision": "c".repeat(64),
13347 "materializer": "dbmd-projection-v1",
13348 "op": "changeset",
13349 "parent_asset_root": Value::Null,
13350 "parent_commit": Value::Null,
13351 "parent_root": Value::Null,
13352 "prev_entry_hash": Value::Null,
13353 "public_key": spki,
13354 "seq": 1,
13355 "signer_epoch": 1,
13356 "state_root": "d".repeat(64),
13357 "ts": "2026-08-19T12:00:00.000Z",
13358 "v": 2,
13359 "v1_bridge": {
13360 "feed_hash": "e".repeat(64),
13361 "head_seq": 7,
13362 "pack_sha256": "f".repeat(64),
13363 },
13364 });
13365 let sign_value = |value: Value| {
13366 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
13367 let mut object = value.as_object().unwrap().clone();
13368 object.insert(
13369 "sig".to_string(),
13370 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
13371 );
13372 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
13373 };
13374 assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
13375
13376 let mut extra = unsigned.clone();
13377 extra
13378 .as_object_mut()
13379 .unwrap()
13380 .insert("future".to_string(), Value::Bool(true));
13381 assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
13382
13383 let mut missing = unsigned.clone();
13384 missing.as_object_mut().unwrap().remove("v1_bridge");
13385 assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
13386
13387 let mut invalid_bridge = unsigned;
13388 invalid_bridge.as_object_mut().unwrap().insert(
13389 "v1_bridge".to_string(),
13390 json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
13391 );
13392 assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
13393 }
13394
13395 #[test]
13396 fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
13397 let vector: Value = serde_json::from_str(include_str!(
13398 "../tests/vectors/linkmd-v2-commit-bridge.json"
13399 ))
13400 .unwrap();
13401 let identity_value = vector.get("identity").unwrap();
13402 let identity = V2HeadIdentity {
13403 custody: "self".to_string(),
13404 fingerprint: identity_value
13405 .get("fingerprint")
13406 .and_then(Value::as_str)
13407 .unwrap()
13408 .to_string(),
13409 public_key_spki: identity_value
13410 .get("public_key_spki")
13411 .and_then(Value::as_str)
13412 .unwrap()
13413 .to_string(),
13414 previous: Vec::new(),
13415 rotations: Vec::new(),
13416 };
13417 let private = URL_SAFE_NO_PAD
13418 .decode(
13419 identity_value
13420 .get("private_key_pkcs8")
13421 .and_then(Value::as_str)
13422 .unwrap(),
13423 )
13424 .unwrap();
13425 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
13426 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
13427 .unwrap();
13428 let base = vector.get("body").unwrap().as_object().unwrap();
13429
13430 for item in vector.get("valid").unwrap().as_array().unwrap() {
13431 let mut body = base.clone();
13432 body.insert(
13433 "v1_bridge".to_string(),
13434 item.get("v1_bridge").unwrap().clone(),
13435 );
13436 body.insert(
13437 "sig".to_string(),
13438 item.get("signature_base64url").unwrap().clone(),
13439 );
13440 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
13441 assert!(verified_v2_commit_object(&signed, &identity).is_ok());
13442 assert_eq!(
13443 crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
13444 item.get("commit_hash").and_then(Value::as_str).unwrap()
13445 );
13446 assert_eq!(
13447 format!("{:x}", Sha256::digest(&signed)),
13448 item.get("feed_hash").and_then(Value::as_str).unwrap()
13449 );
13450 }
13451
13452 for item in vector.get("invalid").unwrap().as_array().unwrap() {
13453 let mut body = base.clone();
13454 if let Some(remove) = item.get("remove").and_then(Value::as_array) {
13455 for field in remove {
13456 body.remove(field.as_str().unwrap());
13457 }
13458 }
13459 if let Some(set) = item.get("set").and_then(Value::as_object) {
13460 for (field, value) in set {
13461 body.insert(field.clone(), value.clone());
13462 }
13463 }
13464 let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
13465 body.insert(
13466 "sig".to_string(),
13467 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
13468 );
13469 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
13470 assert!(
13471 verified_v2_commit_object(&signed, &identity).is_err(),
13472 "accepted invalid shared vector {}",
13473 item.get("reason").and_then(Value::as_str).unwrap()
13474 );
13475 }
13476 }
13477
13478 #[test]
13479 fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
13480 let remote = signed_remote_fixture();
13481 let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
13482 let legacy_item = legacy.entries.first().unwrap();
13483 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
13484 let body = json!({
13485 "actor_ref": "a".repeat(64),
13486 "asset_root": Value::Null,
13487 "brain": remote.key.multikey,
13488 "changes_sha256": "b".repeat(64),
13489 "control_revision": "c".repeat(64),
13490 "materializer": "dbmd-projection-v1",
13491 "op": "changeset",
13492 "parent_asset_root": Value::Null,
13493 "parent_commit": Value::Null,
13494 "parent_root": Value::Null,
13495 "prev_entry_hash": Value::Null,
13496 "public_key": remote.key.public_key_spki,
13497 "seq": 1,
13498 "signer_epoch": 1,
13499 "state_root": "d".repeat(64),
13500 "ts": "2026-08-19T12:00:00.000Z",
13501 "v": 2,
13502 "v1_bridge": {
13503 "feed_hash": legacy_item.hash,
13504 "head_seq": legacy_item.entry.seq,
13505 "pack_sha256": legacy_item.entry.pack_sha256,
13506 },
13507 });
13508 let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
13509 let mut signed = body.as_object().unwrap().clone();
13510 signed.insert(
13511 "sig".to_string(),
13512 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
13513 );
13514 let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
13515 let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
13516 let feed_hash = content_sha256(&raw);
13517 let pointer = V2PointerBody {
13518 v: 2,
13519 brain: TEST_BRAIN_ID.to_string(),
13520 seq: 1,
13521 commit_hash: commit_hash.clone(),
13522 feed_hash: feed_hash.clone(),
13523 content_root: Some("d".repeat(64)),
13524 asset_root: None,
13525 materializer: "dbmd-projection-v1".to_string(),
13526 signer_epoch: 1,
13527 control_revision: "c".repeat(64),
13528 backup_preparation: "e".repeat(64),
13529 prior_pointer_hash: None,
13530 signed_at: "2026-08-19T12:00:00.000Z".to_string(),
13531 };
13532 let v2_page = json!({
13533 "v": 2,
13534 "head_seq": 1,
13535 "head_commit_hash": commit_hash,
13536 "head_feed_hash": feed_hash,
13537 "entries": [{
13538 "seq": 1,
13539 "commit_hash": pointer.commit_hash,
13540 "feed_hash": pointer.feed_hash,
13541 "bytes_base64": STANDARD.encode(&raw),
13542 }],
13543 "next_after": 1,
13544 "complete": true,
13545 })
13546 .to_string();
13547 let identity = V2HeadIdentity {
13548 custody: "self".to_string(),
13549 fingerprint: remote.identity.fingerprint.clone(),
13550 public_key_spki: remote.identity.public_key_spki.clone(),
13551 previous: Vec::new(),
13552 rotations: Vec::new(),
13553 };
13554 let checkpoint = TrustState {
13555 v: 2,
13556 origin: "unused".to_string(),
13557 requested: TEST_BRAIN_ID.to_string(),
13558 brain: TEST_BRAIN_ID.to_string(),
13559 home: None,
13560 anchor: remote.key.multikey.clone(),
13561 current: remote.key.multikey,
13562 head_seq: legacy_item.entry.seq,
13563 feed_hash: Some(legacy_item.hash.clone()),
13564 rotations: Vec::new(),
13565 hub_signer: None,
13566 protocol_profile: None,
13567 };
13568 let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
13569 let state = tempfile::tempdir().unwrap();
13570 let cfg = test_hub_config(hub, state.path().to_path_buf());
13571 verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
13572 server.join().unwrap();
13573
13574 let mut wrong = checkpoint;
13575 wrong.feed_hash = Some("0".repeat(64));
13576 let (hub, server) = scripted_json_hub(vec![(
13577 200,
13578 json!({
13579 "v": 2,
13580 "head_seq": 1,
13581 "head_commit_hash": pointer.commit_hash,
13582 "head_feed_hash": pointer.feed_hash,
13583 "entries": [{
13584 "seq": 1,
13585 "commit_hash": pointer.commit_hash,
13586 "feed_hash": pointer.feed_hash,
13587 "bytes_base64": STANDARD.encode(&raw),
13588 }],
13589 "next_after": 1,
13590 "complete": true,
13591 })
13592 .to_string(),
13593 )]);
13594 let state = tempfile::tempdir().unwrap();
13595 let cfg = test_hub_config(hub, state.path().to_path_buf());
13596 assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
13597 server.join().unwrap();
13598 }
13599
13600 #[test]
13601 fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
13602 let rng = ring::rand::SystemRandom::new();
13603 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13604 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
13605 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13606 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
13607 let (old_spki, old_multikey) = public_identity_for(&old);
13608 let (new_spki, new_multikey) = public_identity_for(&new);
13609 let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
13610 v: 1,
13611 op: "rotate",
13612 brain: &old_multikey,
13613 public_key: &old_spki,
13614 new_brain: &new_multikey,
13615 new_public_key: &new_spki,
13616 prior_head_seq: 1,
13617 prior_feed_hash: Some(&"9".repeat(64)),
13618 ts: "2026-08-19T12:01:00.000Z".to_string(),
13619 })
13620 .unwrap();
13621 let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
13622 let rotation = format!(
13623 "{},\"sig\":\"{}\"}}",
13624 &rotation_unsigned[..rotation_unsigned.len() - 1],
13625 rotation_sig
13626 );
13627 let identity = V2HeadIdentity {
13628 custody: "self".to_string(),
13629 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
13630 public_key_spki: new_spki.clone(),
13631 previous: vec![V2PreviousIdentity {
13632 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
13633 public_key_spki: old_spki.clone(),
13634 }],
13635 rotations: vec![rotation],
13636 };
13637 let commit = |seq: u64,
13638 epoch: u64,
13639 multikey: &str,
13640 spki: &str,
13641 pair: &ring::signature::Ed25519KeyPair| {
13642 let value = json!({
13643 "actor_ref": "a".repeat(64),
13644 "asset_root": Value::Null,
13645 "brain": multikey,
13646 "changes_sha256": "b".repeat(64),
13647 "control_revision": "c".repeat(64),
13648 "materializer": "dbmd-projection-v1",
13649 "op": "changeset",
13650 "parent_asset_root": Value::Null,
13651 "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
13652 "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
13653 "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
13654 "public_key": spki,
13655 "seq": seq,
13656 "signer_epoch": epoch,
13657 "state_root": "1".repeat(64),
13658 "ts": "2026-08-19T12:00:00.000Z",
13659 "v": 2,
13660 "v1_bridge": Value::Null,
13661 });
13662 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
13663 let mut object = value.as_object().unwrap().clone();
13664 object.insert(
13665 "sig".to_string(),
13666 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
13667 );
13668 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
13669 };
13670
13671 assert!(verified_v2_commit_object(
13672 &commit(1, 1, &old_multikey, &old_spki, &old),
13673 &identity,
13674 )
13675 .is_ok());
13676 assert!(verified_v2_commit_object(
13677 &commit(2, 2, &new_multikey, &new_spki, &new),
13678 &identity,
13679 )
13680 .is_ok());
13681 assert!(verified_v2_commit_object(
13682 &commit(2, 1, &old_multikey, &old_spki, &old),
13683 &identity,
13684 )
13685 .is_err());
13686 assert!(verified_v2_commit_object(
13687 &commit(1, 2, &new_multikey, &new_spki, &new),
13688 &identity,
13689 )
13690 .is_err());
13691 }
13692
13693 #[test]
13694 fn a_self_custody_entry_verifies_like_any_hub_entry() {
13695 let rng = ring::rand::SystemRandom::new();
13696 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13697 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
13698 let (spki, multikey) = public_identity_for(&pair);
13699 let key = AgentSigningKey {
13700 pkcs8: pkcs8.as_ref().to_vec(),
13701 multikey: multikey.clone(),
13702 public_key_spki: spki.clone(),
13703 };
13704 let files = vec![WireFeedFile {
13705 path: "DB.md".to_string(),
13706 sha256: "a".repeat(64),
13707 bytes: 3,
13708 }];
13709 let raw = self_custody_entry(
13710 &key,
13711 1,
13712 "2026-07-23T12:00:00.000Z".to_string(),
13713 &"c".repeat(64),
13714 &files,
13715 None,
13716 )
13717 .unwrap();
13718 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
13722 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
13723 let item = FeedItem { hash, entry };
13724 let identity = FeedIdentity {
13725 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
13726 public_key_spki: spki,
13727 previous: Vec::new(),
13728 rotations: Vec::new(),
13729 };
13730 assert!(verify_feed_item(&item, &identity).is_ok());
13731 }
13732
13733 #[test]
13734 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
13735 let rng = ring::rand::SystemRandom::new();
13736 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13737 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
13738 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
13739 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
13740 let (old_spki, old_multikey) = public_identity_for(&old);
13741 let (new_spki, new_multikey) = public_identity_for(&new);
13742 let unsigned = serde_json::to_string(&UnsignedRotation {
13743 v: 1,
13744 op: "rotate",
13745 brain: &old_multikey,
13746 public_key: &old_spki,
13747 new_brain: &new_multikey,
13748 new_public_key: &new_spki,
13749 prior_head_seq: 1,
13750 prior_feed_hash: Some(&"a".repeat(64)),
13751 ts: "2026-07-30T12:00:00.000Z".to_string(),
13752 })
13753 .unwrap();
13754 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
13755 let rotation = format!(
13756 "{},\"sig\":\"{}\"}}",
13757 &unsigned[..unsigned.len() - 1],
13758 signature
13759 );
13760 let identity = FeedIdentity {
13761 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
13762 public_key_spki: new_spki,
13763 previous: vec![PreviousIdentity {
13764 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
13765 public_key_spki: old_spki,
13766 }],
13767 rotations: vec![rotation],
13768 };
13769 let pin = TrustState {
13770 v: 2,
13771 origin: "https://hub.example".to_string(),
13772 requested: "brain".to_string(),
13773 brain: "brain".to_string(),
13774 home: None,
13775 anchor: old_multikey.clone(),
13776 current: old_multikey.clone(),
13777 head_seq: 1,
13778 feed_hash: Some("a".repeat(64)),
13779 rotations: Vec::new(),
13780 hub_signer: None,
13781 protocol_profile: None,
13782 };
13783 assert_eq!(
13784 verify_identity_chain(&identity, Some(&pin)).unwrap(),
13785 old_multikey
13786 );
13787 let mut accepted = pin.clone();
13788 accepted.current = new_multikey.clone();
13789 accepted.rotations = identity.rotations.clone();
13790 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
13791 v: 1,
13792 op: "rotate",
13793 brain: &old_multikey,
13794 public_key: &identity.previous[0].public_key_spki,
13795 new_brain: &new_multikey,
13796 new_public_key: &identity.public_key_spki,
13797 prior_head_seq: 1,
13798 prior_feed_hash: Some(&"a".repeat(64)),
13799 ts: "2026-07-30T12:00:01.000Z".to_string(),
13800 })
13801 .unwrap();
13802 let alternate_signature =
13803 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
13804 let mut rewritten = identity.clone();
13805 rewritten.rotations[0] = format!(
13806 "{},\"sig\":\"{}\"}}",
13807 &alternate_unsigned[..alternate_unsigned.len() - 1],
13808 alternate_signature
13809 );
13810 assert!(
13811 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
13812 "an alternate valid statement must not rewrite accepted history"
13813 );
13814
13815 let mut stale_entry = FeedEntry {
13816 v: 1,
13817 seq: 2,
13818 ts: "2026-07-30T12:01:00.000Z".to_string(),
13819 brain: pin.current.clone(),
13820 public_key: identity.previous[0].public_key_spki.clone(),
13821 kind: "push".to_string(),
13822 op: "snapshot".to_string(),
13823 pack_sha256: "b".repeat(64),
13824 files: Vec::new(),
13825 removed: Vec::new(),
13826 prev_entry_hash: pin.feed_hash.clone(),
13827 sig: String::new(),
13828 };
13829 let stale_unsigned = UnsignedFeedEntry {
13830 v: stale_entry.v,
13831 seq: stale_entry.seq,
13832 ts: &stale_entry.ts,
13833 brain: &stale_entry.brain,
13834 public_key: &stale_entry.public_key,
13835 kind: &stale_entry.kind,
13836 op: &stale_entry.op,
13837 pack_sha256: &stale_entry.pack_sha256,
13838 files: &stale_entry.files,
13839 removed: &stale_entry.removed,
13840 prev_entry_hash: &stale_entry.prev_entry_hash,
13841 };
13842 stale_entry.sig = URL_SAFE_NO_PAD.encode(
13843 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
13844 .as_ref(),
13845 );
13846 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
13847 stale_exact.push(b'\n');
13848 let stale_item = FeedItem {
13849 hash: content_sha256(&stale_exact),
13850 entry: stale_entry,
13851 };
13852 assert!(
13853 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
13854 .is_err(),
13855 "a key retired before the checkpoint must never append after it"
13856 );
13857 assert!(
13858 verify_feed_item(&stale_item, &identity).is_err(),
13859 "an old key must never append after its signed rotation boundary"
13860 );
13861
13862 let mut missing = identity.clone();
13863 missing.rotations.clear();
13864 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
13865
13866 let mut tampered = identity;
13867 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
13868 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
13869 }
13870
13871 #[cfg(unix)]
13872 #[test]
13873 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
13874 use std::os::unix::fs::symlink;
13875
13876 let dir = tempfile::tempdir().unwrap();
13877 let target = dir.path().join("valuable.txt");
13878 let planted = dir.path().join("agent.key");
13879 std::fs::write(&target, "do not overwrite").unwrap();
13880 symlink(&target, &planted).unwrap();
13881
13882 assert!(matches!(
13883 generate_agent_key(&planted),
13884 Err(LinkError::BadAgentKey { .. })
13885 ));
13886 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
13887 }
13888
13889 #[cfg(unix)]
13890 #[test]
13891 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
13892 use std::os::unix::fs::symlink;
13893
13894 let root = tempfile::tempdir().unwrap();
13895 let outside = tempfile::tempdir().unwrap();
13896 symlink(outside.path(), root.path().join("redirect")).unwrap();
13897
13898 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
13899 assert!(!outside.path().join("agent.key").exists());
13900 }
13901
13902 #[test]
13905 fn address_bare_brain_with_and_without_sigil() {
13906 for raw in ["@acme-ops", "acme-ops"] {
13907 let a = Address::parse(raw).expect(raw);
13908 assert_eq!(a.brain, "acme-ops");
13909 assert_eq!(a.target, None);
13910 }
13911 }
13912
13913 #[test]
13914 fn address_ulid_target_parses_as_id() {
13915 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
13916 assert_eq!(a.brain, "acme");
13917 assert_eq!(
13918 a.target,
13919 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
13920 );
13921 }
13922
13923 #[test]
13924 fn address_md_path_target_parses_as_path() {
13925 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
13926 assert_eq!(
13927 a.target,
13928 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
13929 );
13930 }
13931
13932 #[test]
13933 fn address_rejects_malformed_forms() {
13934 for raw in [
13935 "",
13936 "@",
13937 "@/x",
13938 "@acme/",
13939 "@acme/../etc/passwd",
13940 "@acme/records/.hidden.md",
13941 "@ACME", "@acme/notes/x.txt", "@a b", ] {
13945 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
13946 }
13947 }
13948
13949 #[test]
13952 fn safe_paths_accept_store_shapes_and_reject_escapes() {
13953 for ok in [
13954 "DB.md",
13955 "assets.jsonl",
13956 "records/clients/lumio.md",
13957 "sources/emails/2026/07/x.md",
13958 ] {
13959 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
13960 }
13961 for bad in [
13962 "",
13963 "/etc/passwd",
13964 "../up.md",
13965 "records/../../up.md",
13966 "records//x.md",
13967 ".dbmd/config",
13968 "records/.hidden/x.md",
13969 "records/a b.md",
13970 "records\\win.md",
13971 ] {
13972 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
13973 }
13974 }
13975
13976 #[cfg(unix)]
13977 #[test]
13978 fn opened_destination_capability_survives_an_ancestor_path_swap() {
13979 use std::os::unix::fs::symlink;
13980
13981 let work = tempfile::tempdir().unwrap();
13982 let outside = tempfile::tempdir().unwrap();
13983 let original = work.path().join("destination");
13984 let moved = work.path().join("destination-moved");
13985 let directory = open_or_create_dir_nofollow(&original).unwrap();
13986
13987 std::fs::rename(&original, &moved).unwrap();
13988 symlink(outside.path(), &original).unwrap();
13989 write_pull_entries_beneath_dir(
13990 &directory,
13991 &[("records/note.md".to_string(), b"held inode".to_vec())],
13992 )
13993 .unwrap();
13994
13995 assert_eq!(
13996 std::fs::read(moved.join("records/note.md")).unwrap(),
13997 b"held inode"
13998 );
13999 assert!(!outside.path().join("records/note.md").exists());
14000 }
14001
14002 #[test]
14006 fn hub_config_flag_beats_file_and_requires_some_source() {
14007 let dir = tempfile::tempdir().unwrap();
14008 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
14009 std::fs::write(
14010 dir.path().join(CONFIG_REL_PATH),
14011 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
14012 )
14013 .unwrap();
14014
14015 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
14016 assert_eq!(from_flag.hub, "https://flag.example.com");
14017
14018 let from_file = hub_config(None, dir.path()).unwrap();
14019 assert_eq!(from_file.hub, "https://file.example.com");
14020
14021 let none = hub_config(None, tempfile::tempdir().unwrap().path());
14022 assert!(matches!(none, Err(LinkError::NoHub)));
14023 }
14024
14025 #[test]
14026 fn https_guard_allows_loopback_only_for_plain_http() {
14027 assert!(assert_safe_hub("https://hub.example.com").is_ok());
14028 assert!(assert_safe_hub("http://localhost:3000").is_ok());
14029 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
14030 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
14031 assert!(matches!(
14032 assert_safe_hub("http://hub.example.com"),
14033 Err(LinkError::UnsafeHub { .. })
14034 ));
14035 assert!(matches!(
14036 assert_safe_hub("hub.example.com"),
14037 Err(LinkError::UnsafeHub { .. })
14038 ));
14039 assert!(matches!(
14040 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
14041 Err(LinkError::UnsafeHub { .. })
14042 ));
14043 assert!(matches!(
14044 assert_safe_hub("https://hub.example.com@attacker.example"),
14045 Err(LinkError::UnsafeHub { .. })
14046 ));
14047 assert!(matches!(
14048 assert_safe_hub("https://hub.example.com/base"),
14049 Err(LinkError::UnsafeHub { .. })
14050 ));
14051 }
14052
14053 #[test]
14054 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
14055 for blocked in [
14056 "127.0.0.1",
14057 "10.0.0.1",
14058 "100.64.0.1",
14059 "169.254.169.254",
14060 "172.16.0.1",
14061 "192.168.0.1",
14062 "192.88.99.1",
14063 "198.18.0.1",
14064 "203.0.113.1",
14065 "::1",
14066 "fe80::1",
14067 "fd00::1",
14068 "2001:db8::1",
14069 "2001:1::1",
14070 "2002:7f00:1::",
14071 "3fff::1",
14072 ] {
14073 assert!(
14074 !is_public_registry_ip(blocked.parse().unwrap()),
14075 "must block {blocked}"
14076 );
14077 }
14078 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
14079 assert!(is_public_registry_ip(
14080 "2606:4700:4700::1111".parse().unwrap()
14081 ));
14082 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
14083 }
14084
14085 #[test]
14086 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
14087 use ureq::Resolver as _;
14088
14089 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
14090 let resolver = PinnedRegistryResolver {
14091 netloc: "home.example:443".to_string(),
14092 addresses: vec![pinned],
14093 };
14094 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
14095 assert!(resolver.resolve("127.0.0.1:443").is_err());
14096 assert_eq!(
14097 resolver.resolve("home.example:443").unwrap(),
14098 vec![pinned],
14099 "subsequent connects reuse the validated answer instead of DNS"
14100 );
14101 }
14102
14103 #[test]
14104 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
14105 let cfg = HubConfig {
14106 hub: "https://hub.example".to_string(),
14107 key: None,
14108 agent_key: None,
14109 brain_key: None,
14110 state_dir: tempfile::tempdir().unwrap().keep(),
14111 store_selected: false,
14112 };
14113 assert!(
14114 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
14115 "a production hub must not turn its presigned URL into an SSRF primitive"
14116 );
14117
14118 let store_selected = HubConfig {
14119 hub: "https://127.0.0.1".to_string(),
14120 store_selected: true,
14121 ..cfg
14122 };
14123 assert!(
14124 hub_agent(&store_selected).is_err(),
14125 "bytes in a cloned store must not select a private-network hub"
14126 );
14127 }
14128
14129 #[test]
14130 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
14131 assert_eq!(
14132 one_past_bounded_limit(MAX_PACK_BYTES),
14133 Some(MAX_PACK_BYTES + 1),
14134 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
14135 );
14136 assert_eq!(
14137 presigned_download_read_limit(),
14138 MAX_PACK_BYTES + 1,
14139 "the presigned reader is capped by the client constant, not a hub response"
14140 );
14141 assert_eq!(
14142 one_past_bounded_limit(u64::MAX),
14143 None,
14144 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
14145 );
14146 }
14147
14148 #[test]
14149 fn https_guard_matches_the_scheme_case_insensitively() {
14150 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
14153 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
14154 assert!(matches!(
14156 assert_safe_hub("HTTP://hub.example.com"),
14157 Err(LinkError::UnsafeHub { .. })
14158 ));
14159 }
14160
14161 #[test]
14162 fn clean_key_refuses_paste_artifacts_without_echoing() {
14163 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
14164 for bad in ["vc account", "vc\naccount", "ключ", ""] {
14165 let err = clean_key(bad).unwrap_err();
14166 assert!(matches!(err, LinkError::BadKey));
14167 assert!(
14168 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
14169 "error must not echo the key"
14170 );
14171 }
14172 }
14173
14174 fn dead_hub() -> HubConfig {
14180 HubConfig {
14181 hub: "http://127.0.0.1:9".to_string(),
14182 key: Some("k".to_string()),
14183 agent_key: None,
14184 brain_key: None,
14185 state_dir: PathBuf::from("."),
14186 store_selected: false,
14187 }
14188 }
14189
14190 #[test]
14191 fn request_retries_a_connection_failure_before_sending() {
14192 use std::io::{Read as _, Write as _};
14193 use std::net::TcpListener;
14194 use std::thread;
14195 use std::time::Duration;
14196
14197 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
14198 let address = probe.local_addr().unwrap();
14199 drop(probe);
14200 let server = thread::spawn(move || {
14201 thread::sleep(Duration::from_millis(40));
14202 let listener = TcpListener::bind(address).unwrap();
14203 let (mut stream, _) = listener.accept().unwrap();
14204 let mut request_bytes = [0_u8; 1024];
14205 let _ = stream.read(&mut request_bytes).unwrap();
14206 stream
14207 .write_all(
14208 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
14209 )
14210 .unwrap();
14211 });
14212 let cfg = HubConfig {
14213 hub: format!("http://{address}"),
14214 key: None,
14215 agent_key: None,
14216 brain_key: None,
14217 state_dir: tempfile::tempdir().unwrap().keep(),
14218 store_selected: false,
14219 };
14220
14221 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
14222 assert_eq!(response.status, 200);
14223 assert_eq!(response.body, Some(json!({ "ok": true })));
14224 server.join().unwrap();
14225 }
14226
14227 #[test]
14228 fn endpoint_cap_refuses_a_body_before_json_parsing() {
14229 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
14230 let cfg = HubConfig {
14231 hub,
14232 key: None,
14233 agent_key: None,
14234 brain_key: None,
14235 state_dir: tempfile::tempdir().unwrap().keep(),
14236 store_selected: false,
14237 };
14238
14239 assert!(matches!(
14240 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
14241 Err(LinkError::ResponseTooLarge { .. })
14242 ));
14243 server.join().unwrap();
14244 }
14245
14246 #[test]
14247 fn overall_deadline_stops_a_dribbled_response_body() {
14248 use std::io::{Read as _, Write as _};
14249 use std::net::TcpListener;
14250 use std::time::{Duration, Instant};
14251
14252 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
14253 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
14254 let server = std::thread::spawn(move || {
14255 let (mut stream, _) = listener.accept().unwrap();
14256 let mut request = [0_u8; 1024];
14257 let _ = stream.read(&mut request);
14258 stream
14259 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
14260 .unwrap();
14261 for byte in [b'x'; 32] {
14262 if stream.write_all(&[byte]).is_err() {
14263 break;
14264 }
14265 std::thread::sleep(Duration::from_millis(40));
14266 }
14267 });
14268 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
14269 let started = Instant::now();
14270 let response = http.get(&url).call().unwrap();
14271 let mut body = Vec::new();
14272 let error = response
14273 .into_reader()
14274 .read_to_end(&mut body)
14275 .expect_err("per-read progress must not reset the overall deadline");
14276 assert!(
14277 started.elapsed() < Duration::from_millis(700),
14278 "dribbled body exceeded the wall-clock budget: {error}"
14279 );
14280 server.join().unwrap();
14281 }
14282
14283 #[test]
14284 fn overall_deadline_stops_a_stalled_upload() {
14285 use std::net::TcpListener;
14286 use std::time::{Duration, Instant};
14287
14288 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
14289 let url = format!("http://{}/upload", listener.local_addr().unwrap());
14290 let server = std::thread::spawn(move || {
14291 let (_stream, _) = listener.accept().unwrap();
14292 std::thread::sleep(Duration::from_millis(600));
14295 });
14296 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
14297 let body = vec![0x5a; 32 * 1024 * 1024];
14298 let started = Instant::now();
14299 let error = http
14300 .put(&url)
14301 .send_bytes(&body)
14302 .expect_err("stalled request-body writes must time out");
14303 assert!(
14304 started.elapsed() < Duration::from_millis(700),
14305 "stalled upload exceeded the wall-clock budget: {error}"
14306 );
14307 server.join().unwrap();
14308 }
14309
14310 #[test]
14311 fn verb_entry_gates_accept_the_hub_ref_shapes() {
14312 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
14313 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
14314 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
14315 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
14316 }
14317 }
14318
14319 #[test]
14320 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
14321 let cfg = dead_hub();
14322 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
14323 assert!(
14324 matches!(
14325 sync_pull(&cfg, bad, None),
14326 Err(LinkError::BadAddress { .. })
14327 ),
14328 "sync_pull must refuse {bad:?}"
14329 );
14330 assert!(
14331 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
14332 "sync_push must refuse {bad:?}"
14333 );
14334 assert!(
14335 matches!(
14336 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
14337 Err(LinkError::BadAddress { .. })
14338 ),
14339 "grant_issue must refuse {bad:?}"
14340 );
14341 assert!(
14342 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
14343 "grant_list must refuse {bad:?}"
14344 );
14345 assert!(
14346 matches!(
14347 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
14348 Err(LinkError::BadAddress { .. })
14349 ),
14350 "grant_revoke must refuse brain {bad:?}"
14351 );
14352 assert!(
14353 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
14354 "head must refuse {bad:?}"
14355 );
14356 }
14357 }
14358
14359 #[test]
14360 fn grant_revoke_refuses_url_reshaping_grant_ids() {
14361 let cfg = dead_hub();
14362 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
14363 assert!(
14364 matches!(
14365 grant_revoke(&cfg, "acme", bad),
14366 Err(LinkError::BadGrantId { .. })
14367 ),
14368 "grant_revoke must refuse grant id {bad:?}"
14369 );
14370 }
14371 }
14372
14373 #[test]
14374 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
14375 let cfg = dead_hub();
14376 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
14377 assert!(
14378 matches!(
14379 propose(&cfg, bad, "intake", "hi"),
14380 Err(LinkError::BadAddress { .. })
14381 ),
14382 "propose must refuse handle {bad:?}"
14383 );
14384 }
14385 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
14386 assert!(matches!(
14387 propose(&cfg, "acme-site", "intake", &oversize),
14388 Err(LinkError::ProposeTooLarge { .. })
14389 ));
14390 assert!(matches!(
14393 propose(&cfg, "acme-site", "intake", "hi"),
14394 Err(LinkError::Transport { .. })
14395 ));
14396 }
14397
14398 #[test]
14399 fn resolve_refuses_a_hand_built_unsafe_address() {
14400 let cfg = dead_hub();
14401 for brain in ["../up", "a/b", "a?x", "a#f"] {
14402 let addr = Address {
14403 brain: brain.to_string(),
14404 target: None,
14405 };
14406 assert!(
14407 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
14408 "resolve must refuse brain {brain:?}"
14409 );
14410 }
14411 for target in [
14412 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
14413 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
14415 AddressTarget::Path("records/x.md#frag".to_string()),
14416 ] {
14417 let addr = Address {
14418 brain: "acme".to_string(),
14419 target: Some(target.clone()),
14420 };
14421 assert!(
14422 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
14423 "resolve must refuse target {target:?}"
14424 );
14425 }
14426 }
14427
14428 #[test]
14429 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
14430 let mut local = std::collections::BTreeMap::new();
14431 local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
14432 local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
14433 let mut remote = std::collections::BTreeMap::new();
14434 remote.insert(
14435 "records/a.md".to_string(),
14436 V2BaselineFile {
14437 sha256: "c".repeat(64),
14438 bytes: 1,
14439 proof: None,
14440 },
14441 );
14442 remote.insert(
14443 "records/b.md".to_string(),
14444 V2BaselineFile {
14445 sha256: "b".repeat(64),
14446 bytes: 1,
14447 proof: None,
14448 },
14449 );
14450 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
14451 }
14452
14453 #[test]
14454 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
14455 let local = std::collections::BTreeMap::new();
14456 let mut remote = std::collections::BTreeMap::new();
14457 remote.insert(
14458 "private/local.md".to_string(),
14459 V2BaselineFile {
14460 sha256: "d".repeat(64),
14461 bytes: 1,
14462 proof: None,
14463 },
14464 );
14465 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
14466 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
14467 }
14468
14469 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
14470 V2VerifiedHead {
14471 requested: TEST_BRAIN_ID.to_string(),
14472 brain_id: TEST_BRAIN_ID.to_string(),
14473 view_kind: "scoped".to_string(),
14474 view_revision: revision.to_string(),
14475 identity: V2HeadIdentity {
14476 custody: "hub".to_string(),
14477 fingerprint: "test".to_string(),
14478 public_key_spki: "test".to_string(),
14479 previous: Vec::new(),
14480 rotations: Vec::new(),
14481 },
14482 pointer: None,
14483 trust: TrustState {
14484 v: 2,
14485 origin: "https://hub.example".to_string(),
14486 requested: TEST_BRAIN_ID.to_string(),
14487 brain: TEST_BRAIN_ID.to_string(),
14488 home: None,
14489 anchor: "ed25519:test".to_string(),
14490 current: "ed25519:test".to_string(),
14491 head_seq: 0,
14492 feed_hash: None,
14493 rotations: Vec::new(),
14494 hub_signer: None,
14495 protocol_profile: Some("link-v2".to_string()),
14496 },
14497 alias: None,
14498 }
14499 }
14500
14501 #[test]
14502 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
14503 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
14504 assert!(accepted_as_v2(&trust));
14505
14506 trust.protocol_profile = None;
14507 trust.hub_signer = Some("ed25519:hub".to_string());
14508 assert!(accepted_as_v2(&trust));
14509
14510 trust.hub_signer = None;
14511 assert!(!accepted_as_v2(&trust));
14512 }
14513
14514 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
14515 V2SyncBaseline {
14516 v: 2,
14517 origin: "https://hub.example".to_string(),
14518 brain: TEST_BRAIN_ID.to_string(),
14519 head_seq: Some(0),
14520 commit_hash: None,
14521 content_root: None,
14522 asset_root: None,
14523 assets: std::collections::BTreeMap::new(),
14524 view_kind: Some("scoped".to_string()),
14525 view_revision: Some(revision.to_string()),
14526 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
14527 files: std::collections::BTreeMap::new(),
14528 local_policy_digest: None,
14529 local_eligibility: std::collections::BTreeMap::new(),
14530 remote_copy_remains: std::collections::BTreeMap::new(),
14531 }
14532 }
14533
14534 #[test]
14535 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
14536 let directory = tempfile::tempdir().unwrap();
14537 std::fs::write(
14538 directory.path().join("DB.md"),
14539 scoped_projection_bytes(TEST_BRAIN_ID),
14540 )
14541 .unwrap();
14542 let store = Store::open_strict(directory.path()).unwrap();
14543 let head = scoped_test_head(&"a".repeat(64));
14544 let baseline = scoped_test_baseline(&"a".repeat(64));
14545 let mut view = v2_local_files(&store).unwrap();
14546 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
14547 assert!(!view.riding.contains_key("DB.md"));
14548 assert!(!view.eligibility.contains_key("DB.md"));
14549 }
14550
14551 #[test]
14552 fn scoped_projection_edit_and_scope_transition_fail_closed() {
14553 let directory = tempfile::tempdir().unwrap();
14554 std::fs::write(
14555 directory.path().join("DB.md"),
14556 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
14557 )
14558 .unwrap();
14559 let store = Store::open_strict(directory.path()).unwrap();
14560 let head = scoped_test_head(&"a".repeat(64));
14561 let baseline = scoped_test_baseline(&"a".repeat(64));
14562 let mut view = v2_local_files(&store).unwrap();
14563 assert!(matches!(
14564 remove_scoped_projection(&head, Some(&baseline), &mut view),
14565 Err(LinkError::ScopedProjectionModified)
14566 ));
14567
14568 let changed = scoped_test_head(&"b".repeat(64));
14569 assert!(matches!(
14570 ensure_v2_view_compatible(&changed, Some(&baseline)),
14571 Err(LinkError::ScopedViewChanged)
14572 ));
14573 }
14574
14575 #[test]
14576 fn scoped_view_metadata_is_explicitly_non_authoritative() {
14577 let head = scoped_test_head(&"a".repeat(64));
14578 let value: Value =
14579 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
14580 assert_eq!(value["kind"], "link.md-scoped-view");
14581 assert_eq!(value["authoritative"], false);
14582 assert_eq!(value["visible_files"], 7);
14583 assert_eq!(value["brain"], TEST_BRAIN_ID);
14584 }
14585
14586 #[test]
14587 fn local_scoped_marker_requires_the_exact_generated_projection() {
14588 let directory = tempfile::tempdir().unwrap();
14589 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
14590 std::fs::write(
14591 directory.path().join("DB.md"),
14592 scoped_projection_bytes(TEST_BRAIN_ID),
14593 )
14594 .unwrap();
14595 let head = scoped_test_head(&"a".repeat(64));
14596 std::fs::write(
14597 directory.path().join(".dbmd/view.json"),
14598 scoped_view_metadata(&head, 0).unwrap(),
14599 )
14600 .unwrap();
14601 let store = Store::open_strict(directory.path()).unwrap();
14602 assert!(has_verified_local_scoped_view(&store));
14603
14604 std::fs::write(
14605 directory.path().join("DB.md"),
14606 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
14607 )
14608 .unwrap();
14609 let altered = Store::open_strict(directory.path()).unwrap();
14610 assert!(!has_verified_local_scoped_view(&altered));
14611 }
14612
14613 fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
14614 use ring::signature::KeyPair as _;
14615
14616 let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
14617 let rng = ring::rand::SystemRandom::new();
14618 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14619 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14620 let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
14621 let public_key = URL_SAFE_NO_PAD.encode(&public_der);
14622 let fingerprint = format!("{:x}", Sha256::digest(&public_der));
14623 let blob = b"new";
14624 let blob_hash = content_sha256(blob);
14625 let changes = json!({
14626 "mutation_id": "sync:proposal-fixture",
14627 "operations": [{
14628 "blob": blob_hash,
14629 "bytes": blob.len(),
14630 "expected": null,
14631 "op": "put",
14632 "path": "records/new.md",
14633 }],
14634 "reason": "fixture",
14635 "v": 2,
14636 });
14637 let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
14638 let changes_base64 = STANDARD.encode(&changes_bytes);
14639 let descriptor = json!({
14640 "base": null,
14641 "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
14642 "changes_base64": changes_base64,
14643 "rebase": "strict",
14644 "v": 2,
14645 });
14646 let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
14647 let payload_hash = "b".repeat(64);
14648 let submitted_at = "2026-08-19T12:00:00.000Z";
14649 let claim = json!({
14650 "actor_root": {
14651 "actor_class": "foreign_key",
14652 "credential": "ed25519:fixture",
14653 "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
14654 "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
14655 "principal": "key:fixture",
14656 "role": null,
14657 },
14658 "brain": TEST_BRAIN_ID,
14659 "clear_sha256": clear_hash,
14660 "control_revision": "c".repeat(64),
14661 "mutation_id": "sync:proposal-fixture",
14662 "payload_sha256": payload_hash,
14663 "proposal_id": proposal_id,
14664 "submitted_at": submitted_at,
14665 "v": 2,
14666 });
14667 let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
14668 let envelope = json!({
14669 "claim": claim,
14670 "fingerprint": fingerprint,
14671 "public_key": public_key,
14672 "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
14673 });
14674 let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
14675 let submission_hash =
14676 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
14677 let mut head = scoped_test_head(&"c".repeat(64));
14678 head.view_kind = "full".to_string();
14679 head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
14680 let value = json!({
14681 "proposal": {
14682 "base": null,
14683 "blobs": [{
14684 "bytes": blob.len(),
14685 "endpoint": format!(
14686 "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
14687 ),
14688 "sha256": blob_hash,
14689 }],
14690 "changes_base64": changes_base64,
14691 "clear_sha256": clear_hash,
14692 "expires_at": "2026-08-26T12:00:00.000Z",
14693 "id": proposal_id,
14694 "payload_sha256": payload_hash,
14695 "proposer": { "class": "foreign_key" },
14696 "rebase": "strict",
14697 "state": "pending",
14698 "submission_claim_base64": STANDARD.encode(envelope_bytes),
14699 "submission_claim_sha256": submission_hash,
14700 "submitted_at": submitted_at,
14701 },
14702 "v": 2,
14703 });
14704 (head, proposal_id, value)
14705 }
14706
14707 #[test]
14708 fn v2_proposal_verifier_accepts_exact_signed_payload() {
14709 let (head, proposal_id, value) = signed_proposal_fixture();
14710 let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
14711 assert_eq!(verified.blobs.len(), 1);
14712 assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
14713 }
14714
14715 #[test]
14716 fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
14717 let (head, proposal_id, value) = signed_proposal_fixture();
14718
14719 let mut changed = value.clone();
14720 changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
14721 assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
14722
14723 let mut redirected = value.clone();
14724 redirected["proposal"]["blobs"][0]["endpoint"] =
14725 Value::String("https://attacker.example/blob".to_string());
14726 assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
14727
14728 let mut forged = value;
14729 let encoded = forged["proposal"]["submission_claim_base64"]
14730 .as_str()
14731 .unwrap();
14732 let mut envelope: Value =
14733 serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
14734 envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
14735 let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
14736 forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
14737 forged["proposal"]["submission_claim_sha256"] = Value::String(
14738 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
14739 );
14740 assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
14741 }
14742
14743 #[cfg(unix)]
14744 #[test]
14745 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
14746 let sandbox = tempfile::tempdir().unwrap();
14747 let destination = sandbox.path().join("brain");
14748 let entries = vec![
14749 (
14750 "DB.md".to_string(),
14751 scoped_projection_bytes(TEST_BRAIN_ID),
14752 ),
14753 (
14754 "records/contacts/a.md".to_string(),
14755 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
14756 .to_vec(),
14757 ),
14758 ];
14759 install_pulled_delta(&destination, &entries, &[], true).unwrap();
14760 assert!(destination.join("index.md").is_file());
14761 assert!(destination.join("records/index.md").is_file());
14762 assert!(destination.join("records/contacts/index.md").is_file());
14763 assert!(destination.join("records/contacts/index.jsonl").is_file());
14764 }
14765
14766 #[test]
14767 fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
14768 let body = b"bounded bytes";
14769 let path = "records/example.md".to_string();
14770 let file = V2BaselineFile {
14771 sha256: content_sha256(body),
14772 bytes: body.len() as u64,
14773 proof: None,
14774 };
14775 let header = serde_json::to_vec(&json!({
14776 "bytes": body.len(),
14777 "path": path,
14778 "sha256": file.sha256,
14779 "v": 2,
14780 }))
14781 .unwrap();
14782 let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
14783 stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
14784 stream.extend_from_slice(&header);
14785 stream.extend_from_slice(body);
14786 stream.extend_from_slice(&0_u32.to_be_bytes());
14787 let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
14788 assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
14789
14790 let mut tampered = stream.clone();
14791 let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
14792 tampered[body_offset] ^= 1;
14793 assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
14794
14795 let mut trailing = stream;
14796 trailing.push(0);
14797 assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
14798 }
14799
14800 #[test]
14801 fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
14802 let sandbox = tempfile::TempDir::new().unwrap();
14803 let root = sandbox.path().join("brain");
14804 std::fs::create_dir_all(&root).unwrap();
14805 std::fs::write(
14806 root.join("DB.md"),
14807 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
14808 )
14809 .unwrap();
14810 let store = Store::open_strict(&root).unwrap();
14811 let incomplete = crate::ulid::mint();
14812 store
14813 .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
14814 .unwrap();
14815 let expired = crate::ulid::mint();
14816 store
14817 .create_dir_all(&v2_conflict_relative(&expired, "files"))
14818 .unwrap();
14819 let plan = V2ConflictPlan {
14820 v: 2,
14821 class: "content_resolution_required".to_string(),
14822 bundle: expired.clone(),
14823 brain: TEST_BRAIN_ID.to_string(),
14824 origin: "https://example.test".to_string(),
14825 created_unix: 0,
14826 expires_unix: 0,
14827 base_seq: None,
14828 base_commit: None,
14829 remote_seq: 0,
14830 remote_commit: None,
14831 remote_content_root: None,
14832 view_kind: "full".to_string(),
14833 view_revision: "a".repeat(64),
14834 files: vec![V2ConflictFile {
14835 path: "records/value.md".to_string(),
14836 base: V2ConflictCoordinate {
14837 sha256: None,
14838 bytes: None,
14839 file: None,
14840 },
14841 local: V2ConflictCoordinate {
14842 sha256: None,
14843 bytes: None,
14844 file: None,
14845 },
14846 remote: V2ConflictCoordinate {
14847 sha256: None,
14848 bytes: None,
14849 file: None,
14850 },
14851 }],
14852 };
14853 let mut bytes = serde_json::to_vec(&plan).unwrap();
14854 bytes.push(b'\n');
14855 store
14856 .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
14857 .unwrap();
14858
14859 let listed = sync_conflicts(&root, false, false).unwrap();
14860 assert_eq!(listed["bundles"], 2);
14861 assert_eq!(listed["pruned"], 0);
14862 let pruned = sync_conflicts(&root, true, false).unwrap();
14863 assert_eq!(pruned["bundles"], 0);
14864 assert_eq!(pruned["pruned"], 2);
14865 assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
14866 assert!(!root.join(".dbmd/conflicts").join(expired).exists());
14867 }
14868
14869 #[test]
14870 fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
14871 let sandbox = tempfile::TempDir::new().unwrap();
14872 let root = sandbox.path().join("brain");
14873 std::fs::create_dir_all(&root).unwrap();
14874 std::fs::write(
14875 root.join("DB.md"),
14876 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
14877 )
14878 .unwrap();
14879 let store = Store::open_strict(&root).unwrap();
14880 let bundle = crate::ulid::mint();
14881 store
14882 .create_dir_all(&v2_conflict_relative(&bundle, "files"))
14883 .unwrap();
14884 store
14885 .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
14886 .unwrap();
14887
14888 assert!(sync_conflicts(&root, true, false).is_err());
14889 assert!(sync_conflicts(&root, false, true).is_err());
14890 let pruned = sync_conflicts(&root, true, true).unwrap();
14891 assert_eq!(pruned["pruned"], 1);
14892 assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
14893 }
14894
14895 #[cfg(windows)]
14896 #[test]
14897 fn windows_ready_pull_journal_rolls_back_exact_preimages() {
14898 let sandbox = tempfile::TempDir::new().unwrap();
14899 let root = sandbox.path().join("brain");
14900 std::fs::create_dir_all(root.join("records")).unwrap();
14901 std::fs::write(
14902 root.join("DB.md"),
14903 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
14904 )
14905 .unwrap();
14906 let path = "records/value.md";
14907 let old = b"---\ntype: note\n---\n\nold\n";
14908 let new = b"---\ntype: note\n---\n\nnew\n";
14909 std::fs::write(root.join(path), old).unwrap();
14910 let store = Store::open_strict(&root).unwrap();
14911 let bundle = crate::ulid::mint();
14912 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
14913 store.create_dir_all(Path::new(&backup_dir)).unwrap();
14914 store
14915 .write_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
14916 .unwrap();
14917 let journal = WindowsPullJournal {
14918 v: 1,
14919 phase: WindowsPullPhase::Ready,
14920 brain: TEST_BRAIN_ID.to_string(),
14921 previous: WindowsPullCoordinate {
14922 head_seq: Some(1),
14923 commit_hash: Some("a".repeat(64)),
14924 view_kind: Some("full".to_string()),
14925 view_revision: Some("b".repeat(64)),
14926 },
14927 next: WindowsPullCoordinate {
14928 head_seq: Some(2),
14929 commit_hash: Some("c".repeat(64)),
14930 view_kind: Some("full".to_string()),
14931 view_revision: Some("d".repeat(64)),
14932 },
14933 backup_dir: backup_dir.clone(),
14934 entries: vec![WindowsPullJournalEntry {
14935 path: path.to_string(),
14936 old: Some(WindowsPullFileCoordinate {
14937 sha256: content_sha256(old),
14938 bytes: old.len() as u64,
14939 }),
14940 new: Some(WindowsPullFileCoordinate {
14941 sha256: content_sha256(new),
14942 bytes: new.len() as u64,
14943 }),
14944 backup: Some("00000000".to_string()),
14945 }],
14946 };
14947 validate_windows_pull_journal(&journal).unwrap();
14948 store
14949 .write_atomic_new(
14950 Path::new(WINDOWS_PULL_JOURNAL),
14951 &windows_pull_journal_bytes(&journal).unwrap(),
14952 )
14953 .unwrap();
14954 store.write_atomic(Path::new(path), new).unwrap();
14955
14956 let cfg = test_hub_config(
14957 "https://example.test".to_string(),
14958 sandbox.path().join("state"),
14959 );
14960 recover_windows_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
14961 assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
14962 assert!(!root.join(WINDOWS_PULL_JOURNAL).exists());
14963 assert!(!root.join(backup_dir).exists());
14964 }
14965
14966 #[cfg(windows)]
14967 #[test]
14968 fn windows_preparing_pull_journal_discards_only_private_staging() {
14969 let sandbox = tempfile::TempDir::new().unwrap();
14970 let root = sandbox.path().join("brain");
14971 std::fs::create_dir_all(root.join(".dbmd")).unwrap();
14972 std::fs::write(
14973 root.join("DB.md"),
14974 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
14975 )
14976 .unwrap();
14977 let store = Store::open_strict(&root).unwrap();
14978 let bundle = crate::ulid::mint();
14979 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
14980 store.create_dir_all(Path::new(&backup_dir)).unwrap();
14981 let journal = WindowsPullJournal {
14982 v: 1,
14983 phase: WindowsPullPhase::Preparing,
14984 brain: TEST_BRAIN_ID.to_string(),
14985 previous: WindowsPullCoordinate {
14986 head_seq: None,
14987 commit_hash: None,
14988 view_kind: None,
14989 view_revision: None,
14990 },
14991 next: WindowsPullCoordinate {
14992 head_seq: Some(1),
14993 commit_hash: Some("a".repeat(64)),
14994 view_kind: Some("full".to_string()),
14995 view_revision: Some("b".repeat(64)),
14996 },
14997 backup_dir: backup_dir.clone(),
14998 entries: vec![WindowsPullJournalEntry {
14999 path: "records/new.md".to_string(),
15000 old: None,
15001 new: Some(WindowsPullFileCoordinate {
15002 sha256: "c".repeat(64),
15003 bytes: 1,
15004 }),
15005 backup: None,
15006 }],
15007 };
15008 store
15009 .write_atomic_new(
15010 Path::new(WINDOWS_PULL_JOURNAL),
15011 &windows_pull_journal_bytes(&journal).unwrap(),
15012 )
15013 .unwrap();
15014 let cfg = test_hub_config(
15015 "https://example.test".to_string(),
15016 sandbox.path().join("state"),
15017 );
15018
15019 recover_windows_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
15020
15021 assert!(root.join("DB.md").is_file());
15022 assert!(!root.join(WINDOWS_PULL_JOURNAL).exists());
15023 assert!(!root.join(backup_dir).exists());
15024 }
15025
15026 #[cfg(windows)]
15027 #[test]
15028 fn windows_committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
15029 let sandbox = tempfile::TempDir::new().unwrap();
15030 let root = sandbox.path().join("brain");
15031 std::fs::create_dir_all(root.join("records")).unwrap();
15032 std::fs::write(
15033 root.join("DB.md"),
15034 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
15035 )
15036 .unwrap();
15037 let new = b"---\ntype: note\n---\n\nnew\n";
15038 std::fs::write(root.join("records/value.md"), new).unwrap();
15039 let store = Store::open_strict(&root).unwrap();
15040 let bundle = crate::ulid::mint();
15041 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
15042 store.create_dir_all(Path::new(&backup_dir)).unwrap();
15043 let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
15044 store.create_dir_all(Path::new(&orphan)).unwrap();
15045 let next = WindowsPullCoordinate {
15046 head_seq: Some(2),
15047 commit_hash: Some("c".repeat(64)),
15048 view_kind: Some("full".to_string()),
15049 view_revision: Some("d".repeat(64)),
15050 };
15051 let journal = WindowsPullJournal {
15052 v: 1,
15053 phase: WindowsPullPhase::Ready,
15054 brain: TEST_BRAIN_ID.to_string(),
15055 previous: WindowsPullCoordinate {
15056 head_seq: Some(1),
15057 commit_hash: Some("a".repeat(64)),
15058 view_kind: Some("full".to_string()),
15059 view_revision: Some("b".repeat(64)),
15060 },
15061 next: next.clone(),
15062 backup_dir: backup_dir.clone(),
15063 entries: vec![WindowsPullJournalEntry {
15064 path: "records/value.md".to_string(),
15065 old: Some(WindowsPullFileCoordinate {
15066 sha256: "e".repeat(64),
15067 bytes: new.len() as u64,
15068 }),
15069 new: Some(WindowsPullFileCoordinate {
15070 sha256: content_sha256(new),
15071 bytes: new.len() as u64,
15072 }),
15073 backup: Some("00000000".to_string()),
15074 }],
15075 };
15076 store
15077 .write_atomic_new(
15078 Path::new(WINDOWS_PULL_JOURNAL),
15079 &windows_pull_journal_bytes(&journal).unwrap(),
15080 )
15081 .unwrap();
15082 let cfg = test_hub_config(
15083 "https://example.test".to_string(),
15084 sandbox.path().join("state"),
15085 );
15086 save_v2_baseline(
15087 &cfg,
15088 TEST_BRAIN_ID,
15089 &root,
15090 &V2SyncBaseline {
15091 v: 2,
15092 origin: "https://example.test".to_string(),
15093 brain: TEST_BRAIN_ID.to_string(),
15094 head_seq: next.head_seq,
15095 commit_hash: next.commit_hash.clone(),
15096 content_root: Some("f".repeat(64)),
15097 asset_root: None,
15098 assets: Default::default(),
15099 view_kind: next.view_kind.clone(),
15100 view_revision: next.view_revision.clone(),
15101 projection_sha256: None,
15102 files: Default::default(),
15103 local_policy_digest: None,
15104 local_eligibility: Default::default(),
15105 remote_copy_remains: Default::default(),
15106 },
15107 )
15108 .unwrap();
15109
15110 recover_windows_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
15111
15112 assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
15113 assert!(!root.join(WINDOWS_PULL_JOURNAL).exists());
15114 assert!(!root.join(backup_dir).exists());
15115 assert!(!root.join(orphan).exists());
15116 }
15117}