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_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
153const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
154
155const MAX_IDENTITY_ROTATIONS: usize = 1_024;
158
159fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
162 let mut batches: Vec<Vec<Value>> = Vec::new();
163 let mut current: Vec<Value> = Vec::new();
164 let mut current_bytes = 0usize;
165 for declaration in declarations {
166 let declared_bytes = serde_json::to_string(&declaration)
167 .map(|text| text.len())
168 .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
169 + 1;
170 if !current.is_empty()
171 && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
172 || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
173 {
174 batches.push(std::mem::take(&mut current));
175 current_bytes = 0;
176 }
177 current_bytes += declared_bytes;
178 current.push(declaration);
179 }
180 if !current.is_empty() {
181 batches.push(current);
182 }
183 batches
184}
185const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
189const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
190const FEED_PAGE_LIMIT: usize = 100;
191
192pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
197
198const CONNECT_TIMEOUT_SECS: u64 = 10;
201const READ_TIMEOUT_SECS: u64 = 120;
202const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
206const CONNECT_ATTEMPTS: usize = 3;
207const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
208
209const UPLOAD_ATTEMPTS: usize = 6;
213const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
214
215fn upload_retry_backoff_ms(attempt: usize) -> u64 {
216 UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
217}
218
219fn is_retryable_upload_status(status: u16) -> bool {
223 matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
224}
225const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
229#[cfg(unix)]
233const V2_PULL_INSTALL_WORKERS: usize = 16;
234const V2_BULK_STREAM_FILES: usize = 256;
238const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
239const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
240const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
241
242#[derive(Debug, thiserror::Error)]
246pub enum LinkError {
247 #[error(
249 "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
250 )]
251 NoHub,
252
253 #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
255 NoCredential,
256
257 #[error(
260 "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
261 )]
262 BadKey,
263
264 #[error(
270 "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}"
271 )]
272 UnboundCredential,
273
274 #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
278 BadAgentKey {
279 message: String,
281 },
282
283 #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
285 UnsafeHub {
286 hub: String,
288 },
289
290 #[error("hub unreachable at {hub}: {message}")]
292 Transport {
293 hub: String,
295 message: String,
297 },
298
299 #[error("{what} failed (HTTP {status}): {message}")]
301 Http {
302 what: &'static str,
304 status: u16,
306 message: String,
308 code: Option<String>,
310 details: Option<Value>,
312 },
313
314 #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
317 NotJson {
318 what: &'static str,
320 status: u16,
322 },
323
324 #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
326 ResponseTooLarge {
327 limit_bytes: u64,
329 },
330
331 #[error("invalid address `{given}`: {reason}")]
333 BadAddress {
334 given: String,
336 reason: String,
338 },
339
340 #[error(
342 "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
343 )]
344 BadGrantId {
345 given: String,
347 },
348
349 #[error("refusing unsafe path from the hub: `{path}`")]
353 UnsafePath {
354 path: String,
356 },
357
358 #[error(
360 "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
361 MAX_STORE_BYTES / (1024 * 1024),
362 MAX_PACK_BYTES / (1024 * 1024)
363 )]
364 PushTooLarge {
365 detail: String,
367 },
368
369 #[error(
371 "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
372 MAX_PROPOSE_BYTES / 1024
373 )]
374 ProposeTooLarge {
375 bytes: u64,
377 },
378
379 #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
381 NotUtf8 {
382 path: String,
384 },
385
386 #[error("invalid store pack: {message}")]
388 InvalidPack {
389 message: String,
391 },
392
393 #[error("invalid signed feed: {message}")]
395 InvalidFeed {
396 message: String,
398 },
399
400 #[error(
404 "brain alias `{alias}` was pinned to `{from}` but now resolves to `{to}` — review both ids, then run `dbmd sync {alias} rebind --from {from} --to {to}`"
405 )]
406 AliasRebindRequired {
407 alias: String,
408 from: String,
409 to: String,
410 },
411
412 #[error("sync conflict on {paths:?} — resolve the named files and retry")]
415 Conflict {
416 paths: Vec<String>,
418 },
419
420 #[error(
424 "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
425 )]
426 ConflictBundle {
427 bundle: String,
429 paths: Vec<String>,
431 },
432
433 #[error(
437 "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
438 )]
439 LocalPolicyTransition {
440 paths: Vec<String>,
442 },
443
444 #[error(
449 "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
450 )]
451 BulkPreviewRequired {
452 preview: Value,
454 },
455
456 #[error(
459 "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
460 )]
461 ScopedProjectionModified,
462
463 #[error(
467 "the checkout's permission scope changed — clone into a new directory to accept the new view"
468 )]
469 ScopedViewChanged,
470
471 #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
474 BrainUnavailable,
475
476 #[error(
479 "the remote brain advanced during sync — retry to converge from the new verified head"
480 )]
481 RemoteAdvancedDuringSync,
482
483 #[error(
486 "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
487 )]
488 UnsupportedPlatform {
489 operation: &'static str,
491 },
492
493 #[error(transparent)]
495 Io(#[from] std::io::Error),
496
497 #[error(transparent)]
499 Store(#[from] crate::StoreError),
500}
501
502pub type LinkResult<T> = std::result::Result<T, LinkError>;
504
505#[derive(Debug, Clone, PartialEq, Eq)]
507pub struct V2BulkConfirmation {
508 pub id: String,
510 pub digest: String,
513}
514
515impl V2BulkConfirmation {
516 pub fn parse(value: &str) -> LinkResult<Self> {
519 let (id, digest) = value
520 .split_once(':')
521 .ok_or_else(|| LinkError::InvalidPack {
522 message: "bulk confirmation must be <id>:<digest>".to_string(),
523 })?;
524 if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
525 return Err(LinkError::InvalidPack {
526 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
527 .to_string(),
528 });
529 }
530 Ok(Self {
531 id: id.to_string(),
532 digest: digest.to_string(),
533 })
534 }
535}
536
537fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
542 #[cfg(any(target_os = "linux", target_os = "macos", windows))]
543 {
544 let _ = operation;
545 Ok(())
546 }
547 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
548 {
549 Err(LinkError::UnsupportedPlatform { operation })
550 }
551}
552
553#[derive(Debug, Clone, PartialEq, Eq)]
559pub enum AddressTarget {
560 Id(String),
562 Path(String),
566}
567
568const BAD_BRAIN_REASON: &str =
571 "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
572
573const BAD_TARGET_REASON: &str =
576 "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
577
578#[derive(Debug, Clone, PartialEq, Eq)]
583pub struct Address {
584 pub brain: String,
586 pub target: Option<AddressTarget>,
588}
589
590impl Address {
591 pub fn parse(raw: &str) -> LinkResult<Address> {
595 let bad = |reason: &str| LinkError::BadAddress {
596 given: raw.to_string(),
597 reason: reason.to_string(),
598 };
599
600 let trimmed = raw.trim();
601 let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
602 if body.is_empty() {
603 return Err(bad("empty address"));
604 }
605
606 let (brain, rest) = match body.split_once('/') {
607 Some((b, r)) => (b, Some(r)),
608 None => (body, None),
609 };
610
611 if brain.is_empty() {
612 return Err(bad("missing brain reference before `/`"));
613 }
614 if !is_safe_ref(brain) {
615 return Err(bad(BAD_BRAIN_REASON));
616 }
617
618 let target = match rest {
619 None => None,
620 Some("") => return Err(bad("trailing `/` with no record id or path")),
621 Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
622 Some(r) => {
623 if !safe_store_rel_path(r) || !r.ends_with(".md") {
624 return Err(bad(BAD_TARGET_REASON));
625 }
626 Some(AddressTarget::Path(r.to_string()))
627 }
628 };
629
630 Ok(Address {
631 brain: brain.to_string(),
632 target,
633 })
634 }
635}
636
637fn is_safe_ref(s: &str) -> bool {
640 !s.is_empty()
641 && s.len() <= 64
642 && s.bytes()
643 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
644}
645
646pub fn is_valid_handle(s: &str) -> bool {
649 is_safe_ref(s)
650}
651
652pub fn safe_store_rel_path(p: &str) -> bool {
658 if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
659 return false;
660 }
661 if !p
662 .bytes()
663 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
664 {
665 return false;
666 }
667 p.split('/')
668 .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
669}
670
671fn require_safe_ref(brain: &str) -> LinkResult<()> {
679 if is_safe_ref(brain) {
680 Ok(())
681 } else {
682 Err(LinkError::BadAddress {
683 given: brain.to_string(),
684 reason: BAD_BRAIN_REASON.to_string(),
685 })
686 }
687}
688
689fn require_valid_handle(handle: &str) -> LinkResult<()> {
691 if is_valid_handle(handle) {
692 Ok(())
693 } else {
694 Err(LinkError::BadAddress {
695 given: handle.to_string(),
696 reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
697 })
698 }
699}
700
701fn require_safe_grant_id(id: &str) -> LinkResult<()> {
705 if is_safe_ref(id) {
706 Ok(())
707 } else {
708 Err(LinkError::BadGrantId {
709 given: id.to_string(),
710 })
711 }
712}
713
714#[derive(Debug, Clone)]
720pub struct HubConfig {
721 pub hub: String,
723 pub key: Option<String>,
725 pub agent_key: Option<AgentSigningKey>,
728 pub brain_key: Option<AgentSigningKey>,
731 pub state_dir: PathBuf,
734 store_selected: bool,
737}
738
739#[derive(Clone)]
742pub struct AgentSigningKey {
743 pkcs8: Vec<u8>,
744 pub multikey: String,
746 pub public_key_spki: String,
748}
749
750impl std::fmt::Debug for AgentSigningKey {
751 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
752 f.debug_struct("AgentSigningKey")
753 .field("multikey", &self.multikey)
754 .field("pkcs8", &"<redacted>")
755 .finish()
756 }
757}
758
759impl HubConfig {
760 pub fn require_key(&self) -> LinkResult<&str> {
763 self.key.as_deref().ok_or(LinkError::NoCredential)
764 }
765}
766
767pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
772 let explicit_hub = flag_hub
773 .map(str::to_string)
774 .or_else(|| env_nonempty(HUB_URL_ENV));
775 let selected_by_store = explicit_hub.is_none();
776 let hub = explicit_hub
777 .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
778 .ok_or(LinkError::NoHub)?;
779 let hub = hub.trim().trim_end_matches('/').to_string();
780 assert_safe_hub(&hub)?;
781 if selected_by_store {
782 let parsed =
783 url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
784 if !parsed.scheme().eq_ignore_ascii_case("https")
788 || (parsed.path() != "/" && !parsed.path().is_empty())
789 {
790 return Err(LinkError::UnsafeHub { hub });
791 }
792 }
793
794 let key = match env_nonempty(HUB_KEY_ENV) {
795 Some(raw) => Some(clean_key(&raw)?),
796 None => None,
797 };
798
799 let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
800 Some(path) => Some(load_agent_key(Path::new(&path))?),
801 None => None,
802 };
803
804 let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
805 Some(path) => Some(load_agent_key(Path::new(&path))?),
806 None => None,
807 };
808
809 if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
816 let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
817 .and_then(|value| normalized_origin(&value).ok());
818 let selected_origin = normalized_origin(&hub)?;
819 if bound.as_deref() != Some(selected_origin.as_str()) {
820 return Err(LinkError::UnboundCredential);
821 }
822 }
823
824 Ok(HubConfig {
825 hub,
826 key,
827 agent_key,
828 brain_key,
829 state_dir: toolkit_state_dir()?,
830 store_selected: selected_by_store,
831 })
832}
833
834fn toolkit_state_dir() -> LinkResult<PathBuf> {
835 if let Some(path) = env_nonempty(STATE_DIR_ENV) {
836 let path = PathBuf::from(path);
837 if !path.is_absolute() {
838 return Err(LinkError::UnsafePath {
839 path: path.display().to_string(),
840 });
841 }
842 return Ok(path);
843 }
844 #[cfg(windows)]
845 if let Some(base) = env_nonempty("LOCALAPPDATA") {
846 let base = PathBuf::from(base);
847 if base.is_absolute() {
848 return Ok(base.join("dbmd").join("state"));
849 }
850 }
851 #[cfg(windows)]
852 {
853 Err(LinkError::Io(std::io::Error::new(
854 std::io::ErrorKind::NotFound,
855 format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
856 )))
857 }
858 #[cfg(not(windows))]
859 if let Some(base) = env_nonempty("XDG_STATE_HOME") {
860 let base = PathBuf::from(base);
861 if base.is_absolute() {
862 return Ok(base.join("dbmd"));
863 }
864 }
865 #[cfg(not(windows))]
866 let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
867 LinkError::Io(std::io::Error::new(
868 std::io::ErrorKind::NotFound,
869 format!("cannot locate user state; set {STATE_DIR_ENV}"),
870 ))
871 })?);
872 #[cfg(not(windows))]
873 if !home.is_absolute() {
874 return Err(LinkError::UnsafePath {
875 path: home.display().to_string(),
876 });
877 }
878 #[cfg(target_os = "macos")]
879 {
880 Ok(home
881 .join("Library")
882 .join("Application Support")
883 .join("dbmd")
884 .join("state"))
885 }
886 #[cfg(all(not(target_os = "macos"), not(windows)))]
887 {
888 Ok(home.join(".local").join("state").join("dbmd"))
889 }
890}
891
892fn normalized_origin(value: &str) -> LinkResult<String> {
893 let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
894 hub: value.to_string(),
895 })?;
896 if !(parsed.scheme().eq_ignore_ascii_case("https")
897 || parsed.scheme().eq_ignore_ascii_case("http"))
898 || !parsed.username().is_empty()
899 || parsed.password().is_some()
900 || (parsed.path() != "/" && !parsed.path().is_empty())
901 || parsed.query().is_some()
902 || parsed.fragment().is_some()
903 {
904 return Err(LinkError::UnsafeHub {
905 hub: value.to_string(),
906 });
907 }
908 let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
909 hub: value.to_string(),
910 })?;
911 let host = if host.contains(':') {
912 format!("[{host}]")
913 } else {
914 host.to_ascii_lowercase()
915 };
916 let port = parsed
917 .port_or_known_default()
918 .ok_or_else(|| LinkError::UnsafeHub {
919 hub: value.to_string(),
920 })?;
921 let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
922 || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
923 Ok(format!(
924 "{}://{}{}",
925 parsed.scheme().to_ascii_lowercase(),
926 host,
927 if default {
928 String::new()
929 } else {
930 format!(":{port}")
931 }
932 ))
933}
934
935const ED25519_SPKI_PREFIX: [u8; 12] = [
942 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
943];
944
945fn bad_agent_key(message: &str) -> LinkError {
946 LinkError::BadAgentKey {
947 message: message.to_string(),
948 }
949}
950
951fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
952 ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
956 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
957 .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
958}
959
960fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
962 use ring::signature::KeyPair as _;
963 let mut spki = Vec::with_capacity(44);
964 spki.extend_from_slice(&ED25519_SPKI_PREFIX);
965 spki.extend_from_slice(pair.public_key().as_ref());
966 (
967 URL_SAFE_NO_PAD.encode(&spki),
968 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
969 )
970}
971
972pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
976 load_agent_key(path)
977}
978
979fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
981 #[cfg(unix)]
982 let file = {
983 use std::os::fd::{AsRawFd as _, FromRawFd as _};
984 use std::os::unix::ffi::OsStrExt as _;
985 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
986 .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
987 let leaf = path
988 .file_name()
989 .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
990 let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
991 let fd = unsafe {
992 libc::openat(
993 parent.as_raw_fd(),
994 leaf.as_ptr(),
995 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
996 )
997 };
998 if fd < 0 {
999 return Err(bad_agent_key(
1000 "the key path must be an existing regular file without symlink ancestors",
1001 ));
1002 }
1003 unsafe { std::fs::File::from_raw_fd(fd) }
1004 };
1005 #[cfg(not(unix))]
1006 let file = std::fs::File::open(path)
1007 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1008 let metadata = file
1009 .metadata()
1010 .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1011 if !metadata.is_file() {
1012 return Err(bad_agent_key("the key path must be a regular file"));
1013 }
1014 #[cfg(unix)]
1015 {
1016 use std::os::unix::fs::PermissionsExt as _;
1017 if metadata.permissions().mode() & 0o077 != 0 {
1018 return Err(bad_agent_key(
1019 "the key file is accessible to group/other; set mode 0600",
1020 ));
1021 }
1022 }
1023 let mut text = String::new();
1024 file.take(1024 * 1024 + 1)
1025 .read_to_string(&mut text)
1026 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1027 if text.len() > 1024 * 1024 {
1028 return Err(bad_agent_key("the key file exceeds the size limit"));
1029 }
1030 let pkcs8 = URL_SAFE_NO_PAD
1031 .decode(text.trim())
1032 .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1033 let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1034 Ok(AgentSigningKey {
1035 pkcs8,
1036 multikey,
1037 public_key_spki,
1038 })
1039}
1040
1041fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1047 #[cfg(unix)]
1048 let (mut file, parent, leaf) = {
1049 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1050 use std::os::unix::ffi::OsStrExt as _;
1051 let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1052 let leaf_name = path
1053 .file_name()
1054 .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1055 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1056 let fd = unsafe {
1057 libc::openat(
1058 parent.as_raw_fd(),
1059 leaf.as_ptr(),
1060 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1061 0o600,
1062 )
1063 };
1064 if fd < 0 {
1065 let error = std::io::Error::last_os_error();
1066 if error.kind() == std::io::ErrorKind::AlreadyExists {
1067 return Err(bad_agent_key(
1068 "the output file already exists — refusing to overwrite a key",
1069 ));
1070 }
1071 return Err(error.into());
1072 }
1073 (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1074 };
1075 #[cfg(not(unix))]
1076 let mut file = std::fs::OpenOptions::new()
1077 .write(true)
1078 .create_new(true)
1079 .open(path)
1080 .map_err(|error| {
1081 if error.kind() == std::io::ErrorKind::AlreadyExists {
1082 bad_agent_key("the output file already exists — refusing to overwrite a key")
1083 } else {
1084 LinkError::Io(error)
1085 }
1086 })?;
1087 if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1088 drop(file);
1089 #[cfg(unix)]
1090 let _ =
1091 unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1092 #[cfg(not(unix))]
1093 let _ = std::fs::remove_file(path);
1094 return Err(LinkError::Io(error));
1095 }
1096 drop(file);
1097 #[cfg(unix)]
1098 parent.sync_all()?;
1099 Ok(())
1100}
1101
1102#[derive(Debug, Serialize)]
1105pub struct GeneratedAgentKey {
1106 pub multikey: String,
1108 #[serde(rename = "publicKeySpki")]
1110 pub public_key_spki: String,
1111 #[serde(rename = "keyFile")]
1113 pub key_file: String,
1114}
1115
1116pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1121 require_hardened_filesystem("key generation")?;
1122 let rng = ring::rand::SystemRandom::new();
1123 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1124 .map_err(|_| bad_agent_key("key generation failed"))?;
1125 let pair = agent_keypair(pkcs8.as_ref())?;
1126 let (spki_b64u, multikey) = public_identity_for(&pair);
1127
1128 write_secret_new(
1129 out,
1130 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1131 )?;
1132
1133 Ok(GeneratedAgentKey {
1134 multikey,
1135 public_key_spki: spki_b64u,
1136 key_file: out.display().to_string(),
1137 })
1138}
1139
1140fn linkmd_sig_header(
1149 key: &AgentSigningKey,
1150 origin: &str,
1151 method: &str,
1152 path: &str,
1153 body: Option<&str>,
1154) -> LinkResult<String> {
1155 let ts = std::time::SystemTime::now()
1156 .duration_since(std::time::UNIX_EPOCH)
1157 .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1158 .as_secs();
1159 let body_hash = match body {
1160 Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1161 None => "-".to_string(),
1162 };
1163 let canonical = format!(
1164 "v2\n{}\n{}\n{}\n{}\n{}",
1165 origin,
1166 method.to_uppercase(),
1167 path,
1168 ts,
1169 body_hash
1170 );
1171 let pair = agent_keypair(&key.pkcs8)?;
1172 let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1173 let fingerprint = key.multikey.trim_start_matches("ed25519:");
1174 Ok(format!(
1175 "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1176 ))
1177}
1178
1179#[derive(Serialize)]
1186struct WireFeedFile {
1187 path: String,
1188 sha256: String,
1189 bytes: u64,
1190}
1191
1192#[derive(Serialize)]
1195struct UnsignedWireEntry<'a> {
1196 v: u8,
1197 seq: u64,
1198 ts: String,
1199 brain: &'a str,
1200 public_key: &'a str,
1201 kind: &'a str,
1202 op: &'a str,
1203 pack_sha256: &'a str,
1204 files: &'a [WireFeedFile],
1205 removed: &'a [String],
1206 prev_entry_hash: Option<&'a str>,
1207}
1208
1209fn self_custody_entry(
1215 key: &AgentSigningKey,
1216 seq: u64,
1217 ts: String,
1218 pack_sha256: &str,
1219 files: &[WireFeedFile],
1220 prev_entry_hash: Option<&str>,
1221) -> LinkResult<String> {
1222 let removed: [String; 0] = [];
1223 let unsigned = serde_json::to_string(&UnsignedWireEntry {
1224 v: 1,
1225 seq,
1226 ts,
1227 brain: &key.multikey,
1228 public_key: &key.public_key_spki,
1229 kind: "push",
1230 op: "snapshot",
1231 pack_sha256,
1232 files,
1233 removed: &removed,
1234 prev_entry_hash,
1235 })
1236 .expect("serialize feed entry");
1237 let pair = agent_keypair(&key.pkcs8)?;
1238 let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1239 Ok(format!(
1240 "{},\"sig\":\"{}\"}}",
1241 &unsigned[..unsigned.len() - 1],
1242 sig
1243 ))
1244}
1245
1246fn env_nonempty(name: &str) -> Option<String> {
1249 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1250}
1251
1252fn config_file_hub(path: &Path) -> Option<String> {
1257 const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1258 #[cfg(unix)]
1259 let file = {
1260 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1261 use std::os::unix::ffi::OsStrExt as _;
1262 let parent =
1263 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1264 let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1265 let fd = unsafe {
1266 libc::openat(
1267 parent.as_raw_fd(),
1268 leaf.as_ptr(),
1269 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1270 )
1271 };
1272 if fd < 0 {
1273 return None;
1274 }
1275 unsafe { std::fs::File::from_raw_fd(fd) }
1276 };
1277 #[cfg(not(unix))]
1278 let file = std::fs::File::open(path).ok()?;
1279 let metadata = file.metadata().ok()?;
1280 if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1281 return None;
1282 }
1283 let mut bytes = Vec::with_capacity(metadata.len() as usize);
1284 file.take(MAX_CONFIG_BYTES + 1)
1285 .read_to_end(&mut bytes)
1286 .ok()?;
1287 if bytes.len() as u64 > MAX_CONFIG_BYTES {
1288 return None;
1289 }
1290 let text = String::from_utf8(bytes).ok()?;
1291 for line in text.lines() {
1292 let line = line.trim();
1293 if line.is_empty() || line.starts_with('#') {
1294 continue;
1295 }
1296 if let Some((k, v)) = line.split_once('=') {
1297 if k.trim() == "hub" {
1298 let v = v.trim();
1299 if !v.is_empty() {
1300 return Some(v.to_string());
1301 }
1302 }
1303 }
1304 }
1305 None
1306}
1307
1308fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1311 let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1312 hub: hub.to_string(),
1313 })?;
1314 if !(parsed.scheme().eq_ignore_ascii_case("https")
1315 || parsed.scheme().eq_ignore_ascii_case("http"))
1316 || !parsed.username().is_empty()
1317 || parsed.password().is_some()
1318 || (parsed.path() != "/" && !parsed.path().is_empty())
1319 || parsed.query().is_some()
1320 || parsed.fragment().is_some()
1321 {
1322 return Err(LinkError::UnsafeHub {
1323 hub: hub.to_string(),
1324 });
1325 }
1326 let loopback = match parsed.host() {
1327 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1328 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1329 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1330 None => false,
1331 };
1332 if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1333 Ok(())
1334 } else {
1335 Err(LinkError::UnsafeHub {
1336 hub: hub.to_string(),
1337 })
1338 }
1339}
1340
1341fn clean_key(raw: &str) -> LinkResult<String> {
1346 let k = raw.trim();
1347 if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1348 return Err(LinkError::BadKey);
1349 }
1350 Ok(k.to_string())
1351}
1352
1353#[derive(Debug)]
1359pub struct HubResponse {
1360 pub status: u16,
1362 pub body: Option<Value>,
1364}
1365
1366struct RawHubResponse {
1367 status: u16,
1368 body: Vec<u8>,
1369}
1370
1371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1373enum Auth {
1374 Required,
1376 None,
1378 Optional,
1382}
1383
1384fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1385 ureq::AgentBuilder::new()
1386 .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1387 .redirects(0)
1391 .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1392 .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1393 .timeout_write(overall)
1394 .timeout(overall)
1395}
1396
1397fn agent_builder() -> ureq::AgentBuilder {
1398 agent_builder_with_timeout(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS))
1399}
1400
1401fn agent() -> ureq::Agent {
1402 agent_builder().build()
1403}
1404
1405fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1406 if !cfg.store_selected {
1407 return Ok(agent());
1408 }
1409 let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1410 hub: cfg.hub.clone(),
1411 })?;
1412 pinned_public_agent(&parsed, false, "store-selected hub")
1413}
1414
1415fn request_raw(
1420 cfg: &HubConfig,
1421 method: &str,
1422 path: &str,
1423 body: Option<&Value>,
1424 auth: Auth,
1425 max_response_bytes: u64,
1426) -> LinkResult<RawHubResponse> {
1427 let http = hub_agent(cfg)?;
1428 request_raw_with_agent(
1429 cfg,
1430 &http,
1431 method,
1432 path,
1433 body,
1434 RawRequestOptions {
1435 auth,
1436 max_response_bytes,
1437 request_id: None,
1438 },
1439 )
1440}
1441
1442struct RawRequestOptions<'a> {
1443 auth: Auth,
1444 max_response_bytes: u64,
1445 request_id: Option<&'a str>,
1446}
1447
1448fn request_raw_with_agent(
1449 cfg: &HubConfig,
1450 http: &ureq::Agent,
1451 method: &str,
1452 path: &str,
1453 body: Option<&Value>,
1454 options: RawRequestOptions<'_>,
1455) -> LinkResult<RawHubResponse> {
1456 let url = format!("{}{}", cfg.hub, path);
1457 let encoded_body = body.map(Value::to_string);
1458 let origin = normalized_origin(&cfg.hub)?;
1459 let credential = match options.auth {
1462 Auth::Required => Some(match &cfg.agent_key {
1463 Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1464 None => format!("Bearer {}", cfg.require_key()?),
1465 }),
1466 Auth::Optional => match &cfg.agent_key {
1467 Some(key) => Some(linkmd_sig_header(
1468 key,
1469 &origin,
1470 method,
1471 path,
1472 encoded_body.as_deref(),
1473 )?),
1474 None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1475 },
1476 Auth::None => None,
1477 };
1478 let result = with_connect_retries(|| {
1479 let mut req = http.request(method, &url);
1480 if let Some(value) = &credential {
1481 req = req.set("authorization", value);
1482 }
1483 if let Some(value) = options.request_id {
1484 req = req.set("x-request-id", value);
1485 }
1486 match &encoded_body {
1487 Some(value) => req
1488 .set("content-type", "application/json")
1489 .send_string(value)
1490 .map_err(Box::new),
1491 None => req.call().map_err(Box::new),
1492 }
1493 });
1494 let resp = match result {
1495 Ok(resp) => resp,
1496 Err(error) => match *error {
1497 ureq::Error::Status(_, resp) => resp,
1498 ureq::Error::Transport(error) => {
1499 return Err(LinkError::Transport {
1500 hub: cfg.hub.clone(),
1501 message: error.to_string(),
1502 });
1503 }
1504 },
1505 };
1506
1507 let status = resp.status();
1508 let mut buf = Vec::new();
1509 resp.into_reader()
1510 .take(options.max_response_bytes + 1)
1511 .read_to_end(&mut buf)?;
1512 if buf.len() as u64 > options.max_response_bytes {
1513 return Err(LinkError::ResponseTooLarge {
1514 limit_bytes: options.max_response_bytes,
1515 });
1516 }
1517 Ok(RawHubResponse { status, body: buf })
1518}
1519
1520fn request_capped(
1521 cfg: &HubConfig,
1522 method: &str,
1523 path: &str,
1524 body: Option<&Value>,
1525 auth: Auth,
1526 max_response_bytes: u64,
1527) -> LinkResult<HubResponse> {
1528 let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1529 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1530 Ok(HubResponse {
1531 status: raw.status,
1532 body: parsed,
1533 })
1534}
1535
1536fn request(
1537 cfg: &HubConfig,
1538 method: &str,
1539 path: &str,
1540 body: Option<&Value>,
1541 auth: Auth,
1542) -> LinkResult<HubResponse> {
1543 request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1544}
1545
1546fn request_with_request_id(
1551 cfg: &HubConfig,
1552 method: &str,
1553 path: &str,
1554 body: Option<&Value>,
1555 auth: Auth,
1556 request_id: &str,
1557) -> LinkResult<HubResponse> {
1558 if request_id.is_empty()
1559 || request_id.len() > 128
1560 || !request_id
1561 .bytes()
1562 .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1563 {
1564 return Err(invalid_feed("hub returned an unsafe request id"));
1565 }
1566 let http = hub_agent(cfg)?;
1567 let raw = request_raw_with_agent(
1568 cfg,
1569 &http,
1570 method,
1571 path,
1572 body,
1573 RawRequestOptions {
1574 auth,
1575 max_response_bytes: MAX_RESPONSE_BYTES,
1576 request_id: Some(request_id),
1577 },
1578 )?;
1579 Ok(HubResponse {
1580 status: raw.status,
1581 body: serde_json::from_slice(&raw.body).ok(),
1582 })
1583}
1584
1585fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1586 if (200..300).contains(&r.status) {
1587 return Ok(r.body);
1588 }
1589 ensure_ok(
1590 HubResponse {
1591 status: r.status,
1592 body: serde_json::from_slice(&r.body).ok(),
1593 },
1594 what,
1595 )
1596 .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1597}
1598
1599fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1604 matches!(
1605 kind,
1606 ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1607 )
1608}
1609
1610fn with_connect_retries(
1611 mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1612) -> Result<ureq::Response, Box<ureq::Error>> {
1613 let mut attempt = 0;
1614 loop {
1615 match send() {
1616 Err(error)
1617 if matches!(
1618 error.as_ref(),
1619 ureq::Error::Transport(transport)
1620 if is_pre_request_transport(transport.kind())
1621 ) && attempt + 1 < CONNECT_ATTEMPTS =>
1622 {
1623 std::thread::sleep(std::time::Duration::from_millis(
1624 CONNECT_RETRY_BACKOFF_MS[attempt],
1625 ));
1626 attempt += 1;
1627 }
1628 result => return result,
1629 }
1630 }
1631}
1632
1633fn hub_is_loopback(hub: &str) -> bool {
1634 url::Url::parse(hub).ok().is_some_and(|parsed| {
1635 parsed.host().is_some_and(|host| match host {
1636 url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1637 url::Host::Ipv4(ip) => ip.is_loopback(),
1638 url::Host::Ipv6(ip) => ip.is_loopback(),
1639 })
1640 })
1641}
1642
1643fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1644 let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1645 message: "the hub returned an invalid object-store URL".to_string(),
1646 })?;
1647 let allow_private = hub_is_loopback(&cfg.hub)
1648 || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1649 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1650 || !parsed.username().is_empty()
1651 || parsed.password().is_some()
1652 || parsed.fragment().is_some()
1653 {
1654 return Err(LinkError::InvalidPack {
1655 message: "the hub returned an unsafe object-store URL".to_string(),
1656 });
1657 }
1658 pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1659 LinkError::InvalidPack {
1660 message: "the hub returned an object-store URL with an unsafe network target"
1661 .to_string(),
1662 }
1663 })
1664}
1665
1666fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1667 let http = presigned_agent(cfg, raw)?;
1668 let result = with_connect_retries(|| {
1669 let mut req = http.put(raw);
1670 if let Some(map) = headers.as_object() {
1671 for (name, value) in map {
1672 if let Some(value) = value.as_str() {
1673 req = req.set(name, value);
1674 }
1675 }
1676 }
1677 req.send_bytes(bytes).map_err(Box::new)
1678 });
1679 match result {
1680 Ok(resp) if (200..300).contains(&resp.status()) => Ok(()),
1681 Ok(resp) => Err(LinkError::Http {
1682 what: "pack upload",
1683 status: resp.status(),
1684 message: "object store rejected the upload".to_string(),
1685 code: None,
1686 details: None,
1687 }),
1688 Err(error) => match *error {
1689 ureq::Error::Status(412, _) => Ok(()),
1694 ureq::Error::Status(_, resp) => Err(LinkError::Http {
1695 what: "pack upload",
1696 status: resp.status(),
1697 message: "object store rejected the upload".to_string(),
1698 code: None,
1699 details: None,
1700 }),
1701 ureq::Error::Transport(err) => Err(LinkError::Transport {
1702 hub: "the object store".to_string(),
1703 message: err.to_string(),
1704 }),
1705 },
1706 }
1707}
1708
1709fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1710 max_bytes.checked_add(1)
1711}
1712
1713fn presigned_download_read_limit() -> u64 {
1714 one_past_bounded_limit(MAX_PACK_BYTES)
1715 .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1716}
1717
1718fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1719 let http = presigned_agent(cfg, raw)?;
1720 let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1721 Ok(resp) => resp,
1722 Err(error) => match *error {
1723 ureq::Error::Status(_, resp) => {
1724 return Err(LinkError::Http {
1725 what: "pack download",
1726 status: resp.status(),
1727 message: "object store rejected the download".to_string(),
1728 code: None,
1729 details: None,
1730 });
1731 }
1732 ureq::Error::Transport(err) => {
1733 return Err(LinkError::Transport {
1734 hub: "the object store".to_string(),
1735 message: err.to_string(),
1736 });
1737 }
1738 },
1739 };
1740 if !(200..300).contains(&resp.status()) {
1741 return Err(LinkError::Http {
1742 what: "pack download",
1743 status: resp.status(),
1744 message: "object store rejected the download".to_string(),
1745 code: None,
1746 details: None,
1747 });
1748 }
1749 let mut bytes = Vec::new();
1750 resp.into_reader()
1751 .take(presigned_download_read_limit())
1752 .read_to_end(&mut bytes)?;
1753 if bytes.len() as u64 > MAX_PACK_BYTES {
1754 return Err(LinkError::InvalidPack {
1755 message: "download exceeds the compressed-size limit".to_string(),
1756 });
1757 }
1758 Ok(bytes)
1759}
1760
1761fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
1765 if !(200..300).contains(&r.status) {
1766 let message = r
1767 .body
1768 .as_ref()
1769 .and_then(|b| b.get("error"))
1770 .and_then(Value::as_str)
1771 .unwrap_or("unknown error")
1772 .to_string();
1773 let code = r
1774 .body
1775 .as_ref()
1776 .and_then(|b| b.get("code"))
1777 .and_then(Value::as_str)
1778 .map(str::to_string);
1779 let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
1780 return Err(LinkError::Http {
1781 what,
1782 status: r.status,
1783 message,
1784 code,
1785 details,
1786 });
1787 }
1788 r.body.ok_or(LinkError::NotJson {
1789 what,
1790 status: r.status,
1791 })
1792}
1793
1794fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
1803 match ip {
1804 std::net::IpAddr::V4(ip) => {
1805 let [a, b, c, _] = ip.octets();
1806 !(a == 0
1807 || a == 10
1808 || a == 127
1809 || (a == 100 && (64..=127).contains(&b))
1810 || (a == 169 && b == 254)
1811 || (a == 172 && (16..=31).contains(&b))
1812 || (a == 192 && b == 0 && c == 0)
1813 || (a == 192 && b == 0 && c == 2)
1814 || (a == 192 && b == 88 && c == 99)
1815 || (a == 192 && b == 168)
1816 || (a == 198 && (b == 18 || b == 19))
1817 || (a == 198 && b == 51 && c == 100)
1818 || (a == 203 && b == 0 && c == 113)
1819 || a >= 224)
1820 }
1821 std::net::IpAddr::V6(ip) => {
1822 let segments = ip.segments();
1823 (segments[0] & 0xe000) == 0x2000
1828 && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
1829 && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
1830 && segments[0] != 0x2002
1831 && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
1832 }
1833 }
1834}
1835
1836#[derive(Clone)]
1837struct PinnedRegistryResolver {
1838 netloc: String,
1839 addresses: Vec<std::net::SocketAddr>,
1840}
1841
1842impl ureq::Resolver for PinnedRegistryResolver {
1843 fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
1844 if requested == self.netloc {
1845 Ok(self.addresses.clone())
1846 } else {
1847 Err(std::io::Error::new(
1848 std::io::ErrorKind::PermissionDenied,
1849 "registry request attempted to resolve an unvalidated authority",
1850 ))
1851 }
1852 }
1853}
1854
1855fn pinned_public_agent(
1856 url: &url::Url,
1857 allow_private: bool,
1858 label: &str,
1859) -> LinkResult<ureq::Agent> {
1860 let host = url
1861 .host_str()
1862 .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
1863 let port = url
1864 .port_or_known_default()
1865 .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
1866 let addresses = resolve_addresses_with_deadline(
1867 host,
1868 port,
1869 std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
1870 )
1871 .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
1872 if addresses.is_empty() {
1873 return Err(invalid_feed(format!("{label} DNS returned no addresses")));
1874 }
1875 if !allow_private
1876 && addresses
1877 .iter()
1878 .any(|address| !is_public_registry_ip(address.ip()))
1879 {
1880 return Err(invalid_feed(format!(
1881 "{label} resolves to a non-public address"
1882 )));
1883 }
1884 let netloc = if host.contains(':') {
1885 format!("[{host}]:{port}")
1886 } else {
1887 format!("{host}:{port}")
1888 };
1889 Ok(agent_builder()
1890 .resolver(PinnedRegistryResolver { netloc, addresses })
1891 .build())
1892}
1893
1894fn resolve_addresses_with_deadline(
1899 host: &str,
1900 port: u16,
1901 timeout: std::time::Duration,
1902) -> std::io::Result<Vec<std::net::SocketAddr>> {
1903 use std::net::ToSocketAddrs as _;
1904
1905 let host = host.to_string();
1906 let (send, receive) = std::sync::mpsc::sync_channel(1);
1907 std::thread::Builder::new()
1908 .name("dbmd-dns".to_string())
1909 .spawn(move || {
1910 let result = (host.as_str(), port)
1911 .to_socket_addrs()
1912 .map(|addresses| addresses.collect());
1913 let _ = send.send(result);
1914 })
1915 .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
1916 match receive.recv_timeout(timeout) {
1917 Ok(result) => result,
1918 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
1919 std::io::ErrorKind::TimedOut,
1920 "resolution exceeded its deadline",
1921 )),
1922 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
1923 "resolver stopped without returning a result",
1924 )),
1925 }
1926}
1927
1928fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
1929 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1930 pinned_public_agent(url, allow_private, "registry home")
1931}
1932
1933fn get_json_absolute(url: &str) -> LinkResult<Value> {
1938 let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
1939 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
1940 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1941 || !parsed.username().is_empty()
1942 || parsed.password().is_some()
1943 || parsed.query().is_some()
1944 || parsed.fragment().is_some()
1945 {
1946 return Err(invalid_feed("unsafe registry home URL"));
1947 }
1948 let http = registry_agent(&parsed)?;
1949 let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
1950 Ok(resp) => resp,
1951 Err(error) => match *error {
1952 ureq::Error::Status(status, resp) => {
1953 let _ = resp;
1954 return Err(LinkError::Http {
1955 what: "registry home fetch",
1956 status,
1957 message: "the home node rejected the card request".to_string(),
1958 code: None,
1959 details: None,
1960 });
1961 }
1962 ureq::Error::Transport(err) => {
1963 return Err(LinkError::Transport {
1964 hub: url.to_string(),
1965 message: err.to_string(),
1966 });
1967 }
1968 },
1969 };
1970 if !(200..300).contains(&resp.status()) {
1971 return Err(LinkError::Http {
1972 what: "registry home fetch",
1973 status: resp.status(),
1974 message: "the home node returned a redirect or error".to_string(),
1975 code: None,
1976 details: None,
1977 });
1978 }
1979 let mut buf = Vec::new();
1980 resp.into_reader()
1981 .take(MAX_REGISTRY_CARD_BYTES + 1)
1982 .read_to_end(&mut buf)?;
1983 if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
1984 return Err(LinkError::ResponseTooLarge {
1985 limit_bytes: MAX_REGISTRY_CARD_BYTES,
1986 });
1987 }
1988 serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
1989 message: "the home node returned invalid JSON".to_string(),
1990 })
1991}
1992
1993pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2000 require_safe_ref(handle)?;
2001 let trust_directory = open_trust_dir(cfg)?;
2005 let reg = request_capped(
2006 cfg,
2007 "GET",
2008 &format!("/api/hub/registry/{handle}"),
2009 None,
2010 Auth::None,
2011 MAX_REGISTRY_CARD_BYTES,
2012 )?;
2013 if reg.status == 404 {
2014 return Ok(None);
2015 }
2016 let body = ensure_ok(reg, "registry resolve")?;
2017 let home = body
2018 .get("home")
2019 .and_then(Value::as_str)
2020 .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2021 let brain = body
2022 .get("brain")
2023 .and_then(Value::as_str)
2024 .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2025 if !crate::ulid::is_ulid(brain) {
2026 return Err(invalid_feed(
2027 "registry entry brain is not a canonical lowercase ULID",
2028 ));
2029 }
2030 let want_fp = body
2031 .get("identity")
2032 .and_then(|i| i.get("fingerprint"))
2033 .and_then(Value::as_str)
2034 .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2035
2036 let home = home.trim_end_matches('/');
2037 let origin = normalized_origin(home)?;
2038 if origin != home {
2039 return Err(invalid_feed(
2040 "registry home must be an origin without a path, query, or fragment",
2041 ));
2042 }
2043 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2044 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2045 if let Some(binding) = &alias_binding {
2046 if binding
2047 .home
2048 .as_deref()
2049 .is_some_and(|pinned_home| pinned_home != home)
2050 {
2051 return Err(invalid_feed(
2052 "registry relocated a pinned handle to a different home",
2053 ));
2054 }
2055 }
2056 let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2057 if card.get("id").and_then(Value::as_str) != Some(brain) {
2058 return Err(invalid_feed(
2059 "the home node served a card for a different brain",
2060 ));
2061 }
2062 let identity: FeedIdentity = serde_json::from_value(
2063 card.get("identity")
2064 .cloned()
2065 .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2066 )
2067 .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2068 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2069 let got_fp = card
2070 .get("identity")
2071 .and_then(|i| i.get("fingerprint"))
2072 .and_then(Value::as_str)
2073 .unwrap_or_default();
2074 if got_fp != want_fp {
2075 return Err(invalid_feed(
2076 "the home node served an identity that does not match the registry — refusing",
2077 ));
2078 }
2079 let current = format!("ed25519:{}", identity.fingerprint);
2080 let advertised_seq = card
2081 .get("headSeq")
2082 .and_then(Value::as_u64)
2083 .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2084 let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2085 if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2086 || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2087 {
2088 return Err(invalid_feed(
2089 "the home node served an invalid feed head boundary",
2090 ));
2091 }
2092 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2096 let registry_alias = AliasBinding {
2097 v: 1,
2098 origin: normalized_origin(&cfg.hub)?,
2099 requested: handle.to_string(),
2100 brain: brain.to_string(),
2101 home: Some(home.to_string()),
2102 };
2103 save_canonical_pin_and_alias(
2104 cfg,
2105 &trust_directory,
2106 handle,
2107 brain,
2108 TrustState {
2109 v: 2,
2110 origin: normalized_origin(&cfg.hub)?,
2111 requested: brain.to_string(),
2112 brain: brain.to_string(),
2113 home: None,
2114 anchor,
2115 current,
2116 head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2117 feed_hash: pinned
2118 .as_ref()
2119 .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2120 rotations: identity.rotations.clone(),
2121 hub_signer: None,
2122 protocol_profile: None,
2123 },
2124 Some(®istry_alias),
2125 )?;
2126 let mut out = card;
2127 if let Value::Object(map) = &mut out {
2128 map.insert("home".to_string(), Value::String(home.to_string()));
2129 map.insert(
2130 "resolvedVia".to_string(),
2131 Value::String("registry".to_string()),
2132 );
2133 }
2134 Ok(Some(out))
2135}
2136
2137pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2138 require_safe_ref(&addr.brain)?;
2142 if let Some(target) = &addr.target {
2143 let (given, ok) = match target {
2144 AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2145 AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2146 };
2147 if !ok {
2148 return Err(LinkError::BadAddress {
2149 given: given.clone(),
2150 reason: BAD_TARGET_REASON.to_string(),
2151 });
2152 }
2153 }
2154
2155 if let Some(target) = &addr.target {
2161 if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2162 let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2163 what: "resolve",
2164 status: 404,
2165 message: "record not found".to_string(),
2166 code: Some("NOT_FOUND".to_string()),
2167 details: None,
2168 })?;
2169 let (path, file) = match target {
2170 AddressTarget::Path(path) => {
2171 let file =
2172 v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2173 LinkError::Http {
2174 what: "resolve",
2175 status: 404,
2176 message: "record not found".to_string(),
2177 code: Some("NOT_FOUND".to_string()),
2178 details: None,
2179 }
2180 })?;
2181 (path.clone(), file)
2182 }
2183 AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2184 };
2185 let mut downloaded =
2186 download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2187 let (_, bytes) = downloaded
2188 .pop()
2189 .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2190 let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2191 accept_v2_head(cfg, &head)?;
2192 return Ok(resolved);
2193 }
2194 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2195 if !remote.head.verified {
2196 return Err(invalid_feed(
2197 "a path-scoped feed cannot prove a record against the full signed snapshot",
2198 ));
2199 }
2200 if remote.head.seq == 0 {
2201 return Err(LinkError::Http {
2202 what: "resolve",
2203 status: 404,
2204 message: "record not found".to_string(),
2205 code: Some("NOT_FOUND".to_string()),
2206 details: None,
2207 });
2208 }
2209 let brain = remote.head.brain.clone();
2210 let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2211 return resolve_from_verified_pack(&brain, target, pack);
2212 }
2213
2214 let path = format!("/api/hub/brains/{}", addr.brain);
2215 let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2220 if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2221 if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2222 return Ok(card);
2223 }
2224 }
2225 let mut resolved = ensure_ok(direct, "resolve")?;
2226 if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2227 let v2 = v2_verified_head(cfg, &addr.brain)?
2228 .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2229 if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2230 return Err(invalid_feed(
2231 "resolve card is not bound to the verified v2 brain",
2232 ));
2233 }
2234 let card_identity: FeedIdentity = serde_json::from_value(
2235 resolved
2236 .get("identity")
2237 .cloned()
2238 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2239 )
2240 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2241 if card_identity != v2_identity(&v2.identity) {
2242 return Err(invalid_feed(
2243 "resolve card identity differs from the verified v2 identity",
2244 ));
2245 }
2246 accept_v2_head(cfg, &v2)?;
2247 if let Value::Object(card) = &mut resolved {
2248 card.insert(
2249 "headSeq".to_string(),
2250 json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2251 );
2252 card.insert(
2253 "feedHash".to_string(),
2254 v2.pointer
2255 .as_ref()
2256 .map(|pointer| Value::String(pointer.feed_hash.clone()))
2257 .unwrap_or(Value::Null),
2258 );
2259 card.insert(
2260 "storageProfile".to_string(),
2261 Value::String("v2".to_string()),
2262 );
2263 if let Some(pointer) = &v2.pointer {
2264 card.insert(
2265 "updatedAt".to_string(),
2266 Value::String(pointer.signed_at.clone()),
2267 );
2268 }
2269 }
2270 return Ok(resolved);
2271 }
2272 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2276 if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2277 || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2278 || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2279 {
2280 return Err(invalid_feed(
2281 "resolve card is not bound to the exact verified feed checkpoint",
2282 ));
2283 }
2284 let card_identity: FeedIdentity = serde_json::from_value(
2285 resolved
2286 .get("identity")
2287 .cloned()
2288 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2289 )
2290 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2291 if remote.identity.as_ref() != Some(&card_identity) {
2292 return Err(invalid_feed(
2293 "resolve card identity differs from the verified feed identity",
2294 ));
2295 }
2296 Ok(resolved)
2297}
2298
2299fn resolve_from_verified_pack(
2304 brain: &str,
2305 target: &AddressTarget,
2306 pack: Vec<u8>,
2307) -> LinkResult<Value> {
2308 let entries = parse_store_pack(pack)?;
2309 let mut matched: Option<(String, Vec<u8>)> = None;
2310
2311 for (path, bytes) in entries {
2312 let is_candidate = match target {
2313 AddressTarget::Path(want) => &path == want,
2314 AddressTarget::Id(_) => {
2315 path.ends_with(".md")
2316 && (path.starts_with("records/") || path.starts_with("sources/"))
2317 }
2318 };
2319 if !is_candidate {
2320 continue;
2321 }
2322 let text = std::str::from_utf8(&bytes)
2323 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2324 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2325 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2326 if let AddressTarget::Id(want) = target {
2327 let frontmatter =
2328 crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2329 .map_err(|_| {
2330 invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2331 })?;
2332 if frontmatter.id.as_deref() != Some(want) {
2333 continue;
2334 }
2335 }
2336 if matched.is_some() {
2337 return Err(invalid_feed(
2338 "signed snapshot contains more than one record for the requested target",
2339 ));
2340 }
2341 matched = Some((path, bytes));
2342 }
2343
2344 let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2345 what: "resolve",
2346 status: 404,
2347 message: "record not found".to_string(),
2348 code: Some("NOT_FOUND".to_string()),
2349 details: None,
2350 })?;
2351 resolve_from_verified_record_bytes(brain, target, path, bytes)
2352}
2353
2354fn resolve_from_verified_record_bytes(
2355 brain: &str,
2356 target: &AddressTarget,
2357 path: String,
2358 bytes: Vec<u8>,
2359) -> LinkResult<Value> {
2360 match target {
2361 AddressTarget::Path(expected) if expected != &path => {
2362 return Err(invalid_feed(
2363 "verified record path differs from the requested path",
2364 ));
2365 }
2366 AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2367 return Err(invalid_feed(
2368 "verified id resolved outside records or sources",
2369 ));
2370 }
2371 _ => {}
2372 }
2373 let text = std::str::from_utf8(&bytes)
2374 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2375 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2376 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2377 let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2378 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2379 let Value::Object(fields) = frontmatter else {
2380 return Err(invalid_feed(format!(
2381 "signed snapshot record `{path}` frontmatter is not a mapping"
2382 )));
2383 };
2384 if let AddressTarget::Id(expected) = target {
2385 if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2386 return Err(invalid_feed(
2387 "verified record id differs from the requested id",
2388 ));
2389 }
2390 }
2391 let mut document = serde_json::Map::new();
2392 document.insert("path".to_string(), Value::String(path));
2393 for (key, value) in fields {
2394 document.insert(key, value);
2395 }
2396 document.insert("body".to_string(), Value::String(parsed.body));
2397 document.insert(
2398 "contentSha".to_string(),
2399 Value::String(content_sha256(&bytes)),
2400 );
2401 Ok(json!({
2402 "brain": brain,
2403 "document": Value::Object(document),
2404 }))
2405}
2406
2407#[derive(Debug, Clone, serde::Serialize)]
2413pub struct PullReport {
2414 pub brain: String,
2416 pub slug: String,
2418 #[serde(rename = "headSeq")]
2420 pub head_seq: u64,
2421 pub files: usize,
2423 pub dest: String,
2425 #[serde(rename = "extraLocal")]
2428 pub extra_local: Vec<String>,
2429 #[serde(rename = "syncStatus")]
2431 pub sync_status: String,
2432}
2433
2434struct V2PulledSnapshot {
2435 report: PullReport,
2436 head: V2VerifiedHead,
2437 files: std::collections::BTreeMap<String, V2BaselineFile>,
2438 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2439 local: V2LocalView,
2440 local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2441}
2442
2443fn download_verified_snapshot_pack(
2444 cfg: &HubConfig,
2445 brain: &str,
2446 remote: &VerifiedRemote,
2447) -> LinkResult<Vec<u8>> {
2448 let feed_hash = remote
2449 .head
2450 .feed_hash
2451 .as_deref()
2452 .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2453 let signed_head = remote
2454 .head_entry
2455 .as_ref()
2456 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2457 let expected = &signed_head.entry.pack_sha256;
2458 if !is_sha256(expected) {
2459 return Err(invalid_feed(
2460 "signed head carries an invalid snapshot pack digest",
2461 ));
2462 }
2463 let path = format!(
2464 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2465 remote.head.seq
2466 );
2467 let body = ensure_ok(
2468 request(cfg, "GET", &path, None, Auth::Required)?,
2469 "sync pull",
2470 )?;
2471 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2472 || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2473 || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2474 || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2475 {
2476 return Err(invalid_feed(
2477 "export response is not bound to the exact verified snapshot",
2478 ));
2479 }
2480 let url = body
2481 .get("url")
2482 .and_then(Value::as_str)
2483 .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2484 let bytes = get_presigned(cfg, url)?;
2485 if content_sha256(&bytes) != *expected {
2486 return Err(LinkError::InvalidPack {
2487 message: "downloaded pack does not match the signed snapshot digest".to_string(),
2488 });
2489 }
2490 let entries = parse_store_pack(bytes.clone())?;
2491 if signed_head.entry.kind == "push" {
2492 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2493 }
2494 Ok(bytes)
2495}
2496
2497#[derive(Debug, Clone, Deserialize, Serialize)]
2498struct V2PointerBody {
2499 v: u8,
2500 brain: String,
2501 seq: u64,
2502 commit_hash: String,
2503 feed_hash: String,
2504 content_root: Option<String>,
2505 asset_root: Option<String>,
2506 materializer: String,
2507 signer_epoch: u64,
2508 control_revision: String,
2509 backup_preparation: String,
2510 prior_pointer_hash: Option<String>,
2511 signed_at: String,
2512}
2513
2514#[derive(Debug, Clone, Deserialize)]
2515struct V2SignedPointer {
2516 pointer: V2PointerBody,
2517 hub_public_key: String,
2518 hub_fingerprint: String,
2519 sig: String,
2520}
2521
2522#[derive(Debug, Clone, Deserialize)]
2523struct V2HeadIdentity {
2524 #[serde(default)]
2525 custody: String,
2526 fingerprint: String,
2527 public_key_spki: String,
2528 #[serde(default)]
2529 previous: Vec<V2PreviousIdentity>,
2530 #[serde(default)]
2531 rotations: Vec<String>,
2532}
2533
2534#[derive(Debug, Clone, Deserialize)]
2535struct V2PreviousIdentity {
2536 fingerprint: String,
2537 public_key_spki: String,
2538}
2539
2540#[derive(Debug, Deserialize)]
2541struct V2HeadResponse {
2542 v: u8,
2543 brain_id: String,
2544 profile: String,
2545 view: Option<V2HeadView>,
2546 pointer: Option<V2SignedPointer>,
2547 identity: Option<V2HeadIdentity>,
2548}
2549
2550#[derive(Debug, Clone, Deserialize)]
2551struct V2HeadView {
2552 kind: String,
2553 #[serde(default)]
2554 id: Option<String>,
2555 control_revision: String,
2556}
2557
2558#[derive(Debug, Clone)]
2559struct V2VerifiedHead {
2560 requested: String,
2561 brain_id: String,
2562 view_kind: String,
2563 view_revision: String,
2565 control_revision: String,
2567 identity: V2HeadIdentity,
2568 pointer: Option<V2PointerBody>,
2569 trust: TrustState,
2570 alias: Option<AliasBinding>,
2571}
2572
2573fn verify_v2_spki_signature(
2574 public_key: &str,
2575 message: &[u8],
2576 signature: &str,
2577) -> LinkResult<Vec<u8>> {
2578 let der = URL_SAFE_NO_PAD
2579 .decode(public_key)
2580 .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2581 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2582 return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2583 }
2584 let sig = URL_SAFE_NO_PAD
2585 .decode(signature)
2586 .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2587 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2588 .verify(message, &sig)
2589 .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2590 Ok(der)
2591}
2592
2593fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2594 if pointer.pointer.v != 2
2595 || pointer.pointer.brain != expected_brain
2596 || pointer.pointer.seq == 0
2597 || !is_sha256(&pointer.pointer.commit_hash)
2598 || !is_sha256(&pointer.pointer.feed_hash)
2599 || pointer
2600 .pointer
2601 .content_root
2602 .as_deref()
2603 .is_some_and(|hash| !is_sha256(hash))
2604 || !is_sha256(&pointer.pointer.backup_preparation)
2605 {
2606 return Err(invalid_feed("v2 pointer fields are invalid"));
2607 }
2608 let value = serde_json::to_value(&pointer.pointer)
2609 .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2610 let message = crate::linkmd_v2::canonical_bytes(&value)
2611 .map_err(|error| invalid_feed(error.to_string()))?;
2612 let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2613 let fingerprint = format!("{:x}", Sha256::digest(&der));
2614 if fingerprint != pointer.hub_fingerprint {
2615 return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2616 }
2617 Ok(format!(
2618 "{}:{}",
2619 pointer.hub_fingerprint, pointer.hub_public_key
2620 ))
2621}
2622
2623fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2624 FeedIdentity {
2625 fingerprint: identity.fingerprint.clone(),
2626 public_key_spki: identity.public_key_spki.clone(),
2627 previous: identity
2628 .previous
2629 .iter()
2630 .map(|previous| PreviousIdentity {
2631 fingerprint: previous.fingerprint.clone(),
2632 public_key_spki: previous.public_key_spki.clone(),
2633 })
2634 .collect(),
2635 rotations: identity.rotations.clone(),
2636 }
2637}
2638
2639fn verified_v2_commit_object(
2640 raw: &[u8],
2641 identity: &V2HeadIdentity,
2642) -> LinkResult<serde_json::Map<String, Value>> {
2643 let mut value: Value =
2644 serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2645 let canonical = crate::linkmd_v2::canonical_bytes(&value)
2646 .map_err(|error| invalid_feed(error.to_string()))?;
2647 if canonical != raw {
2648 return Err(invalid_feed("v2 commit is not canonical JSON"));
2649 }
2650 let object = value
2651 .as_object_mut()
2652 .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
2653 let sig = object
2654 .remove("sig")
2655 .and_then(|value| value.as_str().map(str::to_string))
2656 .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
2657 const FIELDS: [&str; 18] = [
2658 "actor_ref",
2659 "asset_root",
2660 "brain",
2661 "changes_sha256",
2662 "control_revision",
2663 "materializer",
2664 "op",
2665 "parent_asset_root",
2666 "parent_commit",
2667 "parent_root",
2668 "prev_entry_hash",
2669 "public_key",
2670 "seq",
2671 "signer_epoch",
2672 "state_root",
2673 "ts",
2674 "v",
2675 "v1_bridge",
2676 ];
2677 if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
2678 return Err(invalid_feed("v2 commit has a non-normative field set"));
2679 }
2680 let seq = object
2681 .get("seq")
2682 .and_then(Value::as_u64)
2683 .filter(|seq| *seq > 0)
2684 .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
2685 let signer_epoch = object
2686 .get("signer_epoch")
2687 .and_then(Value::as_u64)
2688 .filter(|epoch| *epoch > 0)
2689 .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
2690 let hash_or_null = |field: &str| {
2691 object
2692 .get(field)
2693 .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
2694 };
2695 if object.get("v").and_then(Value::as_u64) != Some(2)
2696 || object.get("op").and_then(Value::as_str) != Some("changeset")
2697 || !object
2698 .get("changes_sha256")
2699 .and_then(Value::as_str)
2700 .is_some_and(is_sha256)
2701 || !object
2702 .get("actor_ref")
2703 .and_then(Value::as_str)
2704 .is_some_and(is_sha256)
2705 || !object
2706 .get("control_revision")
2707 .and_then(Value::as_str)
2708 .is_some_and(is_sha256)
2709 || !object
2710 .get("state_root")
2711 .and_then(Value::as_str)
2712 .is_some_and(is_sha256)
2713 || !hash_or_null("parent_commit")
2714 || !hash_or_null("parent_root")
2715 || !hash_or_null("parent_asset_root")
2716 || !hash_or_null("asset_root")
2717 || !hash_or_null("prev_entry_hash")
2718 || !object
2719 .get("materializer")
2720 .and_then(Value::as_str)
2721 .is_some_and(|value| !value.is_empty() && value.len() <= 128)
2722 || !object
2723 .get("ts")
2724 .and_then(Value::as_str)
2725 .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
2726 {
2727 return Err(invalid_feed("v2 commit fields are invalid"));
2728 }
2729 if (seq == 1
2730 && [
2731 "parent_commit",
2732 "parent_root",
2733 "parent_asset_root",
2734 "prev_entry_hash",
2735 ]
2736 .iter()
2737 .any(|field| !object.get(*field).is_some_and(Value::is_null)))
2738 || (seq > 1
2739 && ["parent_commit", "parent_root", "prev_entry_hash"]
2740 .iter()
2741 .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
2742 {
2743 return Err(invalid_feed("v2 commit parent shape is invalid"));
2744 }
2745 match object.get("v1_bridge") {
2746 Some(Value::Null) => {}
2747 Some(Value::Object(bridge))
2748 if seq == 1
2749 && bridge.len() == 3
2750 && bridge
2751 .get("head_seq")
2752 .and_then(Value::as_u64)
2753 .is_some_and(|v| v > 0)
2754 && bridge
2755 .get("feed_hash")
2756 .and_then(Value::as_str)
2757 .is_some_and(is_sha256)
2758 && bridge
2759 .get("pack_sha256")
2760 .and_then(Value::as_str)
2761 .is_some_and(is_sha256) => {}
2762 _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
2763 }
2764 let public_key = object
2765 .get("public_key")
2766 .and_then(Value::as_str)
2767 .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
2768 let der = URL_SAFE_NO_PAD
2769 .decode(public_key)
2770 .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
2771 let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
2772 if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
2773 return Err(invalid_feed("v2 commit brain identity mismatch"));
2774 }
2775 verify_identity_chain(&v2_identity(identity), None)?;
2777 let mut chain: Vec<(&str, &str)> = identity
2780 .previous
2781 .iter()
2782 .rev()
2783 .map(|previous| {
2784 (
2785 previous.fingerprint.as_str(),
2786 previous.public_key_spki.as_str(),
2787 )
2788 })
2789 .collect();
2790 chain.push((&identity.fingerprint, &identity.public_key_spki));
2791 let signer_index = chain.iter().position(|(fingerprint, spki)| {
2792 *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
2793 });
2794 let Some(signer_index) = signer_index else {
2795 return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
2796 };
2797 if signer_epoch != signer_index as u64 + 1 {
2798 return Err(invalid_feed("v2 commit signer epoch differs from its key"));
2799 }
2800 let lower_boundary = if signer_index == 0 {
2801 None
2802 } else {
2803 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
2804 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
2805 Some(prior.prior_head_seq)
2806 };
2807 let upper_boundary = if signer_index == identity.rotations.len() {
2808 None
2809 } else {
2810 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
2811 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
2812 Some(next.prior_head_seq)
2813 };
2814 if lower_boundary.is_some_and(|boundary| seq <= boundary)
2815 || upper_boundary.is_some_and(|boundary| seq > boundary)
2816 {
2817 return Err(invalid_feed(
2818 "v2 commit signer is outside its authenticated rotation epoch",
2819 ));
2820 }
2821 let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
2822 .map_err(|error| invalid_feed(error.to_string()))?;
2823 verify_v2_spki_signature(public_key, &unsigned, &sig)?;
2824 Ok(object.clone())
2825}
2826
2827#[derive(Debug, Deserialize)]
2828struct V2FeedWireEntry {
2829 seq: u64,
2830 commit_hash: String,
2831 feed_hash: String,
2832 bytes_base64: String,
2833}
2834
2835#[derive(Debug, Deserialize)]
2836struct V2FeedPage {
2837 v: u8,
2838 head_seq: u64,
2839 head_commit_hash: String,
2840 head_feed_hash: String,
2841 entries: Vec<V2FeedWireEntry>,
2842 next_after: u64,
2843 complete: bool,
2844}
2845
2846fn replay_v2_feed(
2847 cfg: &HubConfig,
2848 brain: &str,
2849 pointer: &V2PointerBody,
2850 identity: &V2HeadIdentity,
2851 start_after: u64,
2852 start_feed: Option<String>,
2853) -> LinkResult<()> {
2854 let mut after = start_after;
2855 let mut prior_feed = start_feed;
2856 let mut final_object = None;
2857 let mut replayed_entries = 0_u64;
2858 let mut replayed_bytes = 0_u64;
2859 while after < pointer.seq {
2860 let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
2861 let value = ensure_ok(
2862 request_capped(
2863 cfg,
2864 "GET",
2865 &path,
2866 None,
2867 Auth::Required,
2868 MAX_FEED_REPLAY_BYTES,
2869 )?,
2870 "v2 feed replay",
2871 )?;
2872 let page: V2FeedPage = serde_json::from_value(value)
2873 .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
2874 if page.v != 2
2875 || page.head_seq != pointer.seq
2876 || page.head_commit_hash != pointer.commit_hash
2877 || page.head_feed_hash != pointer.feed_hash
2878 || page.entries.is_empty()
2879 || page.entries.len() > FEED_PAGE_LIMIT
2880 {
2881 return Err(invalid_feed("v2 feed page differs from the signed head"));
2882 }
2883 for entry in page.entries {
2884 if entry.seq != after + 1
2885 || !is_sha256(&entry.commit_hash)
2886 || !is_sha256(&entry.feed_hash)
2887 {
2888 return Err(invalid_feed("v2 feed sequence is not contiguous"));
2889 }
2890 let raw = base64::engine::general_purpose::STANDARD
2891 .decode(&entry.bytes_base64)
2892 .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
2893 replayed_entries = replayed_entries
2894 .checked_add(1)
2895 .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
2896 replayed_bytes = replayed_bytes
2897 .checked_add(raw.len() as u64)
2898 .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
2899 if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
2900 {
2901 return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
2902 }
2903 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2904 .map_err(|error| invalid_feed(error.to_string()))?
2905 != entry.commit_hash
2906 || content_sha256(&raw) != entry.feed_hash
2907 {
2908 return Err(invalid_feed("v2 feed entry address mismatch"));
2909 }
2910 let object = verified_v2_commit_object(&raw, identity)?;
2911 if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
2912 || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
2913 {
2914 return Err(invalid_feed(
2915 "v2 feed entry does not extend its predecessor",
2916 ));
2917 }
2918 after = entry.seq;
2919 prior_feed = Some(entry.feed_hash);
2920 final_object = Some((entry.commit_hash, object));
2921 }
2922 if page.next_after != after || (page.complete != (after == pointer.seq)) {
2923 return Err(invalid_feed("v2 feed page cursor is inconsistent"));
2924 }
2925 }
2926 let (final_hash, object) =
2927 final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
2928 if final_hash != pointer.commit_hash
2929 || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
2930 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
2931 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
2932 || object.get("control_revision").and_then(Value::as_str)
2933 != Some(pointer.control_revision.as_str())
2934 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
2935 {
2936 return Err(invalid_feed(
2937 "v2 replay did not converge on the signed pointer",
2938 ));
2939 }
2940 Ok(())
2941}
2942
2943fn verify_v1_to_v2_bridge(
2944 cfg: &HubConfig,
2945 brain: &str,
2946 pointer: &V2PointerBody,
2947 identity: &V2HeadIdentity,
2948 checkpoint: &TrustState,
2949) -> LinkResult<()> {
2950 let value = ensure_ok(
2951 request_capped(
2952 cfg,
2953 "GET",
2954 &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
2955 None,
2956 Auth::Required,
2957 MAX_FEED_RESPONSE_BYTES,
2958 )?,
2959 "v2 genesis bridge",
2960 )?;
2961 let page: V2FeedPage = serde_json::from_value(value)
2962 .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
2963 if page.v != 2
2964 || page.head_seq != pointer.seq
2965 || page.head_commit_hash != pointer.commit_hash
2966 || page.head_feed_hash != pointer.feed_hash
2967 || page.entries.len() != 1
2968 || page.entries[0].seq != 1
2969 || !is_sha256(&page.entries[0].commit_hash)
2970 || !is_sha256(&page.entries[0].feed_hash)
2971 {
2972 return Err(invalid_feed(
2973 "v2 genesis bridge page differs from the signed head",
2974 ));
2975 }
2976 let first = &page.entries[0];
2977 let raw = STANDARD
2978 .decode(&first.bytes_base64)
2979 .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
2980 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
2981 .map_err(|error| invalid_feed(error.to_string()))?
2982 != first.commit_hash
2983 || content_sha256(&raw) != first.feed_hash
2984 {
2985 return Err(invalid_feed("v2 genesis bridge address mismatch"));
2986 }
2987 let object = verified_v2_commit_object(&raw, identity)?;
2988 if checkpoint.head_seq == 0 {
2989 if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
2990 return Err(invalid_feed(
2991 "empty v1 checkpoint did not transition through an empty v2 genesis",
2992 ));
2993 }
2994 return Ok(());
2995 }
2996 let bridge = object
2997 .get("v1_bridge")
2998 .and_then(Value::as_object)
2999 .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3000 let checkpoint_feed = checkpoint
3001 .feed_hash
3002 .as_deref()
3003 .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3004 if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3005 || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3006 {
3007 return Err(invalid_feed(
3008 "v2 genesis bridge differs from the pinned v1 checkpoint",
3009 ));
3010 }
3011 let legacy_raw = ensure_raw_ok(
3012 request_raw(
3013 cfg,
3014 "GET",
3015 &format!(
3016 "/api/hub/brains/{brain}/feed?after={}&limit=1",
3017 checkpoint.head_seq - 1
3018 ),
3019 None,
3020 Auth::Required,
3021 MAX_FEED_RESPONSE_BYTES,
3022 )?,
3023 "v1 bridge boundary",
3024 )?;
3025 let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3026 .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3027 let legacy_identity = legacy
3028 .identity
3029 .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3030 let item = legacy
3031 .entries
3032 .first()
3033 .filter(|_| legacy.entries.len() == 1)
3034 .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3035 if legacy.scope_limited
3036 || legacy.head_seq != checkpoint.head_seq
3037 || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3038 || item.entry.seq != checkpoint.head_seq
3039 || item.hash != checkpoint_feed
3040 || legacy_identity != v2_identity(identity)
3041 || bridge.get("pack_sha256").and_then(Value::as_str)
3042 != Some(item.entry.pack_sha256.as_str())
3043 {
3044 return Err(invalid_feed(
3045 "v1 bridge boundary differs from its signed legacy head",
3046 ));
3047 }
3048 let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3049 if anchor != checkpoint.anchor {
3050 return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3051 }
3052 verify_feed_item(item, &legacy_identity)?;
3053 verify_rotation_feed_boundaries(
3054 &legacy_identity,
3055 Some(checkpoint),
3056 std::slice::from_ref(item),
3057 checkpoint.head_seq,
3058 )?;
3059 Ok(())
3060}
3061
3062fn verify_v2_commit(
3063 cfg: &HubConfig,
3064 brain: &str,
3065 pointer: &V2PointerBody,
3066 identity: &V2HeadIdentity,
3067 pinned: Option<&TrustState>,
3068) -> LinkResult<()> {
3069 let path = format!(
3070 "/api/hub/brains/{brain}/v2/commit?commit={}",
3071 pointer.commit_hash
3072 );
3073 let raw = ensure_raw_ok(
3074 request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3075 "v2 commit",
3076 )?;
3077 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3078 .map_err(|error| invalid_feed(error.to_string()))?
3079 != pointer.commit_hash
3080 || content_sha256(&raw) != pointer.feed_hash
3081 {
3082 return Err(invalid_feed("v2 commit address differs from the pointer"));
3083 }
3084 let object = verified_v2_commit_object(&raw, identity)?;
3085 if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3086 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3087 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3088 || object.get("control_revision").and_then(Value::as_str)
3089 != Some(pointer.control_revision.as_str())
3090 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3091 {
3092 return Err(invalid_feed("v2 commit fields differ from the pointer"));
3093 }
3094 if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3095 if pointer.seq == checkpoint.head_seq + 1
3096 && object.get("prev_entry_hash").and_then(Value::as_str)
3097 != checkpoint.feed_hash.as_deref()
3098 {
3099 return Err(invalid_feed(
3100 "v2 commit does not extend the pinned feed hash",
3101 ));
3102 }
3103 if pointer.seq > checkpoint.head_seq + 1 {
3104 return replay_v2_feed(
3105 cfg,
3106 brain,
3107 pointer,
3108 identity,
3109 checkpoint.head_seq,
3110 checkpoint.feed_hash.clone(),
3111 );
3112 }
3113 } else {
3114 if let Some(checkpoint) = pinned {
3115 verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3116 }
3117 if pointer.seq > 1 {
3118 return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3119 }
3120 }
3121 Ok(())
3122}
3123
3124fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3125 require_hardened_filesystem("verified link.md v2 state")?;
3126 require_safe_ref(brain)?;
3127 let trust_directory = open_trust_dir(cfg)?;
3131 let path = format!("/api/hub/brains/{brain}/v2/head");
3132 let response = request(cfg, "GET", &path, None, Auth::Required)?;
3133 if response.status == 404 {
3134 if has_accepted_v2_ref(cfg, brain)? {
3135 return Err(LinkError::BrainUnavailable);
3136 }
3137 return Ok(None);
3138 }
3139 let body = ensure_ok(response, "v2 head")?;
3140 let head: V2HeadResponse = serde_json::from_value(body)
3141 .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3142 if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3143 return Err(invalid_feed("v2 head has no canonical brain id"));
3144 }
3145 if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3146 return Err(invalid_feed("v2 head resolved a different brain id"));
3147 }
3148 if head.profile == "v1" {
3149 return Ok(None);
3150 }
3151 if head.profile != "v2" && head.profile != "v2-empty" {
3152 return Err(invalid_feed("v2 head advertised an unknown profile"));
3153 }
3154 let view = head
3155 .view
3156 .as_ref()
3157 .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3158 if !matches!(view.kind.as_str(), "full" | "scoped")
3159 || !is_sha256(&view.control_revision)
3160 || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3161 {
3162 return Err(invalid_feed("v2 head has an invalid permission view"));
3163 }
3164 let view_kind = view.kind.clone();
3165 let view_revision = view
3168 .id
3169 .clone()
3170 .unwrap_or_else(|| view.control_revision.clone());
3171 let control_revision = view.control_revision.clone();
3172 let identity = head
3173 .identity
3174 .as_ref()
3175 .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3176 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3177 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3178 let feed_identity = v2_identity(identity);
3179 let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3180 let (seq, feed_hash, hub_signer) = match &head.pointer {
3181 None => {
3182 if head.profile != "v2-empty" {
3183 return Err(invalid_feed("initialized v2 head has no pointer"));
3184 }
3185 (
3186 0,
3187 None,
3188 pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3189 )
3190 }
3191 Some(signed) => {
3192 let signer = verify_v2_pointer(signed, &head.brain_id)?;
3193 if pinned
3194 .as_ref()
3195 .and_then(|state| state.hub_signer.as_ref())
3196 .is_some_and(|known| known != &signer)
3197 {
3198 return Err(invalid_feed(
3199 "v2 hub pointer signer changed without a trust transition",
3200 ));
3201 }
3202 if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3203 if signed.pointer.seq < checkpoint.head_seq
3204 || (signed.pointer.seq == checkpoint.head_seq
3205 && checkpoint.feed_hash.as_deref()
3206 != Some(signed.pointer.feed_hash.as_str()))
3207 {
3208 return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3209 }
3210 }
3211 verify_v2_commit(
3212 cfg,
3213 &head.brain_id,
3214 &signed.pointer,
3215 identity,
3216 pinned.as_ref(),
3217 )?;
3218 (
3219 signed.pointer.seq,
3220 Some(signed.pointer.feed_hash.clone()),
3221 Some(signer),
3222 )
3223 }
3224 };
3225 let trust = TrustState {
3226 v: 2,
3227 origin: normalized_origin(&cfg.hub)?,
3228 requested: head.brain_id.clone(),
3229 brain: head.brain_id.clone(),
3230 home: None,
3231 anchor,
3232 current: format!("ed25519:{}", identity.fingerprint),
3233 head_seq: seq,
3234 feed_hash,
3235 rotations: identity.rotations.clone(),
3236 hub_signer,
3237 protocol_profile: Some("link-v2".to_string()),
3238 };
3239 Ok(Some(V2VerifiedHead {
3240 requested: brain.to_string(),
3241 brain_id: head.brain_id,
3242 view_kind,
3243 view_revision,
3244 control_revision,
3245 identity: identity.clone(),
3246 pointer: head.pointer.map(|signed| signed.pointer),
3247 trust,
3248 alias: alias_binding,
3249 }))
3250}
3251
3252fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3253 let directory = open_trust_dir(cfg)?;
3254 let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3255 let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3256 if let Some(current) = current {
3257 let common_invalid = head.trust.anchor != current.anchor
3258 || !head.trust.rotations.starts_with(¤t.rotations);
3259 let profile_invalid = if accepted_as_v2(¤t) {
3260 head.trust.head_seq < current.head_seq
3261 || (head.trust.head_seq == current.head_seq
3262 && head.trust.feed_hash != current.feed_hash)
3263 || current
3264 .hub_signer
3265 .as_ref()
3266 .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3267 } else {
3268 head.trust.protocol_profile.as_deref() != Some("link-v2")
3269 || head.trust.hub_signer.is_none()
3270 };
3271 if common_invalid || profile_invalid {
3272 return Err(invalid_feed(
3273 "v2 head cannot advance the currently accepted trust checkpoint",
3274 ));
3275 }
3276 }
3277 save_canonical_pin_and_alias(
3278 cfg,
3279 &directory,
3280 &head.requested,
3281 &head.brain_id,
3282 head.trust.clone(),
3283 alias.as_ref().or(head.alias.as_ref()),
3284 )
3285}
3286
3287#[derive(Debug, Clone, Deserialize, Serialize)]
3288struct V2BaselineFile {
3289 sha256: String,
3290 bytes: u64,
3291 #[serde(skip)]
3292 proof: Option<Vec<V2ProofStep>>,
3293}
3294
3295#[derive(Debug, Clone, Deserialize, Serialize)]
3296struct V2SyncBaseline {
3297 v: u8,
3298 origin: String,
3299 brain: String,
3300 #[serde(default)]
3301 checkout_id: Option<String>,
3302 #[serde(default)]
3303 head_seq: Option<u64>,
3304 commit_hash: Option<String>,
3305 content_root: Option<String>,
3306 #[serde(default)]
3307 asset_root: Option<String>,
3308 #[serde(default)]
3309 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3310 #[serde(default)]
3311 view_kind: Option<String>,
3312 #[serde(default)]
3313 view_revision: Option<String>,
3314 #[serde(default)]
3315 projection_sha256: Option<String>,
3316 files: std::collections::BTreeMap<String, V2BaselineFile>,
3317 #[serde(default)]
3318 local_policy_digest: Option<String>,
3319 #[serde(default)]
3320 local_eligibility: std::collections::BTreeMap<String, bool>,
3321 #[serde(default)]
3322 remote_copy_remains: std::collections::BTreeMap<String, String>,
3323}
3324
3325struct V2LocalView {
3326 riding: std::collections::BTreeMap<String, (String, u64)>,
3327 eligibility: std::collections::BTreeMap<String, bool>,
3328 policy: crate::linkmd_sync_policy::SyncPolicy,
3329 withheld_links: Vec<V2WithheldLink>,
3330}
3331
3332#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3333struct V2WithheldLink {
3334 source: String,
3335 target: String,
3336}
3337
3338#[derive(Debug, Clone, Deserialize, Serialize)]
3339struct V2ProofStep {
3340 directory_root: String,
3341 component: String,
3342 proof: crate::linkmd_v2::HamtProof,
3343}
3344
3345#[derive(Debug, Deserialize)]
3346struct V2ManifestFile {
3347 path: String,
3348 sha256: String,
3349 bytes: u64,
3350 proof: Vec<V2ProofStep>,
3351}
3352
3353#[derive(Debug, Deserialize)]
3354struct V2ManifestPage {
3355 v: u8,
3356 commit: String,
3357 content_root: Option<String>,
3358 files: Vec<V2ManifestFile>,
3359 next_cursor: Option<String>,
3360}
3361
3362#[derive(Debug, Clone, Deserialize, Serialize)]
3363struct V2BaselineAsset {
3364 blob_sha256: String,
3365 bytes: u64,
3366 media_type: String,
3367 wrappers: Vec<String>,
3368 required: bool,
3369 disposition: String,
3370 leaf_hash: String,
3371}
3372
3373#[derive(Debug, Deserialize)]
3374struct V2AssetManifestItem {
3375 path: String,
3376 blob_sha256: String,
3377 bytes: u64,
3378 media_type: String,
3379 wrappers: Vec<String>,
3380 required: bool,
3381 disposition: String,
3382 leaf_hash: String,
3383 proof: crate::linkmd_v2::HamtProof,
3384}
3385
3386#[derive(Debug, Deserialize)]
3387struct V2AssetManifestPage {
3388 v: u8,
3389 commit: String,
3390 asset_root: Option<String>,
3391 assets: Vec<V2AssetManifestItem>,
3392 next_cursor: Option<String>,
3393}
3394
3395#[derive(Debug, Deserialize)]
3396struct V2SigningCandidate {
3397 seq: u64,
3398 content_root: Option<String>,
3399 asset_root: Option<String>,
3400 signing_bytes_base64: String,
3401 changes_base64: String,
3402 actor_claim_base64: String,
3403}
3404
3405#[derive(Debug, Deserialize)]
3406struct V2SigningCandidatePage {
3407 v: u8,
3408 challenge_id: String,
3409 mutation_id: String,
3410 request_hash: String,
3411 parent: V2SigningParent,
3412 candidate: V2SigningCandidate,
3413 files: Vec<V2ManifestFile>,
3414 #[serde(default)]
3415 assets: Vec<V2AssetManifestItem>,
3416 next_cursor: Option<String>,
3417 expires_at: String,
3418}
3419
3420#[derive(Debug, Deserialize)]
3421struct V2SigningParent {
3422 seq: u64,
3423 commit_hash: Option<String>,
3424}
3425
3426fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3427 let normalized = crate::linkmd_v2::normalize_path(&file.path)
3428 .map_err(|error| invalid_feed(error.to_string()))?;
3429 let components = normalized.split('/').collect::<Vec<_>>();
3430 if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3431 return Err(invalid_feed("v2 file proof has the wrong shape"));
3432 }
3433 let mut directory_root = root.to_string();
3434 for (index, step) in file.proof.iter().enumerate() {
3435 if step.directory_root != directory_root || step.component != components[index] {
3436 return Err(invalid_feed(
3437 "v2 file proof path chain differs from its manifest",
3438 ));
3439 }
3440 if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3441 .map_err(|error| invalid_feed(error.to_string()))?
3442 {
3443 return Err(invalid_feed("v2 file proof failed verification"));
3444 }
3445 let entry = match &step.proof {
3446 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3447 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3448 return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3449 }
3450 };
3451 if index + 1 == components.len() {
3452 if entry.kind != crate::linkmd_v2::EntryKind::Blob
3453 || entry.child_hash != file.sha256
3454 || entry.bytes != Some(file.bytes)
3455 {
3456 return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3457 }
3458 } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3459 return Err(invalid_feed("v2 file proof traversed a non-directory"));
3460 } else {
3461 directory_root = entry.child_hash.clone();
3462 }
3463 }
3464 Ok(())
3465}
3466
3467fn v2_manifest(
3468 cfg: &HubConfig,
3469 brain: &str,
3470 pointer: Option<&V2PointerBody>,
3471) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3472 let Some(pointer) = pointer else {
3473 return Ok(std::collections::BTreeMap::new());
3474 };
3475 let Some(root) = pointer.content_root.as_deref() else {
3476 return Ok(std::collections::BTreeMap::new());
3477 };
3478 let mut files = std::collections::BTreeMap::new();
3479 let mut after = String::new();
3480 loop {
3481 let encoded_after: String =
3482 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3483 let path = format!(
3484 "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3485 pointer.commit_hash
3486 );
3487 let value = ensure_ok(
3488 request_capped(
3489 cfg,
3490 "GET",
3491 &path,
3492 None,
3493 Auth::Required,
3494 MAX_FEED_RESPONSE_BYTES,
3495 )?,
3496 "v2 file manifest",
3497 )?;
3498 let page: V2ManifestPage = serde_json::from_value(value)
3499 .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3500 if page.v != 2
3501 || page.commit != pointer.commit_hash
3502 || page.content_root.as_deref() != Some(root)
3503 || page.files.len() > 500
3504 {
3505 return Err(invalid_feed(
3506 "v2 file manifest is not bound to the verified head",
3507 ));
3508 }
3509 for file in page.files {
3510 verify_v2_file_proof(root, &file)?;
3511 if files
3512 .insert(
3513 file.path.clone(),
3514 V2BaselineFile {
3515 sha256: file.sha256,
3516 bytes: file.bytes,
3517 proof: Some(file.proof),
3518 },
3519 )
3520 .is_some()
3521 {
3522 return Err(invalid_feed("v2 file manifest repeats a path"));
3523 }
3524 if files.len() > MAX_PUSH_FILES {
3525 return Err(invalid_feed(
3526 "v2 file manifest exceeds the file-count bound",
3527 ));
3528 }
3529 }
3530 match page.next_cursor {
3531 None => break,
3532 Some(next) if next > after => after = next,
3533 Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3534 }
3535 }
3536 Ok(files)
3537}
3538
3539fn v2_manifest_file(
3544 cfg: &HubConfig,
3545 brain: &str,
3546 pointer: &V2PointerBody,
3547 path: &str,
3548) -> LinkResult<Option<V2BaselineFile>> {
3549 let Some(root) = pointer.content_root.as_deref() else {
3550 return Ok(None);
3551 };
3552 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
3553 path: error.to_string(),
3554 })?;
3555 let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
3556 let value = ensure_ok(
3557 request_capped(
3558 cfg,
3559 "GET",
3560 &format!(
3561 "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
3562 pointer.commit_hash
3563 ),
3564 None,
3565 Auth::Required,
3566 MAX_FEED_RESPONSE_BYTES,
3567 )?,
3568 "v2 exact file proof",
3569 )?;
3570 let mut page: V2ManifestPage = serde_json::from_value(value)
3571 .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
3572 if page.v != 2
3573 || page.commit != pointer.commit_hash
3574 || page.content_root.as_deref() != Some(root)
3575 || page.next_cursor.is_some()
3576 || page.files.len() != 1
3577 || page.files[0].path != path
3578 {
3579 return Err(invalid_feed(
3580 "v2 exact file proof is not bound to the requested signed path",
3581 ));
3582 }
3583 let file = page.files.pop().expect("exactly one file was checked");
3584 verify_v2_file_proof(root, &file)?;
3585 Ok(Some(V2BaselineFile {
3586 sha256: file.sha256,
3587 bytes: file.bytes,
3588 proof: Some(file.proof),
3589 }))
3590}
3591
3592fn v2_manifest_file_by_id(
3597 cfg: &HubConfig,
3598 brain: &str,
3599 pointer: &V2PointerBody,
3600 id: &str,
3601) -> LinkResult<(String, V2BaselineFile)> {
3602 let root = pointer
3603 .content_root
3604 .as_deref()
3605 .ok_or_else(|| LinkError::Http {
3606 what: "resolve",
3607 status: 404,
3608 message: "record not found".to_string(),
3609 code: Some("NOT_FOUND".to_string()),
3610 details: None,
3611 })?;
3612 if !crate::ulid::is_ulid(id) {
3613 return Err(LinkError::BadAddress {
3614 given: id.to_string(),
3615 reason: BAD_TARGET_REASON.to_string(),
3616 });
3617 }
3618 let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
3619 let value = ensure_ok(
3620 request_capped(
3621 cfg,
3622 "GET",
3623 &format!(
3624 "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
3625 pointer.commit_hash
3626 ),
3627 None,
3628 Auth::Required,
3629 MAX_FEED_RESPONSE_BYTES,
3630 )?,
3631 "v2 exact id proof",
3632 )?;
3633 let mut page: V2ManifestPage = serde_json::from_value(value)
3634 .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
3635 if page.v != 2
3636 || page.commit != pointer.commit_hash
3637 || page.content_root.as_deref() != Some(root)
3638 || page.next_cursor.is_some()
3639 || page.files.len() != 1
3640 {
3641 return Err(invalid_feed(
3642 "v2 exact id proof is not bound to one signed path",
3643 ));
3644 }
3645 let file = page.files.pop().expect("exactly one file was checked");
3646 if !safe_store_rel_path(&file.path)
3647 || !file.path.ends_with(".md")
3648 || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
3649 {
3650 return Err(invalid_feed("v2 exact id proof has an invalid record path"));
3651 }
3652 verify_v2_file_proof(root, &file)?;
3653 Ok((
3654 file.path,
3655 V2BaselineFile {
3656 sha256: file.sha256,
3657 bytes: file.bytes,
3658 proof: Some(file.proof),
3659 },
3660 ))
3661}
3662
3663fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
3664 crate::linkmd_v2::normalize_path(&item.path)
3665 .map_err(|error| invalid_feed(error.to_string()))?;
3666 if !is_sha256(&item.blob_sha256)
3667 || !is_sha256(&item.leaf_hash)
3668 || item.wrappers.is_empty()
3669 || !matches!(item.disposition.as_str(), "hosted" | "withheld")
3670 || item
3671 .wrappers
3672 .iter()
3673 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
3674 {
3675 return Err(invalid_feed("v2 asset manifest item is invalid"));
3676 }
3677 let leaf = json!({
3678 "blob_sha256": item.blob_sha256,
3679 "bytes": item.bytes,
3680 "disposition": item.disposition,
3681 "media_type": item.media_type,
3682 "path": item.path,
3683 "required": item.required,
3684 "v": 2,
3685 "wrappers": item.wrappers,
3686 });
3687 if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
3688 .map_err(|error| invalid_feed(error.to_string()))?
3689 != item.leaf_hash
3690 || !crate::linkmd_v2::verify_proof_with_domain(
3691 root,
3692 &item.path,
3693 &item.proof,
3694 crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
3695 )
3696 .map_err(|error| invalid_feed(error.to_string()))?
3697 {
3698 return Err(invalid_feed("v2 asset inclusion proof failed"));
3699 }
3700 match &item.proof {
3701 crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
3702 if entry.name == item.path
3703 && entry.kind == crate::linkmd_v2::EntryKind::Blob
3704 && entry.child_hash == item.leaf_hash
3705 && entry.bytes == Some(item.bytes) =>
3706 {
3707 Ok(())
3708 }
3709 _ => Err(invalid_feed(
3710 "v2 asset proof leaf differs from its manifest",
3711 )),
3712 }
3713}
3714
3715fn v2_asset_manifest(
3716 cfg: &HubConfig,
3717 brain: &str,
3718 pointer: Option<&V2PointerBody>,
3719) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
3720 let Some(pointer) = pointer else {
3721 return Ok(std::collections::BTreeMap::new());
3722 };
3723 let Some(root) = pointer.asset_root.as_deref() else {
3724 return Ok(std::collections::BTreeMap::new());
3725 };
3726 let mut assets = std::collections::BTreeMap::new();
3727 let mut after = String::new();
3728 loop {
3729 let encoded_after: String =
3730 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3731 let path = format!(
3732 "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
3733 pointer.commit_hash
3734 );
3735 let value = ensure_ok(
3736 request_capped(
3737 cfg,
3738 "GET",
3739 &path,
3740 None,
3741 Auth::Required,
3742 MAX_FEED_RESPONSE_BYTES,
3743 )?,
3744 "v2 asset manifest",
3745 )?;
3746 let page: V2AssetManifestPage = serde_json::from_value(value)
3747 .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
3748 if page.v != 2
3749 || page.commit != pointer.commit_hash
3750 || page.asset_root.as_deref() != Some(root)
3751 || page.assets.len() > 500
3752 {
3753 return Err(invalid_feed(
3754 "v2 asset manifest is not bound to the verified head",
3755 ));
3756 }
3757 for item in page.assets {
3758 verify_v2_asset_proof(root, &item)?;
3759 let path = item.path.clone();
3760 if assets
3761 .insert(
3762 path,
3763 V2BaselineAsset {
3764 blob_sha256: item.blob_sha256,
3765 bytes: item.bytes,
3766 media_type: item.media_type,
3767 wrappers: item.wrappers,
3768 required: item.required,
3769 disposition: item.disposition,
3770 leaf_hash: item.leaf_hash,
3771 },
3772 )
3773 .is_some()
3774 {
3775 return Err(invalid_feed("v2 asset manifest repeats a path"));
3776 }
3777 if assets.len() > MAX_PUSH_FILES {
3778 return Err(invalid_feed(
3779 "v2 asset manifest exceeds the item-count bound",
3780 ));
3781 }
3782 }
3783 match page.next_cursor {
3784 None => break,
3785 Some(next) if next > after => after = next,
3786 Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
3787 }
3788 }
3789 Ok(assets)
3790}
3791
3792fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
3793 crate::AssetRecord {
3794 path: path.to_string(),
3795 sha256: asset.blob_sha256.clone(),
3796 bytes: asset.bytes,
3797 media_type: asset.media_type.clone(),
3798 wrappers: asset.wrappers.clone(),
3799 required: asset.required,
3800 }
3801}
3802
3803fn v2_asset_resumes_hosting(
3804 remote: Option<&V2BaselineAsset>,
3805 path: &str,
3806 record: &crate::AssetRecord,
3807 disposition: &str,
3808) -> bool {
3809 remote.is_some_and(|asset| {
3810 asset.disposition == "withheld"
3811 && disposition == "hosted"
3812 && v2_asset_record(asset, path) == *record
3813 })
3814}
3815
3816fn v2_asset_record_manifest_bytes(
3817 assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
3818) -> LinkResult<Vec<u8>> {
3819 let mut bytes = Vec::new();
3820 for (path, asset) in assets {
3821 if asset.path != *path {
3822 return Err(invalid_feed(
3823 "local asset manifest key differs from its record path",
3824 ));
3825 }
3826 serde_json::to_writer(&mut bytes, asset)
3827 .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
3828 bytes.push(b'\n');
3829 }
3830 Ok(bytes)
3831}
3832
3833fn v2_local_asset_records(
3834 store: &Store,
3835) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
3836 Ok(crate::assets::read_manifest(store)
3837 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
3838 .into_iter()
3839 .map(|asset| (asset.path.clone(), asset))
3840 .collect())
3841}
3842
3843fn v2_asset_records_match_remote(
3844 local: &std::collections::BTreeMap<String, crate::AssetRecord>,
3845 remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
3846) -> bool {
3847 local.len() == remote.len()
3848 && remote
3849 .iter()
3850 .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
3851}
3852
3853#[derive(Debug, Clone, PartialEq, Eq)]
3854struct V2PulledMerge<T> {
3855 records: std::collections::BTreeMap<String, T>,
3856 accept_remote: std::collections::BTreeSet<String>,
3857 conflicts: Vec<String>,
3858}
3859
3860fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
3866 base: &std::collections::BTreeMap<String, Base>,
3867 remote: &std::collections::BTreeMap<String, Remote>,
3868 local: &std::collections::BTreeMap<String, Record>,
3869 base_record: BaseRecord,
3870 remote_record: RemoteRecord,
3871 keep_local: KeepLocal,
3872) -> V2PulledMerge<Record>
3873where
3874 Record: Clone + Eq,
3875 BaseRecord: Fn(&Base, &str) -> Record,
3876 RemoteRecord: Fn(&Remote, &str) -> Record,
3877 KeepLocal: Fn(&str) -> bool,
3878{
3879 let paths = base
3880 .keys()
3881 .chain(remote.keys())
3882 .chain(local.keys())
3883 .cloned()
3884 .collect::<std::collections::BTreeSet<_>>();
3885 let mut records = local.clone();
3886 let mut accept_remote = std::collections::BTreeSet::new();
3887 let mut conflicts = Vec::new();
3888 for path in paths {
3889 if keep_local(&path) {
3890 continue;
3891 }
3892 let base_value = base.get(&path).map(|value| base_record(value, &path));
3893 let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
3894 let local_value = local.get(&path).cloned();
3895 if local_value != base_value && remote_value != base_value && local_value != remote_value {
3896 conflicts.push(path);
3897 continue;
3898 }
3899 if local_value == base_value || local_value == remote_value {
3900 accept_remote.insert(path.clone());
3901 match remote_value {
3902 Some(value) => {
3903 records.insert(path, value);
3904 }
3905 None => {
3906 records.remove(&path);
3907 }
3908 }
3909 }
3910 }
3911 V2PulledMerge {
3912 records,
3913 accept_remote,
3914 conflicts,
3915 }
3916}
3917
3918fn sign_verified_v2_candidate(
3919 cfg: &HubConfig,
3920 head: &V2VerifiedHead,
3921 expected: &std::collections::BTreeMap<String, V2BaselineFile>,
3922 expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
3923 mutation_id: &str,
3924 request_body: &Value,
3925 challenge_value: &Value,
3926) -> LinkResult<(String, String, String)> {
3927 if head.view_kind != "full" {
3928 return Err(invalid_feed(
3929 "a scoped self-custody writer must use the proposal workflow",
3930 ));
3931 }
3932 if head.identity.custody != "self" {
3933 return Err(invalid_feed(
3934 "a hub-custodied brain unexpectedly requested an external signature",
3935 ));
3936 }
3937 let key = cfg
3938 .brain_key
3939 .as_ref()
3940 .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
3941 if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
3942 || key.public_key_spki != head.identity.public_key_spki
3943 {
3944 return Err(bad_agent_key(
3945 "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
3946 ));
3947 }
3948 let challenge_id = challenge_value
3949 .get("id")
3950 .and_then(Value::as_str)
3951 .filter(|id| crate::ulid::is_ulid(id))
3952 .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
3953 let expected_endpoint = format!(
3954 "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
3955 head.brain_id
3956 );
3957 if challenge_value
3958 .get("candidate_endpoint")
3959 .and_then(Value::as_str)
3960 != Some(expected_endpoint.as_str())
3961 {
3962 return Err(invalid_feed(
3963 "self-custody challenge candidate endpoint is not origin-bound",
3964 ));
3965 }
3966
3967 let mut files = std::collections::BTreeMap::new();
3968 let mut after = String::new();
3969 type CandidateCoordinate = (
3970 String,
3971 String,
3972 String,
3973 String,
3974 Option<String>,
3975 Option<String>,
3976 u64,
3977 Option<String>,
3978 );
3979 let mut pinned: Option<CandidateCoordinate> = None;
3980 loop {
3981 let encoded_after: String =
3982 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3983 let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
3984 let value = ensure_ok(
3985 request_capped(
3986 cfg,
3987 "GET",
3988 &path,
3989 None,
3990 Auth::Required,
3991 MAX_FEED_RESPONSE_BYTES,
3992 )?,
3993 "v2 self-custody candidate",
3994 )?;
3995 let page: V2SigningCandidatePage = serde_json::from_value(value)
3996 .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
3997 if page.v != 2
3998 || page.challenge_id != challenge_id
3999 || page.mutation_id != mutation_id
4000 || page.candidate.seq != page.parent.seq + 1
4001 || page.files.len() > 500
4002 || page.expires_at.is_empty()
4003 {
4004 return Err(invalid_feed(
4005 "self-custody candidate is not bound to this mutation",
4006 ));
4007 }
4008 let coordinate = (
4009 page.request_hash.clone(),
4010 page.candidate.signing_bytes_base64.clone(),
4011 page.candidate.changes_base64.clone(),
4012 page.candidate.actor_claim_base64.clone(),
4013 page.candidate.content_root.clone(),
4014 page.candidate.asset_root.clone(),
4015 page.parent.seq,
4016 page.parent.commit_hash.clone(),
4017 );
4018 if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4019 return Err(invalid_feed(
4020 "self-custody candidate changed between manifest pages",
4021 ));
4022 }
4023 pinned = Some(coordinate);
4024 let root = page
4025 .candidate
4026 .content_root
4027 .as_deref()
4028 .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4029 for file in page.files {
4030 verify_v2_file_proof(root, &file)?;
4031 if files
4032 .insert(
4033 file.path.clone(),
4034 V2BaselineFile {
4035 sha256: file.sha256,
4036 bytes: file.bytes,
4037 proof: Some(file.proof),
4038 },
4039 )
4040 .is_some()
4041 {
4042 return Err(invalid_feed(
4043 "self-custody candidate repeats a manifest path",
4044 ));
4045 }
4046 if files.len() > MAX_PUSH_FILES {
4047 return Err(invalid_feed(
4048 "self-custody candidate exceeds the file-count bound",
4049 ));
4050 }
4051 }
4052 match page.next_cursor {
4053 None => break,
4054 Some(next) if next > after => after = next,
4055 Some(_) => {
4056 return Err(invalid_feed(
4057 "self-custody candidate cursor did not advance",
4058 ))
4059 }
4060 }
4061 }
4062 if files.len() != expected.len()
4063 || files.iter().any(|(path, file)| {
4064 expected.get(path).is_none_or(|expected| {
4065 expected.sha256 != file.sha256 || expected.bytes != file.bytes
4066 })
4067 })
4068 {
4069 return Err(invalid_feed(
4070 "self-custody candidate contains an unexpected file mutation",
4071 ));
4072 }
4073 let mut assets = std::collections::BTreeMap::new();
4074 after.clear();
4075 loop {
4076 let encoded_after: String =
4077 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4078 let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4079 let value = ensure_ok(
4080 request_capped(
4081 cfg,
4082 "GET",
4083 &path,
4084 None,
4085 Auth::Required,
4086 MAX_FEED_RESPONSE_BYTES,
4087 )?,
4088 "v2 self-custody asset candidate",
4089 )?;
4090 let page: V2SigningCandidatePage = serde_json::from_value(value)
4091 .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4092 let coordinate = (
4093 page.request_hash.clone(),
4094 page.candidate.signing_bytes_base64.clone(),
4095 page.candidate.changes_base64.clone(),
4096 page.candidate.actor_claim_base64.clone(),
4097 page.candidate.content_root.clone(),
4098 page.candidate.asset_root.clone(),
4099 page.parent.seq,
4100 page.parent.commit_hash.clone(),
4101 );
4102 if page.v != 2
4103 || page.challenge_id != challenge_id
4104 || page.mutation_id != mutation_id
4105 || page.assets.len() > 500
4106 || pinned.as_ref() != Some(&coordinate)
4107 {
4108 return Err(invalid_feed(
4109 "self-custody asset candidate changed or is not bound",
4110 ));
4111 }
4112 let root = page.candidate.asset_root.as_deref();
4113 if !page.assets.is_empty() && root.is_none() {
4114 return Err(invalid_feed("asset candidate has no asset root"));
4115 }
4116 for item in page.assets {
4117 verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4118 if assets
4119 .insert(
4120 item.path.clone(),
4121 V2BaselineAsset {
4122 blob_sha256: item.blob_sha256,
4123 bytes: item.bytes,
4124 media_type: item.media_type,
4125 wrappers: item.wrappers,
4126 required: item.required,
4127 disposition: item.disposition,
4128 leaf_hash: item.leaf_hash,
4129 },
4130 )
4131 .is_some()
4132 {
4133 return Err(invalid_feed("self-custody candidate repeats an asset"));
4134 }
4135 }
4136 match page.next_cursor {
4137 None => break,
4138 Some(next) if next > after => after = next,
4139 Some(_) => {
4140 return Err(invalid_feed(
4141 "self-custody asset candidate cursor did not advance",
4142 ))
4143 }
4144 }
4145 }
4146 if assets.len() != expected_assets.len()
4147 || assets.iter().any(|(path, asset)| {
4148 expected_assets.get(path).is_none_or(|expected| {
4149 asset.blob_sha256 != expected.blob_sha256
4150 || asset.bytes != expected.bytes
4151 || asset.media_type != expected.media_type
4152 || asset.wrappers != expected.wrappers
4153 || asset.required != expected.required
4154 || asset.disposition != expected.disposition
4155 })
4156 })
4157 {
4158 return Err(invalid_feed(
4159 "self-custody candidate contains an unexpected asset mutation",
4160 ));
4161 }
4162 let Some((
4163 request_hash,
4164 signing_b64,
4165 changes_b64,
4166 actor_b64,
4167 root,
4168 asset_root,
4169 parent_seq,
4170 parent,
4171 )) = pinned
4172 else {
4173 return Err(invalid_feed("self-custody candidate has no manifest"));
4174 };
4175 let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4176 let current_commit = head
4177 .pointer
4178 .as_ref()
4179 .map(|pointer| pointer.commit_hash.clone());
4180 if parent_seq != current_seq || parent != current_commit {
4181 return Err(LinkError::RemoteAdvancedDuringSync);
4182 }
4183 let changes = STANDARD
4184 .decode(changes_b64)
4185 .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4186 let mut expected_changes = json!({
4187 "mutation_id": mutation_id,
4188 "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4189 "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4190 "v": 2,
4191 });
4192 if let Some(withheld_links) = request_body.get("withheld_links") {
4193 expected_changes["withheld_links"] = withheld_links.clone();
4194 }
4195 if let Some(checkout_id) = request_body.get("checkout_id") {
4196 expected_changes["checkout_id"] = checkout_id.clone();
4197 }
4198 let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4199 .map_err(|error| invalid_feed(error.to_string()))?;
4200 if changes != expected_changes_bytes {
4201 return Err(invalid_feed(
4202 "self-custody changeset differs from the requested mutation",
4203 ));
4204 }
4205 let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4206 .map_err(|error| invalid_feed(error.to_string()))?;
4207 let request_value = json!({
4208 "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4209 "brain": head.brain_id,
4210 "changes_sha256": changes_hash,
4211 "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4212 "v": 2,
4213 "v1_bridge": Value::Null,
4214 });
4215 let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4216 .map_err(|error| invalid_feed(error.to_string()))?;
4217 if request_hash != expected_request_hash {
4218 return Err(invalid_feed(
4219 "self-custody request hash differs from the requested mutation",
4220 ));
4221 }
4222 let actor = STANDARD
4223 .decode(actor_b64)
4224 .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4225 let actor_value: Value = serde_json::from_slice(&actor)
4226 .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4227 if crate::linkmd_v2::canonical_bytes(&actor_value)
4228 .map_err(|error| invalid_feed(error.to_string()))?
4229 != actor
4230 {
4231 return Err(invalid_feed("self-custody actor claim is not canonical"));
4232 }
4233 let actor_object = actor_value
4234 .as_object()
4235 .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4236 let actor_claim = actor_object
4237 .get("claim")
4238 .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4239 let actor_public_key = actor_object
4240 .get("public_key")
4241 .and_then(Value::as_str)
4242 .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4243 let actor_fingerprint = actor_object
4244 .get("fingerprint")
4245 .and_then(Value::as_str)
4246 .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4247 let actor_signature = actor_object
4248 .get("sig")
4249 .and_then(Value::as_str)
4250 .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4251 let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4252 .map_err(|error| invalid_feed(error.to_string()))?;
4253 let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4254 let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4255 let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4256 let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4257 let impact = actor_claim
4258 .get("result")
4259 .and_then(|result| result.get("impact"))
4260 .and_then(Value::as_object);
4261 let impact_fields = [
4262 "creates",
4263 "updates",
4264 "deletes",
4265 "withdrawals",
4266 "renames",
4267 "restores",
4268 "asset_changes",
4269 "public_expansions",
4270 "executable_activations",
4271 ];
4272 let impact_is_valid = impact.is_some_and(|impact| {
4273 impact.len() == impact_fields.len() + 1
4274 && impact.get("v").and_then(Value::as_u64) == Some(1)
4275 && impact_fields
4276 .iter()
4277 .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4278 });
4279 if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4280 || head
4281 .trust
4282 .hub_signer
4283 .as_ref()
4284 .is_some_and(|known| known != &expected_actor_signer)
4285 || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4286 || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4287 || actor_claim
4288 .get("candidate")
4289 .and_then(|candidate| candidate.get("changes_sha256"))
4290 .and_then(Value::as_str)
4291 != Some(changes_hash.as_str())
4292 || actor_claim
4293 .get("candidate")
4294 .and_then(|candidate| candidate.get("state_root"))
4295 != Some(&expected_actor_root)
4296 || actor_claim
4297 .get("candidate")
4298 .and_then(|candidate| candidate.get("asset_root"))
4299 != Some(&expected_actor_asset_root)
4300 || actor_claim
4301 .get("candidate")
4302 .and_then(|candidate| candidate.get("control_revision"))
4303 .and_then(Value::as_str)
4304 != Some(head.control_revision.as_str())
4305 || !impact_is_valid
4306 {
4307 return Err(invalid_feed(
4308 "self-custody actor claim does not bind the verified authority",
4309 ));
4310 }
4311 let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4312 .map_err(|error| invalid_feed(error.to_string()))?;
4313 let signing = STANDARD
4314 .decode(signing_b64)
4315 .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4316 let signing_value: Value = serde_json::from_slice(&signing)
4317 .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4318 if crate::linkmd_v2::canonical_bytes(&signing_value)
4319 .map_err(|error| invalid_feed(error.to_string()))?
4320 != signing
4321 {
4322 return Err(invalid_feed("self-custody signing bytes are not canonical"));
4323 }
4324 let pointer = head.pointer.as_ref();
4325 let expected_materializer = pointer
4326 .map(|value| value.materializer.as_str())
4327 .unwrap_or("dbmd-projection-v1");
4328 let expected_parent_commit = request_body
4329 .get("base")
4330 .and_then(|base| base.get("commit_hash"))
4331 .cloned()
4332 .unwrap_or(Value::Null);
4333 let expected_parent_root = request_body
4334 .get("base")
4335 .and_then(|base| base.get("content_root"))
4336 .cloned()
4337 .unwrap_or(Value::Null);
4338 let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4339 let expected_parent_asset_root = request_body
4340 .get("base")
4341 .and_then(|base| base.get("asset_root"))
4342 .cloned()
4343 .unwrap_or(Value::Null);
4344 let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4345 let expected_prev_entry = pointer
4346 .map(|value| Value::String(value.feed_hash.clone()))
4347 .unwrap_or(Value::Null);
4348 let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4349 .map_err(|_| invalid_feed("brain identity history is too large"))?
4350 + 1;
4351 if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4352 || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4353 || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4354 || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4355 || signing_value.get("public_key").and_then(Value::as_str)
4356 != Some(key.public_key_spki.as_str())
4357 || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4358 || signing_value.get("parent_root") != Some(&expected_parent_root)
4359 || signing_value.get("state_root") != Some(&expected_state_root)
4360 || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4361 || signing_value.get("asset_root") != Some(&expected_asset_root)
4362 || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4363 || signing_value.get("changes_sha256").and_then(Value::as_str)
4364 != Some(changes_hash.as_str())
4365 || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4366 || signing_value
4367 .get("control_revision")
4368 .and_then(Value::as_str)
4369 != Some(head.control_revision.as_str())
4370 || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4371 || signing_value.get("v1_bridge") != Some(&Value::Null)
4372 || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4373 {
4374 return Err(invalid_feed(
4375 "self-custody signing bytes do not bind the verified candidate",
4376 ));
4377 }
4378 let pair = agent_keypair(&key.pkcs8)?;
4379 let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4380 Ok((challenge_id.to_string(), signature, expected_actor_signer))
4381}
4382
4383fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4384 let origin = normalized_origin(&cfg.hub)?;
4385 let absolute = if checkout.is_absolute() {
4386 checkout.to_path_buf()
4387 } else {
4388 std::env::current_dir()?.join(checkout)
4389 };
4390 Ok(format!(
4391 "sync-{}.json",
4392 content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4393 ))
4394}
4395
4396fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4397 if let Some(value) = existing {
4398 if !is_sha256(value) {
4399 return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4400 }
4401 return Ok(value.to_string());
4402 }
4403 use ring::rand::SecureRandom as _;
4404 let mut random = [0_u8; 32];
4405 ring::rand::SystemRandom::new()
4406 .fill(&mut random)
4407 .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4408 Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4409}
4410
4411#[cfg(any(unix, windows))]
4412fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4413 let directory = open_trust_dir(cfg)?;
4414 let origin = normalized_origin(&cfg.hub)?;
4415 let name = format!(
4416 "operation-{}.lock",
4417 content_sha256(format!("{origin}\0{brain}").as_bytes())
4418 );
4419 lock_trust_name(&directory, &name)
4420}
4421
4422#[cfg(not(any(unix, windows)))]
4423fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4424 Err(LinkError::UnsupportedPlatform {
4425 operation: "serialized link.md v2 sync",
4426 })
4427}
4428
4429fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4430 left.brain_id == right.brain_id
4431 && left.view_kind == right.view_kind
4432 && left.view_revision == right.view_revision
4433 && left.control_revision == right.control_revision
4434 && match (&left.pointer, &right.pointer) {
4435 (None, None) => true,
4436 (Some(left), Some(right)) => {
4437 left.seq == right.seq
4438 && left.commit_hash == right.commit_hash
4439 && left.content_root == right.content_root
4440 && left.asset_root == right.asset_root
4441 && left.feed_hash == right.feed_hash
4442 }
4443 _ => false,
4444 }
4445}
4446
4447fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4448 format!(
4449 "---\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"
4450 )
4451 .into_bytes()
4452}
4453
4454fn scoped_projection_sha256(brain: &str) -> String {
4455 content_sha256(&scoped_projection_bytes(brain))
4456}
4457
4458#[derive(Deserialize)]
4459struct LocalScopedViewMarker {
4460 v: u8,
4461 kind: String,
4462 authoritative: bool,
4463 brain: String,
4464 projection_sha256: String,
4465}
4466
4467pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4471 let marker = store
4472 .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4473 .ok()
4474 .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4475 let Some(marker) = marker else {
4476 return false;
4477 };
4478 if marker.v != 1
4479 || marker.kind != "link.md-scoped-view"
4480 || marker.authoritative
4481 || !crate::ulid::is_ulid(&marker.brain)
4482 || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4483 {
4484 return false;
4485 }
4486 store
4487 .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4488 .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4489}
4490
4491fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4492 let mut bytes = serde_json::to_vec_pretty(&json!({
4493 "v": 1,
4494 "kind": "link.md-scoped-view",
4495 "authoritative": false,
4496 "brain": head.brain_id,
4497 "view_revision": head.view_revision,
4498 "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4499 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4500 "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4501 "visible_files": files,
4502 "projection_sha256": scoped_projection_sha256(&head.brain_id),
4503 }))
4504 .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4505 bytes.push(b'\n');
4506 Ok(bytes)
4507}
4508
4509fn refresh_scoped_view_marker(
4510 store: &Store,
4511 head: &V2VerifiedHead,
4512 files: usize,
4513) -> LinkResult<()> {
4514 if head.view_kind == "scoped" {
4515 store.write_atomic(
4516 Path::new(".dbmd/view.json"),
4517 &scoped_view_metadata(head, files)?,
4518 )?;
4519 }
4520 Ok(())
4521}
4522
4523fn ensure_v2_view_compatible(
4524 head: &V2VerifiedHead,
4525 baseline: Option<&V2SyncBaseline>,
4526) -> LinkResult<()> {
4527 let Some(baseline) = baseline else {
4528 return Ok(());
4529 };
4530 match (
4531 baseline.view_kind.as_deref(),
4532 baseline.view_revision.as_deref(),
4533 ) {
4534 (None, None) if head.view_kind == "full" => Ok(()),
4535 (Some(kind), Some(revision))
4536 if kind == head.view_kind && revision == head.view_revision =>
4537 {
4538 Ok(())
4539 }
4540 _ => Err(LinkError::ScopedViewChanged),
4541 }
4542}
4543
4544fn ensure_established_v2_checkout_opened(
4545 head: &V2VerifiedHead,
4546 baseline: Option<&V2SyncBaseline>,
4547 opened: bool,
4548) -> LinkResult<()> {
4549 if baseline.is_none() || opened {
4550 return Ok(());
4551 }
4552 if head.view_kind == "scoped" {
4553 return Err(LinkError::ScopedProjectionModified);
4554 }
4555 Err(LinkError::InvalidPack {
4556 message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4557 })
4558}
4559
4560fn remove_scoped_projection(
4561 head: &V2VerifiedHead,
4562 baseline: Option<&V2SyncBaseline>,
4563 view: &mut V2LocalView,
4564) -> LinkResult<()> {
4565 if head.view_kind != "scoped" {
4566 return Ok(());
4567 }
4568 let expected = scoped_projection_sha256(&head.brain_id);
4569 if baseline
4570 .and_then(|state| state.projection_sha256.as_deref())
4571 .is_some_and(|pinned| pinned != expected)
4572 {
4573 return Err(LinkError::ScopedViewChanged);
4574 }
4575 if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4576 return Err(LinkError::ScopedProjectionModified);
4577 }
4578 view.riding.remove("DB.md");
4579 view.eligibility.remove("DB.md");
4580 Ok(())
4581}
4582
4583fn local_view_for_v2_push(
4584 store: &Store,
4585 head: &V2VerifiedHead,
4586 baseline: Option<&V2SyncBaseline>,
4587 carried: Option<V2LocalView>,
4588) -> LinkResult<V2LocalView> {
4589 match carried {
4590 Some(view) => Ok(view),
4595 None => {
4596 let mut view = v2_local_files(store)?;
4597 remove_scoped_projection(head, baseline, &mut view)?;
4598 Ok(view)
4599 }
4600 }
4601}
4602
4603fn files_for_v2_view(
4604 head: &V2VerifiedHead,
4605 mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4606) -> std::collections::BTreeMap<String, V2BaselineFile> {
4607 if head.view_kind == "scoped" {
4608 files.remove("DB.md");
4612 }
4613 files
4614}
4615
4616fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4617 let baseline: V2SyncBaseline =
4618 serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
4619 if baseline.v != 2
4620 || baseline.origin != normalized_origin(&cfg.hub)?
4621 || baseline.brain != brain
4622 || baseline
4623 .commit_hash
4624 .as_deref()
4625 .is_some_and(|hash| !is_sha256(hash))
4626 || baseline
4627 .content_root
4628 .as_deref()
4629 .is_some_and(|hash| !is_sha256(hash))
4630 || baseline
4631 .asset_root
4632 .as_deref()
4633 .is_some_and(|hash| !is_sha256(hash))
4634 || baseline
4635 .local_policy_digest
4636 .as_deref()
4637 .is_some_and(|hash| !is_sha256(hash))
4638 || baseline
4639 .view_kind
4640 .as_deref()
4641 .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
4642 || baseline
4643 .view_revision
4644 .as_deref()
4645 .is_some_and(|hash| !is_sha256(hash))
4646 || baseline
4647 .projection_sha256
4648 .as_deref()
4649 .is_some_and(|hash| !is_sha256(hash))
4650 || (baseline.view_kind.as_deref() == Some("scoped")
4651 && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
4652 || baseline.files.len() > MAX_PUSH_FILES
4653 || baseline.assets.len() > MAX_PUSH_FILES
4654 || baseline.local_eligibility.len() > MAX_PUSH_FILES
4655 || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
4656 || baseline.files.iter().any(|(path, file)| {
4657 crate::linkmd_v2::normalize_path(path).is_err()
4658 || !is_sha256(&file.sha256)
4659 || file.bytes > MAX_STORE_BYTES
4660 })
4661 || baseline.assets.iter().any(|(path, asset)| {
4662 crate::linkmd_v2::normalize_path(path).is_err()
4663 || !is_sha256(&asset.blob_sha256)
4664 || !is_sha256(&asset.leaf_hash)
4665 || asset.bytes > MAX_STORE_BYTES
4666 || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
4667 || asset.wrappers.is_empty()
4668 || asset
4669 .wrappers
4670 .iter()
4671 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4672 })
4673 || baseline
4674 .local_eligibility
4675 .keys()
4676 .chain(baseline.remote_copy_remains.keys())
4677 .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
4678 || baseline
4679 .remote_copy_remains
4680 .values()
4681 .any(|hash| !is_sha256(hash))
4682 || baseline
4683 .checkout_id
4684 .as_deref()
4685 .is_some_and(|checkout_id| !is_sha256(checkout_id))
4686 {
4687 return Err(invalid_feed("v2 sync baseline failed validation"));
4688 }
4689 Ok(baseline)
4690}
4691
4692#[cfg(unix)]
4693fn load_v2_baseline(
4694 cfg: &HubConfig,
4695 brain: &str,
4696 checkout: &Path,
4697) -> LinkResult<Option<V2SyncBaseline>> {
4698 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4699 let directory = open_trust_dir(cfg)?;
4700 let name_string = v2_baseline_name(cfg, brain, checkout)?;
4701 let _lock = lock_trust_name(&directory, &name_string)?;
4702 let name = c_name(name_string.as_bytes(), &name_string)?;
4703 let fd = unsafe {
4704 libc::openat(
4705 directory.as_raw_fd(),
4706 name.as_ptr(),
4707 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4708 )
4709 };
4710 if fd < 0 {
4711 let error = std::io::Error::last_os_error();
4712 return if error.kind() == std::io::ErrorKind::NotFound {
4713 Ok(None)
4714 } else {
4715 Err(LinkError::UnsafePath { path: name_string })
4716 };
4717 }
4718 let file = unsafe { std::fs::File::from_raw_fd(fd) };
4719 let mut bytes = Vec::new();
4720 file.take(MAX_FEED_RESPONSE_BYTES + 1)
4721 .read_to_end(&mut bytes)?;
4722 if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
4723 return Err(invalid_feed("v2 sync baseline is oversized"));
4724 }
4725 Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
4726}
4727
4728#[cfg(windows)]
4729fn load_v2_baseline(
4730 cfg: &HubConfig,
4731 brain: &str,
4732 checkout: &Path,
4733) -> LinkResult<Option<V2SyncBaseline>> {
4734 let directory = open_trust_dir(cfg)?;
4735 let name = v2_baseline_name(cfg, brain, checkout)?;
4736 let _lock = lock_trust_name(&directory, &name)?;
4737 let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
4738 match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
4739 Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
4740 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
4741 Err(_) => Err(LinkError::UnsafePath { path: name }),
4742 }
4743}
4744
4745#[cfg(not(any(unix, windows)))]
4746fn load_v2_baseline(
4747 _cfg: &HubConfig,
4748 _brain: &str,
4749 _checkout: &Path,
4750) -> LinkResult<Option<V2SyncBaseline>> {
4751 Err(LinkError::UnsupportedPlatform {
4752 operation: "verified link.md v2 baseline",
4753 })
4754}
4755
4756#[cfg(unix)]
4757fn save_v2_baseline(
4758 cfg: &HubConfig,
4759 brain: &str,
4760 checkout: &Path,
4761 baseline: &V2SyncBaseline,
4762) -> LinkResult<()> {
4763 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4764 let directory = open_trust_dir(cfg)?;
4765 let name_string = v2_baseline_name(cfg, brain, checkout)?;
4766 let _lock = lock_trust_name(&directory, &name_string)?;
4767 let name = c_name(name_string.as_bytes(), &name_string)?;
4768 let mut bytes = serde_json::to_vec(baseline)
4769 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
4770 bytes.push(b'\n');
4771 let temp_string = format!(
4772 ".{name_string}.tmp.{}-{}",
4773 std::process::id(),
4774 std::time::SystemTime::now()
4775 .duration_since(std::time::UNIX_EPOCH)
4776 .unwrap_or_default()
4777 .as_nanos()
4778 );
4779 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
4780 let fd = unsafe {
4781 libc::openat(
4782 directory.as_raw_fd(),
4783 temp.as_ptr(),
4784 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4785 0o600,
4786 )
4787 };
4788 if fd < 0 {
4789 return Err(std::io::Error::last_os_error().into());
4790 }
4791 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
4792 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
4793 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4794 return Err(error.into());
4795 }
4796 drop(file);
4797 if unsafe {
4798 libc::renameat(
4799 directory.as_raw_fd(),
4800 temp.as_ptr(),
4801 directory.as_raw_fd(),
4802 name.as_ptr(),
4803 )
4804 } != 0
4805 {
4806 let error = std::io::Error::last_os_error();
4807 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
4808 return Err(error.into());
4809 }
4810 directory.sync_all()?;
4811 Ok(())
4812}
4813
4814#[cfg(windows)]
4815fn save_v2_baseline(
4816 cfg: &HubConfig,
4817 brain: &str,
4818 checkout: &Path,
4819 baseline: &V2SyncBaseline,
4820) -> LinkResult<()> {
4821 let directory = open_trust_dir(cfg)?;
4822 let name = v2_baseline_name(cfg, brain, checkout)?;
4823 let _lock = lock_trust_name(&directory, &name)?;
4824 let mut bytes = serde_json::to_vec(baseline)
4825 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
4826 bytes.push(b'\n');
4827 crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
4828 Ok(())
4829}
4830
4831#[cfg(not(any(unix, windows)))]
4832fn save_v2_baseline(
4833 _cfg: &HubConfig,
4834 _brain: &str,
4835 _checkout: &Path,
4836 _baseline: &V2SyncBaseline,
4837) -> LinkResult<()> {
4838 Err(LinkError::UnsupportedPlatform {
4839 operation: "verified link.md v2 baseline",
4840 })
4841}
4842
4843fn v2_baseline_from_head(
4844 cfg: &HubConfig,
4845 head: &V2VerifiedHead,
4846 files: std::collections::BTreeMap<String, V2BaselineFile>,
4847 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
4848 local: Option<&V2LocalView>,
4849 checkout_id: Option<&str>,
4850) -> LinkResult<V2SyncBaseline> {
4851 let mut local_eligibility = local
4852 .map(|view| view.eligibility.clone())
4853 .unwrap_or_default();
4854 if let Some(view) = local {
4855 for path in files.keys() {
4856 local_eligibility
4857 .entry(path.clone())
4858 .or_insert_with(|| !view.policy.keeps_home(path));
4859 }
4860 }
4861 let remote_copy_remains = local_eligibility
4862 .iter()
4863 .filter(|(_, riding)| !**riding)
4864 .filter_map(|(path, _)| {
4865 files
4866 .get(path)
4867 .map(|file| (path.clone(), file.sha256.clone()))
4868 })
4869 .collect();
4870 Ok(V2SyncBaseline {
4871 v: 2,
4872 origin: normalized_origin(&cfg.hub)?,
4873 brain: head.brain_id.clone(),
4874 checkout_id: Some(v2_checkout_id(checkout_id)?),
4875 head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
4876 commit_hash: head
4877 .pointer
4878 .as_ref()
4879 .map(|pointer| pointer.commit_hash.clone()),
4880 content_root: head
4881 .pointer
4882 .as_ref()
4883 .and_then(|pointer| pointer.content_root.clone()),
4884 asset_root: head
4885 .pointer
4886 .as_ref()
4887 .and_then(|pointer| pointer.asset_root.clone()),
4888 assets,
4889 view_kind: Some(head.view_kind.clone()),
4890 view_revision: Some(head.view_revision.clone()),
4891 projection_sha256: (head.view_kind == "scoped")
4892 .then(|| scoped_projection_sha256(&head.brain_id)),
4893 files,
4894 local_policy_digest: local.map(|view| view.policy.digest.clone()),
4895 local_eligibility,
4896 remote_copy_remains,
4897 })
4898}
4899
4900fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
4901 let policy = crate::linkmd_sync_policy::load(store)
4902 .map_err(|message| LinkError::InvalidPack { message })?;
4903 let asset_paths = crate::assets::read_manifest(store)
4904 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
4905 .into_iter()
4906 .map(|asset| asset.path)
4907 .collect::<std::collections::BTreeSet<_>>();
4908 let mut result = std::collections::BTreeMap::new();
4909 let mut eligibility = std::collections::BTreeMap::new();
4910 let mut riding_links = Vec::<(String, Vec<String>)>::new();
4911 let mut total = 0_u64;
4912 let mut paths = vec![PathBuf::from("DB.md")];
4913 paths.extend(store.walk()?);
4914 for relative in paths {
4915 let path = relative.to_string_lossy().replace('\\', "/");
4916 if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
4918 continue;
4919 }
4920 if asset_paths.contains(&path) {
4921 continue;
4922 }
4923 crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
4924 path: error.to_string(),
4925 })?;
4926 let riding = !policy.keeps_home(&path);
4927 eligibility.insert(path.clone(), riding);
4928 if !riding {
4929 continue;
4930 }
4931 let remaining = MAX_STORE_BYTES.saturating_sub(total);
4932 let bytes = store.read_bounded(&relative, remaining)?;
4933 total = total
4934 .checked_add(bytes.len() as u64)
4935 .ok_or_else(|| LinkError::PushTooLarge {
4936 detail: "v2 local byte count overflow".to_string(),
4937 })?;
4938 if total > MAX_STORE_BYTES {
4939 return Err(LinkError::PushTooLarge {
4940 detail: format!("{total} uncompressed bytes"),
4941 });
4942 }
4943 if std::str::from_utf8(&bytes).is_err() {
4944 return Err(LinkError::NotUtf8 { path });
4945 }
4946 let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
4947 riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
4948 result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
4949 }
4950 let kept_home = eligibility
4951 .iter()
4952 .filter(|(_, riding)| !**riding)
4953 .map(|(path, _)| path.clone())
4954 .collect::<std::collections::BTreeSet<_>>();
4955 let mut withheld_links = riding_links
4956 .into_iter()
4957 .flat_map(|(source, targets)| {
4958 let kept_home = &kept_home;
4959 targets.into_iter().filter_map(move |target| {
4960 let target = format!("{target}.md");
4961 kept_home.contains(&target).then_some(V2WithheldLink {
4962 source: source.clone(),
4963 target,
4964 })
4965 })
4966 })
4967 .collect::<Vec<_>>();
4968 withheld_links.sort();
4969 withheld_links.dedup();
4970 Ok(V2LocalView {
4971 riding: result,
4972 eligibility,
4973 policy,
4974 withheld_links,
4975 })
4976}
4977
4978#[derive(Debug, Deserialize)]
4979struct V2DownloadItem {
4980 path: String,
4981 sha256: String,
4982 bytes: u64,
4983 url: String,
4984 method: String,
4985}
4986
4987#[derive(Debug, Deserialize)]
4988struct V2DownloadWindow {
4989 v: u8,
4990 commit: String,
4991 downloads: Vec<V2DownloadItem>,
4992}
4993
4994#[derive(Debug, Deserialize)]
4995struct V2BulkStreamHeader {
4996 v: u8,
4997 path: String,
4998 sha256: String,
4999 bytes: u64,
5000}
5001
5002fn parse_v2_bulk_stream(
5003 bytes: &[u8],
5004 expected: &[(&String, &V2BaselineFile)],
5005) -> LinkResult<Vec<(String, Vec<u8>)>> {
5006 if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5007 return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5008 }
5009 let mut cursor = V2_BULK_STREAM_MAGIC.len();
5010 let mut result = Vec::with_capacity(expected.len());
5011 for (expected_path, expected_file) in expected {
5012 let length_bytes = bytes
5013 .get(cursor..cursor + 4)
5014 .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5015 cursor += 4;
5016 let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5017 if header_len == 0 || header_len > 4 * 1024 {
5018 return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5019 }
5020 let header_bytes = bytes
5021 .get(cursor..cursor + header_len)
5022 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5023 cursor += header_len;
5024 let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5025 .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5026 if header.v != 2
5027 || &header.path != *expected_path
5028 || header.sha256 != expected_file.sha256
5029 || header.bytes != expected_file.bytes
5030 || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5031 {
5032 return Err(invalid_feed(
5033 "v2 bulk stream frame differs from its proven manifest entry",
5034 ));
5035 }
5036 let body_len = usize::try_from(header.bytes)
5037 .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5038 let body = bytes
5039 .get(cursor..cursor + body_len)
5040 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5041 cursor += body_len;
5042 if content_sha256(body) != header.sha256 {
5043 return Err(invalid_feed(
5044 "v2 bulk stream file differs from its proven manifest entry",
5045 ));
5046 }
5047 result.push((header.path, body.to_vec()));
5048 }
5049 if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5050 return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5051 }
5052 cursor += 4;
5053 if cursor != bytes.len() {
5054 return Err(invalid_feed("v2 bulk stream carries trailing data"));
5055 }
5056 Ok(result)
5057}
5058
5059fn download_v2_bulk_stream(
5060 cfg: &HubConfig,
5061 brain: &str,
5062 pointer: &V2PointerBody,
5063 pending: &[(&String, &V2BaselineFile)],
5064) -> LinkResult<Vec<(String, Vec<u8>)>> {
5065 let claims = pending
5066 .iter()
5067 .map(|(path, file)| {
5068 Ok(json!({
5069 "path": path,
5070 "sha256": file.sha256,
5071 "bytes": file.bytes,
5072 "proof": file.proof.as_ref().ok_or_else(|| {
5073 invalid_feed("v2 manifest omitted a bulk-stream proof")
5074 })?,
5075 }))
5076 })
5077 .collect::<LinkResult<Vec<_>>>()?;
5078 let raw = request_raw(
5079 cfg,
5080 "POST",
5081 &format!("/api/hub/brains/{brain}/v2/stream"),
5082 Some(&json!({
5083 "commit": pointer.commit_hash,
5084 "files": claims,
5085 })),
5086 Auth::Required,
5087 V2_BULK_STREAM_RESPONSE_BYTES,
5088 )?;
5089 let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5090 parse_v2_bulk_stream(&body, pending)
5091}
5092
5093fn prepare_v2_downloads(
5094 cfg: &HubConfig,
5095 brain: &str,
5096 pointer: &V2PointerBody,
5097 pending: &[(&String, &V2BaselineFile)],
5098) -> LinkResult<Vec<V2DownloadItem>> {
5099 let mut result = Vec::with_capacity(pending.len());
5100 for chunk in pending.chunks(128) {
5101 let claims = chunk
5102 .iter()
5103 .map(|(path, file)| {
5104 Ok(json!({
5105 "path": path,
5106 "sha256": file.sha256,
5107 "bytes": file.bytes,
5108 "proof": file.proof.as_ref().ok_or_else(|| {
5109 invalid_feed("v2 manifest omitted a download proof")
5110 })?,
5111 }))
5112 })
5113 .collect::<LinkResult<Vec<_>>>()?;
5114 let value = ensure_ok(
5115 request_capped(
5116 cfg,
5117 "POST",
5118 &format!("/api/hub/brains/{brain}/v2/downloads"),
5119 Some(&json!({
5120 "commit": pointer.commit_hash,
5121 "files": claims,
5122 })),
5123 Auth::Required,
5124 MAX_FEED_RESPONSE_BYTES,
5125 )?,
5126 "prepare v2 blob downloads",
5127 )?;
5128 let window: V2DownloadWindow = serde_json::from_value(value)
5129 .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5130 if window.v != 2
5131 || window.commit != pointer.commit_hash
5132 || window.downloads.len() != chunk.len()
5133 {
5134 return Err(invalid_feed(
5135 "v2 download window is not bound to the requested files",
5136 ));
5137 }
5138 let mut by_path = window
5139 .downloads
5140 .into_iter()
5141 .map(|item| (item.path.clone(), item))
5142 .collect::<std::collections::BTreeMap<_, _>>();
5143 if by_path.len() != chunk.len() {
5144 return Err(invalid_feed("v2 download window repeats a path"));
5145 }
5146 for (path, file) in chunk {
5147 let item = by_path
5148 .remove(*path)
5149 .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5150 if item.method != "GET"
5151 || item.sha256 != file.sha256
5152 || item.bytes != file.bytes
5153 || item.url.is_empty()
5154 {
5155 return Err(invalid_feed(
5156 "v2 download capability differs from its proven file",
5157 ));
5158 }
5159 result.push(item);
5160 }
5161 }
5162 Ok(result)
5163}
5164
5165fn prepare_v2_asset_downloads(
5166 cfg: &HubConfig,
5167 brain: &str,
5168 pointer: &V2PointerBody,
5169 pending: &[(&String, &V2BaselineAsset)],
5170) -> LinkResult<Vec<V2DownloadItem>> {
5171 let mut result = Vec::with_capacity(pending.len());
5172 for chunk in pending.chunks(128) {
5173 let claims = chunk
5174 .iter()
5175 .map(|(path, asset)| {
5176 json!({
5177 "path": path,
5178 "sha256": asset.blob_sha256,
5179 "bytes": asset.bytes,
5180 "leaf_hash": asset.leaf_hash,
5181 })
5182 })
5183 .collect::<Vec<_>>();
5184 let value = ensure_ok(
5185 request_capped(
5186 cfg,
5187 "POST",
5188 &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5189 Some(&json!({
5190 "commit": pointer.commit_hash,
5191 "assets": claims,
5192 })),
5193 Auth::Required,
5194 MAX_FEED_RESPONSE_BYTES,
5195 )?,
5196 "prepare v2 asset downloads",
5197 )?;
5198 let window: V2DownloadWindow = serde_json::from_value(value)
5199 .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5200 if window.v != 2
5201 || window.commit != pointer.commit_hash
5202 || window.downloads.len() != chunk.len()
5203 {
5204 return Err(invalid_feed(
5205 "v2 asset download window is not bound to the requested assets",
5206 ));
5207 }
5208 let mut by_path = window
5209 .downloads
5210 .into_iter()
5211 .map(|item| (item.path.clone(), item))
5212 .collect::<std::collections::BTreeMap<_, _>>();
5213 if by_path.len() != chunk.len() {
5214 return Err(invalid_feed("v2 asset download window repeats a path"));
5215 }
5216 for (path, asset) in chunk {
5217 let item = by_path
5218 .remove(*path)
5219 .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5220 if item.method != "GET"
5221 || item.sha256 != asset.blob_sha256
5222 || item.bytes != asset.bytes
5223 || item.url.is_empty()
5224 {
5225 return Err(invalid_feed(
5226 "v2 asset download capability differs from its signed leaf",
5227 ));
5228 }
5229 result.push(item);
5230 }
5231 }
5232 Ok(result)
5233}
5234
5235fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5236 let bytes = get_presigned(cfg, &item.url)?;
5237 if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5238 return Err(invalid_feed("v2 blob differs from its proven path entry"));
5239 }
5240 Ok(bytes)
5241}
5242
5243#[derive(Debug, Clone)]
5244struct V2StagedFile {
5245 path: String,
5246 source: PathBuf,
5247 sha256: String,
5248 bytes: u64,
5249}
5250
5251#[cfg(unix)]
5252fn v2_download_cache_dir(
5253 cfg: &HubConfig,
5254 brain: &str,
5255 pointer: &V2PointerBody,
5256) -> LinkResult<PathBuf> {
5257 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5258}
5259
5260#[cfg(unix)]
5261fn v2_download_cache_dir_for(
5262 cfg: &HubConfig,
5263 brain: &str,
5264 transaction: &str,
5265) -> LinkResult<PathBuf> {
5266 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5267 return Err(invalid_feed("v2 download cache address is invalid"));
5268 }
5269 let path = cfg
5270 .state_dir
5271 .join("downloads")
5272 .join(brain)
5273 .join(transaction);
5274 let directory = open_or_create_dir_nofollow(&path)?;
5275 use std::os::fd::AsRawFd as _;
5276 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5277 return Err(std::io::Error::last_os_error().into());
5278 }
5279 directory.sync_all()?;
5280 Ok(path)
5281}
5282
5283#[cfg(windows)]
5284fn v2_download_cache_dir(
5285 cfg: &HubConfig,
5286 brain: &str,
5287 pointer: &V2PointerBody,
5288) -> LinkResult<PathBuf> {
5289 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5290}
5291
5292#[cfg(windows)]
5293fn v2_download_cache_dir_for(
5294 cfg: &HubConfig,
5295 brain: &str,
5296 transaction: &str,
5297) -> LinkResult<PathBuf> {
5298 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5299 return Err(invalid_feed("v2 download cache address is invalid"));
5300 }
5301 let path = cfg
5302 .state_dir
5303 .join("downloads")
5304 .join(brain)
5305 .join(transaction);
5306 crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5307 crate::fsx::open_directory_nofollow(&path)?;
5308 Ok(path)
5309}
5310
5311#[cfg(unix)]
5312fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5313 use std::os::fd::AsRawFd as _;
5314 let parent = cfg.state_dir.join("downloads").join(brain);
5315 let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5316 return;
5317 };
5318 let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5319 return;
5320 };
5321 let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5322 let _ = directory.sync_all();
5323}
5324
5325#[cfg(windows)]
5326fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5327 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5328 return;
5329 }
5330 let parent = cfg.state_dir.join("downloads").join(brain);
5331 let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5332 return;
5333 };
5334 let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5335}
5336
5337#[cfg(not(any(unix, windows)))]
5338fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5339
5340#[cfg(not(any(unix, windows)))]
5341fn v2_download_cache_dir_for(
5342 _cfg: &HubConfig,
5343 _brain: &str,
5344 _transaction: &str,
5345) -> LinkResult<PathBuf> {
5346 Err(LinkError::UnsupportedPlatform {
5347 operation: "resumable v2 download staging",
5348 })
5349}
5350
5351#[cfg(any(unix, windows))]
5352fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5353 let file = match crate::fsx::open_regular_nofollow(path) {
5354 Ok(file) => file,
5355 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5356 Err(error) => return Err(error.into()),
5357 };
5358 if file.metadata()?.len() != bytes {
5359 return Ok(false);
5360 }
5361 Ok(content_sha256_reader(file)? == sha256)
5362}
5363
5364#[cfg(any(unix, windows))]
5365fn cache_v2_blob_bytes(
5366 cache_dir: &Path,
5367 sha256: &str,
5368 expected_bytes: u64,
5369 bytes: &[u8],
5370) -> LinkResult<PathBuf> {
5371 if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5372 return Err(invalid_feed("v2 cached blob differs from its declaration"));
5373 }
5374 let path = cache_dir.join(sha256);
5375 if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5376 crate::fsx::write_atomic(&path, bytes)?;
5377 }
5378 Ok(path)
5379}
5380
5381#[cfg(not(any(unix, windows)))]
5382fn cache_v2_blob_bytes(
5383 _cache_dir: &Path,
5384 _sha256: &str,
5385 _expected_bytes: u64,
5386 _bytes: &[u8],
5387) -> LinkResult<PathBuf> {
5388 Err(LinkError::UnsupportedPlatform {
5389 operation: "resumable v2 download staging",
5390 })
5391}
5392
5393#[cfg(unix)]
5394fn download_presigned_to_cache(
5395 cfg: &HubConfig,
5396 url: &str,
5397 cache_dir: &Path,
5398 sha256: &str,
5399 expected_bytes: u64,
5400) -> LinkResult<PathBuf> {
5401 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5402
5403 let target = cache_dir.join(sha256);
5404 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5405 return Ok(target);
5406 }
5407 let directory = open_existing_dir_nofollow(cache_dir)?;
5408 let mut nonce = [0_u8; 16];
5409 ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5410 .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5411 let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5412 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5413 let fd = unsafe {
5414 libc::openat(
5415 directory.as_raw_fd(),
5416 temp.as_ptr(),
5417 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5418 0o600,
5419 )
5420 };
5421 if fd < 0 {
5422 return Err(std::io::Error::last_os_error().into());
5423 }
5424 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5425 let response = match presigned_agent(cfg, url)?.get(url).call() {
5426 Ok(response) => response,
5427 Err(ureq::Error::Status(_, response)) => {
5428 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5429 return Err(LinkError::Http {
5430 what: "v2 direct download",
5431 status: response.status(),
5432 message: "object store rejected the download".to_string(),
5433 code: None,
5434 details: None,
5435 });
5436 }
5437 Err(ureq::Error::Transport(error)) => {
5438 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5439 return Err(LinkError::Transport {
5440 hub: cfg.hub.clone(),
5441 message: error.to_string(),
5442 });
5443 }
5444 };
5445 let mut reader = response
5446 .into_reader()
5447 .take(expected_bytes.saturating_add(1));
5448 let mut digest = Sha256::new();
5449 let mut total = 0_u64;
5450 let mut buffer = [0_u8; 64 * 1024];
5451 let write_result = (|| -> std::io::Result<()> {
5452 loop {
5453 let read = reader.read(&mut buffer)?;
5454 if read == 0 {
5455 break;
5456 }
5457 total = total.saturating_add(read as u64);
5458 digest.update(&buffer[..read]);
5459 output.write_all(&buffer[..read])?;
5460 }
5461 output.sync_all()
5462 })();
5463 if let Err(error) = write_result {
5464 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5465 return Err(error.into());
5466 }
5467 drop(output);
5468 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5469 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5470 return Err(invalid_feed(
5471 "v2 direct download failed integrity verification",
5472 ));
5473 }
5474 let target_name = c_name(sha256.as_bytes(), sha256)?;
5475 if unsafe {
5478 libc::renameat(
5479 directory.as_raw_fd(),
5480 temp.as_ptr(),
5481 directory.as_raw_fd(),
5482 target_name.as_ptr(),
5483 )
5484 } != 0
5485 {
5486 let error = std::io::Error::last_os_error();
5487 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5488 return Err(error.into());
5489 }
5490 directory.sync_all()?;
5491 Ok(target)
5492}
5493
5494#[cfg(windows)]
5495fn download_presigned_to_cache(
5496 cfg: &HubConfig,
5497 url: &str,
5498 cache_dir: &Path,
5499 sha256: &str,
5500 expected_bytes: u64,
5501) -> LinkResult<PathBuf> {
5502 use std::fs::OpenOptions;
5503
5504 let target = cache_dir.join(sha256);
5505 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5506 return Ok(target);
5507 }
5508 let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
5512 let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
5513 let mut output = OpenOptions::new()
5514 .write(true)
5515 .create_new(true)
5516 .open(&temp)?;
5517 let response = match presigned_agent(cfg, url)?.get(url).call() {
5518 Ok(response) => response,
5519 Err(ureq::Error::Status(_, response)) => {
5520 let _ = std::fs::remove_file(&temp);
5521 return Err(LinkError::Http {
5522 what: "v2 direct download",
5523 status: response.status(),
5524 message: "object store rejected the download".to_string(),
5525 code: None,
5526 details: None,
5527 });
5528 }
5529 Err(ureq::Error::Transport(error)) => {
5530 let _ = std::fs::remove_file(&temp);
5531 return Err(LinkError::Transport {
5532 hub: cfg.hub.clone(),
5533 message: error.to_string(),
5534 });
5535 }
5536 };
5537 let mut reader = response
5538 .into_reader()
5539 .take(expected_bytes.saturating_add(1));
5540 let mut digest = Sha256::new();
5541 let mut total = 0_u64;
5542 let mut buffer = [0_u8; 64 * 1024];
5543 let copied = (|| -> std::io::Result<()> {
5544 loop {
5545 let read = reader.read(&mut buffer)?;
5546 if read == 0 {
5547 break;
5548 }
5549 total = total.saturating_add(read as u64);
5550 digest.update(&buffer[..read]);
5551 output.write_all(&buffer[..read])?;
5552 }
5553 output.sync_all()
5554 })();
5555 if let Err(error) = copied {
5556 let _ = std::fs::remove_file(&temp);
5557 return Err(error.into());
5558 }
5559 drop(output);
5560 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5561 let _ = std::fs::remove_file(&temp);
5562 return Err(invalid_feed(
5563 "v2 direct download failed integrity verification",
5564 ));
5565 }
5566 if target.exists() {
5567 std::fs::remove_file(&target)?;
5568 }
5569 if let Err(error) = std::fs::rename(&temp, &target) {
5570 let _ = std::fs::remove_file(&temp);
5571 return Err(error.into());
5572 }
5573 Ok(target)
5574}
5575
5576#[cfg(not(any(unix, windows)))]
5577fn download_presigned_to_cache(
5578 _cfg: &HubConfig,
5579 _url: &str,
5580 _cache_dir: &Path,
5581 _sha256: &str,
5582 _expected_bytes: u64,
5583) -> LinkResult<PathBuf> {
5584 Err(LinkError::UnsupportedPlatform {
5585 operation: "resumable v2 download staging",
5586 })
5587}
5588
5589fn download_v2_blobs(
5590 cfg: &HubConfig,
5591 brain: &str,
5592 pointer: &V2PointerBody,
5593 pending: Vec<(&String, &V2BaselineFile)>,
5594) -> LinkResult<Vec<(String, Vec<u8>)>> {
5595 if pending.is_empty() {
5596 return Ok(Vec::new());
5597 }
5598 let expected_order = pending
5599 .iter()
5600 .map(|(path, _)| (*path).clone())
5601 .collect::<Vec<_>>();
5602 let mut streamed = std::collections::BTreeMap::new();
5603 let mut direct = Vec::new();
5604 let mut window = Vec::new();
5605 let mut window_bytes = 0_u64;
5606 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5607 window_bytes: &mut u64,
5608 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
5609 -> LinkResult<()> {
5610 if window.is_empty() {
5611 return Ok(());
5612 }
5613 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
5614 if streamed.insert(path, bytes).is_some() {
5615 return Err(invalid_feed("v2 bulk streams repeated a path"));
5616 }
5617 }
5618 window.clear();
5619 *window_bytes = 0;
5620 Ok(())
5621 };
5622 for &(path, file) in &pending {
5623 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5624 flush(&mut window, &mut window_bytes, &mut streamed)?;
5625 direct.push((path, file));
5626 continue;
5627 }
5628 if window.len() == V2_BULK_STREAM_FILES
5629 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5630 {
5631 flush(&mut window, &mut window_bytes, &mut streamed)?;
5632 }
5633 window.push((path, file));
5634 window_bytes += file.bytes;
5635 }
5636 flush(&mut window, &mut window_bytes, &mut streamed)?;
5637
5638 let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
5639 let next = std::sync::atomic::AtomicUsize::new(0);
5640 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5641 let mut results = std::iter::repeat_with(|| None)
5642 .take(downloads.len())
5643 .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
5644 std::thread::scope(|scope| {
5645 let (sender, receiver) = std::sync::mpsc::channel();
5646 for _ in 0..worker_count {
5647 let sender = sender.clone();
5648 let downloads = &downloads;
5649 let next = &next;
5650 scope.spawn(move || loop {
5651 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5652 let Some(item) = downloads.get(index) else {
5653 break;
5654 };
5655 let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
5656 if sender.send((index, result)).is_err() {
5657 break;
5658 }
5659 });
5660 }
5661 drop(sender);
5662 for (index, result) in receiver {
5663 results[index] = Some(result);
5664 }
5665 });
5666 for result in results.into_iter().map(|result| {
5667 result.ok_or_else(|| LinkError::Transport {
5668 hub: cfg.hub.clone(),
5669 message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
5670 })?
5671 }) {
5672 let (path, bytes) = result?;
5673 if streamed.insert(path, bytes).is_some() {
5674 return Err(invalid_feed("v2 download lanes repeated a path"));
5675 }
5676 }
5677 expected_order
5678 .into_iter()
5679 .map(|path| {
5680 streamed
5681 .remove(&path)
5682 .map(|bytes| (path, bytes))
5683 .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
5684 })
5685 .collect()
5686}
5687
5688#[cfg(any(unix, windows))]
5692fn stage_v2_blobs(
5693 cfg: &HubConfig,
5694 brain: &str,
5695 pointer: &V2PointerBody,
5696 pending: Vec<(&String, &V2BaselineFile)>,
5697) -> LinkResult<Vec<V2StagedFile>> {
5698 let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
5699 let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
5700 let mut direct = Vec::new();
5701 let mut window = Vec::new();
5702 let mut window_bytes = 0_u64;
5703 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5704 window_bytes: &mut u64,
5705 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
5706 -> LinkResult<()> {
5707 if window.is_empty() {
5708 return Ok(());
5709 }
5710 let missing = window
5711 .iter()
5712 .filter_map(|(path, file)| {
5713 let target = cache_dir.join(&file.sha256);
5714 match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
5715 Ok(true) => {
5716 staged.insert(
5717 (*path).clone(),
5718 V2StagedFile {
5719 path: (*path).clone(),
5720 source: target,
5721 sha256: file.sha256.clone(),
5722 bytes: file.bytes,
5723 },
5724 );
5725 None
5726 }
5727 Ok(false) => Some(Ok((*path, *file))),
5728 Err(error) => Some(Err(error)),
5729 }
5730 })
5731 .collect::<LinkResult<Vec<_>>>()?;
5732 if !missing.is_empty() {
5733 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
5734 let file = missing
5735 .iter()
5736 .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
5737 .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
5738 let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
5739 staged.insert(
5740 path.clone(),
5741 V2StagedFile {
5742 path,
5743 source,
5744 sha256: file.sha256.clone(),
5745 bytes: file.bytes,
5746 },
5747 );
5748 }
5749 }
5750 window.clear();
5751 *window_bytes = 0;
5752 Ok(())
5753 };
5754 for &(path, file) in &pending {
5755 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5756 flush(&mut window, &mut window_bytes, &mut staged)?;
5757 direct.push((path, file));
5758 continue;
5759 }
5760 if window.len() == V2_BULK_STREAM_FILES
5761 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5762 {
5763 flush(&mut window, &mut window_bytes, &mut staged)?;
5764 }
5765 window.push((path, file));
5766 window_bytes += file.bytes;
5767 }
5768 flush(&mut window, &mut window_bytes, &mut staged)?;
5769 for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
5770 let source =
5771 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
5772 staged.insert(
5773 item.path.clone(),
5774 V2StagedFile {
5775 path: item.path,
5776 source,
5777 sha256: item.sha256,
5778 bytes: item.bytes,
5779 },
5780 );
5781 }
5782 pending
5783 .into_iter()
5784 .map(|(path, _)| {
5785 staged
5786 .remove(path)
5787 .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
5788 })
5789 .collect()
5790}
5791
5792#[cfg(not(any(unix, windows)))]
5793fn stage_v2_blobs(
5794 _cfg: &HubConfig,
5795 _brain: &str,
5796 _pointer: &V2PointerBody,
5797 _pending: Vec<(&String, &V2BaselineFile)>,
5798) -> LinkResult<Vec<V2StagedFile>> {
5799 Err(LinkError::UnsupportedPlatform {
5800 operation: "resumable v2 download staging",
5801 })
5802}
5803
5804const V2_CONFLICT_BUNDLE_MAX: usize = 32;
5805const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
5806const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
5807
5808#[derive(Debug, Clone, Deserialize, Serialize)]
5809struct V2ConflictCoordinate {
5810 sha256: Option<String>,
5811 bytes: Option<u64>,
5812 file: Option<String>,
5813}
5814
5815#[derive(Debug, Clone, Deserialize, Serialize)]
5816struct V2ConflictFile {
5817 path: String,
5818 base: V2ConflictCoordinate,
5819 local: V2ConflictCoordinate,
5820 remote: V2ConflictCoordinate,
5821}
5822
5823#[derive(Debug, Clone, Deserialize, Serialize)]
5824struct V2ConflictPlan {
5825 v: u8,
5826 class: String,
5827 bundle: String,
5828 brain: String,
5829 origin: String,
5830 created_unix: u64,
5831 expires_unix: u64,
5832 base_seq: Option<u64>,
5833 base_commit: Option<String>,
5834 remote_seq: u64,
5835 remote_commit: Option<String>,
5836 remote_content_root: Option<String>,
5837 view_kind: String,
5838 view_revision: String,
5839 files: Vec<V2ConflictFile>,
5840}
5841
5842fn v2_take_remote_selection(
5843 files: &[V2ConflictFile],
5844 current: &std::collections::BTreeMap<String, V2BaselineFile>,
5845) -> LinkResult<(
5846 std::collections::BTreeMap<String, V2BaselineFile>,
5847 Vec<String>,
5848)> {
5849 let mut selected = std::collections::BTreeMap::new();
5850 let mut deleted = Vec::new();
5851 for file in files {
5852 match (&file.remote.sha256, file.remote.bytes) {
5853 (Some(sha256), Some(bytes)) => {
5854 let proven = current.get(&file.path).ok_or_else(|| {
5855 invalid_feed("conflict remote coordinate disappeared from the exact head")
5856 })?;
5857 if proven.sha256 != *sha256 || proven.bytes != bytes {
5858 return Err(invalid_feed(
5859 "conflict remote coordinate differs from the exact head",
5860 ));
5861 }
5862 if selected.insert(file.path.clone(), proven.clone()).is_some() {
5863 return Err(invalid_feed("conflict plan repeats a remote coordinate"));
5864 }
5865 }
5866 (None, None) => {
5867 if current.contains_key(&file.path) {
5868 return Err(invalid_feed(
5869 "conflict remote deletion differs from the exact head",
5870 ));
5871 }
5872 deleted.push(file.path.clone());
5873 }
5874 _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
5875 }
5876 }
5877 Ok((selected, deleted))
5878}
5879
5880fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
5881 PathBuf::from(".dbmd")
5882 .join("conflicts")
5883 .join(bundle)
5884 .join(suffix)
5885}
5886
5887fn read_historical_conflict_blob(
5888 cfg: &HubConfig,
5889 brain: &str,
5890 baseline: &V2SyncBaseline,
5891 path: &str,
5892 file: &V2BaselineFile,
5893) -> LinkResult<Option<Vec<u8>>> {
5894 let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
5895 return Ok(None);
5896 };
5897 if seq == 0 {
5898 return Ok(None);
5899 }
5900 let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
5901 let endpoint = format!(
5902 "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
5903 file.sha256
5904 );
5905 let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
5906 if raw.status == 404 || raw.status == 403 {
5907 return Ok(None);
5908 }
5909 let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
5910 if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
5911 return Err(invalid_feed(
5912 "v2 conflict base failed integrity verification",
5913 ));
5914 }
5915 Ok(Some(bytes))
5916}
5917
5918fn create_v2_conflict_bundle(
5921 cfg: &HubConfig,
5922 store: &Store,
5923 head: &V2VerifiedHead,
5924 baseline: Option<&V2SyncBaseline>,
5925 local: &std::collections::BTreeMap<String, (String, u64)>,
5926 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
5927 paths: &[String],
5928) -> LinkResult<(String, Vec<String>)> {
5929 let conflicts_root = Path::new(".dbmd/conflicts");
5930 store.create_dir_all(conflicts_root)?;
5931 let completed = store
5932 .directory_names(conflicts_root)?
5933 .into_iter()
5934 .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
5935 .count();
5936 if completed >= V2_CONFLICT_BUNDLE_MAX {
5937 return Err(LinkError::InvalidPack {
5938 message: format!(
5939 "private conflict cache has {completed} bundles; resolve or prune one before syncing"
5940 ),
5941 });
5942 }
5943
5944 let mut selected_paths = Vec::new();
5948 let mut selected_remote_bytes = 0_u64;
5949 for path in paths {
5950 let bytes = remote.get(path).map_or(0, |file| file.bytes);
5951 if !selected_paths.is_empty()
5952 && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
5953 {
5954 break;
5955 }
5956 selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
5957 selected_paths.push(path.clone());
5958 if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
5959 break;
5960 }
5961 }
5962 if selected_paths.is_empty() {
5963 return Err(invalid_feed("content conflict set is empty"));
5964 }
5965 let bundle = crate::ulid::mint();
5966 let bundle_root = v2_conflict_relative(&bundle, "");
5967 store.create_dir_all(&bundle_root.join("files"))?;
5968 let pointer = head.pointer.as_ref();
5969 let remote_bytes = match pointer {
5970 Some(pointer) => download_v2_blobs(
5971 cfg,
5972 &head.brain_id,
5973 pointer,
5974 selected_paths
5975 .iter()
5976 .filter_map(|path| {
5977 remote
5978 .get(path)
5979 .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
5980 .map(|file| (path, file))
5981 })
5982 .collect(),
5983 )?
5984 .into_iter()
5985 .collect::<std::collections::BTreeMap<_, _>>(),
5986 None => std::collections::BTreeMap::new(),
5987 };
5988
5989 let mut files = Vec::with_capacity(selected_paths.len());
5990 for (index, path) in selected_paths.iter().enumerate() {
5991 let base_file = baseline.and_then(|state| state.files.get(path));
5992 let base_bytes = match (baseline, base_file) {
5993 (Some(state), Some(file)) => {
5994 read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
5995 }
5996 _ => None,
5997 };
5998 let local_file = local.get(path);
5999 let remote_file = remote.get(path);
6000 let remote_content = remote_bytes.get(path);
6001 let prefix = format!("files/{index:04}");
6002 let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6003 let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6004 let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6005 if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6006 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6007 }
6008 if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6009 let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6010 if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6011 return Err(LinkError::InvalidPack {
6012 message: format!("local conflict path `{path}` changed while bundling"),
6013 });
6014 }
6015 store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6016 }
6017 if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6018 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6019 }
6020 files.push(V2ConflictFile {
6021 path: path.clone(),
6022 base: V2ConflictCoordinate {
6023 sha256: base_file.map(|file| file.sha256.clone()),
6024 bytes: base_file.map(|file| file.bytes),
6025 file: base_name,
6026 },
6027 local: V2ConflictCoordinate {
6028 sha256: local_file.map(|(sha256, _)| sha256.clone()),
6029 bytes: local_file.map(|(_, bytes)| *bytes),
6030 file: local_name,
6031 },
6032 remote: V2ConflictCoordinate {
6033 sha256: remote_file.map(|file| file.sha256.clone()),
6034 bytes: remote_file.map(|file| file.bytes),
6035 file: remote_name,
6036 },
6037 });
6038 }
6039 let now = SystemTime::now()
6040 .duration_since(UNIX_EPOCH)
6041 .unwrap_or_default()
6042 .as_secs();
6043 let plan = V2ConflictPlan {
6044 v: 2,
6045 class: "content_resolution_required".to_string(),
6046 bundle: bundle.clone(),
6047 brain: head.brain_id.clone(),
6048 origin: normalized_origin(&cfg.hub)?,
6049 created_unix: now,
6050 expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6051 base_seq: baseline.and_then(|state| state.head_seq),
6052 base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6053 remote_seq: pointer.map_or(0, |value| value.seq),
6054 remote_commit: pointer.map(|value| value.commit_hash.clone()),
6055 remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6056 view_kind: head.view_kind.clone(),
6057 view_revision: head.view_revision.clone(),
6058 files,
6059 };
6060 let mut bytes = serde_json::to_vec_pretty(&plan)
6061 .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6062 bytes.push(b'\n');
6063 store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6064 Ok((bundle, selected_paths))
6065}
6066
6067fn v2_sync_pull_with_resolution(
6068 cfg: &HubConfig,
6069 requested_brain: &str,
6070 expected_head: V2VerifiedHead,
6071 out: Option<&Path>,
6072 take_remote: Option<&std::collections::BTreeSet<String>>,
6073) -> LinkResult<V2PulledSnapshot> {
6074 let dest = out
6075 .map(Path::to_path_buf)
6076 .unwrap_or_else(|| PathBuf::from(requested_brain));
6077 let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6078 recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6079 let head = v2_verified_head(cfg, requested_brain)?
6080 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6081 if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6082 return Err(LinkError::RemoteAdvancedDuringSync);
6083 }
6084 let remote = files_for_v2_view(
6085 &head,
6086 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6087 );
6088 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
6089 let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6090 ensure_v2_view_compatible(&head, baseline.as_ref())?;
6091 let local_store = Store::open_strict(&dest).ok();
6092 ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6097 let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6098 if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6099 return Err(LinkError::ScopedViewChanged);
6100 }
6101 if let Some(view) = local_view.as_mut() {
6102 remove_scoped_projection(&head, baseline.as_ref(), view)?;
6103 }
6104 let empty_local = std::collections::BTreeMap::new();
6105 let local = local_view
6106 .as_ref()
6107 .map_or(&empty_local, |view| &view.riding);
6108 let kept_home = |path: &str| {
6109 local_view
6110 .as_ref()
6111 .is_some_and(|view| view.policy.keeps_home(path))
6112 };
6113 let empty_base = std::collections::BTreeMap::new();
6114 let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6115 let empty_base_assets = std::collections::BTreeMap::new();
6116 let base_assets = baseline
6117 .as_ref()
6118 .map_or(&empty_base_assets, |state| &state.assets);
6119 let mut local_assets = local_store
6120 .as_ref()
6121 .map(v2_local_asset_records)
6122 .transpose()?
6123 .unwrap_or_default();
6124 let mut content_merge = merge_v2_pulled_records(
6125 base,
6126 &remote,
6127 local,
6128 |file, _| (file.sha256.clone(), file.bytes),
6129 |file, _| (file.sha256.clone(), file.bytes),
6130 kept_home,
6131 );
6132 if let Some(selected) = take_remote {
6133 for path in selected {
6134 if let Some(position) = content_merge
6135 .conflicts
6136 .iter()
6137 .position(|conflict| conflict == path)
6138 {
6139 content_merge.conflicts.remove(position);
6140 content_merge.accept_remote.insert(path.clone());
6141 match remote.get(path) {
6142 Some(file) => {
6143 content_merge
6144 .records
6145 .insert(path.clone(), (file.sha256.clone(), file.bytes));
6146 }
6147 None => {
6148 content_merge.records.remove(path);
6149 }
6150 }
6151 } else if !content_merge.accept_remote.contains(path) {
6152 return Err(LinkError::InvalidPack {
6153 message: format!(
6154 "take-remote path `{path}` is no longer at its conflict coordinate"
6155 ),
6156 });
6157 }
6158 }
6159 }
6160 if !content_merge.conflicts.is_empty() {
6161 let mut conflicts = content_merge.conflicts.clone();
6162 conflicts.truncate(100);
6163 if let Some(store) = local_store.as_ref() {
6164 let (bundle, paths) = create_v2_conflict_bundle(
6165 cfg,
6166 store,
6167 &head,
6168 baseline.as_ref(),
6169 local,
6170 &remote,
6171 &conflicts,
6172 )?;
6173 return Err(LinkError::ConflictBundle { bundle, paths });
6174 }
6175 return Err(LinkError::Conflict { paths: conflicts });
6176 }
6177 let asset_merge = merge_v2_pulled_records(
6178 base_assets,
6179 &remote_assets,
6180 &local_assets,
6181 v2_asset_record,
6182 v2_asset_record,
6183 |_| false,
6184 );
6185 if !asset_merge.conflicts.is_empty() {
6186 let mut conflicts = asset_merge.conflicts.clone();
6187 conflicts.truncate(100);
6188 return Err(LinkError::Conflict { paths: conflicts });
6189 }
6190 let pointer = head.pointer.as_ref();
6191 let cache_transaction = pointer.map_or_else(
6192 || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6193 |value| value.commit_hash.clone(),
6194 );
6195 let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6196 let mut changed = match pointer {
6197 Some(pointer) => stage_v2_blobs(
6198 cfg,
6199 &head.brain_id,
6200 pointer,
6201 remote
6202 .iter()
6203 .filter(|(path, file)| {
6204 content_merge.accept_remote.contains(*path)
6205 && local.get(*path).map(|value| value.0.as_str())
6206 != Some(file.sha256.as_str())
6207 })
6208 .collect(),
6209 )?,
6210 None => Vec::new(),
6211 };
6212 let mut deleted = content_merge
6213 .accept_remote
6214 .iter()
6215 .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6216 .cloned()
6217 .collect::<Vec<_>>();
6218 if local_assets != asset_merge.records {
6219 if asset_merge.records.is_empty() {
6220 deleted.push("assets.jsonl".to_string());
6221 } else {
6222 let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6223 let sha256 = content_sha256(&bytes);
6224 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6225 changed.push(V2StagedFile {
6226 path: "assets.jsonl".to_string(),
6227 source,
6228 sha256,
6229 bytes: bytes.len() as u64,
6230 });
6231 }
6232 }
6233 if let Some(pointer) = pointer {
6234 let mut pending_assets = Vec::new();
6235 for (path, asset) in &remote_assets {
6236 if asset.disposition != "hosted"
6237 || kept_home(path)
6238 || !asset_merge.accept_remote.contains(path)
6239 {
6240 continue;
6241 }
6242 let already_current = local_store.as_ref().is_some_and(|store| {
6243 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6244 && store
6245 .read_bounded(Path::new(path), asset.bytes)
6246 .ok()
6247 .is_some_and(|bytes| {
6248 bytes.len() as u64 == asset.bytes
6249 && content_sha256(&bytes) == asset.blob_sha256
6250 })
6251 });
6252 if !already_current {
6253 pending_assets.push((path, asset));
6254 }
6255 }
6256 for item in prepare_v2_asset_downloads(cfg, &head.brain_id, pointer, &pending_assets)? {
6257 let source =
6258 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6259 changed.push(V2StagedFile {
6260 path: item.path,
6261 source,
6262 sha256: item.sha256,
6263 bytes: item.bytes,
6264 });
6265 }
6266 }
6267 for (path, prior) in base_assets {
6268 if remote_assets.contains_key(path)
6269 || kept_home(path)
6270 || !asset_merge.accept_remote.contains(path)
6271 {
6272 continue;
6273 }
6274 let unchanged = local_store.as_ref().is_some_and(|store| {
6275 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6276 && store
6277 .read_bounded(Path::new(path), prior.bytes)
6278 .ok()
6279 .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6280 });
6281 if unchanged {
6282 deleted.push(path.clone());
6283 }
6284 }
6285 let extra_local = content_merge
6286 .records
6287 .keys()
6288 .filter(|path| !remote.contains_key(*path))
6289 .cloned()
6290 .collect::<Vec<_>>();
6291 if head.view_kind == "scoped" {
6292 for (path, bytes) in [
6293 ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6294 (
6295 ".dbmd/view.json".to_string(),
6296 scoped_view_metadata(&head, remote.len())?,
6297 ),
6298 ] {
6299 let sha256 = content_sha256(&bytes);
6300 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6301 changed.push(V2StagedFile {
6302 path,
6303 source,
6304 sha256,
6305 bytes: bytes.len() as u64,
6306 });
6307 }
6308 }
6309 let install_changed = !changed.is_empty() || !deleted.is_empty();
6310 install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6311 let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6312 let installed_store =
6313 Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6314 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6315 })?;
6316 let installed_local = if install_changed {
6317 let mut scanned = v2_local_files(&installed_store)?;
6318 remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6319 scanned
6320 } else {
6321 local_view
6322 .take()
6323 .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6324 };
6325 if installed_local.riding != content_merge.records {
6326 return Err(LinkError::InvalidPack {
6327 message: "local content changed while installing the v2 pull".to_string(),
6328 });
6329 }
6330 let installed_assets = if install_changed {
6331 v2_local_asset_records(&installed_store)?
6332 } else {
6333 std::mem::take(&mut local_assets)
6334 };
6335 if installed_assets != asset_merge.records {
6336 return Err(LinkError::InvalidPack {
6337 message: "local assets changed while installing the v2 pull".to_string(),
6338 });
6339 }
6340 let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6341 installed_local.policy.keeps_home(path)
6342 })
6343 || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6344 let final_head = v2_verified_head(cfg, requested_brain)?
6345 .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6346 if !same_v2_head(&head, &final_head) {
6347 return Err(LinkError::RemoteAdvancedDuringSync);
6348 }
6349 accept_v2_head(cfg, &final_head)?;
6350 save_v2_baseline(
6351 cfg,
6352 &head.brain_id,
6353 &dest,
6354 &v2_baseline_from_head(
6355 cfg,
6356 &head,
6357 remote.clone(),
6358 remote_assets.clone(),
6359 Some(&installed_local),
6360 baseline
6361 .as_ref()
6362 .and_then(|current| current.checkout_id.as_deref()),
6363 )?,
6364 )?;
6365 complete_v2_pull(&dest)?;
6366 Ok((local_dirty, installed_local, installed_assets))
6367 })();
6368 let (local_dirty, installed_local, installed_assets) = match finalized {
6369 Ok(value) => value,
6370 Err(error) => {
6371 if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6372 return Err(LinkError::InvalidPack {
6373 message: format!("{error}; durable pull recovery also failed: {recovery}"),
6374 });
6375 }
6376 return Err(error);
6377 }
6378 };
6379 cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6380 let report = PullReport {
6381 brain: head.brain_id.clone(),
6382 slug: requested_brain.to_string(),
6383 head_seq: pointer.map_or(0, |value| value.seq),
6384 files: remote.len() + remote_assets.len(),
6385 dest: dest.to_string_lossy().into_owned(),
6386 extra_local,
6387 sync_status: if local_dirty {
6388 "local_dirty_after_install".to_string()
6389 } else {
6390 "synced".to_string()
6391 },
6392 };
6393 Ok(V2PulledSnapshot {
6394 report,
6395 head,
6396 files: remote,
6397 assets: remote_assets,
6398 local: installed_local,
6399 local_assets: installed_assets,
6400 })
6401}
6402
6403fn v2_sync_pull(
6404 cfg: &HubConfig,
6405 requested_brain: &str,
6406 head: V2VerifiedHead,
6407 out: Option<&Path>,
6408) -> LinkResult<PullReport> {
6409 Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
6410}
6411
6412fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
6413 match remote {
6414 Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
6415 None => json!({ "kind": "absent" }),
6416 }
6417}
6418
6419fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
6420 match remote {
6421 Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
6422 None => json!({ "kind": "absent" }),
6423 }
6424}
6425
6426fn v2_content_withdrawal_operation(
6427 store: &Store,
6428 local_view: &V2LocalView,
6429 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6430 path: &str,
6431 reason: &str,
6432) -> LinkResult<Value> {
6433 if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
6434 || path == "DB.md"
6435 {
6436 return Err(LinkError::InvalidPack {
6437 message: format!("content withdrawal path `{path}` is not a record or source"),
6438 });
6439 }
6440 if !local_view.policy.keeps_home(path)
6441 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6442 {
6443 return Err(LinkError::InvalidPack {
6444 message: format!(
6445 "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6446 ),
6447 });
6448 }
6449 let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
6450 message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
6451 })?;
6452 verify_v2_upload_source(store, path, ¤t.sha256, current.bytes)?;
6453 Ok(json!({
6454 "op": "withdraw_from_hosting",
6455 "path": path,
6456 "expected": { "kind": "blob", "hash": current.sha256 },
6457 "reason": reason,
6458 }))
6459}
6460
6461fn v2_asset_withdrawal_operation(
6462 store: &Store,
6463 local_view: &V2LocalView,
6464 path: &str,
6465 local: &crate::AssetRecord,
6466 current: &V2BaselineAsset,
6467 reason: &str,
6468) -> LinkResult<Value> {
6469 if !local_view.policy.keeps_home(path)
6470 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6471 {
6472 return Err(LinkError::InvalidPack {
6473 message: format!(
6474 "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6475 ),
6476 });
6477 }
6478 verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
6479 if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
6480 return Err(LinkError::InvalidPack {
6481 message: format!(
6482 "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
6483 ),
6484 });
6485 }
6486 Ok(json!({
6487 "op": "asset_withdraw",
6488 "path": path,
6489 "expected": v2_asset_expected(Some(current)),
6490 "reason": reason,
6491 }))
6492}
6493
6494fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
6501 let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
6502 let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
6503 for (index, operation) in operations.iter().enumerate() {
6504 match operation.get("op").and_then(Value::as_str) {
6505 Some("delete") => {
6506 let Some(path) = operation.get("path").and_then(Value::as_str) else {
6507 continue;
6508 };
6509 let Some(hash) = operation
6510 .get("expected")
6511 .and_then(|value| value.get("hash"))
6512 .and_then(Value::as_str)
6513 else {
6514 continue;
6515 };
6516 if path.starts_with("sources/") {
6517 deletes
6518 .entry(hash.to_string())
6519 .or_default()
6520 .push((index, path.to_string()));
6521 }
6522 }
6523 Some("put") => {
6524 let Some(path) = operation.get("path").and_then(Value::as_str) else {
6525 continue;
6526 };
6527 let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
6528 continue;
6529 };
6530 let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
6531 continue;
6532 };
6533 let destination_absent = operation
6534 .get("expected")
6535 .and_then(|value| value.get("kind"))
6536 .and_then(Value::as_str)
6537 == Some("absent");
6538 if path.starts_with("sources/") && destination_absent {
6539 puts.entry(hash.to_string()).or_default().push((
6540 index,
6541 path.to_string(),
6542 bytes,
6543 ));
6544 }
6545 }
6546 _ => {}
6547 }
6548 }
6549 let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
6550 let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
6551 for (hash, source) in deletes {
6552 let Some(destination) = puts.get(&hash) else {
6553 continue;
6554 };
6555 if source.len() != 1 || destination.len() != 1 {
6556 continue;
6557 }
6558 let (delete_index, from) = &source[0];
6559 let (put_index, to, bytes) = &destination[0];
6560 if from == to {
6561 continue;
6562 }
6563 rename_at.insert(
6564 *delete_index,
6565 json!({
6566 "op": "rename",
6567 "from": from,
6568 "to": to,
6569 "expected_from": { "kind": "blob", "hash": hash },
6570 "expected_to": { "kind": "absent" },
6571 "blob": hash,
6572 "bytes": bytes,
6573 }),
6574 );
6575 consumed_puts.insert(*put_index);
6576 }
6577 operations
6578 .into_iter()
6579 .enumerate()
6580 .filter_map(|(index, operation)| {
6581 if let Some(rename) = rename_at.remove(&index) {
6582 Some(rename)
6583 } else if consumed_puts.contains(&index) {
6584 None
6585 } else {
6586 Some(operation)
6587 }
6588 })
6589 .collect()
6590}
6591
6592fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
6593 json!({
6594 "blob_sha256": record.sha256,
6595 "bytes": record.bytes,
6596 "media_type": record.media_type,
6597 "wrappers": record.wrappers,
6598 "required": record.required,
6599 "disposition": disposition,
6600 })
6601}
6602
6603fn apply_generated_v2_operations(
6607 operations: &[Value],
6608 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
6609 candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
6610 candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
6611) -> LinkResult<bool> {
6612 let mut asset_changed = false;
6613 for operation in operations {
6614 match operation.get("op").and_then(Value::as_str) {
6615 Some("put") => {
6616 let path = operation
6617 .get("path")
6618 .and_then(Value::as_str)
6619 .ok_or_else(|| invalid_feed("v2 put has no path"))?;
6620 let sha256 = operation
6621 .get("blob")
6622 .and_then(Value::as_str)
6623 .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
6624 let bytes = operation
6625 .get("bytes")
6626 .and_then(Value::as_u64)
6627 .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
6628 candidate.insert(
6629 path.to_string(),
6630 V2BaselineFile {
6631 sha256: sha256.to_string(),
6632 bytes,
6633 proof: None,
6634 },
6635 );
6636 }
6637 Some("rename") => {
6638 let from = operation
6639 .get("from")
6640 .and_then(Value::as_str)
6641 .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
6642 let to = operation
6643 .get("to")
6644 .and_then(Value::as_str)
6645 .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
6646 let sha256 = operation
6647 .get("blob")
6648 .and_then(Value::as_str)
6649 .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
6650 let bytes = operation
6651 .get("bytes")
6652 .and_then(Value::as_u64)
6653 .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
6654 let expected_from = operation
6655 .get("expected_from")
6656 .and_then(|expected| expected.get("hash"))
6657 .and_then(Value::as_str);
6658 let expected_to_absent = operation
6659 .get("expected_to")
6660 .and_then(|expected| expected.get("kind"))
6661 .and_then(Value::as_str)
6662 == Some("absent");
6663 if from == to
6664 || !from.starts_with("sources/")
6665 || !to.starts_with("sources/")
6666 || expected_from != Some(sha256)
6667 || !expected_to_absent
6668 || candidate.contains_key(to)
6669 {
6670 return Err(invalid_feed("generated v2 source rename is malformed"));
6671 }
6672 let source = candidate
6673 .remove(from)
6674 .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
6675 if source.sha256 != sha256 || source.bytes != bytes {
6676 return Err(invalid_feed(
6677 "v2 rename source differs from its exact-byte claim",
6678 ));
6679 }
6680 candidate.insert(
6681 to.to_string(),
6682 V2BaselineFile {
6683 sha256: sha256.to_string(),
6684 bytes,
6685 proof: None,
6686 },
6687 );
6688 }
6689 Some("delete" | "withdraw_from_hosting") => {
6690 let path = operation
6691 .get("path")
6692 .and_then(Value::as_str)
6693 .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
6694 candidate.remove(path);
6695 }
6696 Some("asset_delete") => {
6697 let path = operation
6698 .get("path")
6699 .and_then(Value::as_str)
6700 .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
6701 candidate_assets.remove(path);
6702 asset_changed = true;
6703 }
6704 Some("asset_put" | "asset_resume" | "asset_withdraw") => {
6705 let path = operation
6706 .get("path")
6707 .and_then(Value::as_str)
6708 .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
6709 let record = local_assets
6710 .get(path)
6711 .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
6712 let disposition =
6713 if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
6714 "withheld"
6715 } else {
6716 operation
6717 .get("asset")
6718 .and_then(|asset| asset.get("disposition"))
6719 .and_then(Value::as_str)
6720 .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
6721 };
6722 candidate_assets.insert(
6723 path.to_string(),
6724 V2BaselineAsset {
6725 blob_sha256: record.sha256.clone(),
6726 bytes: record.bytes,
6727 media_type: record.media_type.clone(),
6728 wrappers: record.wrappers.clone(),
6729 required: record.required,
6730 disposition: disposition.to_string(),
6731 leaf_hash: String::new(),
6734 },
6735 );
6736 asset_changed = true;
6737 }
6738 _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
6739 }
6740 }
6741 Ok(asset_changed)
6742}
6743
6744fn v2_riding_matches_remote(
6745 local: &std::collections::BTreeMap<String, (String, u64)>,
6746 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6747 keeps_home: impl Fn(&str) -> bool,
6748) -> bool {
6749 remote.iter().all(|(path, file)| {
6750 keeps_home(path)
6751 || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
6752 }) && local.iter().all(|(path, (hash, _))| {
6753 remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
6754 })
6755}
6756
6757#[derive(Debug, Clone)]
6758struct V2ResolutionOverride {
6759 expected_remote: Option<String>,
6760 selected_local: Option<String>,
6761}
6762
6763#[derive(Debug, Clone)]
6764struct V2UploadSource {
6765 path: String,
6766 bytes: u64,
6767}
6768
6769struct V2SyncPushOptions<'a> {
6770 resume_local_policy: bool,
6771 bulk_confirmation: Option<&'a V2BulkConfirmation>,
6772 resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
6773 pulled: Option<V2PulledSnapshot>,
6774 withdrawal_paths: &'a [String],
6775 withdrawal_reason: Option<&'a str>,
6776}
6777
6778fn verify_v2_upload_source(
6779 store: &Store,
6780 path: &str,
6781 sha256: &str,
6782 expected_bytes: u64,
6783) -> LinkResult<()> {
6784 let file = store.open_regular(Path::new(path))?;
6785 if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
6786 return Err(LinkError::InvalidPack {
6787 message: format!("local path `{path}` changed during sync planning"),
6788 });
6789 }
6790 Ok(())
6791}
6792
6793fn put_presigned_source(
6794 cfg: &HubConfig,
6795 raw: &str,
6796 headers: &Value,
6797 store: &Store,
6798 source: &V2UploadSource,
6799) -> LinkResult<()> {
6800 let http = presigned_agent(cfg, raw)?;
6801 let mut attempt = 0;
6802 let result = loop {
6803 let file = store.open_regular(Path::new(&source.path))?;
6804 if file.metadata()?.len() != source.bytes {
6805 return Err(LinkError::InvalidPack {
6806 message: format!("local path `{}` changed before upload", source.path),
6807 });
6808 }
6809 let mut req = http.put(raw);
6813 let mut has_content_length = false;
6814 if let Some(map) = headers.as_object() {
6815 for (name, value) in map {
6816 if let Some(value) = value.as_str() {
6817 has_content_length |= name.eq_ignore_ascii_case("content-length");
6818 req = req.set(name, value);
6819 }
6820 }
6821 }
6822 if !has_content_length {
6823 req = req.set("Content-Length", &source.bytes.to_string());
6824 }
6825 match req.send(file) {
6826 Err(ureq::Error::Transport(error))
6827 if is_pre_request_transport(error.kind()) && attempt + 1 < UPLOAD_ATTEMPTS =>
6828 {
6829 std::thread::sleep(std::time::Duration::from_millis(upload_retry_backoff_ms(
6830 attempt,
6831 )));
6832 attempt += 1;
6833 }
6834 Err(ureq::Error::Status(status, _))
6840 if status != 412
6841 && is_retryable_upload_status(status)
6842 && attempt + 1 < UPLOAD_ATTEMPTS =>
6843 {
6844 std::thread::sleep(std::time::Duration::from_millis(upload_retry_backoff_ms(
6845 attempt,
6846 )));
6847 attempt += 1;
6848 }
6849 result => break result,
6850 }
6851 };
6852 match result {
6853 Ok(response) if (200..300).contains(&response.status()) => Ok(()),
6854 Ok(response) => {
6855 let status = response.status();
6860 let detail = response
6861 .into_string()
6862 .ok()
6863 .map(|body| body.chars().take(400).collect::<String>())
6864 .filter(|body| !body.trim().is_empty());
6865 Err(LinkError::Http {
6866 what: "v2 changed-byte upload",
6867 status,
6868 message: match detail {
6869 Some(body) => format!(
6870 "object store rejected the upload of `{}`: {}",
6871 source.path,
6872 body.replace('\n', " ")
6873 ),
6874 None => format!("object store rejected the upload of `{}`", source.path),
6875 },
6876 code: None,
6877 details: None,
6878 })
6879 }
6880 Err(error) => match error {
6881 ureq::Error::Status(412, _) => Ok(()),
6882 ureq::Error::Status(_, response) => {
6883 let status = response.status();
6884 let detail = response
6885 .into_string()
6886 .ok()
6887 .map(|body| body.chars().take(400).collect::<String>())
6888 .filter(|body| !body.trim().is_empty());
6889 Err(LinkError::Http {
6890 what: "v2 changed-byte upload",
6891 status,
6892 message: match detail {
6893 Some(body) => format!(
6894 "object store rejected the upload of `{}`: {}",
6895 source.path,
6896 body.replace('\n', " ")
6897 ),
6898 None => {
6899 format!("object store rejected the upload of `{}`", source.path)
6900 }
6901 },
6902 code: None,
6903 details: None,
6904 })
6905 }
6906 ureq::Error::Transport(error) => Err(LinkError::Transport {
6907 hub: "the object store".to_string(),
6908 message: error.to_string(),
6909 }),
6910 },
6911 }
6912}
6913
6914fn v2_sync_push(
6915 cfg: &HubConfig,
6916 requested_brain: &str,
6917 store: &Store,
6918 head: V2VerifiedHead,
6919 options: V2SyncPushOptions<'_>,
6920) -> LinkResult<Value> {
6921 let V2SyncPushOptions {
6922 resume_local_policy,
6923 bulk_confirmation,
6924 resolution,
6925 pulled,
6926 withdrawal_paths,
6927 withdrawal_reason,
6928 } = options;
6929 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
6930 let head = v2_verified_head(cfg, requested_brain)?
6931 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6932 let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
6933 match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
6934 Some(snapshot) => (
6935 snapshot.files,
6936 snapshot.assets,
6937 Some(snapshot.local),
6938 Some(snapshot.local_assets),
6939 ),
6940 None => (
6941 files_for_v2_view(
6942 &head,
6943 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6944 ),
6945 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6946 None,
6947 None,
6948 ),
6949 };
6950 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
6951 ensure_v2_view_compatible(&head, baseline.as_ref())?;
6952 if head.view_kind == "scoped" && baseline.is_none() {
6953 return Err(LinkError::ScopedViewChanged);
6954 }
6955 let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
6956 let local = &local_view.riding;
6957 let local_assets = match carried_local_assets {
6958 Some(assets) => assets,
6959 None => v2_local_asset_records(store)?,
6960 };
6961 if withdrawal_paths.len() > MAX_PUSH_FILES {
6962 return Err(LinkError::PushTooLarge {
6963 detail: "too many explicit withdrawal paths".to_string(),
6964 });
6965 }
6966 let withdrawal_reason = if withdrawal_paths.is_empty() {
6967 None
6968 } else {
6969 let reason = withdrawal_reason
6970 .map(str::trim)
6971 .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
6972 .ok_or_else(|| LinkError::InvalidPack {
6973 message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
6974 })?;
6975 Some(reason)
6976 };
6977 let mut withdrawals = withdrawal_paths
6978 .iter()
6979 .map(|path| {
6980 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
6981 path: error.to_string(),
6982 })
6983 })
6984 .collect::<LinkResult<Vec<_>>>()?;
6985 withdrawals.sort();
6986 withdrawals.dedup();
6987 if withdrawals.len() != withdrawal_paths.len() {
6988 return Err(LinkError::InvalidPack {
6989 message: "explicit withdrawal paths must be unique".to_string(),
6990 });
6991 }
6992 let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
6993 let mut consumed_withdrawals = BTreeSet::new();
6994 if let Some(previous) = baseline.as_ref() {
6995 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
6996 && !resume_local_policy
6997 {
6998 let mut newly_eligible = previous
6999 .local_eligibility
7000 .iter()
7001 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7002 .map(|(path, _)| path.clone())
7003 .collect::<Vec<_>>();
7004 if !newly_eligible.is_empty() {
7005 newly_eligible.truncate(100);
7006 return Err(LinkError::LocalPolicyTransition {
7007 paths: newly_eligible,
7008 });
7009 }
7010 }
7011 }
7012 let base = match baseline.as_ref() {
7013 Some(state) => &state.files,
7014 None if remote.is_empty() => &remote,
7015 None => {
7016 let mut conflicts = remote
7017 .iter()
7018 .filter(|(path, file)| {
7019 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7020 })
7021 .map(|(path, _)| path.clone())
7022 .collect::<Vec<_>>();
7023 if !conflicts.is_empty() {
7024 conflicts.truncate(100);
7025 let (bundle, paths) =
7026 create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7027 return Err(LinkError::ConflictBundle { bundle, paths });
7028 }
7029 &remote
7030 }
7031 };
7032 let all_paths = base
7033 .keys()
7034 .chain(remote.keys())
7035 .chain(local.keys())
7036 .cloned()
7037 .collect::<std::collections::BTreeSet<_>>();
7038 let mut conflicts = Vec::new();
7039 let mut operations = Vec::new();
7040 let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7041 for path in all_paths {
7042 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7043 let remote_file = remote.get(&path);
7044 let remote_hash = remote_file.map(|file| file.sha256.as_str());
7045 let local_file = local.get(&path);
7046 let local_hash = local_file.map(|file| file.0.as_str());
7047 if local_hash == base_hash {
7048 continue;
7049 }
7050 if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
7051 continue;
7052 }
7053 if local_view.policy.keeps_home(&path) {
7054 continue;
7057 }
7058 if remote_hash != base_hash && local_hash != remote_hash {
7059 let explicitly_resolved = resolution
7060 .and_then(|allowed| allowed.get(&path))
7061 .is_some_and(|selected| {
7062 selected.expected_remote.as_deref() == remote_hash
7063 && selected.selected_local.as_deref() == local_hash
7064 });
7065 if !explicitly_resolved {
7066 conflicts.push(path);
7067 continue;
7068 }
7069 }
7070 match local_file {
7071 Some((sha256, byte_count)) => {
7072 verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7073 operations.push(json!({
7074 "op": "put",
7075 "path": path,
7076 "expected": v2_expected(remote_file),
7077 "blob": sha256,
7078 "bytes": byte_count,
7079 }));
7080 upload_sources
7081 .entry(sha256.clone())
7082 .or_insert_with(|| V2UploadSource {
7083 path: path.clone(),
7084 bytes: *byte_count,
7085 });
7086 }
7087 None => {
7088 let Some(current) = remote_file else {
7089 continue;
7090 };
7091 operations.push(json!({
7092 "op": "delete",
7093 "path": path,
7094 "expected": { "kind": "blob", "hash": current.sha256 },
7095 }));
7096 }
7097 }
7098 }
7099 operations = infer_exact_source_promotions(operations);
7100 for path in &withdrawals {
7101 if local_assets.contains_key(path) {
7102 continue;
7103 }
7104 operations.push(v2_content_withdrawal_operation(
7105 store,
7106 &local_view,
7107 &remote,
7108 path,
7109 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7110 )?);
7111 consumed_withdrawals.insert(path.clone());
7112 }
7113 if !conflicts.is_empty() {
7114 conflicts.truncate(100);
7115 let (bundle, paths) = create_v2_conflict_bundle(
7116 cfg,
7117 store,
7118 &head,
7119 baseline.as_ref(),
7120 local,
7121 &remote,
7122 &conflicts,
7123 )?;
7124 return Err(LinkError::ConflictBundle { bundle, paths });
7125 }
7126 let base_assets = match baseline.as_ref() {
7127 Some(state) => &state.assets,
7128 None if remote_assets.is_empty() => &remote_assets,
7129 None => {
7130 let mismatched = remote_assets.iter().any(|(path, remote)| {
7131 local_assets.get(path) != Some(&v2_asset_record(remote, path))
7132 }) || local_assets.len() != remote_assets.len();
7133 if mismatched {
7134 return Err(LinkError::Conflict {
7135 paths: vec!["assets.jsonl".to_string()],
7136 });
7137 }
7138 &remote_assets
7139 }
7140 };
7141 let asset_paths = base_assets
7142 .keys()
7143 .chain(remote_assets.keys())
7144 .chain(local_assets.keys())
7145 .cloned()
7146 .collect::<std::collections::BTreeSet<_>>();
7147 let mut asset_policy_transitions = Vec::new();
7148 for path in asset_paths {
7149 let base_record = base_assets
7150 .get(&path)
7151 .map(|asset| v2_asset_record(asset, &path));
7152 let remote = remote_assets.get(&path);
7153 let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
7154 let local_record = local_assets.get(&path);
7155 if withdrawal_set.contains(&path) {
7156 let record = local_record.ok_or_else(|| LinkError::InvalidPack {
7157 message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
7158 })?;
7159 let current = remote.ok_or_else(|| LinkError::InvalidPack {
7160 message: format!(
7161 "asset withdrawal path `{path}` has no readable hosted coordinate"
7162 ),
7163 })?;
7164 operations.push(v2_asset_withdrawal_operation(
7165 store,
7166 &local_view,
7167 &path,
7168 record,
7169 current,
7170 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7171 )?);
7172 consumed_withdrawals.insert(path.clone());
7173 continue;
7174 }
7175 let mut raw_present = false;
7176 let mut disposition = "withheld";
7177 let mut resumes_hosting = false;
7178 if let Some(record) = local_record {
7179 crate::linkmd_v2::normalize_path(&record.path)
7180 .map_err(|error| invalid_feed(error.to_string()))?;
7181 let kept_home = local_view.policy.keeps_home(&path);
7182 raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
7183 disposition = if kept_home || !raw_present {
7184 "withheld"
7185 } else {
7186 "hosted"
7187 };
7188 if !raw_present && record.required && !kept_home {
7189 return Err(LinkError::InvalidPack {
7190 message: format!("required asset {path} is missing"),
7191 });
7192 }
7193 resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
7194 }
7195 if local_record == base_record.as_ref() && !resumes_hosting {
7196 continue;
7197 }
7198 if remote_record != base_record && local_record != remote_record.as_ref() {
7199 conflicts.push(path);
7200 continue;
7201 }
7202 let Some(record) = local_record else {
7203 if let Some(remote) = remote {
7204 operations.push(json!({
7205 "op": "asset_delete",
7206 "path": path,
7207 "expected": v2_asset_expected(Some(remote)),
7208 }));
7209 }
7210 continue;
7211 };
7212 let raw = if raw_present {
7213 verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
7214 Some(())
7215 } else {
7216 None
7217 };
7218 let op = if resumes_hosting {
7219 if !resume_local_policy {
7220 asset_policy_transitions.push(path);
7221 continue;
7222 }
7223 "asset_resume"
7224 } else {
7225 "asset_put"
7226 };
7227 operations.push(json!({
7228 "op": op,
7229 "path": path,
7230 "expected": v2_asset_expected(remote),
7231 "asset": v2_asset_value(record, disposition),
7232 }));
7233 if disposition == "hosted" {
7234 raw.expect("hosted asset was checked present");
7235 upload_sources
7236 .entry(record.sha256.clone())
7237 .or_insert_with(|| V2UploadSource {
7238 path: path.clone(),
7239 bytes: record.bytes,
7240 });
7241 }
7242 }
7243 if consumed_withdrawals != withdrawal_set {
7244 let missing = withdrawal_set
7245 .difference(&consumed_withdrawals)
7246 .next()
7247 .expect("different withdrawal sets have one member");
7248 return Err(LinkError::InvalidPack {
7249 message: format!(
7250 "withdrawal path `{missing}` is not a readable content or asset coordinate"
7251 ),
7252 });
7253 }
7254 if !conflicts.is_empty() {
7255 conflicts.truncate(100);
7256 return Err(LinkError::Conflict { paths: conflicts });
7257 }
7258 if !asset_policy_transitions.is_empty() {
7259 asset_policy_transitions.truncate(100);
7260 return Err(LinkError::LocalPolicyTransition {
7261 paths: asset_policy_transitions,
7262 });
7263 }
7264 let touched_sources = operations
7265 .iter()
7266 .filter_map(
7267 |operation| match operation.get("op").and_then(Value::as_str) {
7268 Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
7269 Some("rename") => operation.get("to").and_then(Value::as_str),
7270 _ => None,
7271 },
7272 )
7273 .collect::<std::collections::BTreeSet<_>>();
7274 let withheld_links = local_view
7275 .withheld_links
7276 .iter()
7277 .filter(|link| touched_sources.contains(link.source.as_str()))
7278 .collect::<Vec<_>>();
7279 let checkout_pseudonym = v2_checkout_id(
7280 baseline
7281 .as_ref()
7282 .and_then(|current| current.checkout_id.as_deref()),
7283 )?;
7284 let checkout_id = if withheld_links.is_empty() {
7285 None
7286 } else {
7287 Some(checkout_pseudonym.clone())
7288 };
7289 if operations.is_empty() {
7290 let final_head = v2_verified_head(cfg, requested_brain)?
7291 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
7292 if !same_v2_head(&head, &final_head) {
7293 return Err(LinkError::RemoteAdvancedDuringSync);
7294 }
7295 let mut final_local = v2_local_files(store)?;
7296 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
7297 let final_assets = v2_local_asset_records(store)?;
7298 let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
7299 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
7300 final_local.policy.keeps_home(path)
7301 }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
7302 let next = v2_baseline_from_head(
7303 cfg,
7304 &head,
7305 remote,
7306 remote_assets,
7307 Some(&final_local),
7308 Some(&checkout_pseudonym),
7309 )?;
7310 let split_count = next.remote_copy_remains.len();
7311 accept_v2_head(cfg, &final_head)?;
7312 if !local_changed && !remote_ahead {
7313 refresh_scoped_view_marker(store, &head, next.files.len())?;
7314 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
7315 }
7316 return Ok(json!({
7317 "v": 2,
7318 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
7319 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
7320 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
7321 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
7322 "local_policy": {
7323 "remote_copy_remains": split_count,
7324 },
7325 }));
7326 }
7327 let includes_contract = operations
7328 .iter()
7329 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
7330 let rebase = if head.pointer.is_none() || includes_contract {
7331 "strict"
7332 } else {
7333 "disjoint"
7334 };
7335 let base_value = head.pointer.as_ref().map(|pointer| {
7336 json!({
7337 "seq": pointer.seq,
7338 "commit_hash": pointer.commit_hash,
7339 "content_root": pointer.content_root,
7340 "asset_root": pointer.asset_root,
7341 })
7342 });
7343 let entropy = format!(
7347 "{}\0{}\0{}\0{}\0{}\0{}",
7348 normalized_origin(&cfg.hub)?,
7349 head.brain_id,
7350 serde_json::to_string(&base_value).unwrap_or_default(),
7351 serde_json::to_string(&operations).unwrap_or_default(),
7352 serde_json::to_string(&withheld_links).unwrap_or_default(),
7353 checkout_id.as_deref().unwrap_or("")
7354 );
7355 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
7356 let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
7357 total
7358 .checked_add(source.bytes)
7359 .ok_or_else(|| LinkError::PushTooLarge {
7360 detail: "v2 changed-byte total overflow".to_string(),
7361 })
7362 })?;
7363 let inline = changed_bytes <= 3 * 1024 * 1024;
7364 let inline_blobs = if inline {
7365 upload_sources
7366 .iter()
7367 .map(|(sha256, source)| {
7368 let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
7369 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
7370 return Err(LinkError::InvalidPack {
7371 message: format!("local path `{}` changed before upload", source.path),
7372 });
7373 }
7374 Ok(json!({
7375 "sha256": sha256,
7376 "bytes": source.bytes,
7377 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
7378 }))
7379 })
7380 .collect::<LinkResult<Vec<_>>>()?
7381 } else {
7382 Vec::new()
7383 };
7384 let mut body = json!({
7385 "mutation_id": mutation_id,
7386 "base": base_value,
7387 "rebase": rebase,
7388 "reason": "dbmd sync",
7389 "operations": operations,
7390 "blobs": inline_blobs,
7391 });
7392 if !withheld_links.is_empty() {
7393 body["withheld_links"] = serde_json::to_value(&withheld_links)
7394 .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
7395 body["checkout_id"] =
7396 Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
7397 }
7398 if let Some(confirmation) = bulk_confirmation {
7399 if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
7400 return Err(LinkError::InvalidPack {
7401 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
7402 .to_string(),
7403 });
7404 }
7405 body["rebase"] = Value::String("strict".to_string());
7409 body["bulk_preview_id"] = Value::String(confirmation.id.clone());
7410 body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
7411 }
7412 if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
7413 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
7414 for operation in &operations {
7415 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
7416 return Err(invalid_feed("v2 upload operation has no kind"));
7417 };
7418 let hash = match kind {
7419 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
7420 "asset_put" | "asset_resume" => operation
7421 .get("asset")
7422 .and_then(|asset| asset.get("blob_sha256"))
7423 .and_then(Value::as_str),
7424 _ => None,
7425 };
7426 let Some(hash) = hash else { continue };
7427 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
7428 if kind == "rename" {
7429 for field in ["from", "to"] {
7430 coordinates.insert(
7431 operation
7432 .get(field)
7433 .and_then(Value::as_str)
7434 .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
7435 .to_string(),
7436 );
7437 }
7438 } else {
7439 let path = operation
7440 .get("path")
7441 .and_then(Value::as_str)
7442 .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
7443 coordinates.insert(if kind.starts_with("asset_") {
7444 format!("assets/{path}")
7445 } else {
7446 path.to_string()
7447 });
7448 }
7449 }
7450 let declarations = upload_sources
7451 .iter()
7452 .map(|(sha256, source)| {
7453 json!({
7454 "sha256": sha256,
7455 "bytes": source.bytes,
7456 "coordinates": coordinates_by_hash
7457 .get(sha256)
7458 .into_iter()
7459 .flatten()
7460 .collect::<Vec<_>>(),
7461 })
7462 })
7463 .collect::<Vec<_>>();
7464 let mut references = Vec::with_capacity(upload_sources.len());
7465 let mut seen = std::collections::BTreeSet::new();
7466 let mut reserved_count = 0usize;
7467 for batch in batch_upload_declarations(declarations) {
7471 let batch_len = batch.len();
7472 let reserved = ensure_ok(
7473 request(
7474 cfg,
7475 "POST",
7476 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7477 Some(&json!({ "blobs": batch })),
7478 Auth::Required,
7479 )?,
7480 "prepare v2 changed-byte uploads",
7481 )?;
7482 let items = reserved
7483 .get("uploads")
7484 .and_then(Value::as_array)
7485 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
7486 if items.len() != batch_len {
7487 return Err(invalid_feed(
7488 "v2 upload reservation response changed the requested set",
7489 ));
7490 }
7491 reserved_count += items.len();
7492 for item in items {
7493 let sha256 = item
7494 .get("sha256")
7495 .and_then(Value::as_str)
7496 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
7497 let source = upload_sources
7498 .get(sha256)
7499 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
7500 let declared_bytes = item
7501 .get("bytes")
7502 .and_then(Value::as_u64)
7503 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
7504 let reservation_id = item
7505 .get("reservation_id")
7506 .and_then(Value::as_str)
7507 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
7508 let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
7509 invalid_feed("v2 upload reservation has no coordinate binding")
7510 })?;
7511 let returned_coordinates = item
7512 .get("coordinates")
7513 .and_then(Value::as_array)
7514 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
7515 if declared_bytes != source.bytes
7516 || !crate::ulid::is_ulid(reservation_id)
7517 || !seen.insert(sha256.to_string())
7518 || returned_coordinates.len() != expected_coordinates.len()
7519 || returned_coordinates
7520 .iter()
7521 .zip(expected_coordinates)
7522 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
7523 {
7524 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
7525 }
7526 match item.get("status").and_then(Value::as_str) {
7527 Some("upload") => {
7528 let url = item
7529 .get("url")
7530 .and_then(Value::as_str)
7531 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
7532 put_presigned_source(
7533 cfg,
7534 url,
7535 item.get("headers").unwrap_or(&Value::Null),
7536 store,
7537 source,
7538 )?;
7539 verify_v2_upload_source(store, &source.path, sha256, source.bytes)?;
7540 }
7541 Some("already_present") => {}
7542 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
7543 }
7544 references.push(json!({
7545 "sha256": sha256,
7546 "bytes": source.bytes,
7547 "reservation_id": reservation_id,
7548 }));
7549 }
7550 }
7551 if reserved_count != upload_sources.len() {
7552 return Err(invalid_feed(
7553 "v2 upload reservation response changed the requested set",
7554 ));
7555 }
7556 body["blobs"] = Value::Array(references);
7557 }
7558 if body.to_string().len() > MAX_PUSH_BYTES {
7559 return Err(LinkError::PushTooLarge {
7560 detail: "v2 operation metadata exceeds the bounded commit request".to_string(),
7561 });
7562 }
7563 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
7564 let mut candidate_hub_signer: Option<String> = None;
7565 let mut response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
7566 let bulk_preview_required = !(200..300).contains(&response.status)
7567 && response.body.as_ref().is_some_and(|value| {
7568 value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
7569 || value
7570 .get("details")
7571 .and_then(|details| details.get("code"))
7572 .and_then(Value::as_str)
7573 == Some("bulk_preview_required")
7574 });
7575 if bulk_preview_required && bulk_confirmation.is_none() {
7576 body["rebase"] = Value::String("strict".to_string());
7577 body["preview_only"] = Value::Bool(true);
7578 let preview = ensure_ok(
7579 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
7580 "v2 bulk preview",
7581 )?;
7582 let preview_code = preview.get("code").and_then(Value::as_str);
7583 let required = preview.get("required").and_then(Value::as_bool);
7584 if preview.get("v").and_then(Value::as_u64) != Some(2)
7585 || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
7586 || !matches!(
7587 preview_code,
7588 Some("bulk_preview_created" | "bulk_preview_not_required")
7589 )
7590 || required.is_none()
7591 {
7592 return Err(invalid_feed(
7593 "bulk preview response is not bound to the requested mutation",
7594 ));
7595 }
7596 if required == Some(true) {
7597 let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
7598 let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
7599 if preview_code != Some("bulk_preview_created")
7600 || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
7601 || preview_digest.is_none_or(|value| !is_sha256(value))
7602 || preview.get("expires_at").and_then(Value::as_str).is_none()
7603 || !preview.get("impact").is_some_and(Value::is_object)
7604 {
7605 return Err(invalid_feed("bulk preview receipt is malformed"));
7606 }
7607 return Err(LinkError::BulkPreviewRequired { preview });
7608 }
7609 if preview_code != Some("bulk_preview_not_required") {
7610 return Err(invalid_feed("bulk preview requirement is inconsistent"));
7611 }
7612 body.as_object_mut()
7615 .expect("v2 commit request is an object")
7616 .remove("preview_only");
7617 response = request(cfg, "POST", &path, Some(&body), Auth::Required)?;
7618 }
7619 let mut result = ensure_ok(response, "v2 sync push")?;
7620 if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
7621 if let Some(object) = result.as_object_mut() {
7622 object.insert(
7623 "sync_status".to_string(),
7624 Value::String("proposal_pending".to_string()),
7625 );
7626 }
7627 return Ok(result);
7628 }
7629 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
7630 let request_id = result
7631 .get("request_id")
7632 .and_then(Value::as_str)
7633 .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
7634 .to_string();
7635 let challenge = result
7636 .get("signing_challenge")
7637 .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
7638 let mut expected_candidate = remote.clone();
7639 let mut expected_candidate_assets = remote_assets.clone();
7640 apply_generated_v2_operations(
7641 &operations,
7642 &local_assets,
7643 &mut expected_candidate,
7644 &mut expected_candidate_assets,
7645 )?;
7646 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
7647 cfg,
7648 &head,
7649 &expected_candidate,
7650 &expected_candidate_assets,
7651 &mutation_id,
7652 &body,
7653 challenge,
7654 )?;
7655 body["signing_challenge_id"] = Value::String(challenge_id);
7656 body["signature_base64url"] = Value::String(signature);
7657 candidate_hub_signer = Some(actor_signer);
7658 result = ensure_ok(
7659 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
7660 "v2 self-custody commit",
7661 )?;
7662 }
7663 let refreshed = v2_verified_head(cfg, requested_brain)?
7664 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
7665 if candidate_hub_signer
7666 .as_ref()
7667 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
7668 {
7669 return Err(invalid_feed(
7670 "self-custody actor signer differs from the committed hub pointer signer",
7671 ));
7672 }
7673 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
7674 if refreshed
7675 .pointer
7676 .as_ref()
7677 .map(|pointer| pointer.commit_hash.as_str())
7678 != accepted_hash
7679 {
7680 return Err(LinkError::RemoteAdvancedDuringSync);
7681 }
7682 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
7683 let rebased = result
7684 .get("rebased")
7685 .and_then(Value::as_bool)
7686 .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
7687 let (refreshed_files, refreshed_assets) = if rebased {
7688 (
7689 files_for_v2_view(
7690 &refreshed,
7691 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
7692 ),
7693 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
7694 )
7695 } else {
7696 let asset_changed = apply_generated_v2_operations(
7697 &operations,
7698 &local_assets,
7699 &mut remote,
7700 &mut remote_assets,
7701 )?;
7702 let assets = if asset_changed {
7703 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
7706 } else {
7707 remote_assets
7708 };
7709 (remote, assets)
7710 };
7711 let mut final_local = v2_local_files(store)?;
7712 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
7713 let final_assets = v2_local_asset_records(store)?;
7714 let local_dirty = final_local.riding != local_view.riding
7715 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
7716 final_local.policy.keeps_home(path)
7717 })
7718 || final_assets != local_assets
7719 || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
7720 let next = v2_baseline_from_head(
7721 cfg,
7722 &refreshed,
7723 refreshed_files,
7724 refreshed_assets,
7725 Some(&final_local),
7726 Some(&checkout_pseudonym),
7727 )?;
7728 let split_count = next.remote_copy_remains.len();
7729 accept_v2_head(cfg, &refreshed)?;
7730 if !local_dirty {
7731 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
7732 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
7733 }
7734 if let Some(object) = result.as_object_mut() {
7735 object.insert(
7736 "local_policy".to_string(),
7737 json!({ "remote_copy_remains": split_count }),
7738 );
7739 object.insert(
7740 "sync_status".to_string(),
7741 Value::String(if local_dirty {
7742 "remote_committed_local_dirty".to_string()
7743 } else {
7744 "synced".to_string()
7745 }),
7746 );
7747 }
7748 Ok(result)
7749}
7750
7751pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
7754 sync_push_incremental_with_policy(cfg, brain, store, false)
7755}
7756
7757pub fn sync_push_incremental_with_policy(
7760 cfg: &HubConfig,
7761 brain: &str,
7762 store: &Store,
7763 resume_local_policy: bool,
7764) -> LinkResult<Value> {
7765 sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
7766}
7767
7768pub fn sync_push_incremental_with_options(
7771 cfg: &HubConfig,
7772 brain: &str,
7773 store: &Store,
7774 resume_local_policy: bool,
7775 bulk_confirmation: Option<&V2BulkConfirmation>,
7776) -> LinkResult<Value> {
7777 sync_push_incremental_with_controls(
7778 cfg,
7779 brain,
7780 store,
7781 resume_local_policy,
7782 bulk_confirmation,
7783 &[],
7784 None,
7785 )
7786}
7787
7788pub fn sync_push_incremental_with_controls(
7790 cfg: &HubConfig,
7791 brain: &str,
7792 store: &Store,
7793 resume_local_policy: bool,
7794 bulk_confirmation: Option<&V2BulkConfirmation>,
7795 withdrawal_paths: &[String],
7796 withdrawal_reason: Option<&str>,
7797) -> LinkResult<Value> {
7798 require_safe_ref(brain)?;
7799 if let Some(head) = v2_verified_head(cfg, brain)? {
7800 return v2_sync_push(
7801 cfg,
7802 brain,
7803 store,
7804 head,
7805 V2SyncPushOptions {
7806 resume_local_policy,
7807 bulk_confirmation,
7808 resolution: None,
7809 pulled: None,
7810 withdrawal_paths,
7811 withdrawal_reason,
7812 },
7813 );
7814 }
7815 if !withdrawal_paths.is_empty() {
7816 return Err(LinkError::InvalidPack {
7817 message: "explicit withdrawal requires a link.md v2 brain".to_string(),
7818 });
7819 }
7820 legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
7821}
7822
7823pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
7827 require_safe_ref(brain)?;
7828 Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
7829}
7830
7831#[cfg(windows)]
7832fn legacy_sync_push_incremental(
7833 _cfg: &HubConfig,
7834 _brain: &str,
7835 _store: &Store,
7836 _resume_local_policy: bool,
7837 _bulk_confirmation: Option<&V2BulkConfirmation>,
7838) -> LinkResult<Value> {
7839 Err(LinkError::UnsupportedPlatform {
7840 operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
7841 })
7842}
7843
7844#[cfg(not(windows))]
7845fn legacy_sync_push_incremental(
7846 cfg: &HubConfig,
7847 brain: &str,
7848 store: &Store,
7849 resume_local_policy: bool,
7850 bulk_confirmation: Option<&V2BulkConfirmation>,
7851) -> LinkResult<Value> {
7852 if resume_local_policy || bulk_confirmation.is_some() {
7853 return Err(LinkError::InvalidPack {
7854 message: "v2 sync options require a link.md v2 brain".to_string(),
7855 });
7856 }
7857 let files = collect_push_files(store)?;
7858 sync_push(cfg, brain, &files)
7859}
7860
7861#[derive(Debug, Clone)]
7863pub enum V2ConflictChoice {
7864 KeepLocal,
7865 TakeRemote,
7866 From(PathBuf),
7867}
7868
7869fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
7870 if !crate::ulid::is_ulid(bundle) {
7871 return Err(LinkError::InvalidPack {
7872 message: "conflict bundle must be a lowercase ULID".to_string(),
7873 });
7874 }
7875 let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
7876 let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
7877 .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
7878 if plan.v != 2
7879 || plan.class != "content_resolution_required"
7880 || plan.bundle != bundle
7881 || !crate::ulid::is_ulid(&plan.brain)
7882 || plan.files.is_empty()
7883 || plan.files.len() > 100
7884 || plan.files.iter().any(|file| {
7885 crate::linkmd_v2::normalize_path(&file.path).is_err()
7886 || [&file.base, &file.local, &file.remote]
7887 .into_iter()
7888 .any(|coordinate| {
7889 coordinate
7890 .sha256
7891 .as_deref()
7892 .is_some_and(|hash| !is_sha256(hash))
7893 || coordinate.file.as_deref().is_some_and(|name| {
7894 name.starts_with('/')
7895 || name
7896 .split('/')
7897 .any(|part| part.is_empty() || part == "." || part == "..")
7898 })
7899 })
7900 })
7901 {
7902 return Err(invalid_feed("private conflict plan failed validation"));
7903 }
7904 Ok(plan)
7905}
7906
7907pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
7912 require_hardened_filesystem("private conflict maintenance")?;
7913 if all && !prune {
7914 return Err(LinkError::InvalidPack {
7915 message: "discarding all conflict bundles requires prune=true".to_string(),
7916 });
7917 }
7918 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7919 message: format!("conflict checkout is not a valid db.md store: {error}"),
7920 })?;
7921 let _transaction = store.transaction()?;
7922 let root = Path::new(".dbmd/conflicts");
7923 let names = match store.directory_names(root) {
7924 Ok(names) => names,
7925 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
7926 Err(error) => return Err(error.into()),
7927 };
7928 let now = SystemTime::now()
7929 .duration_since(UNIX_EPOCH)
7930 .unwrap_or_default()
7931 .as_secs();
7932 let mut bundles = Vec::new();
7933 let mut pruned = 0_u64;
7934 for name in names {
7935 let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
7936 continue;
7937 };
7938 let plan_path = v2_conflict_relative(bundle, "plan.json");
7939 let plan_exists = store.regular_file_exists(&plan_path)?;
7940 let expired = if plan_exists {
7941 match load_v2_conflict_plan(&store, bundle) {
7942 Ok(plan) => plan.expires_unix < now,
7943 Err(error) if all => {
7944 let _ = error;
7945 true
7946 }
7947 Err(error) => return Err(error),
7948 }
7949 } else {
7950 true
7951 };
7952 if prune && (all || expired) {
7953 store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
7954 pruned += 1;
7955 continue;
7956 }
7957 bundles.push(json!({
7958 "bundle": bundle,
7959 "complete": plan_exists,
7960 "expired": expired,
7961 }));
7962 }
7963 Ok(json!({
7964 "v": 2,
7965 "class": "private_conflict_state",
7966 "bundles": bundles.len(),
7967 "pruned": pruned,
7968 "items": bundles,
7969 }))
7970}
7971
7972pub fn sync_resolve_conflict(
7976 cfg: &HubConfig,
7977 checkout: &Path,
7978 bundle: &str,
7979 choice: V2ConflictChoice,
7980 bulk_confirmation: Option<&V2BulkConfirmation>,
7981) -> LinkResult<Value> {
7982 require_hardened_filesystem("conflict resolution")?;
7983 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
7984 message: format!("conflict checkout is not a valid db.md store: {error}"),
7985 })?;
7986 let plan = load_v2_conflict_plan(&store, bundle)?;
7987 if plan.origin != normalized_origin(&cfg.hub)? {
7988 return Err(invalid_feed(
7989 "conflict bundle belongs to another hub origin",
7990 ));
7991 }
7992 let now = SystemTime::now()
7993 .duration_since(UNIX_EPOCH)
7994 .unwrap_or_default()
7995 .as_secs();
7996 if now > plan.expires_unix {
7997 return Err(LinkError::InvalidPack {
7998 message: "conflict bundle expired; rerun sync to obtain current coordinates"
7999 .to_string(),
8000 });
8001 }
8002 let head = v2_verified_head(cfg, &plan.brain)?
8003 .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8004 let pointer = head.pointer.as_ref();
8005 if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8006 || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8007 || pointer.and_then(|value| value.content_root.as_deref())
8008 != plan.remote_content_root.as_deref()
8009 || head.view_kind != plan.view_kind
8010 || head.view_revision != plan.view_revision
8011 {
8012 return Err(LinkError::RemoteAdvancedDuringSync);
8013 }
8014
8015 for file in &plan.files {
8017 let actual = match store.regular_file_exists(Path::new(&file.path))? {
8018 true => Some(content_sha256(&store.read_bounded(
8019 Path::new(&file.path),
8020 file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8021 )?)),
8022 false => None,
8023 };
8024 if actual.as_deref() != file.local.sha256.as_deref() {
8025 return Err(LinkError::InvalidPack {
8026 message: format!(
8027 "local conflict path `{}` changed after the bundle was created",
8028 file.path
8029 ),
8030 });
8031 }
8032 }
8033
8034 let from_source = match &choice {
8035 V2ConflictChoice::From(source) => Some(source.clone()),
8036 _ => None,
8037 };
8038 let result = match choice {
8039 V2ConflictChoice::TakeRemote => {
8040 if bulk_confirmation.is_some() {
8041 return Err(LinkError::InvalidPack {
8042 message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8043 });
8044 }
8045 let current_remote =
8049 files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8050 let _ = v2_take_remote_selection(&plan.files, ¤t_remote)?;
8051 let selected = plan
8052 .files
8053 .iter()
8054 .map(|file| file.path.clone())
8055 .collect::<std::collections::BTreeSet<_>>();
8056 serde_json::to_value(
8057 v2_sync_pull_with_resolution(
8058 cfg,
8059 &plan.brain,
8060 head,
8061 Some(checkout),
8062 Some(&selected),
8063 )?
8064 .report,
8065 )
8066 .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
8067 }
8068 V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
8069 if let Some(source) = from_source.as_ref() {
8070 if plan.files.len() != 1 {
8071 return Err(LinkError::InvalidPack {
8072 message: "--from requires a bundle with exactly one conflict".to_string(),
8073 });
8074 }
8075 let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
8076 if std::str::from_utf8(&candidate).is_err() {
8077 return Err(LinkError::NotUtf8 {
8078 path: source.display().to_string(),
8079 });
8080 }
8081 store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
8082 }
8083 let refreshed_store =
8084 Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8085 message: format!("resolved checkout is not a valid db.md store: {error}"),
8086 })?;
8087 let mut overrides = std::collections::BTreeMap::new();
8088 for file in &plan.files {
8089 let selected_local = match refreshed_store
8090 .regular_file_exists(Path::new(&file.path))?
8091 {
8092 true => Some(content_sha256(
8093 &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
8094 )),
8095 false => None,
8096 };
8097 overrides.insert(
8098 file.path.clone(),
8099 V2ResolutionOverride {
8100 expected_remote: file.remote.sha256.clone(),
8101 selected_local,
8102 },
8103 );
8104 }
8105 v2_sync_push(
8106 cfg,
8107 &plan.brain,
8108 &refreshed_store,
8109 head,
8110 V2SyncPushOptions {
8111 resume_local_policy: true,
8112 bulk_confirmation,
8113 resolution: Some(&overrides),
8114 pulled: None,
8115 withdrawal_paths: &[],
8116 withdrawal_reason: None,
8117 },
8118 )?
8119 }
8120 };
8121
8122 if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
8123 let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8124 message: format!("resolved checkout is not a valid db.md store: {error}"),
8125 })?;
8126 installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8127 }
8128 Ok(json!({
8129 "v": 2,
8130 "class": "auto_converged",
8131 "bundle": bundle,
8132 "receipt": result,
8133 }))
8134}
8135
8136pub fn sync_converge(
8147 cfg: &HubConfig,
8148 brain: &str,
8149 checkout: &Path,
8150 resume_local_policy: bool,
8151) -> LinkResult<Value> {
8152 sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
8153}
8154
8155pub fn sync_converge_with_options(
8157 cfg: &HubConfig,
8158 brain: &str,
8159 checkout: &Path,
8160 resume_local_policy: bool,
8161 bulk_confirmation: Option<&V2BulkConfirmation>,
8162) -> LinkResult<Value> {
8163 sync_converge_with_controls(
8164 cfg,
8165 brain,
8166 checkout,
8167 resume_local_policy,
8168 bulk_confirmation,
8169 &[],
8170 None,
8171 )
8172}
8173
8174pub fn sync_converge_with_controls(
8176 cfg: &HubConfig,
8177 brain: &str,
8178 checkout: &Path,
8179 resume_local_policy: bool,
8180 bulk_confirmation: Option<&V2BulkConfirmation>,
8181 withdrawal_paths: &[String],
8182 withdrawal_reason: Option<&str>,
8183) -> LinkResult<Value> {
8184 require_hardened_filesystem("bidirectional sync")?;
8185 require_safe_ref(brain)?;
8186 let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
8187 message:
8188 "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
8189 .to_string(),
8190 })?;
8191 let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
8192 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8193 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
8194 })?;
8195 let _transaction = store.transaction()?;
8196 let pulled_report = pulled.report.clone();
8197 let pulled_head = pulled.head.clone();
8198 let mut result = v2_sync_push(
8199 cfg,
8200 brain,
8201 &store,
8202 pulled_head,
8203 V2SyncPushOptions {
8204 resume_local_policy,
8205 bulk_confirmation,
8206 resolution: None,
8207 pulled: Some(pulled),
8208 withdrawal_paths,
8209 withdrawal_reason,
8210 },
8211 )?;
8212 if let Some(object) = result.as_object_mut() {
8213 object.insert("pulled_files".to_string(), json!(pulled_report.files));
8214 object.insert("checkout".to_string(), Value::String(pulled_report.dest));
8215 object.insert(
8216 "mode".to_string(),
8217 Value::String("bidirectional".to_string()),
8218 );
8219 }
8220 Ok(result)
8221}
8222
8223pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
8229 require_hardened_filesystem("sync pull")?;
8230 require_safe_ref(brain)?;
8231 if let Some(head) = v2_verified_head(cfg, brain)? {
8232 return v2_sync_pull(cfg, brain, head, out);
8233 }
8234 legacy_sync_pull(cfg, brain, out)
8235}
8236
8237#[cfg(windows)]
8238fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
8239 Err(LinkError::UnsupportedPlatform {
8240 operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
8241 })
8242}
8243
8244#[cfg(not(windows))]
8245fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
8246 let remote = verified_remote_head(cfg, brain, false)?;
8247 if !remote.head.verified {
8248 return Err(invalid_feed(
8249 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
8250 ));
8251 }
8252 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
8253 let path = format!(
8254 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
8255 remote.head.seq
8256 );
8257 let body = ensure_ok(
8258 request(cfg, "GET", &path, None, Auth::Required)?,
8259 "sync pull",
8260 )?;
8261 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
8262 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
8263 {
8264 return Err(invalid_feed(
8265 "export response is not bound to the verified snapshot token",
8266 ));
8267 }
8268
8269 let remote_slug = body
8270 .get("slug")
8271 .and_then(Value::as_str)
8272 .filter(|slug| is_safe_slug(slug));
8273 let slug = remote_slug
8274 .or_else(|| is_safe_slug(brain).then_some(brain))
8275 .unwrap_or("brain")
8276 .to_string();
8277 let brain_id = body
8278 .get("brain")
8279 .and_then(Value::as_str)
8280 .unwrap_or(&remote.head.brain)
8281 .to_string();
8282 if brain_id != remote.head.brain {
8283 return Err(invalid_feed(
8284 "export response names a different brain than the verified head",
8285 ));
8286 }
8287 let head_seq = remote.head.seq;
8288 let dest: PathBuf = match out {
8289 Some(p) => p.to_path_buf(),
8290 None => PathBuf::from(&slug),
8291 };
8292 let entries = if head_seq == 0 {
8293 let files = body
8294 .get("files")
8295 .and_then(Value::as_array)
8296 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
8297 if !files.is_empty() || body.get("url").is_some() {
8298 return Err(invalid_feed(
8299 "empty signed feed cannot authorize non-empty exported content",
8300 ));
8301 }
8302 Vec::new()
8303 } else {
8304 let signed_head = remote
8305 .head_entry
8306 .as_ref()
8307 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
8308 let expected = &signed_head.entry.pack_sha256;
8309 if !is_sha256(expected) {
8310 return Err(invalid_feed(
8311 "signed head carries an invalid snapshot pack digest",
8312 ));
8313 }
8314 if let Some(url) = body.get("url").and_then(Value::as_str) {
8315 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
8316 return Err(invalid_feed(
8317 "export pack digest does not match the signed head entry",
8318 ));
8319 }
8320 let bytes = get_presigned(cfg, url)?;
8321 let actual = format!("{:x}", Sha256::digest(&bytes));
8322 if actual != *expected {
8323 return Err(LinkError::InvalidPack {
8324 message: "downloaded pack does not match the signed snapshot digest"
8325 .to_string(),
8326 });
8327 }
8328 let entries = parse_store_pack(bytes)?;
8329 if signed_head.entry.kind == "push" {
8330 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
8331 }
8332 entries
8333 } else {
8334 if signed_head.entry.kind != "push" {
8335 return Err(invalid_feed(
8336 "delta snapshots must export the exact signed pack",
8337 ));
8338 }
8339 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
8340 invalid_feed("verified snapshot export carried neither a pack nor files")
8341 })?;
8342 let mut entries = Vec::with_capacity(files.len());
8343 for file in files {
8344 let path = file
8345 .get("path")
8346 .and_then(Value::as_str)
8347 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
8348 let content = file
8349 .get("content")
8350 .and_then(Value::as_str)
8351 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
8352 entries.push((path.to_string(), content.as_bytes().to_vec()));
8353 }
8354 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
8355 entries
8356 }
8357 };
8358
8359 let mut seen = std::collections::HashSet::new();
8361 for (path, _) in &entries {
8362 if !safe_store_rel_path(path) {
8363 return Err(LinkError::UnsafePath { path: path.clone() });
8364 }
8365 if !seen.insert(path) {
8366 return Err(LinkError::InvalidPack {
8367 message: format!("duplicate path `{path}`"),
8368 });
8369 }
8370 }
8371 let pulled: std::collections::BTreeSet<&str> =
8374 entries.iter().map(|(p, _)| p.as_str()).collect();
8375 let mut extra_local = Vec::new();
8376 if let Ok(store) = Store::open(&dest) {
8377 if let Ok(walked) = store.walk() {
8378 for rel in walked {
8379 let rel_str = rel.to_string_lossy().replace('\\', "/");
8380 if !pulled.contains(rel_str.as_str()) {
8381 extra_local.push(rel_str);
8382 }
8383 }
8384 }
8385 }
8386 #[cfg(unix)]
8387 install_pulled_snapshot(&dest, &entries)?;
8388
8389 Ok(PullReport {
8390 brain: brain_id,
8391 slug,
8392 head_seq,
8393 files: entries.len(),
8394 dest: dest.to_string_lossy().into_owned(),
8395 extra_local,
8396 sync_status: "synced".to_string(),
8397 })
8398}
8399
8400#[cfg(unix)]
8401fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
8402 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
8403 path: display.to_string(),
8404 })
8405}
8406
8407#[cfg(unix)]
8408fn open_dir_at(
8409 parent: std::os::fd::RawFd,
8410 name: &std::ffi::CStr,
8411 display: &str,
8412) -> LinkResult<std::fs::File> {
8413 use std::os::fd::FromRawFd as _;
8414 let fd = unsafe {
8415 libc::openat(
8416 parent,
8417 name.as_ptr(),
8418 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8419 )
8420 };
8421 if fd < 0 {
8422 return Err(LinkError::UnsafePath {
8423 path: display.to_string(),
8424 });
8425 }
8426 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
8427}
8428
8429#[cfg(unix)]
8433fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
8434 use std::os::fd::AsRawFd as _;
8435
8436 #[cfg(target_os = "macos")]
8440 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
8441 .into_iter()
8442 .find_map(|(alias, real)| {
8443 path.strip_prefix(alias)
8444 .ok()
8445 .map(|rest| Path::new(real).join(rest))
8446 })
8447 .unwrap_or_else(|| path.to_path_buf());
8448 #[cfg(not(target_os = "macos"))]
8449 let normalized = path.to_path_buf();
8450
8451 let start = if normalized.is_absolute() {
8452 std::fs::File::open("/")?
8453 } else {
8454 std::fs::File::open(".")?
8455 };
8456 let mut directory = start;
8457 for component in normalized.components() {
8458 use std::path::Component;
8459 let name = match component {
8460 Component::RootDir | Component::CurDir => continue,
8461 Component::Normal(name) => name,
8462 Component::ParentDir | Component::Prefix(_) => {
8463 return Err(LinkError::UnsafePath {
8464 path: path.display().to_string(),
8465 });
8466 }
8467 };
8468 use std::os::unix::ffi::OsStrExt as _;
8469 let name = c_name(name.as_bytes(), &path.display().to_string())?;
8470 if create {
8471 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8472 if made != 0 {
8473 let error = std::io::Error::last_os_error();
8474 if error.raw_os_error() != Some(libc::EEXIST) {
8475 return Err(error.into());
8476 }
8477 }
8478 }
8479 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
8480 }
8481 Ok(directory)
8482}
8483
8484#[cfg(unix)]
8485fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
8486 open_dir_path_nofollow(path, true)
8487}
8488
8489#[cfg(unix)]
8490fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
8491 open_dir_path_nofollow(path, false)
8492}
8493
8494#[cfg(unix)]
8495fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
8496 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
8497 let result =
8498 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
8499 if result == 0 {
8500 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
8501 }
8502 let error = std::io::Error::last_os_error();
8503 if error.kind() == std::io::ErrorKind::NotFound {
8504 Ok(None)
8505 } else {
8506 Err(error.into())
8507 }
8508}
8509
8510#[cfg(unix)]
8511fn create_dir_exclusive_at(
8512 parent: std::os::fd::RawFd,
8513 name: &std::ffi::CStr,
8514 display: &str,
8515) -> LinkResult<std::fs::File> {
8516 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
8517 if made != 0 {
8518 return Err(LinkError::UnsafePath {
8519 path: display.to_string(),
8520 });
8521 }
8522 open_dir_at(parent, name, display)
8523}
8524
8525#[cfg(unix)]
8526fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
8527 use std::os::fd::AsRawFd as _;
8528
8529 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
8530 if duplicate < 0 {
8531 return Err(std::io::Error::last_os_error().into());
8532 }
8533 let stream = unsafe { libc::fdopendir(duplicate) };
8534 if stream.is_null() {
8535 let error = std::io::Error::last_os_error();
8536 unsafe {
8537 libc::close(duplicate);
8538 }
8539 return Err(error.into());
8540 }
8541 let mut names = Vec::new();
8542 loop {
8543 let entry = unsafe { libc::readdir(stream) };
8544 if entry.is_null() {
8545 break;
8546 }
8547 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
8548 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
8549 names.push(raw.to_owned());
8550 }
8551 }
8552 if unsafe { libc::closedir(stream) } != 0 {
8553 return Err(std::io::Error::last_os_error().into());
8554 }
8555 Ok(names)
8556}
8557
8558#[cfg(unix)]
8561fn remove_tree_at(
8562 parent: std::os::fd::RawFd,
8563 name: &std::ffi::CStr,
8564 display: &str,
8565) -> LinkResult<()> {
8566 use std::os::fd::AsRawFd as _;
8567
8568 match entry_is_dir_at(parent, name)? {
8569 None => return Ok(()),
8570 Some(false) => {
8571 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
8572 return Err(std::io::Error::last_os_error().into());
8573 }
8574 }
8575 Some(true) => {
8576 let directory = open_dir_at(parent, name, display)?;
8577 for child in directory_entry_names(&directory)? {
8578 let child_display =
8579 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
8580 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
8581 }
8582 drop(directory);
8583 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
8584 return Err(std::io::Error::last_os_error().into());
8585 }
8586 }
8587 }
8588 Ok(())
8589}
8590
8591#[cfg(unix)]
8595fn clone_tree_contents(
8596 source: &std::fs::File,
8597 destination: &std::fs::File,
8598 display: &str,
8599) -> LinkResult<()> {
8600 use std::os::fd::{AsRawFd as _, FromRawFd as _};
8601
8602 for name in directory_entry_names(source)? {
8603 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
8604 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
8605 if unsafe {
8606 libc::fstatat(
8607 source.as_raw_fd(),
8608 name.as_ptr(),
8609 &mut stat,
8610 libc::AT_SYMLINK_NOFOLLOW,
8611 )
8612 } != 0
8613 {
8614 return Err(std::io::Error::last_os_error().into());
8615 }
8616 match stat.st_mode & libc::S_IFMT {
8617 libc::S_IFDIR => {
8618 if unsafe {
8619 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
8620 } != 0
8621 {
8622 return Err(std::io::Error::last_os_error().into());
8623 }
8624 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
8625 let destination_child =
8626 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
8627 clone_tree_contents(&source_child, &destination_child, &child_display)?;
8628 destination_child.sync_all()?;
8629 }
8630 libc::S_IFREG => {
8631 let source_fd = unsafe {
8632 libc::openat(
8633 source.as_raw_fd(),
8634 name.as_ptr(),
8635 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8636 )
8637 };
8638 if source_fd < 0 {
8639 return Err(std::io::Error::last_os_error().into());
8640 }
8641 let destination_fd = unsafe {
8642 libc::openat(
8643 destination.as_raw_fd(),
8644 name.as_ptr(),
8645 libc::O_WRONLY
8646 | libc::O_CREAT
8647 | libc::O_EXCL
8648 | libc::O_CLOEXEC
8649 | libc::O_NOFOLLOW,
8650 (stat.st_mode & 0o777) as libc::c_uint,
8651 )
8652 };
8653 if destination_fd < 0 {
8654 unsafe {
8655 libc::close(source_fd);
8656 }
8657 return Err(std::io::Error::last_os_error().into());
8658 }
8659 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
8660 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
8661 std::io::copy(&mut input, &mut output)?;
8662 output.sync_all()?;
8663 }
8664 libc::S_IFLNK => {
8665 let mut target = vec![0_u8; 4097];
8666 let length = unsafe {
8667 libc::readlinkat(
8668 source.as_raw_fd(),
8669 name.as_ptr(),
8670 target.as_mut_ptr().cast(),
8671 target.len(),
8672 )
8673 };
8674 if length < 0 || length as usize >= target.len() {
8675 return Err(LinkError::UnsafePath {
8676 path: child_display,
8677 });
8678 }
8679 target.truncate(length as usize);
8680 let target = c_name(&target, &child_display)?;
8681 if unsafe {
8682 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
8683 } != 0
8684 {
8685 return Err(std::io::Error::last_os_error().into());
8686 }
8687 }
8688 _ => {
8689 return Err(LinkError::UnsafePath {
8690 path: child_display,
8691 });
8692 }
8693 }
8694 }
8695 destination.sync_all()?;
8696 Ok(())
8697}
8698
8699#[cfg(target_os = "linux")]
8700fn install_stage_at(
8701 parent: std::os::fd::RawFd,
8702 stage: &std::ffi::CStr,
8703 dest: &std::ffi::CStr,
8704 dest_exists: bool,
8705) -> LinkResult<()> {
8706 let flags = if dest_exists {
8707 libc::RENAME_EXCHANGE
8708 } else {
8709 libc::RENAME_NOREPLACE
8710 };
8711 let result = unsafe {
8715 libc::syscall(
8716 libc::SYS_renameat2,
8717 parent,
8718 stage.as_ptr(),
8719 parent,
8720 dest.as_ptr(),
8721 flags,
8722 )
8723 };
8724 if result == 0 {
8725 Ok(())
8726 } else {
8727 Err(std::io::Error::last_os_error().into())
8728 }
8729}
8730
8731#[cfg(target_os = "macos")]
8732fn install_stage_at(
8733 parent: std::os::fd::RawFd,
8734 stage: &std::ffi::CStr,
8735 dest: &std::ffi::CStr,
8736 dest_exists: bool,
8737) -> LinkResult<()> {
8738 let flags = if dest_exists {
8739 libc::RENAME_SWAP
8740 } else {
8741 libc::RENAME_EXCL
8742 };
8743 let result =
8744 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
8745 if result == 0 {
8746 Ok(())
8747 } else {
8748 Err(std::io::Error::last_os_error().into())
8749 }
8750}
8751
8752#[cfg(unix)]
8753fn write_pull_entries_beneath_dir(
8754 root: &std::fs::File,
8755 entries: &[(String, Vec<u8>)],
8756) -> LinkResult<()> {
8757 use std::os::fd::{AsRawFd as _, FromRawFd as _};
8758
8759 for (path, content) in entries {
8760 let components: Vec<&str> = path.split('/').collect();
8761 let (leaf, parents) = components
8762 .split_last()
8763 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8764 let mut directory = root.try_clone()?;
8765 for component in parents {
8766 let name = c_name(component.as_bytes(), path)?;
8767 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8768 if made != 0 {
8769 let error = std::io::Error::last_os_error();
8770 if error.raw_os_error() != Some(libc::EEXIST) {
8771 return Err(error.into());
8772 }
8773 }
8774 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8775 }
8776
8777 let leaf_name = c_name(leaf.as_bytes(), path)?;
8778 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
8779 let inspected = unsafe {
8780 libc::fstatat(
8781 directory.as_raw_fd(),
8782 leaf_name.as_ptr(),
8783 &mut existing,
8784 libc::AT_SYMLINK_NOFOLLOW,
8785 )
8786 };
8787 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
8788 return Err(LinkError::UnsafePath { path: path.clone() });
8789 }
8790
8791 let nonce = std::time::SystemTime::now()
8792 .duration_since(std::time::UNIX_EPOCH)
8793 .unwrap_or_default()
8794 .as_nanos();
8795 let temp_name = format!(
8796 ".dbmd-pull-{}-{nonce}-{}",
8797 std::process::id(),
8798 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
8799 );
8800 let temp = c_name(temp_name.as_bytes(), path)?;
8801 let fd = unsafe {
8802 libc::openat(
8803 directory.as_raw_fd(),
8804 temp.as_ptr(),
8805 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8806 0o600,
8807 )
8808 };
8809 if fd < 0 {
8810 return Err(std::io::Error::last_os_error().into());
8811 }
8812 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
8813 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
8814 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8815 return Err(error.into());
8816 }
8817 drop(file);
8818 let renamed = unsafe {
8819 libc::renameat(
8820 directory.as_raw_fd(),
8821 temp.as_ptr(),
8822 directory.as_raw_fd(),
8823 leaf_name.as_ptr(),
8824 )
8825 };
8826 if renamed != 0 {
8827 let error = std::io::Error::last_os_error();
8828 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8829 return Err(error.into());
8830 }
8831 directory.sync_all()?;
8832 }
8833 root.sync_all()?;
8834 Ok(())
8835}
8836
8837#[cfg(unix)]
8838fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
8839 use std::os::fd::{AsRawFd as _, FromRawFd as _};
8840
8841 let path = &entry.path;
8842 let components: Vec<&str> = path.split('/').collect();
8843 let (leaf, parents) = components
8844 .split_last()
8845 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8846 let mut directory = root.try_clone()?;
8847 for component in parents {
8848 let name = c_name(component.as_bytes(), path)?;
8849 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
8850 if made != 0 {
8851 let error = std::io::Error::last_os_error();
8852 if error.raw_os_error() != Some(libc::EEXIST) {
8853 return Err(error.into());
8854 }
8855 }
8856 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
8857 }
8858 let leaf_name = c_name(leaf.as_bytes(), path)?;
8859 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
8860 if unsafe {
8861 libc::fstatat(
8862 directory.as_raw_fd(),
8863 leaf_name.as_ptr(),
8864 &mut existing,
8865 libc::AT_SYMLINK_NOFOLLOW,
8866 )
8867 } == 0
8868 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
8869 {
8870 return Err(LinkError::UnsafePath { path: path.clone() });
8871 }
8872 let nonce = SystemTime::now()
8873 .duration_since(UNIX_EPOCH)
8874 .unwrap_or_default()
8875 .as_nanos();
8876 let temp_name = format!(
8877 ".dbmd-pull-{}-{nonce}-{}",
8878 std::process::id(),
8879 content_sha256(path.as_bytes())
8880 );
8881 let temp = c_name(temp_name.as_bytes(), path)?;
8882 let fd = unsafe {
8883 libc::openat(
8884 directory.as_raw_fd(),
8885 temp.as_ptr(),
8886 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8887 0o600,
8888 )
8889 };
8890 if fd < 0 {
8891 return Err(std::io::Error::last_os_error().into());
8892 }
8893 let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
8894 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
8895 let mut digest = Sha256::new();
8896 let mut total = 0_u64;
8897 let mut buffer = [0_u8; 64 * 1024];
8898 let copied = (|| -> std::io::Result<()> {
8899 loop {
8900 let read = input.read(&mut buffer)?;
8901 if read == 0 {
8902 break;
8903 }
8904 total = total.saturating_add(read as u64);
8905 if total > entry.bytes {
8906 return Err(std::io::Error::new(
8907 std::io::ErrorKind::InvalidData,
8908 "staged sync source grew beyond its verified length",
8909 ));
8910 }
8911 digest.update(&buffer[..read]);
8912 output.write_all(&buffer[..read])?;
8913 }
8914 Ok(())
8915 })();
8916 if let Err(error) = copied {
8917 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8918 return Err(error.into());
8919 }
8920 drop(output);
8921 if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
8922 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8923 return Err(invalid_feed(
8924 "private staged sync source failed final integrity verification",
8925 ));
8926 }
8927 if unsafe {
8928 libc::renameat(
8929 directory.as_raw_fd(),
8930 temp.as_ptr(),
8931 directory.as_raw_fd(),
8932 leaf_name.as_ptr(),
8933 )
8934 } != 0
8935 {
8936 let error = std::io::Error::last_os_error();
8937 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
8938 return Err(error.into());
8939 }
8940 Ok(())
8941}
8942
8943#[cfg(unix)]
8944fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
8945 use std::os::fd::{AsRawFd as _, FromRawFd as _};
8946
8947 let path = &entry.path;
8948 let components: Vec<&str> = path.split('/').collect();
8949 let (leaf, parents) = components
8950 .split_last()
8951 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
8952 let mut directory = root.try_clone()?;
8953 for component in parents {
8954 directory = open_dir_at(
8955 directory.as_raw_fd(),
8956 &c_name(component.as_bytes(), path)?,
8957 path,
8958 )?;
8959 }
8960 let leaf = c_name(leaf.as_bytes(), path)?;
8961 let fd = unsafe {
8962 libc::openat(
8963 directory.as_raw_fd(),
8964 leaf.as_ptr(),
8965 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8966 )
8967 };
8968 if fd < 0 {
8969 return Err(std::io::Error::last_os_error().into());
8970 }
8971 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
8972 if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
8973 return Err(invalid_feed(
8974 "private pull stage changed before its durability barrier",
8975 ));
8976 }
8977 file.sync_all()?;
8978 Ok(())
8979}
8980
8981#[cfg(unix)]
8982fn run_pull_source_workers(
8983 root: &std::fs::File,
8984 entries: &[V2StagedFile],
8985 operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
8986) -> LinkResult<()> {
8987 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
8988
8989 let next = AtomicUsize::new(0);
8990 let failed = AtomicBool::new(false);
8991 let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
8992 let mut first_error = None;
8993 std::thread::scope(|scope| {
8994 let (sender, receiver) = std::sync::mpsc::channel();
8995 for _ in 0..worker_count {
8996 let sender = sender.clone();
8997 let next = &next;
8998 let failed = &failed;
8999 scope.spawn(move || {
9000 while !failed.load(Ordering::Acquire) {
9001 let index = next.fetch_add(1, Ordering::Relaxed);
9002 let Some(entry) = entries.get(index) else {
9003 break;
9004 };
9005 let result = operation(root, entry);
9006 if result.is_err() {
9007 failed.store(true, Ordering::Release);
9008 }
9009 if sender.send(result).is_err() {
9010 break;
9011 }
9012 }
9013 });
9014 }
9015 drop(sender);
9016 for result in receiver {
9017 if let Err(error) = result {
9018 if first_error.is_none() {
9019 first_error = Some(error);
9020 }
9021 }
9022 }
9023 });
9024 if let Some(error) = first_error {
9025 return Err(error);
9026 }
9027 if next.load(Ordering::Relaxed) < entries.len() {
9028 return Err(invalid_feed(
9029 "a bounded pull worker stopped before reporting every file",
9030 ));
9031 }
9032 Ok(())
9033}
9034
9035#[cfg(unix)]
9036fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9037 use std::os::fd::AsRawFd as _;
9038
9039 for name in directory_entry_names(root)? {
9040 if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9041 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9042 let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9043 sync_pull_directory_tree(&child, &child_display)?;
9044 }
9045 }
9046 root.sync_all()?;
9047 Ok(())
9048}
9049
9050#[cfg(unix)]
9051fn write_pull_sources_beneath_dir(
9052 root: &std::fs::File,
9053 entries: &[V2StagedFile],
9054) -> LinkResult<()> {
9055 run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
9062 run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
9063 sync_pull_directory_tree(root, "v2 pull stage")
9064}
9065
9066#[cfg(unix)]
9067fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
9068 use std::os::fd::AsRawFd as _;
9069 for path in paths {
9070 if !safe_store_rel_path(path) {
9071 return Err(LinkError::UnsafePath { path: path.clone() });
9072 }
9073 let components = path.split('/').collect::<Vec<_>>();
9074 let Some((leaf, parents)) = components.split_last() else {
9075 return Err(LinkError::UnsafePath { path: path.clone() });
9076 };
9077 let mut directory = root.try_clone()?;
9078 let mut missing = false;
9079 for component in parents {
9080 let name = c_name(component.as_bytes(), path)?;
9081 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
9082 None => {
9083 missing = true;
9084 break;
9085 }
9086 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
9087 Some(true) => {
9088 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9089 }
9090 }
9091 }
9092 if missing {
9093 continue;
9094 }
9095 let leaf = c_name(leaf.as_bytes(), path)?;
9096 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
9097 None => {}
9098 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
9099 Some(false) => {
9100 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
9101 return Err(std::io::Error::last_os_error().into());
9102 }
9103 directory.sync_all()?;
9104 }
9105 }
9106 }
9107 Ok(())
9108}
9109
9110#[cfg(unix)]
9111fn install_pulled_delta(
9112 dest: &Path,
9113 entries: &[(String, Vec<u8>)],
9114 deleted: &[String],
9115 rebuild_indexes: bool,
9116) -> LinkResult<()> {
9117 use ring::rand::SecureRandom as _;
9118 use std::os::fd::AsRawFd as _;
9119 use std::os::unix::ffi::OsStrExt as _;
9120
9121 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9122 let name = dest
9123 .file_name()
9124 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9125 .ok_or_else(|| LinkError::UnsafePath {
9126 path: dest.display().to_string(),
9127 })?;
9128 let parent_dir = open_or_create_dir_nofollow(parent)?;
9129 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9130 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9131 None => false,
9132 Some(true) => true,
9133 Some(false) => {
9134 return Err(LinkError::UnsafePath {
9135 path: dest.display().to_string(),
9136 });
9137 }
9138 };
9139
9140 let mut nonce = [0_u8; 16];
9141 ring::rand::SystemRandom::new()
9142 .fill(&mut nonce)
9143 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9144 let stage_label = format!(
9145 ".{}.dbmd-pull-stage-{}",
9146 name.to_string_lossy(),
9147 URL_SAFE_NO_PAD.encode(nonce)
9148 );
9149 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9150 let stage_dir = create_dir_exclusive_at(
9151 parent_dir.as_raw_fd(),
9152 &stage_name,
9153 &dest.display().to_string(),
9154 )?;
9155
9156 let prepared = (|| -> LinkResult<()> {
9157 if dest_exists {
9158 let live = open_dir_at(
9159 parent_dir.as_raw_fd(),
9160 &dest_name,
9161 &dest.display().to_string(),
9162 )?;
9163 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9164 }
9165 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9166 write_pull_entries_beneath_dir(&stage_dir, entries)?;
9167 if rebuild_indexes {
9168 let stage_store =
9169 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
9170 .map_err(|error| LinkError::InvalidPack {
9171 message: format!("v2 staging tree is not a valid db.md store: {error}"),
9172 })?;
9173 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
9174 LinkError::InvalidPack {
9175 message: format!("could not materialize v2 local catalogs: {error}"),
9176 }
9177 })?;
9178 }
9179 stage_dir.sync_all()?;
9180 Ok(())
9181 })();
9182 if let Err(error) = prepared {
9183 let _ = remove_tree_at(
9184 parent_dir.as_raw_fd(),
9185 &stage_name,
9186 &dest.display().to_string(),
9187 );
9188 return Err(error);
9189 }
9190
9191 if let Err(error) =
9192 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
9193 {
9194 let _ = remove_tree_at(
9195 parent_dir.as_raw_fd(),
9196 &stage_name,
9197 &dest.display().to_string(),
9198 );
9199 return Err(error);
9200 }
9201 parent_dir.sync_all()?;
9202 if dest_exists {
9203 let _ = remove_tree_at(
9207 parent_dir.as_raw_fd(),
9208 &stage_name,
9209 &dest.display().to_string(),
9210 );
9211 let _ = parent_dir.sync_all();
9212 }
9213 Ok(())
9214}
9215
9216#[cfg(unix)]
9217fn install_pulled_delta_sources(
9218 dest: &Path,
9219 entries: &[V2StagedFile],
9220 deleted: &[String],
9221 rebuild_indexes: bool,
9222 _previous: Option<&V2SyncBaseline>,
9223 _next: &V2VerifiedHead,
9224) -> LinkResult<()> {
9225 use ring::rand::SecureRandom as _;
9226 use std::os::fd::AsRawFd as _;
9227 use std::os::unix::ffi::OsStrExt as _;
9228
9229 if let Ok(store) = Store::open_strict(dest) {
9233 return install_established_v2_delta(
9234 store,
9235 entries,
9236 deleted,
9237 rebuild_indexes,
9238 _previous,
9239 _next,
9240 );
9241 }
9242
9243 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9244 let name = dest
9245 .file_name()
9246 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9247 .ok_or_else(|| LinkError::UnsafePath {
9248 path: dest.display().to_string(),
9249 })?;
9250 let parent_dir = open_or_create_dir_nofollow(parent)?;
9251 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9252 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9253 None => false,
9254 Some(true) => true,
9255 Some(false) => {
9256 return Err(LinkError::UnsafePath {
9257 path: dest.display().to_string(),
9258 })
9259 }
9260 };
9261 let mut nonce = [0_u8; 16];
9262 ring::rand::SystemRandom::new()
9263 .fill(&mut nonce)
9264 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9265 let stage_label = format!(
9266 ".{}.dbmd-pull-stage-{}",
9267 name.to_string_lossy(),
9268 URL_SAFE_NO_PAD.encode(nonce)
9269 );
9270 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9271 let stage_dir = create_dir_exclusive_at(
9272 parent_dir.as_raw_fd(),
9273 &stage_name,
9274 &dest.display().to_string(),
9275 )?;
9276 let prepared = (|| -> LinkResult<()> {
9277 if dest_exists {
9278 let live = open_dir_at(
9279 parent_dir.as_raw_fd(),
9280 &dest_name,
9281 &dest.display().to_string(),
9282 )?;
9283 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9284 }
9285 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9286 write_pull_sources_beneath_dir(&stage_dir, entries)?;
9287 if rebuild_indexes {
9288 let stage_store =
9289 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
9290 .map_err(|error| LinkError::InvalidPack {
9291 message: format!("v2 staging tree is not a valid db.md store: {error}"),
9292 })?;
9293 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
9294 LinkError::InvalidPack {
9295 message: format!("could not materialize v2 local catalogs: {error}"),
9296 }
9297 })?;
9298 }
9299 stage_dir.sync_all()?;
9300 Ok(())
9301 })();
9302 if let Err(error) = prepared {
9303 let _ = remove_tree_at(
9304 parent_dir.as_raw_fd(),
9305 &stage_name,
9306 &dest.display().to_string(),
9307 );
9308 return Err(error);
9309 }
9310 if let Err(error) =
9311 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
9312 {
9313 let _ = remove_tree_at(
9314 parent_dir.as_raw_fd(),
9315 &stage_name,
9316 &dest.display().to_string(),
9317 );
9318 return Err(error);
9319 }
9320 parent_dir.sync_all()?;
9321 if dest_exists {
9322 let _ = remove_tree_at(
9323 parent_dir.as_raw_fd(),
9324 &stage_name,
9325 &dest.display().to_string(),
9326 );
9327 let _ = parent_dir.sync_all();
9328 }
9329 Ok(())
9330}
9331
9332#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9333struct V2PullCoordinate {
9334 head_seq: Option<u64>,
9335 commit_hash: Option<String>,
9336 view_kind: Option<String>,
9337 view_revision: Option<String>,
9338}
9339
9340#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9341struct V2PullFileCoordinate {
9342 sha256: String,
9343 bytes: u64,
9344}
9345
9346#[derive(Debug, Clone, Deserialize, Serialize)]
9347struct V2PullJournalEntry {
9348 path: String,
9349 old: Option<V2PullFileCoordinate>,
9350 new: Option<V2PullFileCoordinate>,
9351 backup: Option<String>,
9352}
9353
9354#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9355#[serde(rename_all = "snake_case")]
9356enum V2PullPhase {
9357 Preparing,
9358 Ready,
9359}
9360
9361#[derive(Debug, Clone, Deserialize, Serialize)]
9362struct V2PullJournal {
9363 v: u8,
9364 phase: V2PullPhase,
9365 brain: String,
9366 previous: V2PullCoordinate,
9367 next: V2PullCoordinate,
9368 backup_dir: String,
9369 entries: Vec<V2PullJournalEntry>,
9370}
9371
9372const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
9373
9374fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
9375 V2PullCoordinate {
9376 head_seq: baseline.and_then(|value| value.head_seq),
9377 commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
9378 view_kind: baseline.and_then(|value| value.view_kind.clone()),
9379 view_revision: baseline.and_then(|value| value.view_revision.clone()),
9380 }
9381}
9382
9383fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
9384 V2PullCoordinate {
9385 head_seq: head.pointer.as_ref().map(|value| value.seq),
9386 commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
9387 view_kind: Some(head.view_kind.clone()),
9388 view_revision: Some(head.view_revision.clone()),
9389 }
9390}
9391
9392fn v2_pull_file_coordinate(
9393 store: &Store,
9394 path: &str,
9395 limit: u64,
9396) -> LinkResult<Option<V2PullFileCoordinate>> {
9397 let file = match store.open_regular(Path::new(path)) {
9398 Ok(file) => file,
9399 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9400 Err(error) => return Err(error.into()),
9401 };
9402 let bytes = file.metadata()?.len();
9403 if bytes > limit || bytes > MAX_STORE_BYTES {
9404 return Err(invalid_feed(
9405 "pull transaction file exceeds its declared bound",
9406 ));
9407 }
9408 Ok(Some(V2PullFileCoordinate {
9409 sha256: content_sha256_reader(file)?,
9410 bytes,
9411 }))
9412}
9413
9414fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
9415 let mut bytes = serde_json::to_vec_pretty(journal)
9416 .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
9417 bytes.push(b'\n');
9418 Ok(bytes)
9419}
9420
9421fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
9422 let backup_prefix = ".dbmd/pull-backup-";
9423 let suffix = journal
9424 .backup_dir
9425 .strip_prefix(backup_prefix)
9426 .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
9427 let mut paths = std::collections::BTreeSet::new();
9428 if journal.v != 1
9429 || !crate::ulid::is_ulid(&journal.brain)
9430 || !crate::ulid::is_ulid(suffix)
9431 || journal.entries.is_empty()
9432 || journal.entries.len() > MAX_PUSH_FILES + 4
9433 || journal.previous == journal.next
9434 {
9435 return Err(invalid_feed("v2 pull journal failed validation"));
9436 }
9437 for (index, entry) in journal.entries.iter().enumerate() {
9438 if !safe_store_rel_path(&entry.path)
9439 || entry.path == V2_PULL_JOURNAL
9440 || entry.path.starts_with(backup_prefix)
9441 || !paths.insert(entry.path.clone())
9442 || (entry.old.is_none() && entry.new.is_none())
9443 || entry
9444 .old
9445 .iter()
9446 .chain(entry.new.iter())
9447 .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
9448 || entry.backup.as_deref()
9449 != entry
9450 .old
9451 .as_ref()
9452 .map(|_| format!("{index:08x}"))
9453 .as_deref()
9454 {
9455 return Err(invalid_feed("v2 pull journal entry failed validation"));
9456 }
9457 }
9458 Ok(())
9459}
9460
9461fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
9462 #[cfg(unix)]
9463 {
9464 use std::os::unix::fs::PermissionsExt as _;
9465 match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
9466 Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
9467 return Err(invalid_feed(
9468 "v2 pull journal is accessible to group/other; set mode 0600",
9469 ));
9470 }
9471 Ok(_) => {}
9472 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9473 Err(error) => return Err(error.into()),
9474 }
9475 }
9476 let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
9477 Ok(bytes) => bytes,
9478 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9479 Err(error) => return Err(error.into()),
9480 };
9481 let journal: V2PullJournal =
9482 serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
9483 validate_v2_pull_journal(&journal)?;
9484 Ok(Some(journal))
9485}
9486
9487fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
9488 match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
9492 Ok(()) => {}
9493 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
9494 Err(error) => return Err(error.into()),
9495 }
9496 match store.remove_private_tree(Path::new(&journal.backup_dir)) {
9497 Ok(()) => Ok(()),
9498 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
9499 Err(error) => Err(error.into()),
9500 }
9501}
9502
9503fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
9504 let names = match store.directory_names(Path::new(".dbmd")) {
9505 Ok(names) => names,
9506 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
9507 Err(error) => return Err(error.into()),
9508 };
9509 for name in names {
9510 let Some(name) = name.to_str() else {
9511 continue;
9512 };
9513 let Some(suffix) = name.strip_prefix("pull-backup-") else {
9514 continue;
9515 };
9516 if crate::ulid::is_ulid(suffix) {
9517 store.remove_private_tree(&Path::new(".dbmd").join(name))?;
9518 }
9519 }
9520 Ok(())
9521}
9522
9523fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
9524 for entry in &journal.entries {
9526 let limit = entry
9527 .old
9528 .as_ref()
9529 .into_iter()
9530 .chain(entry.new.iter())
9531 .map(|value| value.bytes)
9532 .max()
9533 .unwrap_or(0);
9534 let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
9535 if current != entry.old && current != entry.new {
9536 return Err(LinkError::InvalidPack {
9537 message: format!(
9538 "cannot recover interrupted pull because `{}` changed afterward",
9539 entry.path
9540 ),
9541 });
9542 }
9543 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
9544 let path = Path::new(&journal.backup_dir).join(backup);
9545 let file = store.open_regular(&path)?;
9546 if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
9547 return Err(invalid_feed("v2 pull recovery backup failed verification"));
9548 }
9549 }
9550 }
9551 for entry in journal.entries.iter().rev() {
9552 match (&entry.old, &entry.backup) {
9553 (Some(old), Some(backup)) => {
9554 let bytes =
9555 store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
9556 store.write_atomic(Path::new(&entry.path), &bytes)?;
9557 }
9558 (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
9559 store.remove_file(Path::new(&entry.path))?;
9560 }
9561 (None, None) => {}
9562 _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
9563 }
9564 }
9565 crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
9566 message: format!("could not rebuild catalogs after pull recovery: {error}"),
9567 })?;
9568 cleanup_v2_pull_journal(store, journal)
9569}
9570
9571fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
9572 let Ok(store) = Store::open_strict(dest) else {
9573 return Ok(());
9574 };
9575 if let Some(journal) = load_v2_pull_journal(&store)? {
9576 if journal.brain != brain {
9577 return Err(invalid_feed("v2 pull journal belongs to another brain"));
9578 }
9579 if journal.phase == V2PullPhase::Preparing {
9580 cleanup_v2_pull_journal(&store, &journal)?;
9581 } else {
9582 let baseline = load_v2_baseline(cfg, brain, dest)?;
9583 let current = v2_pull_baseline_coordinate(baseline.as_ref());
9584 if current == journal.next {
9585 cleanup_v2_pull_journal(&store, &journal)?;
9586 } else {
9587 if current != journal.previous {
9588 return Err(invalid_feed(
9589 "cannot recover interrupted pull because its baseline changed afterward",
9590 ));
9591 }
9592 rollback_v2_pull(&store, &journal)?;
9593 }
9594 }
9595 }
9596 prune_orphan_v2_pull_backups(&store)
9601}
9602
9603fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
9604 let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
9605 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9606 })?;
9607 if let Some(journal) = load_v2_pull_journal(&store)? {
9608 cleanup_v2_pull_journal(&store, &journal)?;
9609 }
9610 Ok(())
9611}
9612
9613#[cfg(windows)]
9614fn install_windows_initial_sources(
9615 dest: &Path,
9616 entries: &[V2StagedFile],
9617 rebuild_indexes: bool,
9618) -> LinkResult<()> {
9619 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9620 let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
9621 path: dest.display().to_string(),
9622 })?;
9623 let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
9624 if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
9625 return Err(LinkError::UnsafePath {
9626 path: dest.display().to_string(),
9627 });
9628 }
9629 let stage_name = format!(
9630 ".{}.dbmd-pull-stage-{}",
9631 name.to_string_lossy(),
9632 crate::ulid::mint()
9633 );
9634 let stage_path = parent.join(&stage_name);
9635 let stage_capability =
9636 crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
9637 let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
9638 let prepared = (|| -> LinkResult<()> {
9639 for entry in entries {
9640 let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
9641 if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
9642 return Err(invalid_feed(
9643 "private staged sync source failed final integrity verification",
9644 ));
9645 }
9646 stage.write_atomic(Path::new(&entry.path), &bytes)?;
9647 }
9648 let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
9649 .map_err(|error| LinkError::InvalidPack {
9650 message: format!("v2 staging tree is not a valid db.md store: {error}"),
9651 })?;
9652 if rebuild_indexes {
9653 crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
9654 message: format!("could not materialize v2 local catalogs: {error}"),
9655 })?;
9656 }
9657 Ok(())
9658 })();
9659 if let Err(error) = prepared {
9660 let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
9661 return Err(error);
9662 }
9663 crate::fsx::rename_directory_beneath(
9664 &parent_capability,
9665 Path::new(&stage_name),
9666 Path::new(name),
9667 )?;
9668 Ok(())
9669}
9670
9671fn install_established_v2_delta(
9672 store: Store,
9673 entries: &[V2StagedFile],
9674 deleted: &[String],
9675 rebuild_indexes: bool,
9676 previous: Option<&V2SyncBaseline>,
9677 next: &V2VerifiedHead,
9678) -> LinkResult<()> {
9679 if load_v2_pull_journal(&store)?.is_some() {
9680 return Err(invalid_feed(
9681 "an interrupted pull must be recovered before installing",
9682 ));
9683 }
9684 let mut sources = std::collections::BTreeMap::new();
9685 for entry in entries {
9686 if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
9687 return Err(invalid_feed("pull mutation repeats a path"));
9688 }
9689 }
9690 let mut paths = sources.keys().cloned().collect::<Vec<_>>();
9691 paths.extend(deleted.iter().cloned());
9692 paths.sort();
9693 paths.dedup();
9694 if paths.is_empty() {
9695 return Ok(());
9696 }
9697 let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
9698 let mut journal = V2PullJournal {
9699 v: 1,
9700 phase: V2PullPhase::Preparing,
9701 brain: next.brain_id.clone(),
9702 previous: v2_pull_baseline_coordinate(previous),
9703 next: v2_pull_head_coordinate(next),
9704 backup_dir: backup_dir.clone(),
9705 entries: Vec::with_capacity(paths.len()),
9706 };
9707 for path in &paths {
9708 let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
9709 let new = sources.get(path).map(|entry| V2PullFileCoordinate {
9710 sha256: entry.sha256.clone(),
9711 bytes: entry.bytes,
9712 });
9713 if old == new {
9714 continue;
9715 }
9716 let index = journal.entries.len();
9717 journal.entries.push(V2PullJournalEntry {
9718 path: path.clone(),
9719 backup: old.as_ref().map(|_| format!("{index:08x}")),
9720 old,
9721 new,
9722 });
9723 }
9724 if journal.entries.is_empty() {
9725 return Ok(());
9726 }
9727 let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
9728 entry
9729 .old
9730 .as_ref()
9731 .map_or(Some(total), |old| total.checked_add(old.bytes))
9732 });
9733 if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
9734 return Err(LinkError::InvalidPack {
9735 message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
9736 });
9737 }
9738 validate_v2_pull_journal(&journal)?;
9739 store.write_private_atomic_new(
9740 Path::new(V2_PULL_JOURNAL),
9741 &v2_pull_journal_bytes(&journal)?,
9742 )?;
9743 let prepared = (|| -> LinkResult<()> {
9744 store.create_private_dir_all(Path::new(&backup_dir))?;
9745 for entry in &journal.entries {
9746 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
9747 let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
9748 if content_sha256(&bytes) != old.sha256 {
9749 return Err(invalid_feed("live pull source changed during backup"));
9750 }
9751 store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
9752 }
9753 }
9754 journal.phase = V2PullPhase::Ready;
9755 store.write_private_atomic(
9756 Path::new(V2_PULL_JOURNAL),
9757 &v2_pull_journal_bytes(&journal)?,
9758 )?;
9759 Ok(())
9760 })();
9761 if let Err(error) = prepared {
9762 let cleanup = cleanup_v2_pull_journal(&store, &journal);
9763 return match cleanup {
9764 Ok(()) => Err(error),
9765 Err(cleanup) => Err(LinkError::InvalidPack {
9766 message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
9767 }),
9768 };
9769 }
9770 let installed = (|| -> LinkResult<()> {
9771 for entry in &journal.entries {
9772 if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
9773 return Err(LinkError::InvalidPack {
9774 message: format!("local path `{}` changed during pull", entry.path),
9775 });
9776 }
9777 if let Some(source) = sources.get(&entry.path) {
9778 let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
9779 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
9780 return Err(invalid_feed(
9781 "private staged sync source failed final integrity verification",
9782 ));
9783 }
9784 store.write_atomic(Path::new(&entry.path), &bytes)?;
9785 } else if entry.old.is_some() {
9786 store.remove_file(Path::new(&entry.path))?;
9787 }
9788 }
9789 if rebuild_indexes {
9790 crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
9791 message: format!("could not materialize v2 local catalogs: {error}"),
9792 })?;
9793 }
9794 Ok(())
9795 })();
9796 if let Err(error) = installed {
9797 return match rollback_v2_pull(&store, &journal) {
9798 Ok(()) => Err(error),
9799 Err(rollback) => Err(LinkError::InvalidPack {
9800 message: format!("{error}; durable pull rollback also failed: {rollback}"),
9801 }),
9802 };
9803 }
9804 Ok(())
9805}
9806
9807#[cfg(windows)]
9808fn install_pulled_delta_sources(
9809 dest: &Path,
9810 entries: &[V2StagedFile],
9811 deleted: &[String],
9812 rebuild_indexes: bool,
9813 previous: Option<&V2SyncBaseline>,
9814 next: &V2VerifiedHead,
9815) -> LinkResult<()> {
9816 match Store::open_strict(dest) {
9817 Ok(store) => {
9818 install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
9819 }
9820 Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
9821 }
9822}
9823
9824#[cfg(not(any(unix, windows)))]
9825fn install_pulled_delta_sources(
9826 _dest: &Path,
9827 _entries: &[V2StagedFile],
9828 _deleted: &[String],
9829 _rebuild_indexes: bool,
9830 _previous: Option<&V2SyncBaseline>,
9831 _next: &V2VerifiedHead,
9832) -> LinkResult<()> {
9833 Err(LinkError::UnsupportedPlatform {
9834 operation: "atomic v2 pull install",
9835 })
9836}
9837
9838#[cfg(unix)]
9839fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
9840 install_pulled_delta(dest, entries, &[], false)
9841}
9842
9843#[cfg(not(windows))]
9844fn is_safe_slug(slug: &str) -> bool {
9845 !slug.is_empty()
9846 && slug.len() <= 63
9847 && !slug.starts_with('-')
9848 && !slug.ends_with('-')
9849 && slug
9850 .bytes()
9851 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
9852}
9853
9854fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
9855 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
9856}
9857
9858fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
9859 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
9860}
9861
9862fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
9863 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
9864}
9865
9866fn preflight_zip_central_directory(
9867 bytes: &[u8],
9868 offset: usize,
9869 size: usize,
9870 count: u64,
9871) -> LinkResult<()> {
9872 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
9873 let end = offset
9874 .checked_add(size)
9875 .filter(|end| *end <= bytes.len())
9876 .ok_or_else(|| LinkError::InvalidPack {
9877 message: "ZIP central directory is out of bounds".to_string(),
9878 })?;
9879 let mut cursor = offset;
9880 for _ in 0..count {
9881 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
9882 return Err(LinkError::InvalidPack {
9883 message: "ZIP central directory entry count is inconsistent".to_string(),
9884 });
9885 }
9886 if le_u16(bytes, cursor + 34) != Some(0) {
9887 return Err(LinkError::InvalidPack {
9888 message: "multi-disk ZIP archives are not supported".to_string(),
9889 });
9890 }
9891 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
9892 total.checked_add(le_u16(bytes, cursor + at)? as usize)
9893 });
9894 cursor = cursor
9895 .checked_add(46)
9896 .and_then(|fixed| fixed.checked_add(variable?))
9897 .filter(|cursor| *cursor <= end)
9898 .ok_or_else(|| LinkError::InvalidPack {
9899 message: "ZIP central directory entry is truncated".to_string(),
9900 })?;
9901 }
9902 if cursor != end {
9903 return Err(LinkError::InvalidPack {
9904 message: "ZIP central directory size is inconsistent".to_string(),
9905 });
9906 }
9907 Ok(())
9908}
9909
9910fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
9914 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
9915 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
9916 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
9917 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
9918 let eocd = bytes[search_start..]
9919 .windows(4)
9920 .rposition(|window| window == EOCD_SIG)
9921 .map(|offset| search_start + offset)
9922 .ok_or_else(|| LinkError::InvalidPack {
9923 message: "ZIP has no end-of-central-directory record".to_string(),
9924 })?;
9925 let invalid_end = || LinkError::InvalidPack {
9926 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
9927 };
9928 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
9929 if eocd
9930 .checked_add(22)
9931 .and_then(|end| end.checked_add(comment_len))
9932 != Some(bytes.len())
9933 {
9934 return Err(invalid_end());
9938 }
9939 let disk = le_u16(bytes, eocd + 4);
9940 let central_disk = le_u16(bytes, eocd + 6);
9941 if disk != Some(0) || central_disk != Some(0) {
9942 return Err(LinkError::InvalidPack {
9943 message: "multi-disk ZIP archives are not supported".to_string(),
9944 });
9945 }
9946 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
9947 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
9948 if entries_on_disk != ordinary {
9949 return Err(LinkError::InvalidPack {
9950 message: "multi-disk ZIP archives are not supported".to_string(),
9951 });
9952 }
9953 let zip64_locator = eocd
9954 .checked_sub(20)
9955 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
9956 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
9957 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
9958 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
9959 if central_offset
9960 .checked_add(central_size)
9961 .filter(|end| *end == eocd)
9962 .is_none()
9963 {
9964 return Err(invalid_end());
9965 }
9966 (ordinary as u64, central_offset, central_size)
9967 } else {
9968 let Some(locator) = zip64_locator else {
9969 return Err(invalid_end());
9970 };
9971 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
9972 return Err(LinkError::InvalidPack {
9973 message: "multi-disk ZIP64 archives are not supported".to_string(),
9974 });
9975 }
9976 let record = le_u64(bytes, locator + 8)
9977 .and_then(|offset| usize::try_from(offset).ok())
9978 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
9979 .ok_or_else(|| LinkError::InvalidPack {
9980 message: "ZIP64 archive has an invalid end record".to_string(),
9981 })?;
9982 let record_size = le_u64(bytes, record + 4)
9983 .and_then(|size| usize::try_from(size).ok())
9984 .filter(|size| *size >= 44)
9985 .ok_or_else(invalid_end)?;
9986 if record
9987 .checked_add(12)
9988 .and_then(|end| end.checked_add(record_size))
9989 != Some(locator)
9990 || le_u32(bytes, record + 16) != Some(0)
9991 || le_u32(bytes, record + 20) != Some(0)
9992 {
9993 return Err(invalid_end());
9994 }
9995 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
9996 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
9997 let central_size = le_u64(bytes, record + 40)
9998 .and_then(|size| usize::try_from(size).ok())
9999 .ok_or_else(invalid_end)?;
10000 let central_offset = le_u64(bytes, record + 48)
10001 .and_then(|offset| usize::try_from(offset).ok())
10002 .ok_or_else(invalid_end)?;
10003 if zip64_on_disk != zip64_total
10004 || central_offset
10005 .checked_add(central_size)
10006 .filter(|end| *end == record)
10007 .is_none()
10008 {
10009 return Err(invalid_end());
10010 }
10011 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10012 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10013 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10014 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10015 {
10016 return Err(invalid_end());
10017 }
10018 (zip64_total, central_offset, central_size)
10019 };
10020 if count == 0 || count > max_entries as u64 {
10021 return Err(LinkError::InvalidPack {
10022 message: format!("invalid file count {count}"),
10023 });
10024 }
10025 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10026 Ok(())
10027}
10028
10029fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10030 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10031 let mut archive =
10032 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10033 message: format!("ZIP parse failed: {err}"),
10034 })?;
10035 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10036 return Err(LinkError::InvalidPack {
10037 message: format!("invalid file count {}", archive.len()),
10038 });
10039 }
10040 let mut total = 0u64;
10041 let mut seen = std::collections::HashSet::new();
10042 let mut entries = Vec::with_capacity(archive.len());
10043 for index in 0..archive.len() {
10044 let mut file = archive
10045 .by_index(index)
10046 .map_err(|err| LinkError::InvalidPack {
10047 message: format!("ZIP entry failed: {err}"),
10048 })?;
10049 if file.is_dir() {
10050 continue;
10051 }
10052 let path = file.name().to_string();
10053 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10054 return Err(LinkError::UnsafePath { path });
10055 }
10056 if file
10057 .unix_mode()
10058 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
10059 {
10060 return Err(LinkError::InvalidPack {
10061 message: format!("non-file entry `{path}`"),
10062 });
10063 }
10064 if !seen.insert(path.clone()) {
10065 return Err(LinkError::InvalidPack {
10066 message: format!("duplicate path `{path}`"),
10067 });
10068 }
10069 let remaining = MAX_STORE_BYTES.saturating_sub(total);
10070 if file.size() > remaining {
10071 return Err(LinkError::InvalidPack {
10072 message: "expanded content exceeds the 512 MB limit".to_string(),
10073 });
10074 }
10075 let mut content = Vec::new();
10076 (&mut file)
10077 .take(remaining + 1)
10078 .read_to_end(&mut content)
10079 .map_err(|err| LinkError::InvalidPack {
10080 message: format!("could not decompress `{path}`: {err}"),
10081 })?;
10082 if content.len() as u64 > remaining {
10083 return Err(LinkError::InvalidPack {
10084 message: "expanded content exceeds the 512 MB limit".to_string(),
10085 });
10086 }
10087 if content.len() as u64 != file.size() {
10088 return Err(LinkError::InvalidPack {
10089 message: format!("length mismatch for `{path}`"),
10090 });
10091 }
10092 total += content.len() as u64;
10093 entries.push((path, content));
10094 }
10095 if entries.is_empty() {
10096 return Err(LinkError::InvalidPack {
10097 message: "pack contains no files".to_string(),
10098 });
10099 }
10100 Ok(entries)
10101}
10102
10103fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
10104 let mut expected = std::collections::BTreeMap::new();
10105 for file in signed {
10106 if !safe_store_rel_path(&file.path) {
10107 return Err(LinkError::UnsafePath {
10108 path: file.path.clone(),
10109 });
10110 }
10111 if !is_sha256(&file.sha256)
10112 || expected
10113 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10114 .is_some()
10115 {
10116 return Err(invalid_feed(
10117 "signed snapshot manifest contains an invalid or duplicate file",
10118 ));
10119 }
10120 }
10121 if expected.len() != entries.len() {
10122 return Err(invalid_feed(
10123 "downloaded pack file set differs from the signed snapshot manifest",
10124 ));
10125 }
10126 for (path, bytes) in entries {
10127 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
10128 return Err(invalid_feed(format!(
10129 "downloaded pack contains unsigned path `{path}`"
10130 )));
10131 };
10132 if *declared_bytes != bytes.len() as u64
10133 || *sha256 != format!("{:x}", Sha256::digest(bytes))
10134 {
10135 return Err(invalid_feed(format!(
10136 "downloaded file `{path}` differs from its signed manifest"
10137 )));
10138 }
10139 }
10140 Ok(())
10141}
10142
10143pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
10150 require_hardened_filesystem("sync push")?;
10151 preflight_push_ownership(store)?;
10152 let mut out: Vec<(String, String)> = Vec::new();
10153 let mut total = 0u64;
10154
10155 let mut read_text = |rel: &str| -> LinkResult<String> {
10156 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
10157 total = total
10158 .checked_add(bytes.len() as u64)
10159 .ok_or_else(|| LinkError::PushTooLarge {
10160 detail: "uncompressed byte count overflow".to_string(),
10161 })?;
10162 if total > MAX_STORE_BYTES {
10163 return Err(LinkError::PushTooLarge {
10164 detail: format!("{total} uncompressed bytes"),
10165 });
10166 }
10167 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
10168 path: rel.to_string(),
10169 })
10170 };
10171
10172 out.push(("DB.md".to_string(), read_text("DB.md")?));
10173 if store
10174 .regular_file_exists(Path::new("assets.jsonl"))
10175 .unwrap_or(false)
10176 {
10177 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
10178 }
10179
10180 for rel in store.walk()? {
10181 let rel_str = rel.to_string_lossy().replace('\\', "/");
10182 if !safe_store_rel_path(&rel_str) {
10183 return Err(LinkError::UnsafePath { path: rel_str });
10186 }
10187 let content = read_text(&rel_str)?;
10188 out.push((rel_str, content));
10189 }
10190
10191 out.sort_by(|a, b| a.0.cmp(&b.0));
10192 Ok(out)
10193}
10194
10195fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
10199 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
10200 return Err(LinkError::from(std::io::Error::new(
10201 std::io::ErrorKind::PermissionDenied,
10202 format!("cannot push: nested db.md store at {}", nested.display()),
10203 )));
10204 }
10205
10206 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
10207 return Err(LinkError::from(std::io::Error::new(
10208 std::io::ErrorKind::PermissionDenied,
10209 format!(
10210 "cannot push: {} is a symlink outside the store ownership model",
10211 symlink.display()
10212 ),
10213 )));
10214 }
10215 Ok(())
10216}
10217
10218pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
10224 require_safe_ref(brain)?;
10225 let remote = verified_remote_head(cfg, brain, false)?;
10226 if files.len() > MAX_PUSH_FILES {
10227 return Err(LinkError::PushTooLarge {
10228 detail: format!("{} files", files.len()),
10229 });
10230 }
10231 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
10232 if raw_total > MAX_STORE_BYTES {
10233 return Err(LinkError::PushTooLarge {
10234 detail: format!("{raw_total} uncompressed bytes"),
10235 });
10236 }
10237
10238 if cfg.brain_key.is_none() {
10242 let body = json!({
10243 "files": files
10244 .iter()
10245 .map(|(p, c)| json!({ "path": p, "content": c }))
10246 .collect::<Vec<_>>(),
10247 });
10248 if body.to_string().len() <= MAX_PUSH_BYTES {
10249 let path = format!("/api/hub/brains/{brain}/push");
10250 let pushed = ensure_ok(
10251 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10252 "sync push",
10253 )?;
10254 return Ok(pushed);
10255 }
10256 }
10257
10258 let pack = build_store_pack(files)?;
10259 if pack.len() as u64 > MAX_PACK_BYTES {
10260 return Err(LinkError::PushTooLarge {
10261 detail: format!("{} pack bytes", pack.len()),
10262 });
10263 }
10264 let sha256 = format!("{:x}", Sha256::digest(&pack));
10265 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
10266 if let Some(key) = &cfg.brain_key {
10267 if !remote.head.verified {
10268 return Err(invalid_feed(
10269 "self-custody push requires a fully verified, unscoped feed head",
10270 ));
10271 }
10272 let identity = remote
10273 .identity
10274 .as_ref()
10275 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
10276 let current_multikey = format!("ed25519:{}", identity.fingerprint);
10277 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
10278 return Err(invalid_feed(
10279 "configured brain key is not the verified current brain identity",
10280 ));
10281 }
10282 let next_seq = remote
10285 .head
10286 .seq
10287 .checked_add(1)
10288 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
10289 let mut manifest: Vec<WireFeedFile> = files
10290 .iter()
10291 .map(|(path, content)| WireFeedFile {
10292 path: path.clone(),
10293 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
10294 bytes: content.len() as u64,
10295 })
10296 .collect();
10297 manifest.sort_by(|a, b| a.path.cmp(&b.path));
10298 let ts = crate::now()
10299 .with_timezone(&chrono::Utc)
10300 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
10301 .to_string();
10302 let entry = self_custody_entry(
10303 key,
10304 next_seq,
10305 ts,
10306 &sha256,
10307 &manifest,
10308 remote.head.feed_hash.as_deref(),
10309 )?;
10310 meta["entry"] = Value::String(entry);
10311 }
10312 let presigned = ensure_ok(
10313 request(
10314 cfg,
10315 "POST",
10316 &format!("/api/hub/brains/{brain}/packs/presign"),
10317 Some(&meta),
10318 Auth::Required,
10319 )?,
10320 "prepare pack upload",
10321 )?;
10322 let url = presigned
10323 .get("url")
10324 .and_then(Value::as_str)
10325 .ok_or_else(|| LinkError::InvalidPack {
10326 message: "the hub returned no upload URL".to_string(),
10327 })?;
10328 put_presigned(
10329 cfg,
10330 url,
10331 presigned.get("headers").unwrap_or(&Value::Null),
10332 &pack,
10333 )?;
10334 let committed = ensure_ok(
10335 request(
10336 cfg,
10337 "POST",
10338 &format!("/api/hub/brains/{brain}/packs/commit"),
10339 Some(&meta),
10340 Auth::Required,
10341 )?,
10342 "commit pack",
10343 )?;
10344 Ok(committed)
10345}
10346
10347fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
10348 const LOCAL_HEADER: u32 = 0x0403_4b50;
10349 const CENTRAL_HEADER: u32 = 0x0201_4b50;
10350 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
10351 const VERSION_20: u16 = 20;
10352 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
10353 const UTF8_FLAG: u16 = 1 << 11;
10354 const STORED: u16 = 0;
10355 const DOS_TIME_MIDNIGHT: u16 = 0;
10356 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
10357 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
10358
10359 struct CentralEntry<'a> {
10360 name: &'a [u8],
10361 crc32: u32,
10362 size: u32,
10363 local_offset: u32,
10364 }
10365
10366 fn push_u16(out: &mut Vec<u8>, value: u16) {
10367 out.extend_from_slice(&value.to_le_bytes());
10368 }
10369
10370 fn push_u32(out: &mut Vec<u8>, value: u32) {
10371 out.extend_from_slice(&value.to_le_bytes());
10372 }
10373
10374 if files.is_empty() {
10375 return Err(LinkError::InvalidPack {
10376 message: "cannot create an empty snapshot pack".to_string(),
10377 });
10378 }
10379 if files.len() > u16::MAX as usize {
10380 return Err(LinkError::PushTooLarge {
10381 detail: format!(
10382 "{} files (canonical ZIP32 packs cap at {})",
10383 files.len(),
10384 u16::MAX
10385 ),
10386 });
10387 }
10388
10389 let mut sorted: Vec<_> = files.iter().collect();
10390 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
10391 let mut previous: Option<&str> = None;
10392 for (path, content) in &sorted {
10393 if !safe_store_rel_path(path) {
10394 return Err(LinkError::UnsafePath {
10395 path: (*path).clone(),
10396 });
10397 }
10398 if previous == Some(path.as_str()) {
10399 return Err(LinkError::InvalidPack {
10400 message: format!("duplicate path `{path}`"),
10401 });
10402 }
10403 previous = Some(path.as_str());
10404 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
10405 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
10406 })?;
10407 }
10408
10409 let mut out = Vec::new();
10410 let mut central = Vec::with_capacity(sorted.len());
10411 for (path, content) in sorted {
10412 let name = path.as_bytes();
10413 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
10414 message: format!("ZIP entry name is too long: `{path}`"),
10415 })?;
10416 let bytes = content.as_bytes();
10417 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
10418 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
10419 })?;
10420 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
10421 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
10422 })?;
10423 let crc32 = crc32fast::hash(bytes);
10424
10425 push_u32(&mut out, LOCAL_HEADER);
10428 push_u16(&mut out, VERSION_20);
10429 push_u16(&mut out, UTF8_FLAG);
10430 push_u16(&mut out, STORED);
10431 push_u16(&mut out, DOS_TIME_MIDNIGHT);
10432 push_u16(&mut out, DOS_DATE_1980_01_01);
10433 push_u32(&mut out, crc32);
10434 push_u32(&mut out, size);
10435 push_u32(&mut out, size);
10436 push_u16(&mut out, name_len);
10437 push_u16(&mut out, 0); out.extend_from_slice(name);
10439 out.extend_from_slice(bytes);
10440
10441 central.push(CentralEntry {
10442 name,
10443 crc32,
10444 size,
10445 local_offset,
10446 });
10447 }
10448
10449 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
10450 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
10451 })?;
10452 for entry in ¢ral {
10453 push_u32(&mut out, CENTRAL_HEADER);
10454 push_u16(&mut out, MADE_BY_UNIX_20);
10455 push_u16(&mut out, VERSION_20);
10456 push_u16(&mut out, UTF8_FLAG);
10457 push_u16(&mut out, STORED);
10458 push_u16(&mut out, DOS_TIME_MIDNIGHT);
10459 push_u16(&mut out, DOS_DATE_1980_01_01);
10460 push_u32(&mut out, entry.crc32);
10461 push_u32(&mut out, entry.size);
10462 push_u32(&mut out, entry.size);
10463 push_u16(&mut out, entry.name.len() as u16);
10464 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);
10469 push_u32(&mut out, entry.local_offset);
10470 out.extend_from_slice(entry.name);
10471 }
10472 let central_size = u32::try_from(out.len())
10473 .ok()
10474 .and_then(|end| end.checked_sub(central_offset))
10475 .ok_or_else(|| LinkError::PushTooLarge {
10476 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
10477 })?;
10478 let entry_count = central.len() as u16;
10479
10480 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
10481 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
10484 push_u16(&mut out, entry_count);
10485 push_u32(&mut out, central_size);
10486 push_u32(&mut out, central_offset);
10487 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
10490 return Err(LinkError::PushTooLarge {
10491 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
10492 });
10493 }
10494 Ok(out)
10495}
10496
10497#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10503pub enum Capability {
10504 Read,
10506 Write,
10508}
10509
10510impl Capability {
10511 pub fn as_str(self) -> &'static str {
10513 match self {
10514 Capability::Read => "read",
10515 Capability::Write => "write",
10516 }
10517 }
10518}
10519
10520pub fn grant_issue(
10526 cfg: &HubConfig,
10527 brain: &str,
10528 grantee: &str,
10529 can: Capability,
10530 scope: Option<&str>,
10531 until: Option<&str>,
10532) -> LinkResult<Value> {
10533 require_safe_ref(brain)?;
10534 let is_key_grantee = URL_SAFE_NO_PAD
10539 .decode(grantee)
10540 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
10541 .unwrap_or(false);
10542 if let Some(head) = v2_verified_head(cfg, brain)? {
10543 if is_key_grantee {
10544 let scope = scope.unwrap_or("");
10545 let preset = match can {
10546 Capability::Read => "viewer",
10547 Capability::Write => "editor",
10548 };
10549 let entropy = format!(
10550 "{}\0{}\0{}\0{}\0{}\0{}\0{}",
10551 normalized_origin(&cfg.hub)?,
10552 head.brain_id,
10553 head.control_revision,
10554 grantee,
10555 preset,
10556 scope,
10557 until.unwrap_or("")
10558 );
10559 let mut body = json!({
10560 "context": "external",
10561 "expected_control_revision": head.control_revision,
10562 "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
10563 "preset": preset,
10564 "principal_kind": "key",
10565 "public_key": grantee,
10566 "scope": scope,
10567 "scope_kind": "prefix",
10568 });
10569 if let Some(value) = until {
10570 body["expires_at"] = json!(value);
10571 }
10572 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
10573 let response = ensure_ok(
10574 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10575 "v2 grant issue",
10576 )?;
10577 let expected_fingerprint = identity_fingerprint(grantee)?;
10578 if response.get("v").and_then(Value::as_u64) != Some(2)
10579 || response
10580 .get("id")
10581 .and_then(Value::as_str)
10582 .is_none_or(|id| !crate::ulid::is_ulid(id))
10583 || response.get("principal_kind").and_then(Value::as_str) != Some("key")
10584 || response.get("principal_id").and_then(Value::as_str)
10585 != Some(expected_fingerprint.as_str())
10586 || response
10587 .get("control_revision")
10588 .and_then(Value::as_str)
10589 .is_none_or(|value| !is_sha256(value))
10590 {
10591 return Err(invalid_feed(
10592 "v2 grant issue response is not authority-bound",
10593 ));
10594 }
10595 return Ok(response);
10596 }
10597 let mut body = json!({ "email": grantee, "capability": can.as_str() });
10603 if let Some(value) = scope {
10604 body["scopePrefix"] = json!(value);
10605 }
10606 if let Some(value) = until {
10607 body["expiresAt"] = json!(value);
10608 }
10609 let path = format!("/api/hub/brains/{}/grants", head.brain_id);
10610 return ensure_ok(
10611 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10612 "account grant issue",
10613 );
10614 }
10615 let _ = verified_remote_head(cfg, brain, false)?;
10616 let mut body = if is_key_grantee {
10617 json!({ "keySpki": grantee, "capability": can.as_str() })
10618 } else {
10619 json!({ "email": grantee, "capability": can.as_str() })
10620 };
10621 if let Some(s) = scope {
10622 body["scopePrefix"] = json!(s);
10623 }
10624 if let Some(u) = until {
10625 body["expiresAt"] = json!(u);
10626 }
10627 let path = format!("/api/hub/brains/{brain}/grants");
10628 ensure_ok(
10629 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10630 "grant issue",
10631 )
10632}
10633
10634pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
10636 require_safe_ref(brain)?;
10637 if let Some(head) = v2_verified_head(cfg, brain)? {
10638 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
10639 let response = ensure_ok(
10640 request(cfg, "GET", &path, None, Auth::Required)?,
10641 "v2 grant list",
10642 )?;
10643 if response.get("v").and_then(Value::as_u64) != Some(2)
10644 || response.get("control_revision").and_then(Value::as_str)
10645 != Some(head.control_revision.as_str())
10646 || !response.get("grants").is_some_and(Value::is_array)
10647 {
10648 return Err(invalid_feed(
10649 "v2 grant list is not bound to the verified authority",
10650 ));
10651 }
10652 return Ok(response);
10653 }
10654 let _ = verified_remote_head(cfg, brain, false)?;
10655 let path = format!("/api/hub/brains/{brain}/grants");
10656 ensure_ok(
10657 request(cfg, "GET", &path, None, Auth::Required)?,
10658 "grant list",
10659 )
10660}
10661
10662pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
10665 require_safe_ref(brain)?;
10666 require_safe_grant_id(grant_id)?;
10667 if let Some(head) = v2_verified_head(cfg, brain)? {
10668 let entropy = format!(
10669 "{}\0{}\0{}\0{}",
10670 normalized_origin(&cfg.hub)?,
10671 head.brain_id,
10672 head.control_revision,
10673 grant_id
10674 );
10675 let body = json!({
10676 "expected_control_revision": head.control_revision,
10677 "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
10678 });
10679 let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
10680 let response = ensure_ok(
10681 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
10682 "v2 grant revoke",
10683 )?;
10684 if response.get("v").and_then(Value::as_u64) != Some(2)
10685 || response.get("id").and_then(Value::as_str) != Some(grant_id)
10686 || response.get("revoked").and_then(Value::as_bool) != Some(true)
10687 || response
10688 .get("control_revision")
10689 .and_then(Value::as_str)
10690 .is_none_or(|value| !is_sha256(value))
10691 {
10692 return Err(invalid_feed(
10693 "v2 grant revocation response is not authority-bound",
10694 ));
10695 }
10696 return Ok(response);
10697 }
10698 let _ = verified_remote_head(cfg, brain, false)?;
10699 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
10700 ensure_ok(
10701 request(cfg, "DELETE", &path, None, Auth::Required)?,
10702 "grant revoke",
10703 )
10704}
10705
10706#[derive(Debug)]
10711struct VerifiedV2Proposal {
10712 value: Value,
10713 changes: Value,
10714 blobs: Vec<(String, u64, String)>,
10715}
10716
10717fn require_proposal_id(id: &str) -> LinkResult<()> {
10718 if crate::ulid::is_ulid(id) {
10719 Ok(())
10720 } else {
10721 Err(invalid_feed("proposal id is not a lowercase ULID"))
10722 }
10723}
10724
10725fn verified_v2_proposal(
10726 cfg: &HubConfig,
10727 head: &V2VerifiedHead,
10728 proposal_id: &str,
10729) -> LinkResult<VerifiedV2Proposal> {
10730 require_proposal_id(proposal_id)?;
10731 if head.view_kind != "full" {
10732 return Err(invalid_feed(
10733 "proposal review requires a full readable view",
10734 ));
10735 }
10736 let path = format!(
10737 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
10738 head.brain_id
10739 );
10740 let value = ensure_ok(
10741 request_capped(
10742 cfg,
10743 "GET",
10744 &path,
10745 None,
10746 Auth::Required,
10747 MAX_FEED_RESPONSE_BYTES,
10748 )?,
10749 "v2 proposal",
10750 )?;
10751 verify_v2_proposal_value(head, proposal_id, value)
10752}
10753
10754fn verify_v2_proposal_value(
10755 head: &V2VerifiedHead,
10756 proposal_id: &str,
10757 value: Value,
10758) -> LinkResult<VerifiedV2Proposal> {
10759 if value.get("v").and_then(Value::as_u64) != Some(2) {
10760 return Err(invalid_feed("proposal response has an invalid version"));
10761 }
10762 let proposal = value
10763 .get("proposal")
10764 .and_then(Value::as_object)
10765 .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
10766 if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
10767 return Err(invalid_feed("proposal response changed its id"));
10768 }
10769 let payload_hash = proposal
10770 .get("payload_sha256")
10771 .and_then(Value::as_str)
10772 .filter(|hash| is_sha256(hash))
10773 .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
10774 let clear_hash = proposal
10775 .get("clear_sha256")
10776 .and_then(Value::as_str)
10777 .filter(|hash| is_sha256(hash))
10778 .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
10779 let submission_hash = proposal
10780 .get("submission_claim_sha256")
10781 .and_then(Value::as_str)
10782 .filter(|hash| is_sha256(hash))
10783 .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
10784 let submission = STANDARD
10785 .decode(
10786 proposal
10787 .get("submission_claim_base64")
10788 .and_then(Value::as_str)
10789 .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
10790 )
10791 .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
10792 let submission_value: Value = serde_json::from_slice(&submission)
10793 .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
10794 if crate::linkmd_v2::canonical_bytes(&submission_value)
10795 .map_err(|error| invalid_feed(error.to_string()))?
10796 != submission
10797 || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
10798 .map_err(|error| invalid_feed(error.to_string()))?
10799 != submission_hash
10800 {
10801 return Err(invalid_feed(
10802 "proposal submission claim is not canonical or addressed",
10803 ));
10804 }
10805 let envelope = submission_value
10806 .as_object()
10807 .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
10808 let claim = envelope
10809 .get("claim")
10810 .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
10811 let claim_object = claim
10812 .as_object()
10813 .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
10814 let actor_root = claim_object
10815 .get("actor_root")
10816 .and_then(Value::as_object)
10817 .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
10818 let public_key = envelope
10819 .get("public_key")
10820 .and_then(Value::as_str)
10821 .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
10822 let fingerprint = envelope
10823 .get("fingerprint")
10824 .and_then(Value::as_str)
10825 .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
10826 let signature = envelope
10827 .get("sig")
10828 .and_then(Value::as_str)
10829 .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
10830 let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
10831 .map_err(|error| invalid_feed(error.to_string()))?;
10832 let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
10833 let signer = format!("{fingerprint}:{public_key}");
10834 let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
10835 let grants = actor_root.get("grants").and_then(Value::as_array);
10836 let grants_are_canonical = grants.is_some_and(|items| {
10837 let mut prior: Option<&str> = None;
10838 items.iter().all(|item| {
10839 let Some(grant) = item.as_str() else {
10840 return false;
10841 };
10842 if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
10843 return false;
10844 }
10845 prior = Some(grant);
10846 true
10847 })
10848 });
10849 let optional_actor_field = |name: &str| {
10850 actor_root.get(name).is_some_and(|value| {
10851 value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
10852 })
10853 };
10854 let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
10855 if claim_object.get("v").and_then(Value::as_u64) != Some(2)
10856 || format!("{:x}", Sha256::digest(&der)) != fingerprint
10857 || head
10858 .trust
10859 .hub_signer
10860 .as_ref()
10861 .is_some_and(|known| known != &signer)
10862 || !matches!(
10863 actor_class,
10864 Some(
10865 "user"
10866 | "owned_agent"
10867 | "foreign_key"
10868 | "curation"
10869 | "inbox"
10870 | "restore"
10871 | "migration"
10872 | "operator_recovery"
10873 )
10874 )
10875 || actor_root
10876 .get("principal")
10877 .and_then(Value::as_str)
10878 .is_none_or(|value| value.is_empty())
10879 || actor_root
10880 .get("credential")
10881 .and_then(Value::as_str)
10882 .is_none_or(|value| value.is_empty())
10883 || !optional_actor_field("organization")
10884 || !optional_actor_field("role")
10885 || !grants_are_canonical
10886 || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
10887 || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
10888 || !claim_object
10889 .get("mutation_id")
10890 .and_then(Value::as_str)
10891 .is_some_and(|value| {
10892 !value.is_empty()
10893 && value.len() <= 128
10894 && value.chars().enumerate().all(|(index, char)| {
10895 char.is_ascii_alphanumeric()
10896 || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
10897 })
10898 })
10899 || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
10900 || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
10901 || !claim_object
10902 .get("control_revision")
10903 .and_then(Value::as_str)
10904 .is_some_and(is_sha256)
10905 || submitted_at.is_none_or(|value| {
10906 chrono::DateTime::parse_from_rfc3339(value).is_err()
10907 || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
10908 })
10909 || !proposal
10910 .get("state")
10911 .and_then(Value::as_str)
10912 .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
10913 || proposal
10914 .get("expires_at")
10915 .and_then(Value::as_str)
10916 .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
10917 || proposal
10918 .get("proposer")
10919 .and_then(Value::as_object)
10920 .and_then(|value| value.get("class"))
10921 .and_then(Value::as_str)
10922 != actor_class
10923 {
10924 return Err(invalid_feed(
10925 "proposal submission claim does not bind the verified proposal",
10926 ));
10927 }
10928 let changes_b64 = proposal
10929 .get("changes_base64")
10930 .and_then(Value::as_str)
10931 .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
10932 let changes_bytes = STANDARD
10933 .decode(changes_b64)
10934 .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
10935 let changes: Value = serde_json::from_slice(&changes_bytes)
10936 .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
10937 if crate::linkmd_v2::canonical_bytes(&changes)
10938 .map_err(|error| invalid_feed(error.to_string()))?
10939 != changes_bytes
10940 || changes.get("v").and_then(Value::as_u64) != Some(2)
10941 || !changes.get("operations").is_some_and(Value::is_array)
10942 {
10943 return Err(invalid_feed("proposal changeset is not canonical v2"));
10944 }
10945 let blob_values = proposal
10946 .get("blobs")
10947 .and_then(Value::as_array)
10948 .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
10949 let mut blobs = Vec::with_capacity(blob_values.len());
10950 let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
10951 let mut prior_hash: Option<String> = None;
10952 for item in blob_values {
10953 let hash = item
10954 .get("sha256")
10955 .and_then(Value::as_str)
10956 .filter(|hash| is_sha256(hash))
10957 .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
10958 let bytes = item
10959 .get("bytes")
10960 .and_then(Value::as_u64)
10961 .filter(|bytes| *bytes <= MAX_STORE_BYTES)
10962 .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
10963 if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
10964 return Err(invalid_feed(
10965 "proposal blob declarations are not unique and sorted",
10966 ));
10967 }
10968 prior_hash = Some(hash.to_string());
10969 let endpoint = item
10970 .get("endpoint")
10971 .and_then(Value::as_str)
10972 .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
10973 let expected_endpoint = format!(
10974 "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
10975 head.brain_id
10976 );
10977 if endpoint != expected_endpoint {
10978 return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
10979 }
10980 descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
10981 blobs.push((hash.to_string(), bytes, endpoint.to_string()));
10982 }
10983 let descriptor = json!({
10984 "base": proposal.get("base").cloned().unwrap_or(Value::Null),
10985 "blobs": descriptor_blobs,
10986 "changes_base64": changes_b64,
10987 "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
10988 "v": 2,
10989 });
10990 let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
10991 .map_err(|error| invalid_feed(error.to_string()))?;
10992 if content_sha256(&descriptor_bytes) != clear_hash {
10993 return Err(invalid_feed(
10994 "proposal clear payload differs from its signed submission claim",
10995 ));
10996 }
10997 Ok(VerifiedV2Proposal {
10998 value,
10999 changes,
11000 blobs,
11001 })
11002}
11003
11004pub fn proposal_list(
11005 cfg: &HubConfig,
11006 brain: &str,
11007 state: &str,
11008 after: Option<&str>,
11009 limit: usize,
11010) -> LinkResult<Value> {
11011 require_safe_ref(brain)?;
11012 if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11013 return Err(invalid_feed("proposal state is invalid"));
11014 }
11015 if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11016 return Err(invalid_feed("proposal cursor is invalid"));
11017 }
11018 let head = v2_verified_head(cfg, brain)?
11019 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11020 let path = format!(
11021 "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11022 head.brain_id,
11023 limit.clamp(1, 100),
11024 after.map_or_else(String::new, |value| format!("&after={value}"))
11025 );
11026 ensure_ok(
11027 request_capped(
11028 cfg,
11029 "GET",
11030 &path,
11031 None,
11032 Auth::Required,
11033 MAX_FEED_RESPONSE_BYTES,
11034 )?,
11035 "v2 proposal list",
11036 )
11037}
11038
11039pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11040 require_safe_ref(brain)?;
11041 let head = v2_verified_head(cfg, brain)?
11042 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11043 Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11044}
11045
11046pub fn proposal_reject(
11047 cfg: &HubConfig,
11048 brain: &str,
11049 proposal_id: &str,
11050 mutation_id: &str,
11051 reason: &str,
11052) -> LinkResult<Value> {
11053 require_safe_ref(brain)?;
11054 require_proposal_id(proposal_id)?;
11055 let head = v2_verified_head(cfg, brain)?
11056 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11057 let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
11058 let body = json!({
11059 "mutation_id": mutation_id,
11060 "control_revision": head.control_revision,
11061 "reason": reason,
11062 });
11063 let path = format!(
11064 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11065 head.brain_id
11066 );
11067 ensure_ok(
11068 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11069 "v2 proposal rejection",
11070 )
11071}
11072
11073pub fn proposal_accept_exact(
11074 cfg: &HubConfig,
11075 brain: &str,
11076 proposal_id: &str,
11077 mutation_id: &str,
11078 reason: &str,
11079) -> LinkResult<Value> {
11080 require_safe_ref(brain)?;
11081 require_proposal_id(proposal_id)?;
11082 let head = v2_verified_head(cfg, brain)?
11083 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11084 let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
11085 let operations = proposal
11086 .changes
11087 .get("operations")
11088 .and_then(Value::as_array)
11089 .cloned()
11090 .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
11091 if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
11092 return Err(invalid_feed("proposal operation count is invalid"));
11093 }
11094 let mut downloaded = std::collections::BTreeMap::new();
11095 for (hash, bytes, endpoint) in &proposal.blobs {
11096 let body = ensure_raw_ok(
11097 request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
11098 "v2 proposal blob",
11099 )?;
11100 if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
11101 return Err(invalid_feed("proposal blob does not match its declaration"));
11102 }
11103 downloaded.insert(hash.clone(), body);
11104 }
11105 let remote = files_for_v2_view(
11106 &head,
11107 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
11108 );
11109 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
11110 let mut expected_candidate = remote.clone();
11111 let mut expected_candidate_assets = remote_assets;
11112 for operation in &operations {
11113 let op = operation
11114 .get("op")
11115 .and_then(Value::as_str)
11116 .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
11117 match op {
11118 "put" | "restore" => {
11119 let path = operation
11120 .get("path")
11121 .and_then(Value::as_str)
11122 .ok_or_else(|| invalid_feed("proposal write has no path"))?;
11123 crate::linkmd_v2::normalize_path(path)
11124 .map_err(|error| invalid_feed(error.to_string()))?;
11125 let hash = operation
11126 .get("blob")
11127 .and_then(Value::as_str)
11128 .filter(|hash| is_sha256(hash))
11129 .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
11130 let bytes = operation
11131 .get("bytes")
11132 .and_then(Value::as_u64)
11133 .ok_or_else(|| invalid_feed("proposal write has no size"))?;
11134 expected_candidate.insert(
11135 path.to_string(),
11136 V2BaselineFile {
11137 sha256: hash.to_string(),
11138 bytes,
11139 proof: None,
11140 },
11141 );
11142 }
11143 "delete" | "withdraw_from_hosting" => {
11144 let path = operation
11145 .get("path")
11146 .and_then(Value::as_str)
11147 .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
11148 crate::linkmd_v2::normalize_path(path)
11149 .map_err(|error| invalid_feed(error.to_string()))?;
11150 expected_candidate.remove(path);
11151 }
11152 "rename" => {
11153 let from = operation
11154 .get("from")
11155 .and_then(Value::as_str)
11156 .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
11157 let to = operation
11158 .get("to")
11159 .and_then(Value::as_str)
11160 .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
11161 crate::linkmd_v2::normalize_path(from)
11162 .and_then(|_| crate::linkmd_v2::normalize_path(to))
11163 .map_err(|error| invalid_feed(error.to_string()))?;
11164 let hash = operation
11165 .get("blob")
11166 .and_then(Value::as_str)
11167 .filter(|hash| is_sha256(hash))
11168 .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
11169 let bytes = operation
11170 .get("bytes")
11171 .and_then(Value::as_u64)
11172 .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
11173 expected_candidate.remove(from);
11174 expected_candidate.insert(
11175 to.to_string(),
11176 V2BaselineFile {
11177 sha256: hash.to_string(),
11178 bytes,
11179 proof: None,
11180 },
11181 );
11182 }
11183 "asset_delete" => {
11184 let path = operation
11185 .get("path")
11186 .and_then(Value::as_str)
11187 .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
11188 expected_candidate_assets.remove(path);
11189 }
11190 "asset_withdraw" => {
11191 let path = operation
11192 .get("path")
11193 .and_then(Value::as_str)
11194 .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
11195 let asset = expected_candidate_assets
11196 .get_mut(path)
11197 .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
11198 asset.disposition = "withheld".to_string();
11199 asset.leaf_hash.clear();
11200 }
11201 "asset_put" | "asset_resume" => {
11202 let path = operation
11203 .get("path")
11204 .and_then(Value::as_str)
11205 .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
11206 let asset = operation
11207 .get("asset")
11208 .and_then(Value::as_object)
11209 .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
11210 let blob_sha256 = asset
11211 .get("blob_sha256")
11212 .and_then(Value::as_str)
11213 .filter(|hash| is_sha256(hash))
11214 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
11215 let bytes = asset
11216 .get("bytes")
11217 .and_then(Value::as_u64)
11218 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
11219 let media_type = asset
11220 .get("media_type")
11221 .and_then(Value::as_str)
11222 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
11223 let wrappers = asset
11224 .get("wrappers")
11225 .and_then(Value::as_array)
11226 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
11227 .iter()
11228 .map(|wrapper| {
11229 wrapper
11230 .as_str()
11231 .map(str::to_string)
11232 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
11233 })
11234 .collect::<LinkResult<Vec<_>>>()?;
11235 let required = asset
11236 .get("required")
11237 .and_then(Value::as_bool)
11238 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
11239 let disposition = asset
11240 .get("disposition")
11241 .and_then(Value::as_str)
11242 .filter(|value| matches!(*value, "hosted" | "withheld"))
11243 .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
11244 expected_candidate_assets.insert(
11245 path.to_string(),
11246 V2BaselineAsset {
11247 blob_sha256: blob_sha256.to_string(),
11248 bytes,
11249 media_type: media_type.to_string(),
11250 wrappers,
11251 required,
11252 disposition: disposition.to_string(),
11253 leaf_hash: String::new(),
11254 },
11255 );
11256 }
11257 _ => return Err(invalid_feed("proposal operation kind is unsupported")),
11258 }
11259 }
11260 let base = head.pointer.as_ref().map(|pointer| {
11261 json!({
11262 "seq": pointer.seq,
11263 "commit_hash": pointer.commit_hash,
11264 "content_root": pointer.content_root,
11265 "asset_root": pointer.asset_root,
11266 })
11267 });
11268 let mut body = json!({
11269 "mutation_id": mutation_id,
11270 "base": base,
11271 "rebase": "strict",
11272 "reason": reason,
11273 "operations": operations,
11274 "blobs": downloaded
11275 .iter()
11276 .map(|(sha256, bytes)| json!({
11277 "sha256": sha256,
11278 "bytes": bytes.len(),
11279 "content_base64": STANDARD.encode(bytes),
11280 }))
11281 .collect::<Vec<_>>(),
11282 "proposal_id": proposal_id,
11283 "proposal_mode": "exact",
11284 });
11285 let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
11286 total
11287 .checked_add(bytes.len())
11288 .ok_or_else(|| LinkError::PushTooLarge {
11289 detail: "proposal changed-byte total overflow".to_string(),
11290 })
11291 })?;
11292 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
11293 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11294 for operation in &operations {
11295 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
11296 return Err(invalid_feed("proposal upload operation has no kind"));
11297 };
11298 let hash = match kind {
11299 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
11300 "asset_put" | "asset_resume" => operation
11301 .get("asset")
11302 .and_then(|asset| asset.get("blob_sha256"))
11303 .and_then(Value::as_str),
11304 _ => None,
11305 };
11306 let Some(hash) = hash else { continue };
11307 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
11308 if kind == "rename" {
11309 for field in ["from", "to"] {
11310 coordinates.insert(
11311 operation
11312 .get(field)
11313 .and_then(Value::as_str)
11314 .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
11315 .to_string(),
11316 );
11317 }
11318 } else {
11319 let path = operation
11320 .get("path")
11321 .and_then(Value::as_str)
11322 .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
11323 coordinates.insert(if kind.starts_with("asset_") {
11324 format!("assets/{path}")
11325 } else {
11326 path.to_string()
11327 });
11328 }
11329 }
11330 let declarations = downloaded
11331 .iter()
11332 .map(|(sha256, bytes)| {
11333 json!({
11334 "sha256": sha256,
11335 "bytes": bytes.len(),
11336 "coordinates": coordinates_by_hash
11337 .get(sha256)
11338 .into_iter()
11339 .flatten()
11340 .collect::<Vec<_>>(),
11341 })
11342 })
11343 .collect::<Vec<_>>();
11344 let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
11345 for batch in batch_upload_declarations(declarations) {
11346 let reserved = ensure_ok(
11347 request(
11348 cfg,
11349 "POST",
11350 &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
11351 Some(&json!({ "blobs": batch })),
11352 Auth::Required,
11353 )?,
11354 "prepare proposal blob transport",
11355 )?;
11356 let reserved_items = reserved
11357 .get("uploads")
11358 .and_then(Value::as_array)
11359 .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
11360 items.extend(reserved_items.iter().cloned());
11361 }
11362 if items.len() != downloaded.len() {
11363 return Err(invalid_feed("proposal upload reservation changed the set"));
11364 }
11365 let mut references = Vec::with_capacity(items.len());
11366 for item in items {
11367 let hash = item
11368 .get("sha256")
11369 .and_then(Value::as_str)
11370 .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
11371 let bytes = downloaded
11372 .get(hash)
11373 .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
11374 let reservation_id = item
11375 .get("reservation_id")
11376 .and_then(Value::as_str)
11377 .filter(|id| crate::ulid::is_ulid(id))
11378 .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
11379 let expected_coordinates = coordinates_by_hash
11380 .get(hash)
11381 .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
11382 let returned_coordinates = item
11383 .get("coordinates")
11384 .and_then(Value::as_array)
11385 .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
11386 if returned_coordinates.len() != expected_coordinates.len()
11387 || returned_coordinates
11388 .iter()
11389 .zip(expected_coordinates)
11390 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
11391 {
11392 return Err(invalid_feed(
11393 "proposal upload reservation changed its coordinates",
11394 ));
11395 }
11396 match item.get("status").and_then(Value::as_str) {
11397 Some("upload") => put_presigned(
11398 cfg,
11399 item.get("url")
11400 .and_then(Value::as_str)
11401 .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
11402 item.get("headers").unwrap_or(&Value::Null),
11403 bytes,
11404 )?,
11405 Some("already_present") => {}
11406 _ => return Err(invalid_feed("proposal upload status is invalid")),
11407 }
11408 references.push(json!({
11409 "sha256": hash,
11410 "bytes": bytes.len(),
11411 "reservation_id": reservation_id,
11412 }));
11413 }
11414 body["blobs"] = Value::Array(references);
11415 }
11416 if body.to_string().len() > MAX_PUSH_BYTES {
11417 return Err(LinkError::PushTooLarge {
11418 detail: "proposal operation metadata exceeds the commit request cap".to_string(),
11419 });
11420 }
11421 let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
11422 let mut result = ensure_ok(
11423 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11424 "exact proposal acceptance",
11425 )?;
11426 let mut candidate_hub_signer = None;
11427 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
11428 let request_id = result
11429 .get("request_id")
11430 .and_then(Value::as_str)
11431 .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
11432 .to_string();
11433 let challenge = result
11434 .get("signing_challenge")
11435 .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
11436 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
11437 cfg,
11438 &head,
11439 &expected_candidate,
11440 &expected_candidate_assets,
11441 mutation_id,
11442 &body,
11443 challenge,
11444 )?;
11445 body["signing_challenge_id"] = Value::String(challenge_id);
11446 body["signature_base64url"] = Value::String(signature);
11447 candidate_hub_signer = Some(actor_signer);
11448 result = ensure_ok(
11449 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
11450 "signed exact proposal acceptance",
11451 )?;
11452 }
11453 let refreshed = v2_verified_head(cfg, brain)?
11454 .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
11455 if candidate_hub_signer
11456 .as_ref()
11457 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
11458 || refreshed
11459 .pointer
11460 .as_ref()
11461 .map(|pointer| pointer.commit_hash.as_str())
11462 != result.get("commit_hash").and_then(Value::as_str)
11463 || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11464 || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
11465 {
11466 return Err(LinkError::RemoteAdvancedDuringSync);
11467 }
11468 accept_v2_head(cfg, &refreshed)?;
11469 Ok(result)
11470}
11471
11472pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
11483 require_valid_handle(handle)?;
11484 if body.len() as u64 > MAX_PROPOSE_BYTES {
11485 return Err(LinkError::ProposeTooLarge {
11486 bytes: body.len() as u64,
11487 });
11488 }
11489 let payload = json!({ "app": app, "body": body });
11490 let (path, auth) = if crate::ulid::is_ulid(handle) {
11495 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
11496 } else {
11497 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
11498 };
11499 ensure_ok(
11500 request(cfg, "POST", &path, Some(&payload), auth)?,
11501 "propose",
11502 )
11503}
11504
11505#[derive(Debug, serde::Serialize)]
11511pub struct Head {
11512 pub brain: String,
11514 pub seq: u64,
11516 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
11518 pub updated_at: Option<String>,
11519 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
11521 pub feed_hash: Option<String>,
11522 pub verified: bool,
11525}
11526
11527struct BoundedVecVisitor<T, const MAX: usize> {
11528 label: &'static str,
11529 marker: std::marker::PhantomData<T>,
11530}
11531
11532impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
11533where
11534 T: Deserialize<'de>,
11535{
11536 type Value = Vec<T>;
11537
11538 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11539 write!(formatter, "at most {MAX} {}", self.label)
11540 }
11541
11542 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
11543 where
11544 A: serde::de::SeqAccess<'de>,
11545 {
11546 if sequence.size_hint().is_some_and(|size| size > MAX) {
11547 return Err(serde::de::Error::custom(format!(
11548 "{} exceeds the {MAX}-item limit",
11549 self.label
11550 )));
11551 }
11552 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
11553 while let Some(value) = sequence.next_element()? {
11554 if values.len() == MAX {
11555 return Err(serde::de::Error::custom(format!(
11556 "{} exceeds the {MAX}-item limit",
11557 self.label
11558 )));
11559 }
11560 values.push(value);
11561 }
11562 Ok(values)
11563 }
11564}
11565
11566fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
11567 deserializer: D,
11568 label: &'static str,
11569) -> Result<Vec<T>, D::Error>
11570where
11571 D: serde::Deserializer<'de>,
11572 T: Deserialize<'de>,
11573{
11574 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
11575 label,
11576 marker: std::marker::PhantomData,
11577 })
11578}
11579
11580fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
11581where
11582 D: serde::Deserializer<'de>,
11583{
11584 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
11585}
11586
11587fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
11588where
11589 D: serde::Deserializer<'de>,
11590{
11591 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
11592}
11593
11594fn deserialize_previous_identities<'de, D>(
11595 deserializer: D,
11596) -> Result<Vec<PreviousIdentity>, D::Error>
11597where
11598 D: serde::Deserializer<'de>,
11599{
11600 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
11601 deserializer,
11602 "previous identities",
11603 )
11604}
11605
11606fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
11607where
11608 D: serde::Deserializer<'de>,
11609{
11610 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
11611 deserializer,
11612 "rotation statements",
11613 )
11614}
11615
11616fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
11617where
11618 D: serde::Deserializer<'de>,
11619{
11620 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
11621}
11622
11623#[derive(Debug, Clone, Deserialize, Serialize)]
11624struct FeedFile {
11625 path: String,
11626 sha256: String,
11627 bytes: u64,
11628}
11629
11630#[cfg(test)]
11631#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11632enum V1DisclosureError {
11633 DuplicateFile,
11634 DuplicateRemoved,
11635 PushManifestMismatch,
11636 EditMissingChange,
11637 EditFalseFile,
11638 RemovedMismatch,
11639}
11640
11641#[cfg(test)]
11645fn verify_v1_manifest_disclosure(
11646 kind: &str,
11647 previous: &[FeedFile],
11648 resulting: &[FeedFile],
11649 files: &[FeedFile],
11650 removed: &[String],
11651) -> Result<(), V1DisclosureError> {
11652 fn as_map(
11653 files: &[FeedFile],
11654 ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
11655 let mut result = std::collections::BTreeMap::new();
11656 for file in files {
11657 if result
11658 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11659 .is_some()
11660 {
11661 return Err(V1DisclosureError::DuplicateFile);
11662 }
11663 }
11664 Ok(result)
11665 }
11666 let previous = as_map(previous)?;
11667 let resulting = as_map(resulting)?;
11668 let disclosed = as_map(files)?;
11669 let removed_set: std::collections::BTreeSet<&str> =
11670 removed.iter().map(String::as_str).collect();
11671 if removed_set.len() != removed.len() {
11672 return Err(V1DisclosureError::DuplicateRemoved);
11673 }
11674 let expected_removed: std::collections::BTreeSet<&str> = previous
11675 .keys()
11676 .copied()
11677 .filter(|path| !resulting.contains_key(path))
11678 .collect();
11679 if removed_set != expected_removed {
11680 return Err(V1DisclosureError::RemovedMismatch);
11681 }
11682 if kind == "push" {
11683 return if disclosed == resulting {
11684 Ok(())
11685 } else {
11686 Err(V1DisclosureError::PushManifestMismatch)
11687 };
11688 }
11689 if kind != "edit" {
11690 return Err(V1DisclosureError::EditFalseFile);
11691 }
11692 if disclosed
11693 .iter()
11694 .any(|(path, value)| resulting.get(path) != Some(value))
11695 {
11696 return Err(V1DisclosureError::EditFalseFile);
11697 }
11698 for (path, value) in &resulting {
11699 if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
11700 return Err(V1DisclosureError::EditMissingChange);
11701 }
11702 }
11703 Ok(())
11704}
11705
11706#[derive(Debug, Clone, Deserialize, Serialize)]
11707struct FeedEntry {
11708 v: u8,
11709 seq: u64,
11710 ts: String,
11711 brain: String,
11712 public_key: String,
11713 kind: String,
11714 op: String,
11715 pack_sha256: String,
11716 #[serde(deserialize_with = "deserialize_feed_files")]
11717 files: Vec<FeedFile>,
11718 #[serde(deserialize_with = "deserialize_removed_paths")]
11719 removed: Vec<String>,
11720 prev_entry_hash: Option<String>,
11721 sig: String,
11722}
11723
11724#[derive(Serialize)]
11725struct UnsignedFeedEntry<'a> {
11726 v: u8,
11727 seq: u64,
11728 ts: &'a str,
11729 brain: &'a str,
11730 public_key: &'a str,
11731 kind: &'a str,
11732 op: &'a str,
11733 pack_sha256: &'a str,
11734 files: &'a [FeedFile],
11735 removed: &'a [String],
11736 prev_entry_hash: &'a Option<String>,
11737}
11738
11739#[derive(Debug, Clone, Deserialize, Serialize)]
11740struct FeedItem {
11741 hash: String,
11742 entry: FeedEntry,
11743}
11744
11745#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
11746struct FeedIdentity {
11747 fingerprint: String,
11748 #[serde(rename = "publicKeySpki")]
11749 public_key_spki: String,
11750 #[serde(default, deserialize_with = "deserialize_previous_identities")]
11754 previous: Vec<PreviousIdentity>,
11755 #[serde(default, deserialize_with = "deserialize_rotations")]
11758 rotations: Vec<String>,
11759}
11760
11761#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
11762struct PreviousIdentity {
11763 fingerprint: String,
11764 #[serde(rename = "publicKeySpki")]
11765 public_key_spki: String,
11766}
11767
11768#[derive(Debug, Deserialize)]
11769struct FeedResponse {
11770 #[serde(rename = "headSeq")]
11771 head_seq: u64,
11772 #[serde(rename = "feedHash")]
11773 feed_hash: Option<String>,
11774 identity: Option<FeedIdentity>,
11775 #[serde(deserialize_with = "deserialize_feed_items")]
11776 entries: Vec<FeedItem>,
11777 #[serde(rename = "scopeLimited")]
11778 scope_limited: bool,
11779}
11780
11781#[derive(Debug, Deserialize, Serialize)]
11782#[serde(deny_unknown_fields)]
11783struct RotationStatement {
11784 v: u8,
11785 op: String,
11786 brain: String,
11787 public_key: String,
11788 new_brain: String,
11789 new_public_key: String,
11790 prior_head_seq: u64,
11791 prior_feed_hash: Option<String>,
11792 ts: String,
11793 sig: String,
11794}
11795
11796#[derive(Debug, Clone, Deserialize, Serialize)]
11797struct TrustState {
11798 v: u8,
11799 origin: String,
11800 #[serde(default)]
11804 requested: String,
11805 brain: String,
11807 #[serde(default, skip_serializing_if = "Option::is_none")]
11810 home: Option<String>,
11811 anchor: String,
11812 current: String,
11813 #[serde(rename = "headSeq")]
11814 head_seq: u64,
11815 #[serde(rename = "feedHash")]
11816 feed_hash: Option<String>,
11817 #[serde(default)]
11821 rotations: Vec<String>,
11822 #[serde(default, skip_serializing_if = "Option::is_none")]
11825 hub_signer: Option<String>,
11826 #[serde(default, skip_serializing_if = "Option::is_none")]
11829 protocol_profile: Option<String>,
11830}
11831
11832fn accepted_as_v2(state: &TrustState) -> bool {
11833 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
11834}
11835
11836fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
11837 let directory = open_trust_dir(cfg)?;
11838 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
11839 return Ok(true);
11840 }
11841 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
11842 return Ok(false);
11843 };
11844 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
11845}
11846
11847#[derive(Debug, Clone, Deserialize, Serialize)]
11848struct AliasBinding {
11849 v: u8,
11850 origin: String,
11851 requested: String,
11852 brain: String,
11853 #[serde(default, skip_serializing_if = "Option::is_none")]
11854 home: Option<String>,
11855}
11856
11857struct VerifiedRemote {
11858 head: Head,
11859 identity: Option<FeedIdentity>,
11860 head_entry: Option<FeedItem>,
11861 entries: Vec<FeedItem>,
11863 anchor: Option<String>,
11864}
11865
11866fn invalid_feed(message: impl Into<String>) -> LinkError {
11867 LinkError::InvalidFeed {
11868 message: message.into(),
11869 }
11870}
11871
11872fn is_sha256(value: &str) -> bool {
11873 value.len() == 64
11874 && value
11875 .bytes()
11876 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
11877}
11878
11879fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
11880 let der = URL_SAFE_NO_PAD
11881 .decode(public_key_spki)
11882 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
11883 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
11884 return Err(invalid_feed(
11885 "identity public key is not a valid Ed25519 SPKI",
11886 ));
11887 }
11888 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
11889}
11890
11891fn verify_identity_chain(
11895 identity: &FeedIdentity,
11896 pinned: Option<&TrustState>,
11897) -> LinkResult<String> {
11898 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
11899 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
11900 {
11901 return Err(invalid_feed(
11902 "identity rotation history exceeds the client cap",
11903 ));
11904 }
11905 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
11906 return Err(invalid_feed(
11907 "current identity fingerprint does not match its public key",
11908 ));
11909 }
11910 for previous in &identity.previous {
11911 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
11912 return Err(invalid_feed(
11913 "previous identity fingerprint does not match its public key",
11914 ));
11915 }
11916 }
11917 if identity.rotations.len() != identity.previous.len() {
11918 return Err(invalid_feed(
11919 "identity history is missing an old-key-signed rotation statement",
11920 ));
11921 }
11922
11923 let mut chain: Vec<(&str, &str)> = identity
11927 .previous
11928 .iter()
11929 .rev()
11930 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
11931 .collect();
11932 chain.push((&identity.fingerprint, &identity.public_key_spki));
11933
11934 for (index, raw) in identity.rotations.iter().enumerate() {
11935 let statement: RotationStatement = serde_json::from_str(raw)
11936 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
11937 let (old_fingerprint, old_spki) = chain[index];
11938 let (new_fingerprint, new_spki) = chain[index + 1];
11939 if statement.v != 1
11940 || statement.op != "rotate"
11941 || statement.brain != format!("ed25519:{old_fingerprint}")
11942 || statement.public_key != old_spki
11943 || statement.new_brain != format!("ed25519:{new_fingerprint}")
11944 || statement.new_public_key != new_spki
11945 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
11946 || (statement.prior_head_seq > 0
11947 && statement
11948 .prior_feed_hash
11949 .as_deref()
11950 .is_none_or(|hash| !is_sha256(hash)))
11951 {
11952 return Err(invalid_feed(
11953 "rotation statement does not connect adjacent identities",
11954 ));
11955 }
11956 let unsigned = serde_json::to_string(&UnsignedRotation {
11957 v: statement.v,
11958 op: &statement.op,
11959 brain: &statement.brain,
11960 public_key: &statement.public_key,
11961 new_brain: &statement.new_brain,
11962 new_public_key: &statement.new_public_key,
11963 prior_head_seq: statement.prior_head_seq,
11964 prior_feed_hash: statement.prior_feed_hash.as_deref(),
11965 ts: statement.ts.clone(),
11966 })
11967 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
11968 let exact = format!(
11969 "{},\"sig\":\"{}\"}}",
11970 &unsigned[..unsigned.len() - 1],
11971 statement.sig
11972 );
11973 if exact != *raw {
11974 return Err(invalid_feed(
11975 "rotation statement is not in normative serialization",
11976 ));
11977 }
11978 let der = URL_SAFE_NO_PAD
11979 .decode(old_spki)
11980 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
11981 let signature = URL_SAFE_NO_PAD
11982 .decode(&statement.sig)
11983 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
11984 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
11985 .verify(unsigned.as_bytes(), &signature)
11986 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
11987 if index > 0 {
11988 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
11989 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
11990 if statement.prior_head_seq < prior.prior_head_seq {
11991 return Err(invalid_feed("rotation feed boundaries move backward"));
11992 }
11993 }
11994 }
11995
11996 let anchor = format!("ed25519:{}", chain[0].0);
11997 let current = format!("ed25519:{}", identity.fingerprint);
11998 if let Some(pin) = pinned {
11999 if pin.anchor != anchor {
12000 return Err(invalid_feed(
12001 "served identity chain does not descend from the pinned anchor",
12002 ));
12003 }
12004 if !chain
12005 .iter()
12006 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12007 {
12008 return Err(invalid_feed(
12009 "served identity chain forked away from the last pinned identity",
12010 ));
12011 }
12012 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
12013 return Err(invalid_feed("served identity discarded its rotation chain"));
12014 }
12015 if pin.v >= 2
12016 && (identity.rotations.len() < pin.rotations.len()
12017 || identity.rotations[..pin.rotations.len()] != pin.rotations)
12018 {
12019 return Err(invalid_feed(
12020 "served identity rewrote the locally accepted rotation history",
12021 ));
12022 }
12023 }
12024 Ok(anchor)
12025}
12026
12027fn verify_rotation_feed_boundaries(
12028 identity: &FeedIdentity,
12029 pinned: Option<&TrustState>,
12030 observed: &[FeedItem],
12031 advertised_seq: u64,
12032) -> LinkResult<()> {
12033 let mut chain: Vec<String> = identity
12034 .previous
12035 .iter()
12036 .rev()
12037 .map(|previous| format!("ed25519:{}", previous.fingerprint))
12038 .collect();
12039 chain.push(format!("ed25519:{}", identity.fingerprint));
12040 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
12041
12042 for (index, raw) in identity.rotations.iter().enumerate() {
12043 let rotation: RotationStatement = serde_json::from_str(raw)
12044 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12045 if rotation.prior_head_seq > advertised_seq {
12046 return Err(invalid_feed(
12047 "rotation claims a feed boundary beyond the advertised head",
12048 ));
12049 }
12050 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
12051 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
12052 return Err(invalid_feed(
12053 "newly disclosed rotation predates the local feed checkpoint",
12054 ));
12055 }
12056 }
12057 let actual = if rotation.prior_head_seq == 0 {
12058 None
12059 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
12060 pinned.and_then(|pin| pin.feed_hash.as_deref())
12061 } else {
12062 observed
12063 .iter()
12064 .find(|item| item.entry.seq == rotation.prior_head_seq)
12065 .map(|item| item.hash.as_str())
12066 };
12067 if let Some(actual) = actual {
12068 if rotation.prior_feed_hash.as_deref() != Some(actual) {
12069 return Err(invalid_feed(
12070 "rotation statement does not commit the verified feed boundary",
12071 ));
12072 }
12073 } else if rotation.prior_head_seq == 0 {
12074 } else if pinned.is_some_and(|pin| {
12077 pinned_index.is_some_and(|pin_index| index >= pin_index)
12078 || rotation.prior_head_seq >= pin.head_seq
12079 }) {
12080 return Err(invalid_feed(
12081 "rotation feed boundary was not present in the verified chain",
12082 ));
12083 }
12084 }
12085 Ok(())
12086}
12087
12088fn reject_retired_signer_after_checkpoint(
12093 identity: &FeedIdentity,
12094 pinned: Option<&TrustState>,
12095 item: &FeedItem,
12096) -> LinkResult<()> {
12097 let Some(pin) = pinned else {
12098 return Ok(());
12099 };
12100 if item.entry.seq <= pin.head_seq {
12101 return Ok(());
12102 }
12103 let mut chain: Vec<String> = identity
12104 .previous
12105 .iter()
12106 .rev()
12107 .map(|previous| format!("ed25519:{}", previous.fingerprint))
12108 .collect();
12109 chain.push(format!("ed25519:{}", identity.fingerprint));
12110 let pinned_index = chain
12111 .iter()
12112 .position(|key| key == &pin.current)
12113 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
12114 let signer_index = chain
12115 .iter()
12116 .position(|key| key == &item.entry.brain)
12117 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
12118 if signer_index < pinned_index {
12119 return Err(invalid_feed(
12120 "a retired identity attempted to sign after the local checkpoint",
12121 ));
12122 }
12123 Ok(())
12124}
12125
12126fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
12127 let origin = normalized_origin(&cfg.hub)?;
12128 let key = format!(
12129 "{:x}",
12130 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
12131 );
12132 Ok(format!("{key}.json"))
12133}
12134
12135fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
12136 let origin = normalized_origin(&cfg.hub)?;
12137 let key = format!(
12138 "{:x}",
12139 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
12140 );
12141 Ok(format!("alias-{key}.json"))
12142}
12143
12144#[cfg(any(unix, windows))]
12145struct TrustLock {
12146 _file: std::fs::File,
12147}
12148
12149#[cfg(unix)]
12150fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12151 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12152
12153 let lock_string = format!(".{state_name}.lock");
12154 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
12155 let fd = unsafe {
12156 libc::openat(
12157 directory.as_raw_fd(),
12158 lock_name.as_ptr(),
12159 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12160 0o600,
12161 )
12162 };
12163 if fd < 0 {
12164 return Err(std::io::Error::last_os_error().into());
12165 }
12166 let file = unsafe { std::fs::File::from_raw_fd(fd) };
12167 if !file.metadata()?.is_file() {
12168 return Err(LinkError::UnsafePath { path: lock_string });
12169 }
12170 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
12171 return Err(std::io::Error::last_os_error().into());
12172 }
12173 Ok(TrustLock { _file: file })
12174}
12175
12176#[cfg(windows)]
12177fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12178 let lock_name = format!(".{state_name}.lock");
12179 let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
12180 Ok(TrustLock { _file: file })
12181}
12182
12183#[cfg(any(unix, windows))]
12184fn lock_trust_many(
12185 cfg: &HubConfig,
12186 directory: &std::fs::File,
12187 refs: &[&str],
12188) -> LinkResult<Vec<TrustLock>> {
12189 let mut names = refs
12190 .iter()
12191 .map(|reference| trust_file_name(cfg, reference))
12192 .collect::<LinkResult<Vec<_>>>()?;
12193 names.sort();
12194 names.dedup();
12195 names
12196 .iter()
12197 .map(|name| lock_trust_name(directory, name))
12198 .collect()
12199}
12200
12201#[cfg(not(any(unix, windows)))]
12202fn lock_trust_many(
12203 _cfg: &HubConfig,
12204 _directory: &TrustDirectory,
12205 _refs: &[&str],
12206) -> LinkResult<Vec<()>> {
12207 Err(LinkError::UnsupportedPlatform {
12208 operation: "verified link.md state",
12209 })
12210}
12211
12212#[cfg(any(unix, windows))]
12213type TrustDirectory = std::fs::File;
12214
12215#[cfg(not(any(unix, windows)))]
12216struct TrustDirectory;
12217
12218#[cfg(unix)]
12219fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12220 use std::os::fd::AsRawFd as _;
12221
12222 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
12223 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
12224 return Err(std::io::Error::last_os_error().into());
12225 }
12226 directory.sync_all()?;
12227 Ok(directory)
12228}
12229
12230#[cfg(windows)]
12231fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12232 let marker = cfg.state_dir.join("trust").join(".directory");
12233 crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
12234 Ok(crate::fsx::open_directory_nofollow(
12235 marker.parent().expect("trust marker has a parent"),
12236 )?)
12237}
12238
12239#[cfg(not(any(unix, windows)))]
12240fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12241 Err(LinkError::UnsupportedPlatform {
12242 operation: "verified link.md state",
12243 })
12244}
12245
12246#[cfg(unix)]
12247fn load_trust_in(
12248 cfg: &HubConfig,
12249 directory: &TrustDirectory,
12250 requested: &str,
12251) -> LinkResult<Option<TrustState>> {
12252 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12253
12254 let name_string = trust_file_name(cfg, requested)?;
12255 let name = c_name(name_string.as_bytes(), &name_string)?;
12256 let fd = unsafe {
12257 libc::openat(
12258 directory.as_raw_fd(),
12259 name.as_ptr(),
12260 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12261 )
12262 };
12263 if fd < 0 {
12264 let error = std::io::Error::last_os_error();
12265 if error.kind() == std::io::ErrorKind::NotFound {
12266 return Ok(None);
12267 }
12268 return Err(LinkError::UnsafePath { path: name_string });
12269 }
12270 let file = unsafe { std::fs::File::from_raw_fd(fd) };
12271 if !file.metadata()?.is_file() {
12272 return Err(LinkError::UnsafePath { path: name_string });
12273 }
12274 let mut bytes = Vec::new();
12275 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
12276 if bytes.len() > 1024 * 1024 {
12277 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
12278 }
12279 let mut state: TrustState = serde_json::from_slice(&bytes)
12280 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
12281 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
12282 return Err(invalid_feed(
12283 "local identity/feed checkpoint does not match this hub and brain",
12284 ));
12285 }
12286 if state.v == 1 {
12287 if state.brain != requested {
12291 return Err(invalid_feed(
12292 "legacy checkpoint is not bound to the requested brain id",
12293 ));
12294 }
12295 state.requested = requested.to_string();
12296 } else if state.requested != requested {
12297 return Err(invalid_feed(
12298 "local identity/feed checkpoint is bound to a different requested ref",
12299 ));
12300 }
12301 Ok(Some(state))
12302}
12303
12304#[cfg(windows)]
12305fn load_trust_in(
12306 cfg: &HubConfig,
12307 directory: &TrustDirectory,
12308 requested: &str,
12309) -> LinkResult<Option<TrustState>> {
12310 let name = trust_file_name(cfg, requested)?;
12311 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
12312 let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
12313 Ok(bytes) => bytes,
12314 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
12315 Err(_) => return Err(LinkError::UnsafePath { path: name }),
12316 };
12317 let mut state: TrustState = serde_json::from_slice(&bytes)
12318 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
12319 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
12320 return Err(invalid_feed(
12321 "local identity/feed checkpoint does not match this hub and brain",
12322 ));
12323 }
12324 if state.v == 1 {
12325 if state.brain != requested {
12326 return Err(invalid_feed(
12327 "legacy checkpoint is not bound to the requested brain id",
12328 ));
12329 }
12330 state.requested = requested.to_string();
12331 } else if state.requested != requested {
12332 return Err(invalid_feed(
12333 "local identity/feed checkpoint is bound to a different requested ref",
12334 ));
12335 }
12336 Ok(Some(state))
12337}
12338
12339#[cfg(not(any(unix, windows)))]
12340fn load_trust_in(
12341 _cfg: &HubConfig,
12342 _directory: &TrustDirectory,
12343 _brain: &str,
12344) -> LinkResult<Option<TrustState>> {
12345 Err(LinkError::UnsupportedPlatform {
12346 operation: "verified link.md state",
12347 })
12348}
12349
12350#[cfg(all(test, any(unix, windows)))]
12351fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
12352 let directory = open_trust_dir(cfg)?;
12353 load_trust_in(cfg, &directory, requested)
12354}
12355
12356#[cfg(unix)]
12357fn save_trust_in(
12358 cfg: &HubConfig,
12359 directory: &TrustDirectory,
12360 state: &TrustState,
12361) -> LinkResult<()> {
12362 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12363
12364 let name_string = trust_file_name(cfg, &state.requested)?;
12365 let name = c_name(name_string.as_bytes(), &name_string)?;
12366 let mut bytes = serde_json::to_vec(state)
12367 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
12368 bytes.push(b'\n');
12369
12370 let nonce = std::time::SystemTime::now()
12371 .duration_since(std::time::UNIX_EPOCH)
12372 .unwrap_or_default()
12373 .as_nanos();
12374 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
12375 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
12376 let fd = unsafe {
12377 libc::openat(
12378 directory.as_raw_fd(),
12379 temp.as_ptr(),
12380 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12381 0o600,
12382 )
12383 };
12384 if fd < 0 {
12385 return Err(std::io::Error::last_os_error().into());
12386 }
12387 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
12388 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
12389 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12390 return Err(error.into());
12391 }
12392 drop(file);
12393 if unsafe {
12394 libc::renameat(
12395 directory.as_raw_fd(),
12396 temp.as_ptr(),
12397 directory.as_raw_fd(),
12398 name.as_ptr(),
12399 )
12400 } != 0
12401 {
12402 let error = std::io::Error::last_os_error();
12403 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12404 return Err(error.into());
12405 }
12406 directory.sync_all()?;
12407 Ok(())
12408}
12409
12410#[cfg(windows)]
12411fn save_trust_in(
12412 cfg: &HubConfig,
12413 directory: &TrustDirectory,
12414 state: &TrustState,
12415) -> LinkResult<()> {
12416 let name = trust_file_name(cfg, &state.requested)?;
12417 let mut bytes = serde_json::to_vec(state)
12418 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
12419 bytes.push(b'\n');
12420 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
12421 Ok(())
12422}
12423
12424#[cfg(not(any(unix, windows)))]
12425fn save_trust_in(
12426 _cfg: &HubConfig,
12427 _directory: &TrustDirectory,
12428 _state: &TrustState,
12429) -> LinkResult<()> {
12430 Err(LinkError::UnsupportedPlatform {
12431 operation: "verified link.md state",
12432 })
12433}
12434
12435#[cfg(unix)]
12436fn load_alias_in(
12437 cfg: &HubConfig,
12438 directory: &TrustDirectory,
12439 requested: &str,
12440) -> LinkResult<Option<AliasBinding>> {
12441 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12442
12443 let name_string = alias_file_name(cfg, requested)?;
12444 let name = c_name(name_string.as_bytes(), &name_string)?;
12445 let fd = unsafe {
12446 libc::openat(
12447 directory.as_raw_fd(),
12448 name.as_ptr(),
12449 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12450 )
12451 };
12452 if fd < 0 {
12453 let error = std::io::Error::last_os_error();
12454 if error.kind() == std::io::ErrorKind::NotFound {
12455 return Ok(None);
12456 }
12457 return Err(LinkError::UnsafePath { path: name_string });
12458 }
12459 let file = unsafe { std::fs::File::from_raw_fd(fd) };
12460 if !file.metadata()?.is_file() {
12461 return Err(LinkError::UnsafePath { path: name_string });
12462 }
12463 let mut bytes = Vec::new();
12464 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
12465 if bytes.len() > 64 * 1024 {
12466 return Err(invalid_feed("local alias binding is oversized"));
12467 }
12468 let alias: AliasBinding = serde_json::from_slice(&bytes)
12469 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
12470 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
12471 {
12472 return Err(invalid_feed(
12473 "local alias binding does not match this hub and requested ref",
12474 ));
12475 }
12476 Ok(Some(alias))
12477}
12478
12479#[cfg(windows)]
12480fn load_alias_in(
12481 cfg: &HubConfig,
12482 directory: &TrustDirectory,
12483 requested: &str,
12484) -> LinkResult<Option<AliasBinding>> {
12485 let name = alias_file_name(cfg, requested)?;
12486 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
12487 let bytes = match reader.read(Path::new(&name), 64 * 1024) {
12488 Ok(bytes) => bytes,
12489 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
12490 Err(_) => return Err(LinkError::UnsafePath { path: name }),
12491 };
12492 let alias: AliasBinding = serde_json::from_slice(&bytes)
12493 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
12494 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
12495 {
12496 return Err(invalid_feed(
12497 "local alias binding does not match this hub and requested ref",
12498 ));
12499 }
12500 Ok(Some(alias))
12501}
12502
12503#[cfg(not(any(unix, windows)))]
12504fn load_alias_in(
12505 _cfg: &HubConfig,
12506 _directory: &TrustDirectory,
12507 _requested: &str,
12508) -> LinkResult<Option<AliasBinding>> {
12509 Err(LinkError::UnsupportedPlatform {
12510 operation: "verified link.md state",
12511 })
12512}
12513
12514#[cfg(unix)]
12515fn save_alias_in(
12516 cfg: &HubConfig,
12517 directory: &TrustDirectory,
12518 alias: &AliasBinding,
12519) -> LinkResult<()> {
12520 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12521
12522 let name_string = alias_file_name(cfg, &alias.requested)?;
12523 let name = c_name(name_string.as_bytes(), &name_string)?;
12524 let mut bytes = serde_json::to_vec(alias)
12525 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
12526 bytes.push(b'\n');
12527 let nonce = std::time::SystemTime::now()
12528 .duration_since(std::time::UNIX_EPOCH)
12529 .unwrap_or_default()
12530 .as_nanos();
12531 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
12532 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
12533 let fd = unsafe {
12534 libc::openat(
12535 directory.as_raw_fd(),
12536 temp.as_ptr(),
12537 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12538 0o600,
12539 )
12540 };
12541 if fd < 0 {
12542 return Err(std::io::Error::last_os_error().into());
12543 }
12544 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
12545 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
12546 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12547 return Err(error.into());
12548 }
12549 drop(file);
12550 if unsafe {
12551 libc::renameat(
12552 directory.as_raw_fd(),
12553 temp.as_ptr(),
12554 directory.as_raw_fd(),
12555 name.as_ptr(),
12556 )
12557 } != 0
12558 {
12559 let error = std::io::Error::last_os_error();
12560 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12561 return Err(error.into());
12562 }
12563 directory.sync_all()?;
12564 Ok(())
12565}
12566
12567#[cfg(windows)]
12568fn save_alias_in(
12569 cfg: &HubConfig,
12570 directory: &TrustDirectory,
12571 alias: &AliasBinding,
12572) -> LinkResult<()> {
12573 let name = alias_file_name(cfg, &alias.requested)?;
12574 let mut bytes = serde_json::to_vec(alias)
12575 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
12576 bytes.push(b'\n');
12577 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
12578 Ok(())
12579}
12580
12581#[cfg(not(any(unix, windows)))]
12582fn save_alias_in(
12583 _cfg: &HubConfig,
12584 _directory: &TrustDirectory,
12585 _alias: &AliasBinding,
12586) -> LinkResult<()> {
12587 Err(LinkError::UnsupportedPlatform {
12588 operation: "verified link.md state",
12589 })
12590}
12591
12592fn load_canonical_pin(
12597 cfg: &HubConfig,
12598 directory: &TrustDirectory,
12599 requested: &str,
12600 resolved_brain: &str,
12601) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
12602 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
12603 if requested == resolved_brain {
12604 return Ok((canonical, None));
12605 }
12606
12607 let mut alias = load_alias_in(cfg, directory, requested)?;
12608 if let Some(binding) = &alias {
12609 if binding.brain != resolved_brain {
12610 return Err(LinkError::AliasRebindRequired {
12611 alias: requested.to_string(),
12612 from: binding.brain.clone(),
12613 to: resolved_brain.to_string(),
12614 });
12615 }
12616 return Ok((canonical, alias));
12617 }
12618
12619 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
12623 if legacy.brain != resolved_brain {
12624 return Err(invalid_feed(
12625 "legacy alias checkpoint names a different canonical brain",
12626 ));
12627 }
12628 if let Some(existing) = &canonical {
12629 if existing.brain != legacy.brain
12630 || existing.anchor != legacy.anchor
12631 || existing.current != legacy.current
12632 || existing.head_seq != legacy.head_seq
12633 || existing.feed_hash != legacy.feed_hash
12634 || existing.rotations != legacy.rotations
12635 {
12636 return Err(invalid_feed(
12637 "legacy alias checkpoint conflicts with the canonical checkpoint",
12638 ));
12639 }
12640 } else {
12641 let mut promoted = legacy.clone();
12642 promoted.requested = resolved_brain.to_string();
12643 promoted.home = None;
12644 save_trust_in(cfg, directory, &promoted)?;
12645 canonical = Some(promoted);
12646 }
12647 alias = Some(AliasBinding {
12648 v: 1,
12649 origin: normalized_origin(&cfg.hub)?,
12650 requested: requested.to_string(),
12651 brain: resolved_brain.to_string(),
12652 home: legacy.home,
12653 });
12654 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
12655 }
12656 Ok((canonical, alias))
12657}
12658
12659pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
12664 require_hardened_filesystem("verified alias rebind")?;
12665 require_safe_ref(alias)?;
12666 require_safe_ref(from)?;
12667 require_safe_ref(to)?;
12668 if crate::ulid::is_ulid(alias)
12669 || !crate::ulid::is_ulid(from)
12670 || !crate::ulid::is_ulid(to)
12671 || from == to
12672 {
12673 return Err(LinkError::InvalidPack {
12674 message:
12675 "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
12676 .to_string(),
12677 });
12678 }
12679
12680 let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
12681 message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
12682 })?;
12683 accept_v2_head(cfg, &verified)?;
12684
12685 let alias_response = ensure_ok(
12686 request(
12687 cfg,
12688 "GET",
12689 &format!("/api/hub/brains/{alias}/v2/head"),
12690 None,
12691 Auth::Required,
12692 )?,
12693 "resolve alias for explicit rebind",
12694 )?;
12695 let resolved: V2HeadResponse = serde_json::from_value(alias_response)
12696 .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
12697 if resolved.v != 2 || resolved.brain_id != to {
12698 return Err(LinkError::RemoteAdvancedDuringSync);
12699 }
12700
12701 let directory = open_trust_dir(cfg)?;
12702 let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
12703 let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
12704 message: "the requested alias has no existing local binding to replace".to_string(),
12705 })?;
12706 if binding.brain != from {
12707 return Err(LinkError::AliasRebindRequired {
12708 alias: alias.to_string(),
12709 from: binding.brain,
12710 to: to.to_string(),
12711 });
12712 }
12713 save_alias_in(
12714 cfg,
12715 &directory,
12716 &AliasBinding {
12717 v: 1,
12718 origin: normalized_origin(&cfg.hub)?,
12719 requested: alias.to_string(),
12720 brain: to.to_string(),
12721 home: binding.home,
12722 },
12723 )?;
12724 Ok(json!({
12725 "v": 2,
12726 "alias": alias,
12727 "from": from,
12728 "to": to,
12729 "outcome": "alias_rebound",
12730 }))
12731}
12732
12733fn save_canonical_pin_and_alias(
12734 cfg: &HubConfig,
12735 directory: &TrustDirectory,
12736 requested: &str,
12737 resolved_brain: &str,
12738 mut state: TrustState,
12739 existing_alias: Option<&AliasBinding>,
12740) -> LinkResult<()> {
12741 state.requested = resolved_brain.to_string();
12742 state.brain = resolved_brain.to_string();
12743 state.home = None;
12744 save_trust_in(cfg, directory, &state)?;
12745 if requested != resolved_brain {
12746 save_alias_in(
12747 cfg,
12748 directory,
12749 &AliasBinding {
12750 v: 1,
12751 origin: normalized_origin(&cfg.hub)?,
12752 requested: requested.to_string(),
12753 brain: resolved_brain.to_string(),
12754 home: existing_alias.and_then(|alias| alias.home.clone()),
12755 },
12756 )?;
12757 }
12758 Ok(())
12759}
12760
12761fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
12762 const ED25519_SPKI_PREFIX: &[u8] = &[
12763 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
12764 ];
12765 let entry = &item.entry;
12766 let public_der = URL_SAFE_NO_PAD
12767 .decode(&entry.public_key)
12768 .map_err(|_| invalid_feed("public key is not base64url"))?;
12769 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
12770 || !public_der.starts_with(ED25519_SPKI_PREFIX)
12771 {
12772 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
12773 }
12774 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
12775 if entry.brain != format!("ed25519:{fingerprint}") {
12776 return Err(invalid_feed(
12777 "brain fingerprint does not match its public key",
12778 ));
12779 }
12780 let _ = verify_identity_chain(identity, None)?;
12782 let mut chain: Vec<(&str, &str)> = identity
12783 .previous
12784 .iter()
12785 .rev()
12786 .map(|previous| {
12787 (
12788 previous.fingerprint.as_str(),
12789 previous.public_key_spki.as_str(),
12790 )
12791 })
12792 .collect();
12793 chain.push((&identity.fingerprint, &identity.public_key_spki));
12794 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
12795 *known_fingerprint == fingerprint && *spki == entry.public_key
12796 });
12797 let Some(signer_index) = signer_index else {
12798 return Err(invalid_feed(
12799 "entry signer is not this brain's identity (current or rotated-from)",
12800 ));
12801 };
12802 let lower_boundary = if signer_index == 0 {
12803 None
12804 } else {
12805 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
12806 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12807 Some(prior.prior_head_seq)
12808 };
12809 let upper_boundary = if signer_index == identity.rotations.len() {
12810 None
12811 } else {
12812 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
12813 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12814 Some(next.prior_head_seq)
12815 };
12816 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
12817 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
12818 {
12819 return Err(invalid_feed(
12820 "entry signer is outside its authenticated rotation epoch",
12821 ));
12822 }
12823 let unsigned = UnsignedFeedEntry {
12824 v: entry.v,
12825 seq: entry.seq,
12826 ts: &entry.ts,
12827 brain: &entry.brain,
12828 public_key: &entry.public_key,
12829 kind: &entry.kind,
12830 op: &entry.op,
12831 pack_sha256: &entry.pack_sha256,
12832 files: &entry.files,
12833 removed: &entry.removed,
12834 prev_entry_hash: &entry.prev_entry_hash,
12835 };
12836 let message =
12837 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
12838 let signature = URL_SAFE_NO_PAD
12839 .decode(&entry.sig)
12840 .map_err(|_| invalid_feed("signature is not base64url"))?;
12841 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
12842 .verify(&message, &signature)
12843 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
12844
12845 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
12846 exact.push(b'\n');
12847 let actual_hash = format!("{:x}", Sha256::digest(&exact));
12848 if actual_hash != item.hash {
12849 return Err(invalid_feed("entry SHA-256 does not match"));
12850 }
12851 Ok(())
12852}
12853
12854#[derive(Serialize)]
12860struct UnsignedRotation<'a> {
12861 v: u8,
12862 op: &'a str,
12863 brain: &'a str,
12864 public_key: &'a str,
12865 new_brain: &'a str,
12866 new_public_key: &'a str,
12867 prior_head_seq: u64,
12868 prior_feed_hash: Option<&'a str>,
12869 ts: String,
12870}
12871
12872#[derive(Debug, Deserialize, Serialize)]
12877#[serde(deny_unknown_fields)]
12878struct RotationJournal {
12879 v: u8,
12880 origin: String,
12881 brain: String,
12882 old_brain: String,
12883 new_brain: String,
12884 prior_head_seq: u64,
12885 prior_feed_hash: Option<String>,
12886 statement: String,
12887}
12888
12889fn rotation_journal_path(key_path: &Path) -> PathBuf {
12890 let mut path = key_path.as_os_str().to_os_string();
12891 path.push(".rotation.json");
12892 PathBuf::from(path)
12893}
12894
12895fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
12896 #[cfg(unix)]
12897 let file = {
12898 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12899 use std::os::unix::ffi::OsStrExt as _;
12900 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
12901 .map_err(|error| {
12902 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
12903 })?;
12904 let leaf_name = path
12905 .file_name()
12906 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
12907 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
12908 let fd = unsafe {
12909 libc::openat(
12910 parent.as_raw_fd(),
12911 leaf.as_ptr(),
12912 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12913 )
12914 };
12915 if fd < 0 {
12916 return Err(bad_agent_key(
12917 "the rotation journal must be an existing regular file without symlink ancestors",
12918 ));
12919 }
12920 unsafe { std::fs::File::from_raw_fd(fd) }
12921 };
12922 #[cfg(not(unix))]
12923 let file = std::fs::File::open(path)
12924 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
12925 let metadata = file
12926 .metadata()
12927 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
12928 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
12929 return Err(bad_agent_key(
12930 "the rotation journal must be a bounded regular file",
12931 ));
12932 }
12933 #[cfg(unix)]
12934 {
12935 use std::os::unix::fs::PermissionsExt as _;
12936 if metadata.permissions().mode() & 0o077 != 0 {
12937 return Err(bad_agent_key(
12938 "the rotation journal is accessible to group/other; set mode 0600",
12939 ));
12940 }
12941 }
12942 serde_json::from_reader(file)
12943 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
12944}
12945
12946fn remove_rotation_journal(path: &Path) {
12947 #[cfg(unix)]
12948 {
12949 use std::os::fd::AsRawFd as _;
12950 use std::os::unix::ffi::OsStrExt as _;
12951 let Ok(parent) =
12952 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
12953 else {
12954 return;
12955 };
12956 let Some(leaf_name) = path.file_name() else {
12957 return;
12958 };
12959 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
12960 return;
12961 };
12962 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
12963 let _ = parent.sync_all();
12964 }
12965 }
12966 #[cfg(not(unix))]
12967 {
12968 let _ = std::fs::remove_file(path);
12969 }
12970}
12971
12972fn validate_rotation_journal(
12973 journal: &RotationJournal,
12974 cfg: &HubConfig,
12975 canonical_brain: &str,
12976 old_key: &AgentSigningKey,
12977 new_key: &AgentSigningKey,
12978 head: &Head,
12979) -> LinkResult<()> {
12980 if journal.v != 1
12981 || journal.origin != normalized_origin(&cfg.hub)?
12982 || journal.brain != canonical_brain
12983 || journal.old_brain != old_key.multikey
12984 || journal.new_brain != new_key.multikey
12985 || journal.prior_head_seq != head.seq
12986 || journal.prior_feed_hash != head.feed_hash
12987 {
12988 return Err(invalid_feed(
12989 "rotation journal does not match the verified key and feed boundary",
12990 ));
12991 }
12992 let statement: RotationStatement = serde_json::from_str(&journal.statement)
12993 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
12994 if statement.prior_head_seq != journal.prior_head_seq
12995 || statement.prior_feed_hash != journal.prior_feed_hash
12996 || statement.brain != old_key.multikey
12997 || statement.public_key != old_key.public_key_spki
12998 || statement.new_brain != new_key.multikey
12999 || statement.new_public_key != new_key.public_key_spki
13000 {
13001 return Err(invalid_feed(
13002 "rotation journal statement does not match its durable intent",
13003 ));
13004 }
13005 let identity = FeedIdentity {
13006 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13007 public_key_spki: new_key.public_key_spki.clone(),
13008 previous: vec![PreviousIdentity {
13009 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
13010 public_key_spki: old_key.public_key_spki.clone(),
13011 }],
13012 rotations: vec![journal.statement.clone()],
13013 };
13014 verify_identity_chain(&identity, None)?;
13015 Ok(())
13016}
13017
13018#[derive(Debug, Serialize)]
13020pub struct RotationReport {
13021 pub brain: String,
13023 pub multikey: String,
13025 #[serde(rename = "keyFile")]
13027 pub key_file: String,
13028 pub previous: Vec<String>,
13030}
13031
13032pub fn rotate_brain_key(
13038 cfg: &HubConfig,
13039 brain: &str,
13040 old_key: &AgentSigningKey,
13041 out: &Path,
13042) -> LinkResult<RotationReport> {
13043 require_hardened_filesystem("key rotation")?;
13044 require_safe_ref(brain)?;
13045 let new_key = if out.exists() {
13049 load_signing_key(out)?
13050 } else {
13051 let rng = ring::rand::SystemRandom::new();
13052 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
13053 .map_err(|_| bad_agent_key("key generation failed"))?;
13054 let pair = agent_keypair(pkcs8.as_ref())?;
13055 let (public_key_spki, multikey) = public_identity_for(&pair);
13056 write_secret_new(
13057 out,
13058 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
13059 )?;
13060 AgentSigningKey {
13061 pkcs8: pkcs8.as_ref().to_vec(),
13062 multikey,
13063 public_key_spki,
13064 }
13065 };
13066 let new_spki = new_key.public_key_spki.clone();
13067 let new_multikey = new_key.multikey.clone();
13068 let journal_path = rotation_journal_path(out);
13069 let before_v2 = v2_verified_head(cfg, brain)?;
13070 let (canonical_brain, served_identity, observed_head, v2_profile) =
13071 if let Some(head) = before_v2 {
13072 let observed = Head {
13073 brain: head.brain_id.clone(),
13074 seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13075 updated_at: head
13076 .pointer
13077 .as_ref()
13078 .map(|pointer| pointer.signed_at.clone()),
13079 feed_hash: head
13080 .pointer
13081 .as_ref()
13082 .map(|pointer| pointer.feed_hash.clone()),
13083 verified: true,
13084 };
13085 let identity = v2_identity(&head.identity);
13086 let canonical = head.brain_id.clone();
13087 accept_v2_head(cfg, &head)?;
13088 (canonical, identity, observed, true)
13089 } else {
13090 let remote = verified_remote_head(cfg, brain, false)?;
13091 let identity = remote
13092 .identity
13093 .clone()
13094 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
13095 (remote.head.brain.clone(), identity, remote.head, false)
13096 };
13097 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
13098 let already_rotated = served_multikey == new_multikey;
13099 if already_rotated && !journal_path.exists() {
13104 remove_rotation_journal(&journal_path);
13105 return Ok(RotationReport {
13106 brain: brain.to_string(),
13107 multikey: new_multikey,
13108 key_file: out.display().to_string(),
13109 previous: served_identity
13110 .previous
13111 .iter()
13112 .map(|identity| format!("ed25519:{}", identity.fingerprint))
13113 .collect(),
13114 });
13115 }
13116 if !already_rotated && served_multikey != old_key.multikey {
13117 return Err(invalid_feed(
13118 "the supplied old key is not the brain's verified current identity",
13119 ));
13120 }
13121
13122 let journal = if journal_path.exists() {
13123 read_rotation_journal(&journal_path)?
13124 } else {
13125 let ts = crate::now()
13126 .with_timezone(&chrono::Utc)
13127 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
13128 .to_string();
13129 let unsigned = serde_json::to_string(&UnsignedRotation {
13130 v: 1,
13131 op: "rotate",
13132 brain: &old_key.multikey,
13133 public_key: &old_key.public_key_spki,
13134 new_brain: &new_multikey,
13135 new_public_key: &new_spki,
13136 prior_head_seq: observed_head.seq,
13137 prior_feed_hash: observed_head.feed_hash.as_deref(),
13138 ts,
13139 })
13140 .expect("serialize rotation");
13141 let old_pair = agent_keypair(&old_key.pkcs8)?;
13142 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
13143 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
13144 let journal = RotationJournal {
13145 v: 1,
13146 origin: normalized_origin(&cfg.hub)?,
13147 brain: canonical_brain.clone(),
13148 old_brain: old_key.multikey.clone(),
13149 new_brain: new_multikey.clone(),
13150 prior_head_seq: observed_head.seq,
13151 prior_feed_hash: observed_head.feed_hash.clone(),
13152 statement,
13153 };
13154 let mut exact = serde_json::to_vec(&journal)
13155 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
13156 exact.push(b'\n');
13157 if write_secret_new(&journal_path, &exact).is_err() {
13158 read_rotation_journal(&journal_path)?
13161 } else {
13162 journal
13163 }
13164 };
13165 validate_rotation_journal(
13166 &journal,
13167 cfg,
13168 &canonical_brain,
13169 old_key,
13170 &new_key,
13171 &observed_head,
13172 )?;
13173
13174 let body = json!({ "statement": journal.statement });
13175 let path = format!("/api/hub/brains/{brain}/rotate");
13176 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
13177 let attempted_failure = match attempted {
13178 Ok(response) if (200..300).contains(&response.status) => None,
13179 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
13180 Err(error) => Some(error),
13181 };
13182
13183 let identity = if v2_profile {
13187 match v2_verified_head(cfg, brain) {
13188 Ok(Some(after)) => {
13189 let identity = v2_identity(&after.identity);
13190 accept_v2_head(cfg, &after)?;
13191 identity
13192 }
13193 Ok(None) => {
13194 return Err(attempted_failure.unwrap_or_else(|| {
13195 invalid_feed("rotated v2 brain no longer serves a v2 head")
13196 }));
13197 }
13198 Err(error) => return Err(attempted_failure.unwrap_or(error)),
13199 }
13200 } else {
13201 match verified_remote_head(cfg, brain, false) {
13202 Ok(after) => after
13203 .identity
13204 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
13205 Err(error) => return Err(attempted_failure.unwrap_or(error)),
13206 }
13207 };
13208 if format!("ed25519:{}", identity.fingerprint) != new_multikey
13209 || identity.public_key_spki != new_spki
13210 {
13211 return Err(attempted_failure.unwrap_or_else(|| {
13212 invalid_feed("hub acknowledged rotation without committing the verified new identity")
13213 }));
13214 }
13215 if v2_profile {
13216 if let Some(error) = attempted_failure {
13217 return Err(error);
13222 }
13223 }
13224 let previous = identity
13225 .previous
13226 .iter()
13227 .map(|prior| format!("ed25519:{}", prior.fingerprint))
13228 .collect();
13229 remove_rotation_journal(&journal_path);
13230
13231 Ok(RotationReport {
13232 brain: brain.to_string(),
13233 multikey: new_multikey,
13234 key_file: out.display().to_string(),
13235 previous,
13236 })
13237}
13238
13239#[derive(Debug, Serialize)]
13245pub struct MirrorReport {
13246 pub brain: String,
13248 #[serde(rename = "headSeq")]
13250 pub head_seq: u64,
13251 #[serde(rename = "feedHash")]
13253 pub feed_hash: Option<String>,
13254 pub entries: u64,
13256 pub pinned: String,
13258 pub files: usize,
13260}
13261
13262pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
13264
13265#[derive(Debug)]
13267pub struct VerifiedMirrorMaterial {
13268 pub brain: String,
13269 pub head_seq: u64,
13270 pub feed_hash: Option<String>,
13271 pub identity: serde_json::Value,
13272 pub entries: Vec<(u64, String, String)>,
13274 pub pack_sha256: Option<String>,
13275}
13276
13277#[derive(Deserialize)]
13278#[serde(deny_unknown_fields)]
13279struct StoredMirrorHead {
13280 brain: String,
13281 #[serde(rename = "headSeq")]
13282 head_seq: u64,
13283 #[serde(rename = "feedHash")]
13284 feed_hash: Option<String>,
13285}
13286
13287pub fn verify_mirror_material(
13290 head_bytes: &[u8],
13291 identity_bytes: &[u8],
13292 feed_bytes: &[Vec<u8>],
13293 snapshot_pack: Option<&[u8]>,
13294 expected_anchor: &str,
13295) -> LinkResult<VerifiedMirrorMaterial> {
13296 let snapshot_hash = snapshot_pack
13297 .filter(|pack| !pack.is_empty())
13298 .map(content_sha256);
13299 verify_mirror_material_with_pack_hash(
13300 head_bytes,
13301 identity_bytes,
13302 feed_bytes,
13303 snapshot_hash.as_deref(),
13304 expected_anchor,
13305 )
13306}
13307
13308pub fn verify_mirror_material_with_pack_hash(
13312 head_bytes: &[u8],
13313 identity_bytes: &[u8],
13314 feed_bytes: &[Vec<u8>],
13315 snapshot_pack_sha256: Option<&str>,
13316 expected_anchor: &str,
13317) -> LinkResult<VerifiedMirrorMaterial> {
13318 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
13319 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
13320 require_safe_ref(&head.brain)?;
13321 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
13322 return Err(invalid_feed(
13323 "stored mirror feed count does not match its bounded head sequence",
13324 ));
13325 }
13326 let aggregate = feed_bytes
13327 .iter()
13328 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
13329 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
13330 if aggregate > MAX_FEED_REPLAY_BYTES {
13331 return Err(invalid_feed(
13332 "stored mirror feed metadata exceeds the aggregate limit",
13333 ));
13334 }
13335 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
13336 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
13337 let anchor = verify_identity_chain(&identity, None)?;
13338 if anchor != expected_anchor {
13339 return Err(invalid_feed(
13340 "stored mirror identity does not descend from the explicitly trusted anchor",
13341 ));
13342 }
13343
13344 let mut entries = Vec::with_capacity(feed_bytes.len());
13345 let mut items = Vec::with_capacity(feed_bytes.len());
13346 let mut previous_hash = None;
13347 let mut pack_sha256 = None;
13348 for (index, bytes) in feed_bytes.iter().enumerate() {
13349 let exact = bytes
13350 .strip_suffix(b"\n")
13351 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
13352 if exact.ends_with(b"\n") {
13353 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
13354 }
13355 let entry: FeedEntry = serde_json::from_slice(exact)
13356 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
13357 let expected_seq = index as u64 + 1;
13358 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
13359 return Err(invalid_feed(
13360 "stored mirror feed is not contiguous and hash-chained",
13361 ));
13362 }
13363 let canonical = serde_json::to_vec(&entry)
13364 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
13365 if canonical != exact {
13366 return Err(invalid_feed(
13367 "stored feed entry is not in normative serialization",
13368 ));
13369 }
13370 let hash = content_sha256(bytes);
13371 let item = FeedItem {
13372 hash: hash.clone(),
13373 entry,
13374 };
13375 verify_feed_item(&item, &identity)?;
13376 previous_hash = Some(hash.clone());
13377 if expected_seq == head.head_seq {
13378 pack_sha256 = Some(item.entry.pack_sha256.clone());
13379 }
13380 entries.push((
13381 expected_seq,
13382 std::str::from_utf8(exact)
13383 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
13384 .to_string(),
13385 hash,
13386 ));
13387 items.push(item);
13388 }
13389 if previous_hash != head.feed_hash {
13390 return Err(invalid_feed(
13391 "stored mirror feed does not converge on its advertised head",
13392 ));
13393 }
13394 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
13395 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
13396 (0, None, None) => {}
13397 (_, Some(actual), Some(expected)) if actual == expected => {}
13398 _ => {
13399 return Err(LinkError::InvalidPack {
13400 message: "stored snapshot pack does not match the signed head digest".to_string(),
13401 });
13402 }
13403 }
13404 let identity_value = serde_json::to_value(&identity)
13405 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
13406 Ok(VerifiedMirrorMaterial {
13407 brain: head.brain,
13408 head_seq: head.head_seq,
13409 feed_hash: head.feed_hash,
13410 identity: identity_value,
13411 entries,
13412 pack_sha256,
13413 })
13414}
13415
13416pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
13419 format!(
13420 "{:x}",
13421 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
13422 )
13423}
13424
13425pub fn content_sha256(bytes: &[u8]) -> String {
13428 format!("{:x}", Sha256::digest(bytes))
13429}
13430
13431pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
13433 let mut digest = Sha256::new();
13434 let mut buffer = [0u8; 64 * 1024];
13435 loop {
13436 let read = reader.read(&mut buffer)?;
13437 if read == 0 {
13438 break;
13439 }
13440 digest.update(&buffer[..read]);
13441 }
13442 Ok(format!("{:x}", digest.finalize()))
13443}
13444
13445#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
13453pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
13454 require_hardened_filesystem("mirror")?;
13455 require_safe_ref(brain)?;
13456 #[cfg(windows)]
13457 {
13458 let _ = (cfg, dest);
13459 return Err(LinkError::UnsupportedPlatform {
13460 operation: "atomic whole-mirror replacement on Windows",
13461 });
13462 }
13463 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
13464 let name = dest
13465 .file_name()
13466 .and_then(|name| name.to_str())
13467 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
13468 .ok_or_else(|| LinkError::UnsafePath {
13469 path: dest.display().to_string(),
13470 })?;
13471 #[cfg(unix)]
13472 let parent_dir = open_or_create_dir_nofollow(parent)?;
13473 #[cfg(unix)]
13474 use std::os::fd::AsRawFd as _;
13475 #[cfg(unix)]
13476 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
13477 #[cfg(unix)]
13478 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
13479 None => false,
13480 Some(true) => true,
13481 Some(false) => {
13482 return Err(LinkError::UnsafePath {
13483 path: dest.display().to_string(),
13484 });
13485 }
13486 };
13487
13488 #[cfg(unix)]
13491 let legacy_backup_name = c_name(
13492 format!(".{name}.dbmd-backup").as_bytes(),
13493 &dest.display().to_string(),
13494 )?;
13495 #[cfg(unix)]
13496 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
13497 return Err(LinkError::UnsafePath {
13498 path: parent
13499 .join(format!(".{name}.dbmd-backup"))
13500 .display()
13501 .to_string(),
13502 });
13503 }
13504
13505 let nonce = std::time::SystemTime::now()
13506 .duration_since(std::time::UNIX_EPOCH)
13507 .unwrap_or_default()
13508 .as_nanos();
13509 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
13510 #[cfg(unix)]
13511 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
13512 #[cfg(unix)]
13513 let stage_dir = create_dir_exclusive_at(
13514 parent_dir.as_raw_fd(),
13515 &stage_name,
13516 &dest.display().to_string(),
13517 )?;
13518
13519 let assembled = (|| -> LinkResult<MirrorReport> {
13520 let remote = verified_remote_head(cfg, brain, true)?;
13521 let brain_id = remote.head.brain.clone();
13522 let identity = remote
13523 .identity
13524 .as_ref()
13525 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
13526 let anchor = remote
13527 .anchor
13528 .clone()
13529 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
13530 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
13531 let snapshot_entries = parse_store_pack(pack.clone())?;
13532 let snapshot_count = snapshot_entries.len();
13533 let mut staged_entries = snapshot_entries;
13534 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
13535 for item in &remote.entries {
13536 let mut exact = serde_json::to_vec(&item.entry)
13537 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
13538 exact.push(b'\n');
13539 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
13540 return Err(invalid_feed(
13541 "serialized mirror entry differs from its verified hash",
13542 ));
13543 }
13544 staged_entries.push((
13545 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
13546 exact,
13547 ));
13548 }
13549 let mut identity_bytes = serde_json::to_vec(identity)
13550 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
13551 identity_bytes.push(b'\n');
13552 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
13553 let mut head_bytes = serde_json::to_vec(&json!({
13554 "brain": brain_id,
13555 "headSeq": remote.head.seq,
13556 "feedHash": remote.head.feed_hash,
13557 }))
13558 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
13559 head_bytes.push(b'\n');
13560 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
13561 staged_entries.push((
13562 CONFIG_REL_PATH.to_string(),
13563 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
13564 ));
13565 #[cfg(unix)]
13566 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
13567
13568 Ok(MirrorReport {
13569 brain: brain_id,
13570 head_seq: remote.head.seq,
13571 feed_hash: remote.head.feed_hash,
13572 entries: remote.entries.len() as u64,
13573 pinned: anchor,
13574 files: snapshot_count,
13575 })
13576 })();
13577
13578 let report = match assembled {
13579 Ok(report) => report,
13580 Err(error) => {
13581 #[cfg(unix)]
13582 let _ = remove_tree_at(
13583 parent_dir.as_raw_fd(),
13584 &stage_name,
13585 &dest.display().to_string(),
13586 );
13587 return Err(error);
13588 }
13589 };
13590
13591 #[cfg(unix)]
13592 if let Err(error) =
13593 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
13594 {
13595 let _ = remove_tree_at(
13596 parent_dir.as_raw_fd(),
13597 &stage_name,
13598 &dest.display().to_string(),
13599 );
13600 return Err(error);
13601 }
13602 #[cfg(unix)]
13605 if dest_exists {
13606 remove_tree_at(
13607 parent_dir.as_raw_fd(),
13608 &stage_name,
13609 &dest.display().to_string(),
13610 )?;
13611 }
13612 #[cfg(unix)]
13613 parent_dir.sync_all()?;
13614 Ok(report)
13615}
13616
13617fn verified_remote_head(
13618 cfg: &HubConfig,
13619 brain: &str,
13620 require_full_chain: bool,
13621) -> LinkResult<VerifiedRemote> {
13622 require_hardened_filesystem("verified link.md state")?;
13623 require_safe_ref(brain)?;
13624 let trust_directory = open_trust_dir(cfg)?;
13628 let path = format!("/api/hub/brains/{brain}");
13629 let body = ensure_ok(
13630 request(cfg, "GET", &path, None, Auth::Required)?,
13631 "subscribe",
13632 )?;
13633 let resolved_brain = body
13634 .get("id")
13635 .and_then(Value::as_str)
13636 .filter(|id| crate::ulid::is_ulid(id))
13637 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
13638 .to_string();
13639 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
13640 return Err(invalid_feed(
13641 "brain card id differs from the explicitly requested brain id",
13642 ));
13643 }
13644 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
13649 let (pinned, alias_binding) =
13650 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
13651 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
13652 let advertised_hash = body
13653 .get("feedHash")
13654 .and_then(Value::as_str)
13655 .map(str::to_string);
13656 let updated_at = body
13657 .get("updatedAt")
13658 .and_then(Value::as_str)
13659 .map(str::to_string);
13660 if let Some(pin) = &pinned {
13661 if seq < pin.head_seq {
13662 return Err(invalid_feed(format!(
13663 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
13664 pin.head_seq
13665 )));
13666 }
13667 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
13668 return Err(invalid_feed(
13669 "feed equivocation: the checkpoint sequence now has a different hash",
13670 ));
13671 }
13672 }
13673 if seq == 0 {
13674 if advertised_hash.is_some() {
13675 return Err(invalid_feed("an empty feed advertised a head hash"));
13676 }
13677 let identity: FeedIdentity = serde_json::from_value(
13678 body.get("identity")
13679 .cloned()
13680 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
13681 )
13682 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
13683 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
13684 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
13689 save_canonical_pin_and_alias(
13690 cfg,
13691 &trust_directory,
13692 brain,
13693 &resolved_brain,
13694 TrustState {
13695 v: 2,
13696 origin: normalized_origin(&cfg.hub)?,
13697 requested: resolved_brain.clone(),
13698 brain: resolved_brain.clone(),
13699 home: None,
13700 anchor: anchor.clone(),
13701 current: format!("ed25519:{}", identity.fingerprint),
13702 head_seq: 0,
13703 feed_hash: None,
13704 rotations: identity.rotations.clone(),
13705 hub_signer: None,
13706 protocol_profile: None,
13707 },
13708 alias_binding.as_ref(),
13709 )?;
13710 return Ok(VerifiedRemote {
13711 head: Head {
13712 brain: resolved_brain,
13713 seq,
13714 updated_at,
13715 feed_hash: None,
13716 verified: true,
13717 },
13718 identity: Some(identity),
13719 head_entry: None,
13720 entries: Vec::new(),
13721 anchor: Some(anchor),
13722 });
13723 }
13724 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
13725 return Err(invalid_feed(
13726 "non-empty feed did not advertise a valid SHA-256 head",
13727 ));
13728 }
13729
13730 let replay_head_only = !require_full_chain
13734 && pinned
13735 .as_ref()
13736 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
13737 let mut after = if replay_head_only {
13738 seq - 1
13739 } else if require_full_chain || pinned.is_none() {
13740 0
13741 } else {
13742 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
13743 };
13744 let mut expected_seq = after + 1;
13745 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
13746 None
13747 } else {
13748 pinned
13749 .as_ref()
13750 .and_then(|checkpoint| checkpoint.feed_hash.clone())
13751 };
13752 let mut identity: Option<FeedIdentity> = None;
13753 let mut anchor: Option<String> = None;
13754 let mut head_entry: Option<FeedItem> = None;
13755 let mut all_entries = Vec::new();
13756 let mut observed_entries = Vec::new();
13757 let replay_count = seq
13758 .checked_sub(after)
13759 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
13760 if replay_count > MAX_FEED_REPLAY_ENTRIES {
13761 return Err(invalid_feed(format!(
13762 "feed replay requires {replay_count} entries, over the client cap"
13763 )));
13764 }
13765 let mut replay_bytes = 0u64;
13766
13767 loop {
13768 let feed_bytes = ensure_raw_ok(
13769 request_raw(
13770 cfg,
13771 "GET",
13772 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
13773 None,
13774 Auth::Required,
13775 MAX_FEED_RESPONSE_BYTES,
13776 )?,
13777 "subscribe feed",
13778 )?;
13779 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
13780 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
13781 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
13782 return Err(invalid_feed("brain card and feed head disagree"));
13783 }
13784 if feed.entries.len() > FEED_PAGE_LIMIT {
13785 return Err(invalid_feed("feed page exceeds the requested entry limit"));
13786 }
13787 if feed.scope_limited {
13788 if require_full_chain {
13789 return Err(invalid_feed(
13790 "path-scoped grants cannot verify a full snapshot chain",
13791 ));
13792 }
13793 return Ok(VerifiedRemote {
13794 head: Head {
13795 brain: resolved_brain,
13796 seq,
13797 updated_at,
13798 feed_hash: advertised_hash,
13799 verified: false,
13800 },
13801 identity: None,
13802 head_entry: None,
13803 entries: Vec::new(),
13804 anchor: None,
13805 });
13806 }
13807 let page_identity = feed
13808 .identity
13809 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
13810 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
13811 if identity
13812 .as_ref()
13813 .is_some_and(|existing| existing != &page_identity)
13814 {
13815 return Err(invalid_feed("identity changed while reading the feed"));
13816 }
13817 if anchor
13818 .as_ref()
13819 .is_some_and(|existing| existing != &page_anchor)
13820 {
13821 return Err(invalid_feed(
13822 "identity anchor changed while reading the feed",
13823 ));
13824 }
13825 identity = Some(page_identity.clone());
13826 if anchor.is_none() {
13827 anchor = Some(page_anchor);
13828 }
13829 if feed.entries.is_empty() {
13830 return Err(invalid_feed("feed page was empty before the signed head"));
13831 }
13832
13833 for item in feed.entries {
13834 if item.entry.seq != expected_seq {
13835 return Err(invalid_feed(format!(
13836 "expected entry {expected_seq}, feed served {}",
13837 item.entry.seq
13838 )));
13839 }
13840 if item.entry.seq > seq {
13841 return Err(invalid_feed("feed advanced past the card snapshot"));
13842 }
13843 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
13844 return Err(invalid_feed(format!(
13845 "entry {} does not chain to the local checkpoint",
13846 item.entry.seq
13847 )));
13848 }
13849 verify_feed_item(&item, &page_identity)?;
13850 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
13851 replay_bytes = replay_bytes.saturating_add(
13852 serde_json::to_vec(&item)
13853 .map_err(|_| invalid_feed("could not size feed entry"))?
13854 .len() as u64,
13855 );
13856 if replay_bytes > MAX_FEED_REPLAY_BYTES {
13857 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
13858 }
13859 previous_hash = Some(item.hash.clone());
13860 after = item.entry.seq;
13861 expected_seq = expected_seq
13862 .checked_add(1)
13863 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
13864 if require_full_chain {
13865 all_entries.push(item.clone());
13866 }
13867 observed_entries.push(item.clone());
13868 head_entry = Some(item);
13869 }
13870 if after == seq {
13871 break;
13872 }
13873 }
13874
13875 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
13876 return Err(invalid_feed(
13877 "verified chain does not converge on the advertised head",
13878 ));
13879 }
13880 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
13881 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
13882 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
13883 save_canonical_pin_and_alias(
13884 cfg,
13885 &trust_directory,
13886 brain,
13887 &resolved_brain,
13888 TrustState {
13889 v: 2,
13890 origin: normalized_origin(&cfg.hub)?,
13891 requested: resolved_brain.clone(),
13892 brain: resolved_brain.clone(),
13893 home: None,
13894 anchor: anchor.clone(),
13895 current: format!("ed25519:{}", identity.fingerprint),
13896 head_seq: seq,
13897 feed_hash: advertised_hash.clone(),
13898 rotations: identity.rotations.clone(),
13899 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
13900 protocol_profile: pinned
13901 .as_ref()
13902 .and_then(|state| state.protocol_profile.clone()),
13903 },
13904 alias_binding.as_ref(),
13905 )?;
13906 Ok(VerifiedRemote {
13907 head: Head {
13908 brain: resolved_brain,
13909 seq,
13910 updated_at,
13911 feed_hash: advertised_hash,
13912 verified: true,
13913 },
13914 identity: Some(identity),
13915 head_entry,
13916 entries: all_entries,
13917 anchor: Some(anchor),
13918 })
13919}
13920
13921pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
13926 if let Some(verified) = v2_verified_head(cfg, brain)? {
13927 let observation = Head {
13928 brain: verified.brain_id.clone(),
13929 seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13930 updated_at: verified
13931 .pointer
13932 .as_ref()
13933 .map(|pointer| pointer.signed_at.clone()),
13934 feed_hash: verified
13935 .pointer
13936 .as_ref()
13937 .map(|pointer| pointer.feed_hash.clone()),
13938 verified: true,
13939 };
13940 accept_v2_head(cfg, &verified)?;
13941 return Ok(observation);
13942 }
13943 Ok(verified_remote_head(cfg, brain, false)?.head)
13944}
13945
13946#[cfg(test)]
13947mod tests {
13948 use super::*;
13949
13950 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
13951
13952 fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
13953 json!({
13954 "sha256": "a".repeat(64),
13955 "bytes": 10,
13956 "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
13957 })
13958 }
13959
13960 #[test]
13961 fn upload_reservations_batch_by_count_and_by_size() {
13962 let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
13966 let batches = batch_upload_declarations(declarations.clone());
13967
13968 assert!(batches.len() > 1, "5,000 blobs must not ride one request");
13969 for batch in &batches {
13970 assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
13971 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
13972 .expect("batch serializes")
13973 .len();
13974 assert!(
13975 bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
13976 "batch body {bytes} exceeds the reservation budget"
13977 );
13978 }
13979 let flattened: Vec<Value> = batches.into_iter().flatten().collect();
13980 assert_eq!(
13981 flattened, declarations,
13982 "batching must preserve the set and order"
13983 );
13984 }
13985
13986 #[test]
13987 fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
13988 let declarations: Vec<Value> = (0..2_000)
13992 .map(|index| {
13993 json!({
13994 "sha256": "a".repeat(64),
13995 "bytes": 10,
13996 "coordinates": (0..24)
13997 .map(|slot| format!(
13998 "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
13999 ))
14000 .collect::<Vec<_>>(),
14001 })
14002 })
14003 .collect();
14004 let batches = batch_upload_declarations(declarations);
14005 assert!(
14006 batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
14007 "wide coordinate sets must bound the batch by size"
14008 );
14009 for batch in &batches {
14010 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14011 .expect("batch serializes")
14012 .len();
14013 assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
14014 }
14015 }
14016
14017 #[test]
14018 fn a_small_push_still_rides_exactly_one_request() {
14019 let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
14020 assert_eq!(batch_upload_declarations(declarations).len(), 1);
14021 assert!(batch_upload_declarations(Vec::new()).is_empty());
14022 }
14023
14024 #[test]
14025 fn exact_source_move_becomes_one_provenance_preserving_rename() {
14026 let hash = "a".repeat(64);
14027 let operations = vec![
14028 json!({
14029 "op": "put",
14030 "path": "sources/curated/item.md",
14031 "expected": { "kind": "absent" },
14032 "blob": hash,
14033 "bytes": 19,
14034 }),
14035 json!({
14036 "op": "delete",
14037 "path": "sources/inbox/item.md",
14038 "expected": { "kind": "blob", "hash": hash },
14039 }),
14040 ];
14041
14042 assert_eq!(
14043 infer_exact_source_promotions(operations),
14044 vec![json!({
14045 "op": "rename",
14046 "from": "sources/inbox/item.md",
14047 "to": "sources/curated/item.md",
14048 "expected_from": { "kind": "blob", "hash": hash },
14049 "expected_to": { "kind": "absent" },
14050 "blob": hash,
14051 "bytes": 19,
14052 })]
14053 );
14054 }
14055
14056 #[test]
14057 fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
14058 let hash = "b".repeat(64);
14059 let operations = vec![
14060 json!({
14061 "op": "delete",
14062 "path": "sources/inbox/a.md",
14063 "expected": { "kind": "blob", "hash": hash },
14064 }),
14065 json!({
14066 "op": "delete",
14067 "path": "sources/inbox/b.md",
14068 "expected": { "kind": "blob", "hash": hash },
14069 }),
14070 json!({
14071 "op": "put",
14072 "path": "sources/curated/item.md",
14073 "expected": { "kind": "absent" },
14074 "blob": hash,
14075 "bytes": 19,
14076 }),
14077 ];
14078
14079 assert_eq!(
14080 infer_exact_source_promotions(operations.clone()),
14081 operations,
14082 "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
14083 );
14084 }
14085
14086 #[test]
14087 fn accepted_source_promotion_advances_the_local_baseline_exactly() {
14088 let hash = "c".repeat(64);
14089 let mut candidate = std::collections::BTreeMap::from([(
14090 "sources/inbox/item.md".to_string(),
14091 V2BaselineFile {
14092 sha256: hash.clone(),
14093 bytes: 19,
14094 proof: None,
14095 },
14096 )]);
14097 let mut candidate_assets = std::collections::BTreeMap::new();
14098 let operations = vec![
14099 json!({
14100 "op": "rename",
14101 "from": "sources/inbox/item.md",
14102 "to": "sources/curated/item.md",
14103 "expected_from": { "kind": "blob", "hash": hash },
14104 "expected_to": { "kind": "absent" },
14105 "blob": hash,
14106 "bytes": 19,
14107 }),
14108 json!({
14109 "op": "put",
14110 "path": "records/rsvps/item.md",
14111 "expected": { "kind": "absent" },
14112 "blob": "d".repeat(64),
14113 "bytes": 23,
14114 }),
14115 ];
14116
14117 assert!(!apply_generated_v2_operations(
14118 &operations,
14119 &std::collections::BTreeMap::new(),
14120 &mut candidate,
14121 &mut candidate_assets,
14122 )
14123 .unwrap());
14124 assert!(!candidate.contains_key("sources/inbox/item.md"));
14125 assert_eq!(
14126 candidate
14127 .get("sources/curated/item.md")
14128 .map(|file| (&file.sha256, file.bytes)),
14129 Some((&hash, 19))
14130 );
14131 assert_eq!(
14132 candidate
14133 .get("records/rsvps/item.md")
14134 .map(|file| (file.sha256.as_str(), file.bytes)),
14135 Some((
14136 "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
14137 23
14138 ))
14139 );
14140 }
14141
14142 fn merge_fixture(
14143 base: Option<&str>,
14144 remote: Option<&str>,
14145 local: Option<&str>,
14146 keep_local: bool,
14147 ) -> V2PulledMerge<String> {
14148 let map = |value: Option<&str>| {
14149 value
14150 .map(|value| [("records/a.md".to_string(), value.to_string())])
14151 .into_iter()
14152 .flatten()
14153 .collect::<std::collections::BTreeMap<_, _>>()
14154 };
14155 merge_v2_pulled_records(
14156 &map(base),
14157 &map(remote),
14158 &map(local),
14159 |value, _| value.clone(),
14160 |value, _| value.clone(),
14161 |_| keep_local,
14162 )
14163 }
14164
14165 #[test]
14166 fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
14167 let path = "records/a.md".to_string();
14168
14169 let local_add = merge_fixture(None, None, Some("local"), false);
14170 assert_eq!(
14171 local_add.records.get(&path).map(String::as_str),
14172 Some("local")
14173 );
14174 assert!(local_add.accept_remote.is_empty());
14175 assert!(local_add.conflicts.is_empty());
14176
14177 let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
14178 assert_eq!(
14179 local_edit.records.get(&path).map(String::as_str),
14180 Some("local")
14181 );
14182 assert!(local_edit.accept_remote.is_empty());
14183 assert!(local_edit.conflicts.is_empty());
14184
14185 let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
14186 assert!(!local_delete.records.contains_key(&path));
14187 assert!(local_delete.accept_remote.is_empty());
14188 assert!(local_delete.conflicts.is_empty());
14189
14190 let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
14191 assert_eq!(
14192 remote_edit.records.get(&path).map(String::as_str),
14193 Some("remote")
14194 );
14195 assert!(remote_edit.accept_remote.contains(&path));
14196 assert!(remote_edit.conflicts.is_empty());
14197
14198 let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
14199 assert!(!remote_delete.records.contains_key(&path));
14200 assert!(remote_delete.accept_remote.contains(&path));
14201 assert!(remote_delete.conflicts.is_empty());
14202
14203 let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
14204 assert_eq!(
14205 same_edit.records.get(&path).map(String::as_str),
14206 Some("same")
14207 );
14208 assert!(same_edit.accept_remote.contains(&path));
14209 assert!(same_edit.conflicts.is_empty());
14210
14211 let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
14212 assert_eq!(conflict.conflicts, vec![path.clone()]);
14213 assert_eq!(
14214 conflict.records.get(&path).map(String::as_str),
14215 Some("local")
14216 );
14217 assert!(conflict.accept_remote.is_empty());
14218
14219 let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
14220 assert_eq!(
14221 kept_home.records.get(&path).map(String::as_str),
14222 Some("local")
14223 );
14224 assert!(kept_home.accept_remote.is_empty());
14225 assert!(kept_home.conflicts.is_empty());
14226 }
14227
14228 #[test]
14229 fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
14230 let path = "sources/report.pdf";
14231 let record = crate::AssetRecord {
14232 path: path.to_string(),
14233 sha256: "a".repeat(64),
14234 bytes: 42,
14235 media_type: "application/pdf".to_string(),
14236 wrappers: vec!["gzip".to_string()],
14237 required: true,
14238 };
14239 let mut remote = V2BaselineAsset {
14240 blob_sha256: record.sha256.clone(),
14241 bytes: record.bytes,
14242 media_type: record.media_type.clone(),
14243 wrappers: record.wrappers.clone(),
14244 required: record.required,
14245 disposition: "withheld".to_string(),
14246 leaf_hash: "b".repeat(64),
14247 };
14248
14249 assert!(v2_asset_resumes_hosting(
14250 Some(&remote),
14251 path,
14252 &record,
14253 "hosted"
14254 ));
14255 assert!(!v2_asset_resumes_hosting(
14256 Some(&remote),
14257 path,
14258 &record,
14259 "withheld"
14260 ));
14261
14262 remote.disposition = "hosted".to_string();
14263 assert!(!v2_asset_resumes_hosting(
14264 Some(&remote),
14265 path,
14266 &record,
14267 "hosted"
14268 ));
14269
14270 remote.disposition = "withheld".to_string();
14271 remote.blob_sha256 = "c".repeat(64);
14272 assert!(!v2_asset_resumes_hosting(
14273 Some(&remote),
14274 path,
14275 &record,
14276 "hosted"
14277 ));
14278 assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
14279 }
14280
14281 #[test]
14282 fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
14283 let path = "records/team/alpha.md".to_string();
14284 let deleted_path = "records/team/deleted.md".to_string();
14285 let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
14286 sha256,
14287 bytes,
14288 file: None,
14289 };
14290 let files = vec![
14291 V2ConflictFile {
14292 path: path.clone(),
14293 base: coordinate(None, None),
14294 local: coordinate(Some("b".repeat(64)), Some(7)),
14295 remote: coordinate(Some("a".repeat(64)), Some(5)),
14296 },
14297 V2ConflictFile {
14298 path: deleted_path.clone(),
14299 base: coordinate(Some("c".repeat(64)), Some(9)),
14300 local: coordinate(Some("d".repeat(64)), Some(11)),
14301 remote: coordinate(None, None),
14302 },
14303 ];
14304 let proven = V2BaselineFile {
14305 sha256: "a".repeat(64),
14306 bytes: 5,
14307 proof: None,
14308 };
14309 let current = [(path.clone(), proven.clone())]
14310 .into_iter()
14311 .collect::<std::collections::BTreeMap<_, _>>();
14312
14313 let (selected, deleted) = v2_take_remote_selection(&files, ¤t).unwrap();
14314 assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
14315 assert_eq!(deleted, vec![deleted_path.clone()]);
14316
14317 let changed = [(
14318 path.clone(),
14319 V2BaselineFile {
14320 sha256: "e".repeat(64),
14321 bytes: 5,
14322 proof: None,
14323 },
14324 )]
14325 .into_iter()
14326 .collect::<std::collections::BTreeMap<_, _>>();
14327 assert!(v2_take_remote_selection(&files, &changed).is_err());
14328
14329 let resurrected = [
14330 (path, proven),
14331 (
14332 deleted_path,
14333 V2BaselineFile {
14334 sha256: "f".repeat(64),
14335 bytes: 13,
14336 proof: None,
14337 },
14338 ),
14339 ]
14340 .into_iter()
14341 .collect::<std::collections::BTreeMap<_, _>>();
14342 assert!(v2_take_remote_selection(&files, &resurrected).is_err());
14343 }
14344
14345 #[cfg(target_os = "linux")]
14346 #[test]
14347 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
14348 use std::os::fd::AsRawFd as _;
14349
14350 let sandbox = tempfile::TempDir::new().unwrap();
14351 let parent = std::fs::File::open(sandbox.path()).unwrap();
14352 let stage = std::ffi::CString::new("stage").unwrap();
14353 let destination = std::ffi::CString::new("brain").unwrap();
14354
14355 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
14356 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
14357 install_stage_at(
14358 parent.as_raw_fd(),
14359 stage.as_c_str(),
14360 destination.as_c_str(),
14361 false,
14362 )
14363 .unwrap();
14364 assert!(!sandbox.path().join("stage").exists());
14365 assert_eq!(
14366 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
14367 b"created"
14368 );
14369
14370 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
14371 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
14372 install_stage_at(
14373 parent.as_raw_fd(),
14374 stage.as_c_str(),
14375 destination.as_c_str(),
14376 true,
14377 )
14378 .unwrap();
14379 assert_eq!(
14380 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
14381 b"replacement"
14382 );
14383 assert_eq!(
14384 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
14385 b"created",
14386 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
14387 );
14388 }
14389
14390 struct SignedRemoteFixture {
14391 card: String,
14392 feed: String,
14393 key: AgentSigningKey,
14394 identity: FeedIdentity,
14395 }
14396
14397 fn signed_remote_fixture() -> SignedRemoteFixture {
14398 let rng = ring::rand::SystemRandom::new();
14399 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14400 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14401 let (public_key, multikey) = public_identity_for(&pair);
14402 let identity = FeedIdentity {
14403 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
14404 public_key_spki: public_key.clone(),
14405 previous: Vec::new(),
14406 rotations: Vec::new(),
14407 };
14408 let mut entry = FeedEntry {
14409 v: 1,
14410 seq: 1,
14411 ts: "2026-07-30T12:00:00.000Z".to_string(),
14412 brain: multikey.clone(),
14413 public_key: public_key.clone(),
14414 kind: "push".to_string(),
14415 op: "snapshot".to_string(),
14416 pack_sha256: "a".repeat(64),
14417 files: Vec::new(),
14418 removed: Vec::new(),
14419 prev_entry_hash: None,
14420 sig: String::new(),
14421 };
14422 let unsigned = UnsignedFeedEntry {
14423 v: entry.v,
14424 seq: entry.seq,
14425 ts: &entry.ts,
14426 brain: &entry.brain,
14427 public_key: &entry.public_key,
14428 kind: &entry.kind,
14429 op: &entry.op,
14430 pack_sha256: &entry.pack_sha256,
14431 files: &entry.files,
14432 removed: &entry.removed,
14433 prev_entry_hash: &entry.prev_entry_hash,
14434 };
14435 entry.sig =
14436 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
14437 let mut exact = serde_json::to_vec(&entry).unwrap();
14438 exact.push(b'\n');
14439 let hash = content_sha256(&exact);
14440 let card = json!({
14441 "id": TEST_BRAIN_ID,
14442 "headSeq": 1,
14443 "feedHash": hash,
14444 "identity": identity.clone(),
14445 })
14446 .to_string();
14447 let feed = json!({
14448 "headSeq": 1,
14449 "feedHash": hash,
14450 "identity": identity.clone(),
14451 "entries": [{"hash": hash, "entry": entry}],
14452 "scopeLimited": false,
14453 })
14454 .to_string();
14455 SignedRemoteFixture {
14456 card,
14457 feed,
14458 key: AgentSigningKey {
14459 pkcs8: pkcs8.as_ref().to_vec(),
14460 multikey,
14461 public_key_spki: public_key,
14462 },
14463 identity,
14464 }
14465 }
14466
14467 #[test]
14468 fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
14469 let file = |path: &str, byte: char| FeedFile {
14470 path: path.to_string(),
14471 sha256: byte.to_string().repeat(64),
14472 bytes: 1,
14473 };
14474 let a0 = file("records/a.md", 'a');
14475 let a1 = file("records/a.md", 'b');
14476 let stable = file("records/stable.md", 'c');
14477 let added = file("records/added.md", 'd');
14478 let removed_file = file("records/removed.md", 'e');
14479 let previous = vec![a0, stable.clone(), removed_file.clone()];
14480 let resulting = vec![a1.clone(), stable.clone(), added.clone()];
14481 let removed = vec![removed_file.path.clone()];
14482
14483 assert_eq!(
14484 verify_v1_manifest_disclosure(
14485 "edit",
14486 &previous,
14487 &resulting,
14488 &[a1.clone(), added.clone()],
14489 &removed,
14490 ),
14491 Ok(())
14492 );
14493 assert_eq!(
14494 verify_v1_manifest_disclosure(
14495 "edit",
14496 &previous,
14497 &resulting,
14498 &[stable.clone(), added.clone(), a1.clone()],
14499 &removed,
14500 ),
14501 Ok(())
14502 );
14503 assert_eq!(
14504 verify_v1_manifest_disclosure(
14505 "edit",
14506 &previous,
14507 &resulting,
14508 std::slice::from_ref(&added),
14509 &removed,
14510 ),
14511 Err(V1DisclosureError::EditMissingChange)
14512 );
14513 assert_eq!(
14514 verify_v1_manifest_disclosure(
14515 "edit",
14516 &previous,
14517 &resulting,
14518 &[file("records/a.md", 'f'), added.clone()],
14519 &removed,
14520 ),
14521 Err(V1DisclosureError::EditFalseFile)
14522 );
14523 assert_eq!(
14524 verify_v1_manifest_disclosure(
14525 "edit",
14526 &previous,
14527 &resulting,
14528 &[a1.clone(), added.clone()],
14529 &[],
14530 ),
14531 Err(V1DisclosureError::RemovedMismatch)
14532 );
14533 assert_eq!(
14534 verify_v1_manifest_disclosure(
14535 "push",
14536 &previous,
14537 &resulting,
14538 &[added.clone(), stable, a1],
14539 &removed,
14540 ),
14541 Ok(())
14542 );
14543 assert_eq!(
14544 verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
14545 Err(V1DisclosureError::PushManifestMismatch)
14546 );
14547 }
14548
14549 #[test]
14550 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
14551 let fixture = signed_remote_fixture();
14552 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
14553 let item = feed["entries"][0].to_string();
14554 let oversized_page = format!(
14555 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
14556 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
14557 .collect::<Vec<_>>()
14558 .join(",")
14559 );
14560 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
14561
14562 let oversized_identity = format!(
14563 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
14564 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
14565 .collect::<Vec<_>>()
14566 .join(",")
14567 );
14568 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
14569
14570 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
14571 let oversized_entry = format!(
14572 "{{\"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\"}}",
14573 "a".repeat(64),
14574 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
14575 .collect::<Vec<_>>()
14576 .join(",")
14577 );
14578 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
14579 }
14580
14581 #[test]
14582 fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
14583 let id = "01arz3ndektsv4rrffq69g5fav";
14584 let digest = "a".repeat(64);
14585 assert_eq!(
14586 V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
14587 V2BulkConfirmation {
14588 id: id.to_string(),
14589 digest,
14590 }
14591 );
14592 for invalid in [
14593 "",
14594 "01arz3ndektsv4rrffq69g5fav",
14595 "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
14596 "01arz3ndektsv4rrffq69g5fav:ABCDEF",
14597 "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
14598 ] {
14599 assert!(matches!(
14600 V2BulkConfirmation::parse(invalid),
14601 Err(LinkError::InvalidPack { .. })
14602 ));
14603 }
14604 }
14605
14606 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
14607 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
14608 use std::net::TcpListener;
14609
14610 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
14611 let url = format!("http://{}", listener.local_addr().unwrap());
14612 let handle = std::thread::spawn(move || {
14613 for (status, body) in responses {
14614 let (stream, _) = listener.accept().unwrap();
14615 let mut reader = BufReader::new(stream);
14616 let mut line = String::new();
14617 reader.read_line(&mut line).unwrap();
14618 let mut content_length = 0usize;
14619 loop {
14620 line.clear();
14621 reader.read_line(&mut line).unwrap();
14622 if line == "\r\n" || line == "\n" || line.is_empty() {
14623 break;
14624 }
14625 if let Some((name, value)) = line.split_once(':') {
14626 if name.eq_ignore_ascii_case("content-length") {
14627 content_length = value.trim().parse().unwrap();
14628 }
14629 }
14630 }
14631 let mut request_body = vec![0_u8; content_length];
14632 reader.read_exact(&mut request_body).unwrap();
14633 let response = format!(
14634 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
14635 body.len()
14636 );
14637 reader.get_mut().write_all(response.as_bytes()).unwrap();
14638 }
14639 });
14640 (url, handle)
14641 }
14642
14643 fn routed_json_hub(
14644 requests: usize,
14645 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
14646 ) -> (String, std::thread::JoinHandle<()>) {
14647 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
14648 use std::net::TcpListener;
14649
14650 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
14651 let url = format!("http://{}", listener.local_addr().unwrap());
14652 let handle = std::thread::spawn(move || {
14653 for _ in 0..requests {
14654 let (stream, _) = listener.accept().unwrap();
14655 let mut reader = BufReader::new(stream);
14656 let mut line = String::new();
14657 reader.read_line(&mut line).unwrap();
14658 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
14659 let mut content_length = 0usize;
14660 loop {
14661 line.clear();
14662 reader.read_line(&mut line).unwrap();
14663 if line == "\r\n" || line == "\n" || line.is_empty() {
14664 break;
14665 }
14666 if let Some((name, value)) = line.split_once(':') {
14667 if name.eq_ignore_ascii_case("content-length") {
14668 content_length = value.trim().parse().unwrap();
14669 }
14670 }
14671 }
14672 let mut request_body = vec![0_u8; content_length];
14673 reader.read_exact(&mut request_body).unwrap();
14674 let (status, body) = respond(&path);
14675 let response = format!(
14676 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
14677 body.len()
14678 );
14679 reader.get_mut().write_all(response.as_bytes()).unwrap();
14680 }
14681 });
14682 (url, handle)
14683 }
14684
14685 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
14686 HubConfig {
14687 hub,
14688 key: Some("test-key".to_string()),
14689 agent_key: None,
14690 brain_key: None,
14691 state_dir,
14692 store_selected: false,
14693 }
14694 }
14695
14696 #[test]
14697 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
14698 use ring::signature::KeyPair as _;
14699
14700 let rng = ring::rand::SystemRandom::new();
14701 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
14702 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
14703 let (spki, multikey) = public_identity_for(&pair);
14704 let key = AgentSigningKey {
14705 pkcs8: pkcs8.as_ref().to_vec(),
14706 multikey,
14707 public_key_spki: spki,
14708 };
14709 let header = linkmd_sig_header(
14710 &key,
14711 "https://hub-a.example",
14712 "post",
14713 "/api/hub/brains/brain/push?mode=exact",
14714 Some("{\"ok\":true}"),
14715 )
14716 .unwrap();
14717 assert!(header.starts_with("LinkMD-Sig v2,"));
14718 let ts = header
14719 .split(",ts=")
14720 .nth(1)
14721 .unwrap()
14722 .split(',')
14723 .next()
14724 .unwrap();
14725 let signature = URL_SAFE_NO_PAD
14726 .decode(header.rsplit(",sig=").next().unwrap())
14727 .unwrap();
14728 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
14729 let accepted = format!(
14730 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
14731 );
14732 let replayed = format!(
14733 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
14734 );
14735 let public = pair.public_key().as_ref();
14736 assert!(UnparsedPublicKey::new(&ED25519, public)
14737 .verify(accepted.as_bytes(), &signature)
14738 .is_ok());
14739 assert!(
14740 UnparsedPublicKey::new(&ED25519, public)
14741 .verify(replayed.as_bytes(), &signature)
14742 .is_err(),
14743 "a proof captured at hub A must not authenticate at hub B"
14744 );
14745 }
14746
14747 #[test]
14748 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
14749 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
14750 let card = json!({
14751 "id": other,
14752 "headSeq": 0,
14753 "identity": signed_remote_fixture().identity,
14754 })
14755 .to_string();
14756 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
14757 let state = tempfile::tempdir().unwrap();
14758 let cfg = test_hub_config(hub, state.path().to_path_buf());
14759 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14760 assert!(
14761 error.contains("differs from the explicitly requested"),
14762 "{error}"
14763 );
14764 server.join().unwrap();
14765 }
14766
14767 #[test]
14768 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
14769 let first = signed_remote_fixture().identity;
14770 let second = signed_remote_fixture().identity;
14771 let card = |identity: FeedIdentity| {
14772 json!({
14773 "id": TEST_BRAIN_ID,
14774 "headSeq": 0,
14775 "identity": identity,
14776 })
14777 .to_string()
14778 };
14779 let (hub, server) = scripted_json_hub(vec![
14780 (404, "{}".to_string()),
14781 (200, card(first)),
14782 (404, "{}".to_string()),
14783 (200, card(second)),
14784 ]);
14785 let state = tempfile::tempdir().unwrap();
14786 let cfg = test_hub_config(hub, state.path().to_path_buf());
14787 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
14788 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14789 assert!(
14790 error.contains("pinned anchor") || error.contains("forked away"),
14791 "{error}"
14792 );
14793 server.join().unwrap();
14794 }
14795
14796 #[test]
14797 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
14798 let old = signed_remote_fixture();
14799 let new = signed_remote_fixture();
14800 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
14801 let unsigned = serde_json::to_string(&UnsignedRotation {
14802 v: 1,
14803 op: "rotate",
14804 brain: &old.key.multikey,
14805 public_key: &old.key.public_key_spki,
14806 new_brain: &new.key.multikey,
14807 new_public_key: &new.key.public_key_spki,
14808 prior_head_seq: 1,
14809 prior_feed_hash: Some(&"a".repeat(64)),
14810 ts: "2026-07-30T12:00:00.000Z".to_string(),
14811 })
14812 .unwrap();
14813 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14814 let rotation = format!(
14815 "{},\"sig\":\"{}\"}}",
14816 &unsigned[..unsigned.len() - 1],
14817 signature
14818 );
14819 let identity = FeedIdentity {
14820 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
14821 public_key_spki: new.key.public_key_spki,
14822 previous: vec![PreviousIdentity {
14823 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
14824 public_key_spki: old.key.public_key_spki,
14825 }],
14826 rotations: vec![rotation],
14827 };
14828 let card = json!({
14829 "id": TEST_BRAIN_ID,
14830 "headSeq": 0,
14831 "feedHash": null,
14832 "identity": identity,
14833 })
14834 .to_string();
14835 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
14836 let state = tempfile::tempdir().unwrap();
14837 let cfg = test_hub_config(hub, state.path().to_path_buf());
14838 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14839 assert!(
14840 error.contains("rotation claims a feed boundary beyond the advertised head"),
14841 "{error}"
14842 );
14843 assert!(
14844 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
14845 "an inconsistent empty-head identity must not become the TOFU checkpoint"
14846 );
14847 server.join().unwrap();
14848 }
14849
14850 #[test]
14851 fn trust_checkpoint_rejects_a_later_fork() {
14852 let fixture = signed_remote_fixture();
14853 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
14854 fork["feedHash"] = Value::String("b".repeat(64));
14855 let (hub, server) = scripted_json_hub(vec![
14856 (404, "{}".to_string()),
14857 (200, fixture.card),
14858 (200, fixture.feed),
14859 (404, "{}".to_string()),
14860 (200, fork.to_string()),
14861 ]);
14862 let state = tempfile::tempdir().unwrap();
14863 let cfg = test_hub_config(hub, state.path().to_path_buf());
14864 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
14865 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
14866 server.join().unwrap();
14867 }
14868
14869 #[test]
14870 fn alias_and_canonical_id_share_one_identity_checkpoint() {
14871 let trusted = signed_remote_fixture();
14872 let attacker = signed_remote_fixture();
14873 let (hub, server) = scripted_json_hub(vec![
14874 (404, "{}".to_string()),
14875 (200, trusted.card),
14876 (200, trusted.feed),
14877 (404, "{}".to_string()),
14878 (200, attacker.card),
14879 ]);
14880 let state = tempfile::tempdir().unwrap();
14881 let cfg = test_hub_config(hub, state.path().to_path_buf());
14882 assert!(head(&cfg, "trusted-slug").unwrap().verified);
14883 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
14884 assert!(
14885 error.contains("equivocation")
14886 || error.contains("pinned")
14887 || error.contains("identity"),
14888 "{error}"
14889 );
14890 server.join().unwrap();
14891 }
14892
14893 #[test]
14894 fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
14895 let state = tempfile::tempdir().unwrap();
14896 let cfg = test_hub_config(
14897 "https://hub.example".to_string(),
14898 state.path().to_path_buf(),
14899 );
14900 let directory = open_trust_dir(&cfg).unwrap();
14901 let old = TEST_BRAIN_ID;
14902 let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
14903 save_alias_in(
14904 &cfg,
14905 &directory,
14906 &AliasBinding {
14907 v: 1,
14908 origin: normalized_origin(&cfg.hub).unwrap(),
14909 requested: "company-brain".to_string(),
14910 brain: old.to_string(),
14911 home: Some("company-brain".to_string()),
14912 },
14913 )
14914 .unwrap();
14915
14916 let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
14917 assert!(matches!(
14918 error,
14919 LinkError::AliasRebindRequired {
14920 alias,
14921 from,
14922 to
14923 } if alias == "company-brain" && from == old && to == new
14924 ));
14925 let unchanged = load_alias_in(&cfg, &directory, "company-brain")
14926 .unwrap()
14927 .unwrap();
14928 assert_eq!(unchanged.brain, old);
14929 assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
14930 }
14931
14932 #[test]
14933 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
14934 let alpha = signed_remote_fixture();
14935 let beta = signed_remote_fixture();
14936 let alpha_card = alpha.card.clone();
14937 let alpha_feed = alpha.feed.clone();
14938 let beta_card = beta.card.clone();
14939 let beta_feed = beta.feed.clone();
14940 let (hub, server) = routed_json_hub(5, move |path| {
14941 if path.ends_with("/v2/head") {
14942 (404, "{}".to_string())
14943 } else if path.contains("/alpha/feed?") {
14944 (200, alpha_feed.clone())
14945 } else if path.contains("/beta/feed?") {
14946 (200, beta_feed.clone())
14947 } else if path.ends_with("/alpha") {
14948 (200, alpha_card.clone())
14949 } else if path.ends_with("/beta") {
14950 (200, beta_card.clone())
14951 } else {
14952 (500, r#"{"error":"unexpected path"}"#.to_string())
14953 }
14954 });
14955 let state = tempfile::tempdir().unwrap();
14956 let cfg = test_hub_config(hub, state.path().to_path_buf());
14957 let alpha_cfg = cfg.clone();
14958 let beta_cfg = cfg;
14959 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
14960 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
14961 let results = [first.join().unwrap(), second.join().unwrap()];
14962 assert_eq!(
14963 results.iter().filter(|result| result.is_ok()).count(),
14964 1,
14965 "only one alias identity may establish canonical TOFU: {results:?}"
14966 );
14967 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
14968 server.join().unwrap();
14969 }
14970
14971 #[cfg(unix)]
14972 #[test]
14973 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
14974 use std::os::unix::fs::symlink;
14975
14976 let fixture = signed_remote_fixture();
14977 let card = json!({
14978 "id": TEST_BRAIN_ID,
14979 "headSeq": 0,
14980 "feedHash": Value::Null,
14981 "identity": fixture.identity,
14982 })
14983 .to_string();
14984 let work = tempfile::tempdir().unwrap();
14985 let outside = tempfile::tempdir().unwrap();
14986 let state = work.path().join("state");
14987 let moved = work.path().join("state-held");
14988 let swap_state = state.clone();
14989 let swap_moved = moved.clone();
14990 let outside_path = outside.path().to_path_buf();
14991 let (hub, server) = routed_json_hub(1, move |_| {
14992 std::fs::rename(&swap_state, &swap_moved).unwrap();
14994 symlink(&outside_path, &swap_state).unwrap();
14995 (200, card.clone())
14996 });
14997 let cfg = test_hub_config(hub, state);
14998
14999 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
15000 assert_eq!(verified.head.seq, 0);
15001 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
15002 assert!(std::fs::read_dir(moved.join("trust"))
15003 .unwrap()
15004 .flatten()
15005 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
15006 server.join().unwrap();
15007 }
15008
15009 #[test]
15010 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
15011 let remote = signed_remote_fixture();
15012 let unrelated = signed_remote_fixture().key;
15013 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
15014 let state = tempfile::tempdir().unwrap();
15015 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
15016 cfg.brain_key = Some(unrelated);
15017 let error = sync_push(
15018 &cfg,
15019 TEST_BRAIN_ID,
15020 &[("DB.md".to_string(), "signed local content".to_string())],
15021 )
15022 .unwrap_err()
15023 .to_string();
15024 assert!(
15025 error.contains("not the verified current brain identity"),
15026 "{error}"
15027 );
15028 server.join().unwrap();
15029 }
15030
15031 #[test]
15032 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
15033 let remote = signed_remote_fixture();
15034 let new = signed_remote_fixture().key;
15035 let state = tempfile::tempdir().unwrap();
15036 let new_file = state.path().join("new.key");
15037 std::fs::write(
15038 &new_file,
15039 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
15040 )
15041 .unwrap();
15042 #[cfg(unix)]
15043 {
15044 use std::os::unix::fs::PermissionsExt as _;
15045 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
15046 }
15047 let forged = json!({
15048 "brain": TEST_BRAIN_ID,
15049 "identity": {
15050 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
15051 "publicKeySpki": new.public_key_spki,
15052 }
15053 })
15054 .to_string();
15055 let (hub, server) = scripted_json_hub(vec![
15056 (404, "{}".to_string()),
15057 (200, remote.card.clone()),
15058 (200, remote.feed.clone()),
15059 (200, forged),
15060 (200, remote.card),
15061 (200, remote.feed),
15062 ]);
15063 let cfg = test_hub_config(hub, state.path().to_path_buf());
15064 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
15065 .unwrap_err()
15066 .to_string();
15067 assert!(
15068 error.contains("without committing the verified new identity"),
15069 "{error}"
15070 );
15071 server.join().unwrap();
15072 }
15073
15074 #[test]
15075 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
15076 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15077 let raw = format!(
15078 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
15079 );
15080 let pack = build_store_pack(&[
15081 (
15082 "DB.md".to_string(),
15083 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
15084 ),
15085 ("records/clients/truth.md".to_string(), raw.clone()),
15086 ])
15087 .unwrap();
15088 let by_id = resolve_from_verified_pack(
15089 "01j5qc3v9k4ym8rwbn2tqe6f7d",
15090 &AddressTarget::Id(record_id.to_string()),
15091 pack.clone(),
15092 )
15093 .unwrap();
15094 assert_eq!(by_id["document"]["summary"], "Signed truth");
15095 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
15096 assert_eq!(
15097 by_id["document"]["contentSha"],
15098 content_sha256(raw.as_bytes())
15099 );
15100
15101 let by_path = resolve_from_verified_pack(
15102 "01j5qc3v9k4ym8rwbn2tqe6f7d",
15103 &AddressTarget::Path("records/clients/truth.md".to_string()),
15104 pack,
15105 )
15106 .unwrap();
15107 assert_eq!(by_path["document"]["id"], record_id);
15108 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
15109
15110 let wrong_id = resolve_from_verified_record_bytes(
15111 TEST_BRAIN_ID,
15112 &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
15113 "records/clients/truth.md".to_string(),
15114 raw.as_bytes().to_vec(),
15115 )
15116 .unwrap_err()
15117 .to_string();
15118 assert!(wrong_id.contains("id differs"), "{wrong_id}");
15119
15120 let wrong_path = resolve_from_verified_record_bytes(
15121 TEST_BRAIN_ID,
15122 &AddressTarget::Path("records/clients/other.md".to_string()),
15123 "records/clients/truth.md".to_string(),
15124 raw.into_bytes(),
15125 )
15126 .unwrap_err()
15127 .to_string();
15128 assert!(wrong_path.contains("path differs"), "{wrong_path}");
15129 }
15130
15131 #[test]
15132 fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
15133 let path = "records/clients/truth.md";
15134 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15135 let raw = format!(
15136 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
15137 );
15138 let sha256 = content_sha256(raw.as_bytes());
15139 let mut nonce = 0_u128;
15140 let tree = crate::linkmd_v2::build_content_tree(
15141 &[crate::linkmd_v2::ContentFile {
15142 path: path.to_string(),
15143 blob_hash: sha256.clone(),
15144 bytes: raw.len() as u64,
15145 }],
15146 None,
15147 &mut || {
15148 nonce += 1;
15149 format!("{nonce:032x}")
15150 },
15151 )
15152 .unwrap();
15153 let root = tree.root.clone().unwrap();
15154 let mut directory_root = root.clone();
15155 let mut proof = Vec::new();
15156 for component in path.split('/') {
15157 let inclusion =
15158 crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
15159 let child = match &inclusion {
15160 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
15161 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
15162 panic!("fixture path must have an inclusion proof")
15163 }
15164 };
15165 proof.push(json!({
15166 "directory_root": directory_root,
15167 "component": component,
15168 "proof": inclusion,
15169 }));
15170 directory_root = child;
15171 }
15172 let commit_hash = "c".repeat(64);
15173 let pointer = V2PointerBody {
15174 v: 2,
15175 brain: TEST_BRAIN_ID.to_string(),
15176 seq: 1,
15177 commit_hash: commit_hash.clone(),
15178 feed_hash: "f".repeat(64),
15179 content_root: Some(root.clone()),
15180 asset_root: None,
15181 materializer: "dbmd-projection-v1".to_string(),
15182 signer_epoch: 1,
15183 control_revision: "d".repeat(64),
15184 backup_preparation: "e".repeat(64),
15185 prior_pointer_hash: None,
15186 signed_at: "2026-08-21T12:00:00.000Z".to_string(),
15187 };
15188 let manifest = json!({
15189 "v": 2,
15190 "commit": commit_hash,
15191 "content_root": root,
15192 "files": [{
15193 "path": path,
15194 "sha256": sha256,
15195 "bytes": raw.len(),
15196 "proof": proof,
15197 }],
15198 "next_cursor": Value::Null,
15199 })
15200 .to_string();
15201
15202 let path_manifest = manifest.clone();
15203 let (hub, server) = routed_json_hub(1, move |request| {
15204 assert_eq!(
15205 request,
15206 format!(
15207 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
15208 "c".repeat(64)
15209 )
15210 );
15211 (200, path_manifest.clone())
15212 });
15213 let state = tempfile::tempdir().unwrap();
15214 let cfg = test_hub_config(hub, state.path().to_path_buf());
15215 let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
15216 .unwrap()
15217 .unwrap();
15218 assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
15219 assert!(by_path.proof.is_some());
15220 server.join().unwrap();
15221
15222 let id_manifest = manifest;
15223 let (hub, server) = routed_json_hub(1, move |request| {
15224 assert_eq!(
15225 request,
15226 format!(
15227 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
15228 "c".repeat(64)
15229 )
15230 );
15231 (200, id_manifest.clone())
15232 });
15233 let state = tempfile::tempdir().unwrap();
15234 let cfg = test_hub_config(hub, state.path().to_path_buf());
15235 let (located_path, by_id) =
15236 v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
15237 assert_eq!(located_path, path);
15238 assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
15239 server.join().unwrap();
15240 }
15241
15242 #[test]
15243 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
15244 let unsorted = vec![
15245 ("records/a.md".to_string(), "alpha\n".to_string()),
15246 ("DB.md".to_string(), "# db\n".to_string()),
15247 ];
15248 let sorted = vec![
15249 ("DB.md".to_string(), "# db\n".to_string()),
15250 ("records/a.md".to_string(), "alpha\n".to_string()),
15251 ];
15252 let pack = build_store_pack(&unsorted).unwrap();
15253
15254 assert_eq!(pack.len(), 219);
15259 assert_eq!(
15260 content_sha256(&pack),
15261 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
15262 );
15263 assert_eq!(pack, build_store_pack(&sorted).unwrap());
15264 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
15265 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
15266 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
15267
15268 assert_eq!(
15269 parse_store_pack(pack).unwrap(),
15270 vec![
15271 ("DB.md".to_string(), b"# db\n".to_vec()),
15272 ("records/a.md".to_string(), b"alpha\n".to_vec()),
15273 ]
15274 );
15275 }
15276
15277 #[test]
15278 fn canonical_store_pack_validates_every_path_before_writing() {
15279 let duplicate = vec![
15280 ("DB.md".to_string(), "first".to_string()),
15281 ("DB.md".to_string(), "second".to_string()),
15282 ];
15283 assert!(build_store_pack(&duplicate)
15284 .unwrap_err()
15285 .to_string()
15286 .contains("duplicate path"));
15287 assert!(matches!(
15288 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
15289 Err(LinkError::UnsafePath { .. })
15290 ));
15291 }
15292
15293 #[test]
15294 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
15295 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
15296 let mut bytes = vec![0_u8];
15299 let zip64_offset = bytes.len() as u64;
15300 bytes.extend_from_slice(b"PK\x06\x06");
15301 bytes.extend_from_slice(&44_u64.to_le_bytes());
15302 bytes.extend_from_slice(&[0_u8; 12]);
15303 bytes.extend_from_slice(&COUNT.to_le_bytes());
15304 bytes.extend_from_slice(&COUNT.to_le_bytes());
15305 bytes.extend_from_slice(&1_u64.to_le_bytes());
15306 bytes.extend_from_slice(&0_u64.to_le_bytes());
15307 bytes.extend_from_slice(b"PK\x06\x07");
15308 bytes.extend_from_slice(&0_u32.to_le_bytes());
15309 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
15310 bytes.extend_from_slice(&1_u32.to_le_bytes());
15311 bytes.extend_from_slice(b"PK\x05\x06");
15312 bytes.extend_from_slice(&0_u16.to_le_bytes());
15313 bytes.extend_from_slice(&0_u16.to_le_bytes());
15314 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
15315 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
15316 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
15317 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
15318 bytes.extend_from_slice(&0_u16.to_le_bytes());
15319
15320 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
15321 .unwrap_err()
15322 .to_string();
15323 assert!(error.contains("invalid file count"), "{error}");
15324 }
15325
15326 #[test]
15327 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
15328 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
15329 let mut bytes = vec![0_u8];
15330 let zip64_offset = bytes.len() as u64;
15331 bytes.extend_from_slice(b"PK\x06\x06");
15332 bytes.extend_from_slice(&44_u64.to_le_bytes());
15333 bytes.extend_from_slice(&[0_u8; 12]);
15334 bytes.extend_from_slice(&COUNT.to_le_bytes());
15335 bytes.extend_from_slice(&COUNT.to_le_bytes());
15336 bytes.extend_from_slice(&1_u64.to_le_bytes());
15337 bytes.extend_from_slice(&0_u64.to_le_bytes());
15338 bytes.extend_from_slice(b"PK\x06\x07");
15339 bytes.extend_from_slice(&0_u32.to_le_bytes());
15340 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
15341 bytes.extend_from_slice(&1_u32.to_le_bytes());
15342 bytes.extend_from_slice(b"PK\x05\x06");
15343 bytes.extend_from_slice(&0_u16.to_le_bytes());
15344 bytes.extend_from_slice(&0_u16.to_le_bytes());
15345 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
15346 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
15347 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
15348 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
15349 bytes.extend_from_slice(&0_u16.to_le_bytes());
15350 let fake_eocd = bytes.len() as u32;
15354 bytes.extend_from_slice(b"PK\x05\x06");
15355 bytes.extend_from_slice(&0_u16.to_le_bytes());
15356 bytes.extend_from_slice(&0_u16.to_le_bytes());
15357 bytes.extend_from_slice(&1_u16.to_le_bytes());
15358 bytes.extend_from_slice(&1_u16.to_le_bytes());
15359 bytes.extend_from_slice(&0_u32.to_le_bytes());
15360 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
15361 bytes.extend_from_slice(&0_u16.to_le_bytes());
15362
15363 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
15364 .unwrap_err()
15365 .to_string();
15366 assert!(error.contains("central directory"), "{error}");
15367 }
15368
15369 #[test]
15370 fn strict_http_status_handling_rejects_redirects_without_panicking() {
15371 let error = ensure_ok(
15372 HubResponse {
15373 status: 302,
15374 body: Some(json!({"redirect": "/elsewhere"})),
15375 },
15376 "mutation",
15377 )
15378 .unwrap_err();
15379 assert!(matches!(error, LinkError::Http { status: 302, .. }));
15380
15381 let error = ensure_raw_ok(
15382 RawHubResponse {
15383 status: 302,
15384 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
15385 },
15386 "feed",
15387 )
15388 .unwrap_err();
15389 assert!(matches!(error, LinkError::Http { status: 302, .. }));
15390 }
15391
15392 #[cfg(unix)]
15393 #[test]
15394 fn collect_push_files_refuses_external_symlink_and_nested_store() {
15395 use std::os::unix::fs::symlink;
15396
15397 let root = tempfile::tempdir().unwrap();
15398 std::fs::write(
15399 root.path().join("DB.md"),
15400 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
15401 )
15402 .unwrap();
15403 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
15404
15405 let external = tempfile::tempdir().unwrap();
15406 let secret = external.path().join("secret.md");
15407 std::fs::write(&secret, "TOP SECRET").unwrap();
15408 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
15409
15410 let store = Store::open_strict(root.path()).unwrap();
15411 let err = collect_push_files(&store).unwrap_err().to_string();
15412 assert!(err.contains("cannot push"), "{err}");
15413 assert!(
15414 !err.contains("TOP SECRET"),
15415 "external bytes must never leak"
15416 );
15417
15418 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
15419 let nested = root.path().join("records/nested");
15420 std::fs::create_dir_all(&nested).unwrap();
15421 std::fs::write(
15422 nested.join("DB.md"),
15423 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
15424 )
15425 .unwrap();
15426 let err = collect_push_files(&store).unwrap_err().to_string();
15427 assert!(err.contains("nested db.md store"), "{err}");
15428 }
15429
15430 #[cfg(unix)]
15431 #[test]
15432 fn remote_push_uses_opened_root_after_path_replacement() {
15433 use std::os::unix::fs::symlink;
15434
15435 let sandbox = tempfile::tempdir().unwrap();
15436 let root = sandbox.path().join("store");
15437 std::fs::create_dir_all(root.join("records/notes")).unwrap();
15438 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
15439 std::fs::write(
15440 root.join("records/notes/owned.md"),
15441 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
15442 )
15443 .unwrap();
15444 let store = Store::open_strict(&root).unwrap();
15445 let detached = sandbox.path().join("detached");
15446 std::fs::rename(&root, &detached).unwrap();
15447
15448 let replacement = sandbox.path().join("replacement");
15449 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
15450 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
15451 std::fs::write(
15452 replacement.join("records/notes/secret.md"),
15453 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
15454 )
15455 .unwrap();
15456 symlink(&replacement, &root).unwrap();
15457
15458 let files = collect_push_files(&store).unwrap();
15459 let wire_text = files
15460 .iter()
15461 .map(|(path, content)| format!("{path}\n{content}"))
15462 .collect::<Vec<_>>()
15463 .join("\n");
15464 assert!(wire_text.contains("owned upload"));
15465 assert!(!wire_text.contains("replacement sentinel"));
15466 assert!(!wire_text.contains("records/notes/secret.md"));
15467
15468 let remote = signed_remote_fixture();
15469 let (hub, server) = scripted_json_hub(vec![
15470 (200, remote.card),
15471 (200, remote.feed),
15472 (200, json!({"ok": true}).to_string()),
15473 ]);
15474 let state = tempfile::tempdir().unwrap();
15475 let cfg = test_hub_config(hub, state.path().to_path_buf());
15476 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
15477 assert_eq!(pushed, json!({"ok": true}));
15478 server.join().unwrap();
15479 }
15480
15481 #[test]
15482 fn signed_feed_item_verifies_identity_hash_and_signature() {
15483 use ring::rand::SystemRandom;
15484 use ring::signature::{Ed25519KeyPair, KeyPair};
15485
15486 const PREFIX: &[u8] = &[
15487 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
15488 ];
15489 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
15490 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15491 let mut spki = PREFIX.to_vec();
15492 spki.extend_from_slice(pair.public_key().as_ref());
15493 let public_key = URL_SAFE_NO_PAD.encode(&spki);
15494 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
15495 let mut entry = FeedEntry {
15496 v: 1,
15497 seq: 1,
15498 ts: "2026-07-14T00:00:00.000Z".to_string(),
15499 brain: format!("ed25519:{fingerprint}"),
15500 public_key: public_key.clone(),
15501 kind: "push".to_string(),
15502 op: "snapshot".to_string(),
15503 pack_sha256: "a".repeat(64),
15504 files: vec![FeedFile {
15505 path: "DB.md".to_string(),
15506 sha256: "b".repeat(64),
15507 bytes: 3,
15508 }],
15509 removed: vec![],
15510 prev_entry_hash: None,
15511 sig: String::new(),
15512 };
15513 let unsigned = UnsignedFeedEntry {
15514 v: entry.v,
15515 seq: entry.seq,
15516 ts: &entry.ts,
15517 brain: &entry.brain,
15518 public_key: &entry.public_key,
15519 kind: &entry.kind,
15520 op: &entry.op,
15521 pack_sha256: &entry.pack_sha256,
15522 files: &entry.files,
15523 removed: &entry.removed,
15524 prev_entry_hash: &entry.prev_entry_hash,
15525 };
15526 entry.sig =
15527 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15528 let mut exact = serde_json::to_vec(&entry).unwrap();
15529 exact.push(b'\n');
15530 let item = FeedItem {
15531 hash: format!("{:x}", Sha256::digest(&exact)),
15532 entry,
15533 };
15534 let identity = FeedIdentity {
15535 fingerprint,
15536 public_key_spki: public_key,
15537 previous: Vec::new(),
15538 rotations: Vec::new(),
15539 };
15540 assert!(verify_feed_item(&item, &identity).is_ok());
15541 let mut tampered = item;
15542 tampered.entry.pack_sha256 = "c".repeat(64);
15543 assert!(verify_feed_item(&tampered, &identity).is_err());
15544 }
15545
15546 #[test]
15547 fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
15548 let rng = ring::rand::SystemRandom::new();
15549 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15550 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15551 let (spki, multikey) = public_identity_for(&pair);
15552 let identity = V2HeadIdentity {
15553 custody: "self".to_string(),
15554 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15555 public_key_spki: spki.clone(),
15556 previous: Vec::new(),
15557 rotations: Vec::new(),
15558 };
15559 let unsigned = json!({
15560 "actor_ref": "a".repeat(64),
15561 "asset_root": Value::Null,
15562 "brain": multikey,
15563 "changes_sha256": "b".repeat(64),
15564 "control_revision": "c".repeat(64),
15565 "materializer": "dbmd-projection-v1",
15566 "op": "changeset",
15567 "parent_asset_root": Value::Null,
15568 "parent_commit": Value::Null,
15569 "parent_root": Value::Null,
15570 "prev_entry_hash": Value::Null,
15571 "public_key": spki,
15572 "seq": 1,
15573 "signer_epoch": 1,
15574 "state_root": "d".repeat(64),
15575 "ts": "2026-08-19T12:00:00.000Z",
15576 "v": 2,
15577 "v1_bridge": {
15578 "feed_hash": "e".repeat(64),
15579 "head_seq": 7,
15580 "pack_sha256": "f".repeat(64),
15581 },
15582 });
15583 let sign_value = |value: Value| {
15584 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
15585 let mut object = value.as_object().unwrap().clone();
15586 object.insert(
15587 "sig".to_string(),
15588 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
15589 );
15590 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
15591 };
15592 assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
15593
15594 let mut extra = unsigned.clone();
15595 extra
15596 .as_object_mut()
15597 .unwrap()
15598 .insert("future".to_string(), Value::Bool(true));
15599 assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
15600
15601 let mut missing = unsigned.clone();
15602 missing.as_object_mut().unwrap().remove("v1_bridge");
15603 assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
15604
15605 let mut invalid_bridge = unsigned;
15606 invalid_bridge.as_object_mut().unwrap().insert(
15607 "v1_bridge".to_string(),
15608 json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
15609 );
15610 assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
15611 }
15612
15613 #[test]
15614 fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
15615 let vector: Value = serde_json::from_str(include_str!(
15616 "../tests/vectors/linkmd-v2-commit-bridge.json"
15617 ))
15618 .unwrap();
15619 let identity_value = vector.get("identity").unwrap();
15620 let identity = V2HeadIdentity {
15621 custody: "self".to_string(),
15622 fingerprint: identity_value
15623 .get("fingerprint")
15624 .and_then(Value::as_str)
15625 .unwrap()
15626 .to_string(),
15627 public_key_spki: identity_value
15628 .get("public_key_spki")
15629 .and_then(Value::as_str)
15630 .unwrap()
15631 .to_string(),
15632 previous: Vec::new(),
15633 rotations: Vec::new(),
15634 };
15635 let private = URL_SAFE_NO_PAD
15636 .decode(
15637 identity_value
15638 .get("private_key_pkcs8")
15639 .and_then(Value::as_str)
15640 .unwrap(),
15641 )
15642 .unwrap();
15643 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
15644 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
15645 .unwrap();
15646 let base = vector.get("body").unwrap().as_object().unwrap();
15647
15648 for item in vector.get("valid").unwrap().as_array().unwrap() {
15649 let mut body = base.clone();
15650 body.insert(
15651 "v1_bridge".to_string(),
15652 item.get("v1_bridge").unwrap().clone(),
15653 );
15654 body.insert(
15655 "sig".to_string(),
15656 item.get("signature_base64url").unwrap().clone(),
15657 );
15658 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
15659 assert!(verified_v2_commit_object(&signed, &identity).is_ok());
15660 assert_eq!(
15661 crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
15662 item.get("commit_hash").and_then(Value::as_str).unwrap()
15663 );
15664 assert_eq!(
15665 format!("{:x}", Sha256::digest(&signed)),
15666 item.get("feed_hash").and_then(Value::as_str).unwrap()
15667 );
15668 }
15669
15670 for item in vector.get("invalid").unwrap().as_array().unwrap() {
15671 let mut body = base.clone();
15672 if let Some(remove) = item.get("remove").and_then(Value::as_array) {
15673 for field in remove {
15674 body.remove(field.as_str().unwrap());
15675 }
15676 }
15677 if let Some(set) = item.get("set").and_then(Value::as_object) {
15678 for (field, value) in set {
15679 body.insert(field.clone(), value.clone());
15680 }
15681 }
15682 let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
15683 body.insert(
15684 "sig".to_string(),
15685 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
15686 );
15687 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
15688 assert!(
15689 verified_v2_commit_object(&signed, &identity).is_err(),
15690 "accepted invalid shared vector {}",
15691 item.get("reason").and_then(Value::as_str).unwrap()
15692 );
15693 }
15694 }
15695
15696 #[test]
15697 fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
15698 let vector: Value = serde_json::from_str(include_str!(
15699 "../tests/vectors/linkmd-v2-changeset-withheld.json"
15700 ))
15701 .unwrap();
15702 assert_eq!(
15703 vector.get("profile").and_then(Value::as_str),
15704 Some("link.md-v2-changeset-withheld")
15705 );
15706 let canonical =
15707 crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
15708 let expected = STANDARD
15709 .decode(
15710 vector
15711 .get("canonical_base64")
15712 .and_then(Value::as_str)
15713 .unwrap(),
15714 )
15715 .unwrap();
15716 assert_eq!(canonical, expected);
15717 assert_eq!(
15718 crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
15719 vector.get("domain_hash").and_then(Value::as_str).unwrap()
15720 );
15721 }
15722
15723 #[test]
15724 fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
15725 let remote = signed_remote_fixture();
15726 let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
15727 let legacy_item = legacy.entries.first().unwrap();
15728 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
15729 let body = json!({
15730 "actor_ref": "a".repeat(64),
15731 "asset_root": Value::Null,
15732 "brain": remote.key.multikey,
15733 "changes_sha256": "b".repeat(64),
15734 "control_revision": "c".repeat(64),
15735 "materializer": "dbmd-projection-v1",
15736 "op": "changeset",
15737 "parent_asset_root": Value::Null,
15738 "parent_commit": Value::Null,
15739 "parent_root": Value::Null,
15740 "prev_entry_hash": Value::Null,
15741 "public_key": remote.key.public_key_spki,
15742 "seq": 1,
15743 "signer_epoch": 1,
15744 "state_root": "d".repeat(64),
15745 "ts": "2026-08-19T12:00:00.000Z",
15746 "v": 2,
15747 "v1_bridge": {
15748 "feed_hash": legacy_item.hash,
15749 "head_seq": legacy_item.entry.seq,
15750 "pack_sha256": legacy_item.entry.pack_sha256,
15751 },
15752 });
15753 let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
15754 let mut signed = body.as_object().unwrap().clone();
15755 signed.insert(
15756 "sig".to_string(),
15757 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
15758 );
15759 let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
15760 let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
15761 let feed_hash = content_sha256(&raw);
15762 let pointer = V2PointerBody {
15763 v: 2,
15764 brain: TEST_BRAIN_ID.to_string(),
15765 seq: 1,
15766 commit_hash: commit_hash.clone(),
15767 feed_hash: feed_hash.clone(),
15768 content_root: Some("d".repeat(64)),
15769 asset_root: None,
15770 materializer: "dbmd-projection-v1".to_string(),
15771 signer_epoch: 1,
15772 control_revision: "c".repeat(64),
15773 backup_preparation: "e".repeat(64),
15774 prior_pointer_hash: None,
15775 signed_at: "2026-08-19T12:00:00.000Z".to_string(),
15776 };
15777 let v2_page = json!({
15778 "v": 2,
15779 "head_seq": 1,
15780 "head_commit_hash": commit_hash,
15781 "head_feed_hash": feed_hash,
15782 "entries": [{
15783 "seq": 1,
15784 "commit_hash": pointer.commit_hash,
15785 "feed_hash": pointer.feed_hash,
15786 "bytes_base64": STANDARD.encode(&raw),
15787 }],
15788 "next_after": 1,
15789 "complete": true,
15790 })
15791 .to_string();
15792 let identity = V2HeadIdentity {
15793 custody: "self".to_string(),
15794 fingerprint: remote.identity.fingerprint.clone(),
15795 public_key_spki: remote.identity.public_key_spki.clone(),
15796 previous: Vec::new(),
15797 rotations: Vec::new(),
15798 };
15799 let checkpoint = TrustState {
15800 v: 2,
15801 origin: "unused".to_string(),
15802 requested: TEST_BRAIN_ID.to_string(),
15803 brain: TEST_BRAIN_ID.to_string(),
15804 home: None,
15805 anchor: remote.key.multikey.clone(),
15806 current: remote.key.multikey,
15807 head_seq: legacy_item.entry.seq,
15808 feed_hash: Some(legacy_item.hash.clone()),
15809 rotations: Vec::new(),
15810 hub_signer: None,
15811 protocol_profile: None,
15812 };
15813 let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
15814 let state = tempfile::tempdir().unwrap();
15815 let cfg = test_hub_config(hub, state.path().to_path_buf());
15816 verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
15817 server.join().unwrap();
15818
15819 let mut wrong = checkpoint;
15820 wrong.feed_hash = Some("0".repeat(64));
15821 let (hub, server) = scripted_json_hub(vec![(
15822 200,
15823 json!({
15824 "v": 2,
15825 "head_seq": 1,
15826 "head_commit_hash": pointer.commit_hash,
15827 "head_feed_hash": pointer.feed_hash,
15828 "entries": [{
15829 "seq": 1,
15830 "commit_hash": pointer.commit_hash,
15831 "feed_hash": pointer.feed_hash,
15832 "bytes_base64": STANDARD.encode(&raw),
15833 }],
15834 "next_after": 1,
15835 "complete": true,
15836 })
15837 .to_string(),
15838 )]);
15839 let state = tempfile::tempdir().unwrap();
15840 let cfg = test_hub_config(hub, state.path().to_path_buf());
15841 assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
15842 server.join().unwrap();
15843 }
15844
15845 #[test]
15846 fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
15847 let rng = ring::rand::SystemRandom::new();
15848 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15849 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
15850 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15851 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
15852 let (old_spki, old_multikey) = public_identity_for(&old);
15853 let (new_spki, new_multikey) = public_identity_for(&new);
15854 let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
15855 v: 1,
15856 op: "rotate",
15857 brain: &old_multikey,
15858 public_key: &old_spki,
15859 new_brain: &new_multikey,
15860 new_public_key: &new_spki,
15861 prior_head_seq: 1,
15862 prior_feed_hash: Some(&"9".repeat(64)),
15863 ts: "2026-08-19T12:01:00.000Z".to_string(),
15864 })
15865 .unwrap();
15866 let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
15867 let rotation = format!(
15868 "{},\"sig\":\"{}\"}}",
15869 &rotation_unsigned[..rotation_unsigned.len() - 1],
15870 rotation_sig
15871 );
15872 let identity = V2HeadIdentity {
15873 custody: "self".to_string(),
15874 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
15875 public_key_spki: new_spki.clone(),
15876 previous: vec![V2PreviousIdentity {
15877 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
15878 public_key_spki: old_spki.clone(),
15879 }],
15880 rotations: vec![rotation],
15881 };
15882 let commit = |seq: u64,
15883 epoch: u64,
15884 multikey: &str,
15885 spki: &str,
15886 pair: &ring::signature::Ed25519KeyPair| {
15887 let value = json!({
15888 "actor_ref": "a".repeat(64),
15889 "asset_root": Value::Null,
15890 "brain": multikey,
15891 "changes_sha256": "b".repeat(64),
15892 "control_revision": "c".repeat(64),
15893 "materializer": "dbmd-projection-v1",
15894 "op": "changeset",
15895 "parent_asset_root": Value::Null,
15896 "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
15897 "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
15898 "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
15899 "public_key": spki,
15900 "seq": seq,
15901 "signer_epoch": epoch,
15902 "state_root": "1".repeat(64),
15903 "ts": "2026-08-19T12:00:00.000Z",
15904 "v": 2,
15905 "v1_bridge": Value::Null,
15906 });
15907 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
15908 let mut object = value.as_object().unwrap().clone();
15909 object.insert(
15910 "sig".to_string(),
15911 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
15912 );
15913 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
15914 };
15915
15916 assert!(verified_v2_commit_object(
15917 &commit(1, 1, &old_multikey, &old_spki, &old),
15918 &identity,
15919 )
15920 .is_ok());
15921 assert!(verified_v2_commit_object(
15922 &commit(2, 2, &new_multikey, &new_spki, &new),
15923 &identity,
15924 )
15925 .is_ok());
15926 assert!(verified_v2_commit_object(
15927 &commit(2, 1, &old_multikey, &old_spki, &old),
15928 &identity,
15929 )
15930 .is_err());
15931 assert!(verified_v2_commit_object(
15932 &commit(1, 2, &new_multikey, &new_spki, &new),
15933 &identity,
15934 )
15935 .is_err());
15936 }
15937
15938 #[test]
15939 fn a_self_custody_entry_verifies_like_any_hub_entry() {
15940 let rng = ring::rand::SystemRandom::new();
15941 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15942 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15943 let (spki, multikey) = public_identity_for(&pair);
15944 let key = AgentSigningKey {
15945 pkcs8: pkcs8.as_ref().to_vec(),
15946 multikey: multikey.clone(),
15947 public_key_spki: spki.clone(),
15948 };
15949 let files = vec![WireFeedFile {
15950 path: "DB.md".to_string(),
15951 sha256: "a".repeat(64),
15952 bytes: 3,
15953 }];
15954 let raw = self_custody_entry(
15955 &key,
15956 1,
15957 "2026-07-23T12:00:00.000Z".to_string(),
15958 &"c".repeat(64),
15959 &files,
15960 None,
15961 )
15962 .unwrap();
15963 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
15967 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
15968 let item = FeedItem { hash, entry };
15969 let identity = FeedIdentity {
15970 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15971 public_key_spki: spki,
15972 previous: Vec::new(),
15973 rotations: Vec::new(),
15974 };
15975 assert!(verify_feed_item(&item, &identity).is_ok());
15976 }
15977
15978 #[test]
15979 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
15980 let rng = ring::rand::SystemRandom::new();
15981 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15982 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
15983 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15984 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
15985 let (old_spki, old_multikey) = public_identity_for(&old);
15986 let (new_spki, new_multikey) = public_identity_for(&new);
15987 let unsigned = serde_json::to_string(&UnsignedRotation {
15988 v: 1,
15989 op: "rotate",
15990 brain: &old_multikey,
15991 public_key: &old_spki,
15992 new_brain: &new_multikey,
15993 new_public_key: &new_spki,
15994 prior_head_seq: 1,
15995 prior_feed_hash: Some(&"a".repeat(64)),
15996 ts: "2026-07-30T12:00:00.000Z".to_string(),
15997 })
15998 .unwrap();
15999 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
16000 let rotation = format!(
16001 "{},\"sig\":\"{}\"}}",
16002 &unsigned[..unsigned.len() - 1],
16003 signature
16004 );
16005 let identity = FeedIdentity {
16006 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
16007 public_key_spki: new_spki,
16008 previous: vec![PreviousIdentity {
16009 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
16010 public_key_spki: old_spki,
16011 }],
16012 rotations: vec![rotation],
16013 };
16014 let pin = TrustState {
16015 v: 2,
16016 origin: "https://hub.example".to_string(),
16017 requested: "brain".to_string(),
16018 brain: "brain".to_string(),
16019 home: None,
16020 anchor: old_multikey.clone(),
16021 current: old_multikey.clone(),
16022 head_seq: 1,
16023 feed_hash: Some("a".repeat(64)),
16024 rotations: Vec::new(),
16025 hub_signer: None,
16026 protocol_profile: None,
16027 };
16028 assert_eq!(
16029 verify_identity_chain(&identity, Some(&pin)).unwrap(),
16030 old_multikey
16031 );
16032 let mut accepted = pin.clone();
16033 accepted.current = new_multikey.clone();
16034 accepted.rotations = identity.rotations.clone();
16035 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
16036 v: 1,
16037 op: "rotate",
16038 brain: &old_multikey,
16039 public_key: &identity.previous[0].public_key_spki,
16040 new_brain: &new_multikey,
16041 new_public_key: &identity.public_key_spki,
16042 prior_head_seq: 1,
16043 prior_feed_hash: Some(&"a".repeat(64)),
16044 ts: "2026-07-30T12:00:01.000Z".to_string(),
16045 })
16046 .unwrap();
16047 let alternate_signature =
16048 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
16049 let mut rewritten = identity.clone();
16050 rewritten.rotations[0] = format!(
16051 "{},\"sig\":\"{}\"}}",
16052 &alternate_unsigned[..alternate_unsigned.len() - 1],
16053 alternate_signature
16054 );
16055 assert!(
16056 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
16057 "an alternate valid statement must not rewrite accepted history"
16058 );
16059
16060 let mut stale_entry = FeedEntry {
16061 v: 1,
16062 seq: 2,
16063 ts: "2026-07-30T12:01:00.000Z".to_string(),
16064 brain: pin.current.clone(),
16065 public_key: identity.previous[0].public_key_spki.clone(),
16066 kind: "push".to_string(),
16067 op: "snapshot".to_string(),
16068 pack_sha256: "b".repeat(64),
16069 files: Vec::new(),
16070 removed: Vec::new(),
16071 prev_entry_hash: pin.feed_hash.clone(),
16072 sig: String::new(),
16073 };
16074 let stale_unsigned = UnsignedFeedEntry {
16075 v: stale_entry.v,
16076 seq: stale_entry.seq,
16077 ts: &stale_entry.ts,
16078 brain: &stale_entry.brain,
16079 public_key: &stale_entry.public_key,
16080 kind: &stale_entry.kind,
16081 op: &stale_entry.op,
16082 pack_sha256: &stale_entry.pack_sha256,
16083 files: &stale_entry.files,
16084 removed: &stale_entry.removed,
16085 prev_entry_hash: &stale_entry.prev_entry_hash,
16086 };
16087 stale_entry.sig = URL_SAFE_NO_PAD.encode(
16088 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
16089 .as_ref(),
16090 );
16091 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
16092 stale_exact.push(b'\n');
16093 let stale_item = FeedItem {
16094 hash: content_sha256(&stale_exact),
16095 entry: stale_entry,
16096 };
16097 assert!(
16098 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
16099 .is_err(),
16100 "a key retired before the checkpoint must never append after it"
16101 );
16102 assert!(
16103 verify_feed_item(&stale_item, &identity).is_err(),
16104 "an old key must never append after its signed rotation boundary"
16105 );
16106
16107 let mut missing = identity.clone();
16108 missing.rotations.clear();
16109 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
16110
16111 let mut tampered = identity;
16112 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
16113 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
16114 }
16115
16116 #[cfg(unix)]
16117 #[test]
16118 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
16119 use std::os::unix::fs::symlink;
16120
16121 let dir = tempfile::tempdir().unwrap();
16122 let target = dir.path().join("valuable.txt");
16123 let planted = dir.path().join("agent.key");
16124 std::fs::write(&target, "do not overwrite").unwrap();
16125 symlink(&target, &planted).unwrap();
16126
16127 assert!(matches!(
16128 generate_agent_key(&planted),
16129 Err(LinkError::BadAgentKey { .. })
16130 ));
16131 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
16132 }
16133
16134 #[cfg(unix)]
16135 #[test]
16136 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
16137 use std::os::unix::fs::symlink;
16138
16139 let root = tempfile::tempdir().unwrap();
16140 let outside = tempfile::tempdir().unwrap();
16141 symlink(outside.path(), root.path().join("redirect")).unwrap();
16142
16143 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
16144 assert!(!outside.path().join("agent.key").exists());
16145 }
16146
16147 #[test]
16150 fn address_bare_brain_with_and_without_sigil() {
16151 for raw in ["@acme-ops", "acme-ops"] {
16152 let a = Address::parse(raw).expect(raw);
16153 assert_eq!(a.brain, "acme-ops");
16154 assert_eq!(a.target, None);
16155 }
16156 }
16157
16158 #[test]
16159 fn address_ulid_target_parses_as_id() {
16160 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
16161 assert_eq!(a.brain, "acme");
16162 assert_eq!(
16163 a.target,
16164 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
16165 );
16166 }
16167
16168 #[test]
16169 fn address_md_path_target_parses_as_path() {
16170 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
16171 assert_eq!(
16172 a.target,
16173 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
16174 );
16175 }
16176
16177 #[test]
16178 fn address_rejects_malformed_forms() {
16179 for raw in [
16180 "",
16181 "@",
16182 "@/x",
16183 "@acme/",
16184 "@acme/../etc/passwd",
16185 "@acme/records/.hidden.md",
16186 "@ACME", "@acme/notes/x.txt", "@a b", ] {
16190 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
16191 }
16192 }
16193
16194 #[test]
16197 fn safe_paths_accept_store_shapes_and_reject_escapes() {
16198 for ok in [
16199 "DB.md",
16200 "assets.jsonl",
16201 "records/clients/lumio.md",
16202 "sources/emails/2026/07/x.md",
16203 ] {
16204 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
16205 }
16206 for bad in [
16207 "",
16208 "/etc/passwd",
16209 "../up.md",
16210 "records/../../up.md",
16211 "records//x.md",
16212 ".dbmd/config",
16213 "records/.hidden/x.md",
16214 "records/a b.md",
16215 "records\\win.md",
16216 ] {
16217 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
16218 }
16219 }
16220
16221 #[cfg(unix)]
16222 #[test]
16223 fn opened_destination_capability_survives_an_ancestor_path_swap() {
16224 use std::os::unix::fs::symlink;
16225
16226 let work = tempfile::tempdir().unwrap();
16227 let outside = tempfile::tempdir().unwrap();
16228 let original = work.path().join("destination");
16229 let moved = work.path().join("destination-moved");
16230 let directory = open_or_create_dir_nofollow(&original).unwrap();
16231
16232 std::fs::rename(&original, &moved).unwrap();
16233 symlink(outside.path(), &original).unwrap();
16234 write_pull_entries_beneath_dir(
16235 &directory,
16236 &[("records/note.md".to_string(), b"held inode".to_vec())],
16237 )
16238 .unwrap();
16239
16240 assert_eq!(
16241 std::fs::read(moved.join("records/note.md")).unwrap(),
16242 b"held inode"
16243 );
16244 assert!(!outside.path().join("records/note.md").exists());
16245 }
16246
16247 #[test]
16251 fn hub_config_flag_beats_file_and_requires_some_source() {
16252 let dir = tempfile::tempdir().unwrap();
16253 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
16254 std::fs::write(
16255 dir.path().join(CONFIG_REL_PATH),
16256 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
16257 )
16258 .unwrap();
16259
16260 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
16261 assert_eq!(from_flag.hub, "https://flag.example.com");
16262
16263 let from_file = hub_config(None, dir.path()).unwrap();
16264 assert_eq!(from_file.hub, "https://file.example.com");
16265
16266 let none = hub_config(None, tempfile::tempdir().unwrap().path());
16267 assert!(matches!(none, Err(LinkError::NoHub)));
16268 }
16269
16270 #[test]
16271 fn https_guard_allows_loopback_only_for_plain_http() {
16272 assert!(assert_safe_hub("https://hub.example.com").is_ok());
16273 assert!(assert_safe_hub("http://localhost:3000").is_ok());
16274 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
16275 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
16276 assert!(matches!(
16277 assert_safe_hub("http://hub.example.com"),
16278 Err(LinkError::UnsafeHub { .. })
16279 ));
16280 assert!(matches!(
16281 assert_safe_hub("hub.example.com"),
16282 Err(LinkError::UnsafeHub { .. })
16283 ));
16284 assert!(matches!(
16285 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
16286 Err(LinkError::UnsafeHub { .. })
16287 ));
16288 assert!(matches!(
16289 assert_safe_hub("https://hub.example.com@attacker.example"),
16290 Err(LinkError::UnsafeHub { .. })
16291 ));
16292 assert!(matches!(
16293 assert_safe_hub("https://hub.example.com/base"),
16294 Err(LinkError::UnsafeHub { .. })
16295 ));
16296 }
16297
16298 #[test]
16299 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
16300 for blocked in [
16301 "127.0.0.1",
16302 "10.0.0.1",
16303 "100.64.0.1",
16304 "169.254.169.254",
16305 "172.16.0.1",
16306 "192.168.0.1",
16307 "192.88.99.1",
16308 "198.18.0.1",
16309 "203.0.113.1",
16310 "::1",
16311 "fe80::1",
16312 "fd00::1",
16313 "2001:db8::1",
16314 "2001:1::1",
16315 "2002:7f00:1::",
16316 "3fff::1",
16317 ] {
16318 assert!(
16319 !is_public_registry_ip(blocked.parse().unwrap()),
16320 "must block {blocked}"
16321 );
16322 }
16323 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
16324 assert!(is_public_registry_ip(
16325 "2606:4700:4700::1111".parse().unwrap()
16326 ));
16327 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
16328 }
16329
16330 #[test]
16331 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
16332 use ureq::Resolver as _;
16333
16334 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
16335 let resolver = PinnedRegistryResolver {
16336 netloc: "home.example:443".to_string(),
16337 addresses: vec![pinned],
16338 };
16339 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
16340 assert!(resolver.resolve("127.0.0.1:443").is_err());
16341 assert_eq!(
16342 resolver.resolve("home.example:443").unwrap(),
16343 vec![pinned],
16344 "subsequent connects reuse the validated answer instead of DNS"
16345 );
16346 }
16347
16348 #[test]
16349 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
16350 let cfg = HubConfig {
16351 hub: "https://hub.example".to_string(),
16352 key: None,
16353 agent_key: None,
16354 brain_key: None,
16355 state_dir: tempfile::tempdir().unwrap().keep(),
16356 store_selected: false,
16357 };
16358 assert!(
16359 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
16360 "a production hub must not turn its presigned URL into an SSRF primitive"
16361 );
16362
16363 let store_selected = HubConfig {
16364 hub: "https://127.0.0.1".to_string(),
16365 store_selected: true,
16366 ..cfg
16367 };
16368 assert!(
16369 hub_agent(&store_selected).is_err(),
16370 "bytes in a cloned store must not select a private-network hub"
16371 );
16372 }
16373
16374 #[test]
16375 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
16376 assert_eq!(
16377 one_past_bounded_limit(MAX_PACK_BYTES),
16378 Some(MAX_PACK_BYTES + 1),
16379 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
16380 );
16381 assert_eq!(
16382 presigned_download_read_limit(),
16383 MAX_PACK_BYTES + 1,
16384 "the presigned reader is capped by the client constant, not a hub response"
16385 );
16386 assert_eq!(
16387 one_past_bounded_limit(u64::MAX),
16388 None,
16389 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
16390 );
16391 }
16392
16393 #[test]
16394 fn https_guard_matches_the_scheme_case_insensitively() {
16395 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
16398 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
16399 assert!(matches!(
16401 assert_safe_hub("HTTP://hub.example.com"),
16402 Err(LinkError::UnsafeHub { .. })
16403 ));
16404 }
16405
16406 #[test]
16407 fn clean_key_refuses_paste_artifacts_without_echoing() {
16408 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
16409 for bad in ["vc account", "vc\naccount", "ключ", ""] {
16410 let err = clean_key(bad).unwrap_err();
16411 assert!(matches!(err, LinkError::BadKey));
16412 assert!(
16413 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
16414 "error must not echo the key"
16415 );
16416 }
16417 }
16418
16419 fn dead_hub() -> HubConfig {
16425 HubConfig {
16426 hub: "http://127.0.0.1:9".to_string(),
16427 key: Some("k".to_string()),
16428 agent_key: None,
16429 brain_key: None,
16430 state_dir: PathBuf::from("."),
16431 store_selected: false,
16432 }
16433 }
16434
16435 #[test]
16436 fn request_retries_a_connection_failure_before_sending() {
16437 use std::io::{Read as _, Write as _};
16438 use std::net::TcpListener;
16439 use std::thread;
16440 use std::time::Duration;
16441
16442 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
16443 let address = probe.local_addr().unwrap();
16444 drop(probe);
16445 let server = thread::spawn(move || {
16446 thread::sleep(Duration::from_millis(40));
16447 let listener = TcpListener::bind(address).unwrap();
16448 let (mut stream, _) = listener.accept().unwrap();
16449 let mut request_bytes = [0_u8; 1024];
16450 let _ = stream.read(&mut request_bytes).unwrap();
16451 stream
16452 .write_all(
16453 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
16454 )
16455 .unwrap();
16456 });
16457 let cfg = HubConfig {
16458 hub: format!("http://{address}"),
16459 key: None,
16460 agent_key: None,
16461 brain_key: None,
16462 state_dir: tempfile::tempdir().unwrap().keep(),
16463 store_selected: false,
16464 };
16465
16466 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
16467 assert_eq!(response.status, 200);
16468 assert_eq!(response.body, Some(json!({ "ok": true })));
16469 server.join().unwrap();
16470 }
16471
16472 #[test]
16473 fn endpoint_cap_refuses_a_body_before_json_parsing() {
16474 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
16475 let cfg = HubConfig {
16476 hub,
16477 key: None,
16478 agent_key: None,
16479 brain_key: None,
16480 state_dir: tempfile::tempdir().unwrap().keep(),
16481 store_selected: false,
16482 };
16483
16484 assert!(matches!(
16485 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
16486 Err(LinkError::ResponseTooLarge { .. })
16487 ));
16488 server.join().unwrap();
16489 }
16490
16491 #[test]
16492 fn overall_deadline_stops_a_dribbled_response_body() {
16493 use std::io::{Read as _, Write as _};
16494 use std::net::TcpListener;
16495 use std::time::{Duration, Instant};
16496
16497 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16498 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
16499 let server = std::thread::spawn(move || {
16500 let (mut stream, _) = listener.accept().unwrap();
16501 let mut request = [0_u8; 1024];
16502 let _ = stream.read(&mut request);
16503 stream
16504 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
16505 .unwrap();
16506 for byte in [b'x'; 32] {
16507 if stream.write_all(&[byte]).is_err() {
16508 break;
16509 }
16510 std::thread::sleep(Duration::from_millis(40));
16511 }
16512 });
16513 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
16514 let started = Instant::now();
16515 let response = http.get(&url).call().unwrap();
16516 let mut body = Vec::new();
16517 let error = response
16518 .into_reader()
16519 .read_to_end(&mut body)
16520 .expect_err("per-read progress must not reset the overall deadline");
16521 assert!(
16522 started.elapsed() < Duration::from_millis(700),
16523 "dribbled body exceeded the wall-clock budget: {error}"
16524 );
16525 server.join().unwrap();
16526 }
16527
16528 #[test]
16529 fn overall_deadline_stops_a_stalled_upload() {
16530 use std::net::TcpListener;
16531 use std::time::{Duration, Instant};
16532
16533 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16534 let url = format!("http://{}/upload", listener.local_addr().unwrap());
16535 let server = std::thread::spawn(move || {
16536 let (_stream, _) = listener.accept().unwrap();
16537 std::thread::sleep(Duration::from_millis(600));
16540 });
16541 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
16542 let body = vec![0x5a; 32 * 1024 * 1024];
16543 let started = Instant::now();
16544 let error = http
16545 .put(&url)
16546 .send_bytes(&body)
16547 .expect_err("stalled request-body writes must time out");
16548 assert!(
16549 started.elapsed() < Duration::from_millis(700),
16550 "stalled upload exceeded the wall-clock budget: {error}"
16551 );
16552 server.join().unwrap();
16553 }
16554
16555 #[test]
16556 fn verb_entry_gates_accept_the_hub_ref_shapes() {
16557 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
16558 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
16559 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
16560 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
16561 }
16562 }
16563
16564 #[test]
16565 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
16566 let cfg = dead_hub();
16567 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
16568 assert!(
16569 matches!(
16570 sync_pull(&cfg, bad, None),
16571 Err(LinkError::BadAddress { .. })
16572 ),
16573 "sync_pull must refuse {bad:?}"
16574 );
16575 assert!(
16576 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
16577 "sync_push must refuse {bad:?}"
16578 );
16579 assert!(
16580 matches!(
16581 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
16582 Err(LinkError::BadAddress { .. })
16583 ),
16584 "grant_issue must refuse {bad:?}"
16585 );
16586 assert!(
16587 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
16588 "grant_list must refuse {bad:?}"
16589 );
16590 assert!(
16591 matches!(
16592 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
16593 Err(LinkError::BadAddress { .. })
16594 ),
16595 "grant_revoke must refuse brain {bad:?}"
16596 );
16597 assert!(
16598 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
16599 "head must refuse {bad:?}"
16600 );
16601 }
16602 }
16603
16604 #[test]
16605 fn grant_revoke_refuses_url_reshaping_grant_ids() {
16606 let cfg = dead_hub();
16607 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
16608 assert!(
16609 matches!(
16610 grant_revoke(&cfg, "acme", bad),
16611 Err(LinkError::BadGrantId { .. })
16612 ),
16613 "grant_revoke must refuse grant id {bad:?}"
16614 );
16615 }
16616 }
16617
16618 #[test]
16619 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
16620 let cfg = dead_hub();
16621 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
16622 assert!(
16623 matches!(
16624 propose(&cfg, bad, "intake", "hi"),
16625 Err(LinkError::BadAddress { .. })
16626 ),
16627 "propose must refuse handle {bad:?}"
16628 );
16629 }
16630 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
16631 assert!(matches!(
16632 propose(&cfg, "acme-site", "intake", &oversize),
16633 Err(LinkError::ProposeTooLarge { .. })
16634 ));
16635 assert!(matches!(
16638 propose(&cfg, "acme-site", "intake", "hi"),
16639 Err(LinkError::Transport { .. })
16640 ));
16641 }
16642
16643 #[test]
16644 fn resolve_refuses_a_hand_built_unsafe_address() {
16645 let cfg = dead_hub();
16646 for brain in ["../up", "a/b", "a?x", "a#f"] {
16647 let addr = Address {
16648 brain: brain.to_string(),
16649 target: None,
16650 };
16651 assert!(
16652 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
16653 "resolve must refuse brain {brain:?}"
16654 );
16655 }
16656 for target in [
16657 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
16658 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
16660 AddressTarget::Path("records/x.md#frag".to_string()),
16661 ] {
16662 let addr = Address {
16663 brain: "acme".to_string(),
16664 target: Some(target.clone()),
16665 };
16666 assert!(
16667 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
16668 "resolve must refuse target {target:?}"
16669 );
16670 }
16671 }
16672
16673 #[test]
16674 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
16675 let mut local = std::collections::BTreeMap::new();
16676 local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
16677 local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
16678 let mut remote = std::collections::BTreeMap::new();
16679 remote.insert(
16680 "records/a.md".to_string(),
16681 V2BaselineFile {
16682 sha256: "c".repeat(64),
16683 bytes: 1,
16684 proof: None,
16685 },
16686 );
16687 remote.insert(
16688 "records/b.md".to_string(),
16689 V2BaselineFile {
16690 sha256: "b".repeat(64),
16691 bytes: 1,
16692 proof: None,
16693 },
16694 );
16695 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
16696 }
16697
16698 #[test]
16699 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
16700 let local = std::collections::BTreeMap::new();
16701 let mut remote = std::collections::BTreeMap::new();
16702 remote.insert(
16703 "private/local.md".to_string(),
16704 V2BaselineFile {
16705 sha256: "d".repeat(64),
16706 bytes: 1,
16707 proof: None,
16708 },
16709 );
16710 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
16711 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
16712 }
16713
16714 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
16715 V2VerifiedHead {
16716 requested: TEST_BRAIN_ID.to_string(),
16717 brain_id: TEST_BRAIN_ID.to_string(),
16718 view_kind: "scoped".to_string(),
16719 view_revision: revision.to_string(),
16720 control_revision: revision.to_string(),
16721 identity: V2HeadIdentity {
16722 custody: "hub".to_string(),
16723 fingerprint: "test".to_string(),
16724 public_key_spki: "test".to_string(),
16725 previous: Vec::new(),
16726 rotations: Vec::new(),
16727 },
16728 pointer: None,
16729 trust: TrustState {
16730 v: 2,
16731 origin: "https://hub.example".to_string(),
16732 requested: TEST_BRAIN_ID.to_string(),
16733 brain: TEST_BRAIN_ID.to_string(),
16734 home: None,
16735 anchor: "ed25519:test".to_string(),
16736 current: "ed25519:test".to_string(),
16737 head_seq: 0,
16738 feed_hash: None,
16739 rotations: Vec::new(),
16740 hub_signer: None,
16741 protocol_profile: Some("link-v2".to_string()),
16742 },
16743 alias: None,
16744 }
16745 }
16746
16747 #[test]
16748 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
16749 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
16750 assert!(accepted_as_v2(&trust));
16751
16752 trust.protocol_profile = None;
16753 trust.hub_signer = Some("ed25519:hub".to_string());
16754 assert!(accepted_as_v2(&trust));
16755
16756 trust.hub_signer = None;
16757 assert!(!accepted_as_v2(&trust));
16758 }
16759
16760 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
16761 V2SyncBaseline {
16762 v: 2,
16763 origin: "https://hub.example".to_string(),
16764 brain: TEST_BRAIN_ID.to_string(),
16765 checkout_id: Some("c".repeat(64)),
16766 head_seq: Some(0),
16767 commit_hash: None,
16768 content_root: None,
16769 asset_root: None,
16770 assets: std::collections::BTreeMap::new(),
16771 view_kind: Some("scoped".to_string()),
16772 view_revision: Some(revision.to_string()),
16773 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
16774 files: std::collections::BTreeMap::new(),
16775 local_policy_digest: None,
16776 local_eligibility: std::collections::BTreeMap::new(),
16777 remote_copy_remains: std::collections::BTreeMap::new(),
16778 }
16779 }
16780
16781 #[test]
16782 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
16783 let directory = tempfile::tempdir().unwrap();
16784 std::fs::write(
16785 directory.path().join("DB.md"),
16786 scoped_projection_bytes(TEST_BRAIN_ID),
16787 )
16788 .unwrap();
16789 let store = Store::open_strict(directory.path()).unwrap();
16790 let head = scoped_test_head(&"a".repeat(64));
16791 let baseline = scoped_test_baseline(&"a".repeat(64));
16792 let mut view = v2_local_files(&store).unwrap();
16793 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
16794 assert!(!view.riding.contains_key("DB.md"));
16795 assert!(!view.eligibility.contains_key("DB.md"));
16796 }
16797
16798 #[test]
16799 fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
16800 let directory = tempfile::tempdir().unwrap();
16801 std::fs::write(
16802 directory.path().join("DB.md"),
16803 scoped_projection_bytes(TEST_BRAIN_ID),
16804 )
16805 .unwrap();
16806 let store = Store::open_strict(directory.path()).unwrap();
16807 let head = scoped_test_head(&"a".repeat(64));
16808 let baseline = scoped_test_baseline(&"a".repeat(64));
16809
16810 let mut carried = v2_local_files(&store).unwrap();
16811 remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
16812 let handed_off =
16813 local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
16814 assert!(!handed_off.riding.contains_key("DB.md"));
16815
16816 let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
16817 assert!(!freshly_scanned.riding.contains_key("DB.md"));
16818
16819 std::fs::write(
16820 directory.path().join("DB.md"),
16821 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
16822 )
16823 .unwrap();
16824 let tampered = Store::open_strict(directory.path()).unwrap();
16825 assert!(matches!(
16826 local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
16827 Err(LinkError::ScopedProjectionModified)
16828 ));
16829 }
16830
16831 #[test]
16832 fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
16833 let directory = tempfile::tempdir().unwrap();
16834 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
16835 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
16836 std::fs::write(
16837 directory.path().join("DB.md"),
16838 b"---\nname: Kept home test\n---\n",
16839 )
16840 .unwrap();
16841 std::fs::write(
16842 directory.path().join("records/notes/a.md"),
16843 b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
16844 )
16845 .unwrap();
16846 std::fs::write(
16847 directory.path().join("sources/private/secret.md"),
16848 b"---\ntype: note\n---\nlocal only\n",
16849 )
16850 .unwrap();
16851 std::fs::write(
16852 directory.path().join("sources/private/unlinked.md"),
16853 b"---\ntype: note\n---\nnot disclosed\n",
16854 )
16855 .unwrap();
16856 std::fs::write(
16857 directory.path().join(".sevralocal"),
16858 b"sources/private/**\n",
16859 )
16860 .unwrap();
16861
16862 let store = Store::open_strict(directory.path()).unwrap();
16863 let view = v2_local_files(&store).unwrap();
16864 assert!(!view.riding.contains_key("sources/private/secret.md"));
16865 assert_eq!(
16866 view.withheld_links,
16867 vec![V2WithheldLink {
16868 source: "records/notes/a.md".to_string(),
16869 target: "sources/private/secret.md".to_string(),
16870 }]
16871 );
16872 }
16873
16874 #[test]
16875 fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
16876 let directory = tempfile::tempdir().unwrap();
16877 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
16878 std::fs::write(
16879 directory.path().join("DB.md"),
16880 b"---\nname: Withdrawal test\n---\n",
16881 )
16882 .unwrap();
16883 let source = b"---\ntype: note\n---\nlocal evidence\n";
16884 std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
16885 std::fs::write(
16886 directory.path().join(".sevralocal"),
16887 b"sources/private/**\n",
16888 )
16889 .unwrap();
16890 let store = Store::open_strict(directory.path()).unwrap();
16891 let view = v2_local_files(&store).unwrap();
16892 let mut remote = std::collections::BTreeMap::new();
16893 remote.insert(
16894 "sources/private/evidence.md".to_string(),
16895 V2BaselineFile {
16896 sha256: content_sha256(source),
16897 bytes: source.len() as u64,
16898 proof: None,
16899 },
16900 );
16901 assert_eq!(
16902 v2_content_withdrawal_operation(
16903 &store,
16904 &view,
16905 &remote,
16906 "sources/private/evidence.md",
16907 "approved retention change",
16908 )
16909 .unwrap(),
16910 json!({
16911 "op": "withdraw_from_hosting",
16912 "path": "sources/private/evidence.md",
16913 "expected": { "kind": "blob", "hash": content_sha256(source) },
16914 "reason": "approved retention change",
16915 })
16916 );
16917
16918 std::fs::write(
16919 directory.path().join("sources/private/evidence.md"),
16920 b"changed after review",
16921 )
16922 .unwrap();
16923 assert!(matches!(
16924 v2_content_withdrawal_operation(
16925 &store,
16926 &view,
16927 &remote,
16928 "sources/private/evidence.md",
16929 "approved retention change",
16930 ),
16931 Err(LinkError::InvalidPack { .. })
16932 ));
16933 }
16934
16935 #[test]
16936 fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
16937 let directory = tempfile::tempdir().unwrap();
16938 std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
16939 std::fs::write(
16940 directory.path().join("DB.md"),
16941 b"---\nname: Asset withdrawal test\n---\n",
16942 )
16943 .unwrap();
16944 let bytes = b"private binary";
16945 std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
16946 std::fs::write(
16947 directory.path().join(".sevralocal"),
16948 b"sources/files/private.pdf\n",
16949 )
16950 .unwrap();
16951 let store = Store::open_strict(directory.path()).unwrap();
16952 let view = v2_local_files(&store).unwrap();
16953 let local = crate::AssetRecord {
16954 path: "sources/files/private.pdf".to_string(),
16955 sha256: content_sha256(bytes),
16956 bytes: bytes.len() as u64,
16957 media_type: "application/pdf".to_string(),
16958 wrappers: vec!["sources/files/private.md".to_string()],
16959 required: true,
16960 };
16961 let current = V2BaselineAsset {
16962 blob_sha256: local.sha256.clone(),
16963 bytes: local.bytes,
16964 media_type: local.media_type.clone(),
16965 wrappers: local.wrappers.clone(),
16966 required: local.required,
16967 disposition: "hosted".to_string(),
16968 leaf_hash: "d".repeat(64),
16969 };
16970 assert_eq!(
16971 v2_asset_withdrawal_operation(
16972 &store,
16973 &view,
16974 &local.path,
16975 &local,
16976 ¤t,
16977 "approved retention change",
16978 )
16979 .unwrap(),
16980 json!({
16981 "op": "asset_withdraw",
16982 "path": local.path,
16983 "expected": { "kind": "asset", "hash": "d".repeat(64) },
16984 "reason": "approved retention change",
16985 })
16986 );
16987
16988 let mut mismatched = current.clone();
16989 mismatched.required = false;
16990 assert!(matches!(
16991 v2_asset_withdrawal_operation(
16992 &store,
16993 &view,
16994 &local.path,
16995 &local,
16996 &mismatched,
16997 "approved retention change",
16998 ),
16999 Err(LinkError::InvalidPack { .. })
17000 ));
17001 }
17002
17003 #[test]
17004 fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
17005 let first = v2_checkout_id(None).unwrap();
17006 assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
17007 assert_ne!(first, v2_checkout_id(None).unwrap());
17008 assert!(is_sha256(&first));
17009 }
17010
17011 #[test]
17012 fn scoped_projection_edit_and_scope_transition_fail_closed() {
17013 let directory = tempfile::tempdir().unwrap();
17014 std::fs::write(
17015 directory.path().join("DB.md"),
17016 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
17017 )
17018 .unwrap();
17019 let store = Store::open_strict(directory.path()).unwrap();
17020 let head = scoped_test_head(&"a".repeat(64));
17021 let baseline = scoped_test_baseline(&"a".repeat(64));
17022 let mut view = v2_local_files(&store).unwrap();
17023 assert!(matches!(
17024 remove_scoped_projection(&head, Some(&baseline), &mut view),
17025 Err(LinkError::ScopedProjectionModified)
17026 ));
17027
17028 let changed = scoped_test_head(&"b".repeat(64));
17029 assert!(matches!(
17030 ensure_v2_view_compatible(&changed, Some(&baseline)),
17031 Err(LinkError::ScopedViewChanged)
17032 ));
17033
17034 let mut same_view_new_control = head.clone();
17035 same_view_new_control.control_revision = "c".repeat(64);
17036 assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
17037 assert!(!same_v2_head(&head, &same_view_new_control));
17038 }
17039
17040 #[test]
17041 fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
17042 let scoped = scoped_test_head(&"a".repeat(64));
17043 let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
17044 assert!(matches!(
17045 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
17046 Err(LinkError::ScopedProjectionModified)
17047 ));
17048
17049 let mut full = scoped.clone();
17050 full.view_kind = "full".to_string();
17051 let mut full_baseline = scoped_baseline.clone();
17052 full_baseline.view_kind = Some("full".to_string());
17053 full_baseline.projection_sha256 = None;
17054 assert!(matches!(
17055 ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
17056 Err(LinkError::InvalidPack { .. })
17057 ));
17058
17059 assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
17060 assert!(
17061 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
17062 );
17063 }
17064
17065 #[test]
17066 fn scoped_view_metadata_is_explicitly_non_authoritative() {
17067 let head = scoped_test_head(&"a".repeat(64));
17068 let value: Value =
17069 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
17070 assert_eq!(value["kind"], "link.md-scoped-view");
17071 assert_eq!(value["authoritative"], false);
17072 assert_eq!(value["visible_files"], 7);
17073 assert_eq!(value["brain"], TEST_BRAIN_ID);
17074 }
17075
17076 #[test]
17077 fn local_scoped_marker_requires_the_exact_generated_projection() {
17078 let directory = tempfile::tempdir().unwrap();
17079 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
17080 std::fs::write(
17081 directory.path().join("DB.md"),
17082 scoped_projection_bytes(TEST_BRAIN_ID),
17083 )
17084 .unwrap();
17085 let head = scoped_test_head(&"a".repeat(64));
17086 std::fs::write(
17087 directory.path().join(".dbmd/view.json"),
17088 scoped_view_metadata(&head, 0).unwrap(),
17089 )
17090 .unwrap();
17091 let store = Store::open_strict(directory.path()).unwrap();
17092 assert!(has_verified_local_scoped_view(&store));
17093
17094 std::fs::write(
17095 directory.path().join("DB.md"),
17096 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
17097 )
17098 .unwrap();
17099 let altered = Store::open_strict(directory.path()).unwrap();
17100 assert!(!has_verified_local_scoped_view(&altered));
17101 }
17102
17103 fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
17104 use ring::signature::KeyPair as _;
17105
17106 let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
17107 let rng = ring::rand::SystemRandom::new();
17108 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17109 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17110 let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
17111 let public_key = URL_SAFE_NO_PAD.encode(&public_der);
17112 let fingerprint = format!("{:x}", Sha256::digest(&public_der));
17113 let blob = b"new";
17114 let blob_hash = content_sha256(blob);
17115 let changes = json!({
17116 "mutation_id": "sync:proposal-fixture",
17117 "operations": [{
17118 "blob": blob_hash,
17119 "bytes": blob.len(),
17120 "expected": null,
17121 "op": "put",
17122 "path": "records/new.md",
17123 }],
17124 "reason": "fixture",
17125 "v": 2,
17126 });
17127 let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
17128 let changes_base64 = STANDARD.encode(&changes_bytes);
17129 let descriptor = json!({
17130 "base": null,
17131 "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
17132 "changes_base64": changes_base64,
17133 "rebase": "strict",
17134 "v": 2,
17135 });
17136 let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
17137 let payload_hash = "b".repeat(64);
17138 let submitted_at = "2026-08-19T12:00:00.000Z";
17139 let claim = json!({
17140 "actor_root": {
17141 "actor_class": "foreign_key",
17142 "credential": "ed25519:fixture",
17143 "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
17144 "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
17145 "principal": "key:fixture",
17146 "role": null,
17147 },
17148 "brain": TEST_BRAIN_ID,
17149 "clear_sha256": clear_hash,
17150 "control_revision": "c".repeat(64),
17151 "mutation_id": "sync:proposal-fixture",
17152 "payload_sha256": payload_hash,
17153 "proposal_id": proposal_id,
17154 "submitted_at": submitted_at,
17155 "v": 2,
17156 });
17157 let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
17158 let envelope = json!({
17159 "claim": claim,
17160 "fingerprint": fingerprint,
17161 "public_key": public_key,
17162 "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
17163 });
17164 let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
17165 let submission_hash =
17166 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
17167 let mut head = scoped_test_head(&"c".repeat(64));
17168 head.view_kind = "full".to_string();
17169 head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
17170 let value = json!({
17171 "proposal": {
17172 "base": null,
17173 "blobs": [{
17174 "bytes": blob.len(),
17175 "endpoint": format!(
17176 "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
17177 ),
17178 "sha256": blob_hash,
17179 }],
17180 "changes_base64": changes_base64,
17181 "clear_sha256": clear_hash,
17182 "expires_at": "2026-08-26T12:00:00.000Z",
17183 "id": proposal_id,
17184 "payload_sha256": payload_hash,
17185 "proposer": { "class": "foreign_key" },
17186 "rebase": "strict",
17187 "state": "pending",
17188 "submission_claim_base64": STANDARD.encode(envelope_bytes),
17189 "submission_claim_sha256": submission_hash,
17190 "submitted_at": submitted_at,
17191 },
17192 "v": 2,
17193 });
17194 (head, proposal_id, value)
17195 }
17196
17197 #[test]
17198 fn v2_proposal_verifier_accepts_exact_signed_payload() {
17199 let (head, proposal_id, value) = signed_proposal_fixture();
17200 let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
17201 assert_eq!(verified.blobs.len(), 1);
17202 assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
17203 }
17204
17205 #[test]
17206 fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
17207 let (head, proposal_id, value) = signed_proposal_fixture();
17208
17209 let mut changed = value.clone();
17210 changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
17211 assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
17212
17213 let mut redirected = value.clone();
17214 redirected["proposal"]["blobs"][0]["endpoint"] =
17215 Value::String("https://attacker.example/blob".to_string());
17216 assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
17217
17218 let mut forged = value;
17219 let encoded = forged["proposal"]["submission_claim_base64"]
17220 .as_str()
17221 .unwrap();
17222 let mut envelope: Value =
17223 serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
17224 envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
17225 let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
17226 forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
17227 forged["proposal"]["submission_claim_sha256"] = Value::String(
17228 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
17229 );
17230 assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
17231 }
17232
17233 #[cfg(unix)]
17234 #[test]
17235 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
17236 let sandbox = tempfile::tempdir().unwrap();
17237 let destination = sandbox.path().join("brain");
17238 let entries = vec![
17239 (
17240 "DB.md".to_string(),
17241 scoped_projection_bytes(TEST_BRAIN_ID),
17242 ),
17243 (
17244 "records/contacts/a.md".to_string(),
17245 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
17246 .to_vec(),
17247 ),
17248 ];
17249 install_pulled_delta(&destination, &entries, &[], true).unwrap();
17250 assert!(destination.join("index.md").is_file());
17251 assert!(destination.join("records/index.md").is_file());
17252 assert!(destination.join("records/contacts/index.md").is_file());
17253 assert!(destination.join("records/contacts/index.jsonl").is_file());
17254 }
17255
17256 #[cfg(unix)]
17257 #[test]
17258 fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
17259 let sandbox = tempfile::tempdir().unwrap();
17260 let destination = sandbox.path().join("brain");
17261 let cache = sandbox.path().join("cache");
17262 std::fs::create_dir(&cache).unwrap();
17263 let db = scoped_projection_bytes(TEST_BRAIN_ID);
17264 let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
17265 let db_source = cache.join("db");
17266 let shared_source = cache.join("shared");
17267 crate::fsx::write_atomic(&db_source, &db).unwrap();
17268 crate::fsx::write_atomic(&shared_source, shared).unwrap();
17269 let mut entries = vec![V2StagedFile {
17270 path: "DB.md".to_string(),
17271 source: db_source,
17272 sha256: content_sha256(&db),
17273 bytes: db.len() as u64,
17274 }];
17275 for index in 0..512 {
17276 entries.push(V2StagedFile {
17277 path: format!("records/items/{index:05}.md"),
17278 source: shared_source.clone(),
17279 sha256: content_sha256(shared),
17280 bytes: shared.len() as u64,
17281 });
17282 }
17283 install_pulled_delta_sources(
17284 &destination,
17285 &entries,
17286 &[],
17287 false,
17288 None,
17289 &scoped_test_head(&"c".repeat(64)),
17290 )
17291 .unwrap();
17292 assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
17293 for index in 0..512 {
17294 assert_eq!(
17295 std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
17296 shared
17297 );
17298 }
17299 assert!(
17300 std::fs::read_dir(sandbox.path())
17301 .unwrap()
17302 .all(|entry| !entry
17303 .unwrap()
17304 .file_name()
17305 .to_string_lossy()
17306 .contains("pull-stage")),
17307 "the private stage must be atomically installed or removed"
17308 );
17309 }
17310
17311 #[cfg(unix)]
17312 #[test]
17313 fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
17314 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
17315
17316 let sandbox = tempfile::tempdir().unwrap();
17317 let root = sandbox.path().join("brain");
17318 std::fs::create_dir_all(root.join("records/items")).unwrap();
17319 let db = scoped_projection_bytes(TEST_BRAIN_ID);
17320 let old = b"---\ntype: note\n---\n\nold\n";
17321 let new = b"---\ntype: note\n---\n\nnew\n";
17322 let removed = b"---\ntype: note\n---\n\nremove me\n";
17323 std::fs::write(root.join("DB.md"), &db).unwrap();
17324 std::fs::write(root.join("records/items/change.md"), old).unwrap();
17325 std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
17326 for index in 0..512 {
17327 std::fs::write(
17328 root.join(format!("records/items/untouched-{index:04}.md")),
17329 old,
17330 )
17331 .unwrap();
17332 }
17333 let untouched = root.join("records/items/untouched-0256.md");
17334 let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
17335 let source = sandbox.path().join("changed-source");
17336 crate::fsx::write_atomic(&source, new).unwrap();
17337 let same_source = sandbox.path().join("unchanged-source");
17338 crate::fsx::write_atomic(&same_source, old).unwrap();
17339 let same_entry = V2StagedFile {
17340 path: "records/items/change.md".to_string(),
17341 source: same_source,
17342 sha256: content_sha256(old),
17343 bytes: old.len() as u64,
17344 };
17345 let entry = V2StagedFile {
17346 path: "records/items/change.md".to_string(),
17347 source,
17348 sha256: content_sha256(new),
17349 bytes: new.len() as u64,
17350 };
17351 let head = scoped_test_head(&"c".repeat(64));
17352
17353 install_established_v2_delta(
17357 Store::open_strict(&root).unwrap(),
17358 &[same_entry],
17359 &["records/items/already-absent.md".to_string()],
17360 true,
17361 None,
17362 &head,
17363 )
17364 .unwrap();
17365 assert_eq!(
17366 std::fs::metadata(&untouched).unwrap().ino(),
17367 untouched_inode
17368 );
17369 assert!(!root.join(V2_PULL_JOURNAL).exists());
17370
17371 install_established_v2_delta(
17372 Store::open_strict(&root).unwrap(),
17373 &[entry],
17374 &["records/items/delete.md".to_string()],
17375 false,
17376 None,
17377 &head,
17378 )
17379 .unwrap();
17380 assert_eq!(
17381 std::fs::read(root.join("records/items/change.md")).unwrap(),
17382 new
17383 );
17384 assert!(!root.join("records/items/delete.md").exists());
17385 assert_eq!(
17386 std::fs::metadata(&untouched).unwrap().ino(),
17387 untouched_inode
17388 );
17389 assert!(root.join(V2_PULL_JOURNAL).is_file());
17390 assert_eq!(
17391 std::fs::metadata(root.join(V2_PULL_JOURNAL))
17392 .unwrap()
17393 .permissions()
17394 .mode()
17395 & 0o777,
17396 0o600
17397 );
17398 let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
17399 .unwrap()
17400 .unwrap();
17401 assert_eq!(
17402 std::fs::metadata(root.join(&journal.backup_dir))
17403 .unwrap()
17404 .permissions()
17405 .mode()
17406 & 0o777,
17407 0o700
17408 );
17409 for entry in &journal.entries {
17410 if let Some(backup) = &entry.backup {
17411 assert_eq!(
17412 std::fs::metadata(root.join(&journal.backup_dir).join(backup))
17413 .unwrap()
17414 .permissions()
17415 .mode()
17416 & 0o777,
17417 0o600
17418 );
17419 }
17420 }
17421
17422 let cfg = test_hub_config(
17423 "https://example.test".to_string(),
17424 sandbox.path().join("state"),
17425 );
17426 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
17427 assert_eq!(
17428 std::fs::read(root.join("records/items/change.md")).unwrap(),
17429 old
17430 );
17431 assert_eq!(
17432 std::fs::read(root.join("records/items/delete.md")).unwrap(),
17433 removed
17434 );
17435 assert_eq!(
17436 std::fs::metadata(&untouched).unwrap().ino(),
17437 untouched_inode
17438 );
17439 assert!(!root.join(V2_PULL_JOURNAL).exists());
17440 }
17441
17442 #[test]
17443 fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
17444 let body = b"bounded bytes";
17445 let path = "records/example.md".to_string();
17446 let file = V2BaselineFile {
17447 sha256: content_sha256(body),
17448 bytes: body.len() as u64,
17449 proof: None,
17450 };
17451 let header = serde_json::to_vec(&json!({
17452 "bytes": body.len(),
17453 "path": path,
17454 "sha256": file.sha256,
17455 "v": 2,
17456 }))
17457 .unwrap();
17458 let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
17459 stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
17460 stream.extend_from_slice(&header);
17461 stream.extend_from_slice(body);
17462 stream.extend_from_slice(&0_u32.to_be_bytes());
17463 let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
17464 assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
17465
17466 let mut tampered = stream.clone();
17467 let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
17468 tampered[body_offset] ^= 1;
17469 assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
17470
17471 let mut trailing = stream;
17472 trailing.push(0);
17473 assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
17474 }
17475
17476 #[test]
17477 fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
17478 let sandbox = tempfile::TempDir::new().unwrap();
17479 let root = sandbox.path().join("brain");
17480 std::fs::create_dir_all(&root).unwrap();
17481 std::fs::write(
17482 root.join("DB.md"),
17483 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
17484 )
17485 .unwrap();
17486 let store = Store::open_strict(&root).unwrap();
17487 let incomplete = crate::ulid::mint();
17488 store
17489 .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
17490 .unwrap();
17491 let expired = crate::ulid::mint();
17492 store
17493 .create_dir_all(&v2_conflict_relative(&expired, "files"))
17494 .unwrap();
17495 let plan = V2ConflictPlan {
17496 v: 2,
17497 class: "content_resolution_required".to_string(),
17498 bundle: expired.clone(),
17499 brain: TEST_BRAIN_ID.to_string(),
17500 origin: "https://example.test".to_string(),
17501 created_unix: 0,
17502 expires_unix: 0,
17503 base_seq: None,
17504 base_commit: None,
17505 remote_seq: 0,
17506 remote_commit: None,
17507 remote_content_root: None,
17508 view_kind: "full".to_string(),
17509 view_revision: "a".repeat(64),
17510 files: vec![V2ConflictFile {
17511 path: "records/value.md".to_string(),
17512 base: V2ConflictCoordinate {
17513 sha256: None,
17514 bytes: None,
17515 file: None,
17516 },
17517 local: V2ConflictCoordinate {
17518 sha256: None,
17519 bytes: None,
17520 file: None,
17521 },
17522 remote: V2ConflictCoordinate {
17523 sha256: None,
17524 bytes: None,
17525 file: None,
17526 },
17527 }],
17528 };
17529 let mut bytes = serde_json::to_vec(&plan).unwrap();
17530 bytes.push(b'\n');
17531 store
17532 .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
17533 .unwrap();
17534
17535 let listed = sync_conflicts(&root, false, false).unwrap();
17536 assert_eq!(listed["bundles"], 2);
17537 assert_eq!(listed["pruned"], 0);
17538 let pruned = sync_conflicts(&root, true, false).unwrap();
17539 assert_eq!(pruned["bundles"], 0);
17540 assert_eq!(pruned["pruned"], 2);
17541 assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
17542 assert!(!root.join(".dbmd/conflicts").join(expired).exists());
17543 }
17544
17545 #[test]
17546 fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
17547 let sandbox = tempfile::TempDir::new().unwrap();
17548 let root = sandbox.path().join("brain");
17549 std::fs::create_dir_all(&root).unwrap();
17550 std::fs::write(
17551 root.join("DB.md"),
17552 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
17553 )
17554 .unwrap();
17555 let store = Store::open_strict(&root).unwrap();
17556 let bundle = crate::ulid::mint();
17557 store
17558 .create_dir_all(&v2_conflict_relative(&bundle, "files"))
17559 .unwrap();
17560 store
17561 .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
17562 .unwrap();
17563
17564 assert!(sync_conflicts(&root, true, false).is_err());
17565 assert!(sync_conflicts(&root, false, true).is_err());
17566 let pruned = sync_conflicts(&root, true, true).unwrap();
17567 assert_eq!(pruned["pruned"], 1);
17568 assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
17569 }
17570
17571 #[test]
17572 fn ready_pull_journal_rolls_back_exact_preimages() {
17573 let sandbox = tempfile::TempDir::new().unwrap();
17574 let root = sandbox.path().join("brain");
17575 std::fs::create_dir_all(root.join("records")).unwrap();
17576 std::fs::write(
17577 root.join("DB.md"),
17578 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
17579 )
17580 .unwrap();
17581 let path = "records/value.md";
17582 let old = b"---\ntype: note\n---\n\nold\n";
17583 let new = b"---\ntype: note\n---\n\nnew\n";
17584 std::fs::write(root.join(path), old).unwrap();
17585 let store = Store::open_strict(&root).unwrap();
17586 let bundle = crate::ulid::mint();
17587 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
17588 store
17589 .create_private_dir_all(Path::new(&backup_dir))
17590 .unwrap();
17591 store
17592 .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
17593 .unwrap();
17594 let journal = V2PullJournal {
17595 v: 1,
17596 phase: V2PullPhase::Ready,
17597 brain: TEST_BRAIN_ID.to_string(),
17598 previous: V2PullCoordinate {
17599 head_seq: None,
17600 commit_hash: None,
17601 view_kind: None,
17602 view_revision: None,
17603 },
17604 next: V2PullCoordinate {
17605 head_seq: Some(2),
17606 commit_hash: Some("c".repeat(64)),
17607 view_kind: Some("full".to_string()),
17608 view_revision: Some("d".repeat(64)),
17609 },
17610 backup_dir: backup_dir.clone(),
17611 entries: vec![V2PullJournalEntry {
17612 path: path.to_string(),
17613 old: Some(V2PullFileCoordinate {
17614 sha256: content_sha256(old),
17615 bytes: old.len() as u64,
17616 }),
17617 new: Some(V2PullFileCoordinate {
17618 sha256: content_sha256(new),
17619 bytes: new.len() as u64,
17620 }),
17621 backup: Some("00000000".to_string()),
17622 }],
17623 };
17624 validate_v2_pull_journal(&journal).unwrap();
17625 store
17626 .write_private_atomic_new(
17627 Path::new(V2_PULL_JOURNAL),
17628 &v2_pull_journal_bytes(&journal).unwrap(),
17629 )
17630 .unwrap();
17631 store.write_atomic(Path::new(path), new).unwrap();
17632
17633 let cfg = test_hub_config(
17634 "https://example.test".to_string(),
17635 sandbox.path().join("state"),
17636 );
17637 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
17638 assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
17639 assert!(!root.join(V2_PULL_JOURNAL).exists());
17640 assert!(!root.join(backup_dir).exists());
17641 }
17642
17643 #[test]
17644 fn preparing_pull_journal_discards_only_private_staging() {
17645 let sandbox = tempfile::TempDir::new().unwrap();
17646 let root = sandbox.path().join("brain");
17647 std::fs::create_dir_all(root.join(".dbmd")).unwrap();
17648 std::fs::write(
17649 root.join("DB.md"),
17650 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
17651 )
17652 .unwrap();
17653 let store = Store::open_strict(&root).unwrap();
17654 let bundle = crate::ulid::mint();
17655 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
17656 store
17657 .create_private_dir_all(Path::new(&backup_dir))
17658 .unwrap();
17659 let journal = V2PullJournal {
17660 v: 1,
17661 phase: V2PullPhase::Preparing,
17662 brain: TEST_BRAIN_ID.to_string(),
17663 previous: V2PullCoordinate {
17664 head_seq: None,
17665 commit_hash: None,
17666 view_kind: None,
17667 view_revision: None,
17668 },
17669 next: V2PullCoordinate {
17670 head_seq: Some(1),
17671 commit_hash: Some("a".repeat(64)),
17672 view_kind: Some("full".to_string()),
17673 view_revision: Some("b".repeat(64)),
17674 },
17675 backup_dir: backup_dir.clone(),
17676 entries: vec![V2PullJournalEntry {
17677 path: "records/new.md".to_string(),
17678 old: None,
17679 new: Some(V2PullFileCoordinate {
17680 sha256: "c".repeat(64),
17681 bytes: 1,
17682 }),
17683 backup: None,
17684 }],
17685 };
17686 store
17687 .write_private_atomic_new(
17688 Path::new(V2_PULL_JOURNAL),
17689 &v2_pull_journal_bytes(&journal).unwrap(),
17690 )
17691 .unwrap();
17692 let cfg = test_hub_config(
17693 "https://example.test".to_string(),
17694 sandbox.path().join("state"),
17695 );
17696
17697 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
17698
17699 assert!(root.join("DB.md").is_file());
17700 assert!(!root.join(V2_PULL_JOURNAL).exists());
17701 assert!(!root.join(backup_dir).exists());
17702 }
17703
17704 #[test]
17705 fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
17706 let sandbox = tempfile::TempDir::new().unwrap();
17707 let root = sandbox.path().join("brain");
17708 std::fs::create_dir_all(root.join("records")).unwrap();
17709 std::fs::write(
17710 root.join("DB.md"),
17711 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
17712 )
17713 .unwrap();
17714 let new = b"---\ntype: note\n---\n\nnew\n";
17715 std::fs::write(root.join("records/value.md"), new).unwrap();
17716 let store = Store::open_strict(&root).unwrap();
17717 let bundle = crate::ulid::mint();
17718 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
17719 store
17720 .create_private_dir_all(Path::new(&backup_dir))
17721 .unwrap();
17722 let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
17723 store.create_private_dir_all(Path::new(&orphan)).unwrap();
17724 let next = V2PullCoordinate {
17725 head_seq: Some(2),
17726 commit_hash: Some("c".repeat(64)),
17727 view_kind: Some("full".to_string()),
17728 view_revision: Some("d".repeat(64)),
17729 };
17730 let journal = V2PullJournal {
17731 v: 1,
17732 phase: V2PullPhase::Ready,
17733 brain: TEST_BRAIN_ID.to_string(),
17734 previous: V2PullCoordinate {
17735 head_seq: Some(1),
17736 commit_hash: Some("a".repeat(64)),
17737 view_kind: Some("full".to_string()),
17738 view_revision: Some("b".repeat(64)),
17739 },
17740 next: next.clone(),
17741 backup_dir: backup_dir.clone(),
17742 entries: vec![V2PullJournalEntry {
17743 path: "records/value.md".to_string(),
17744 old: Some(V2PullFileCoordinate {
17745 sha256: "e".repeat(64),
17746 bytes: new.len() as u64,
17747 }),
17748 new: Some(V2PullFileCoordinate {
17749 sha256: content_sha256(new),
17750 bytes: new.len() as u64,
17751 }),
17752 backup: Some("00000000".to_string()),
17753 }],
17754 };
17755 store
17756 .write_private_atomic_new(
17757 Path::new(V2_PULL_JOURNAL),
17758 &v2_pull_journal_bytes(&journal).unwrap(),
17759 )
17760 .unwrap();
17761 let cfg = test_hub_config(
17762 "https://example.test".to_string(),
17763 sandbox.path().join("state"),
17764 );
17765 save_v2_baseline(
17766 &cfg,
17767 TEST_BRAIN_ID,
17768 &root,
17769 &V2SyncBaseline {
17770 v: 2,
17771 origin: "https://example.test".to_string(),
17772 brain: TEST_BRAIN_ID.to_string(),
17773 checkout_id: Some("c".repeat(64)),
17774 head_seq: next.head_seq,
17775 commit_hash: next.commit_hash.clone(),
17776 content_root: Some("f".repeat(64)),
17777 asset_root: None,
17778 assets: Default::default(),
17779 view_kind: next.view_kind.clone(),
17780 view_revision: next.view_revision.clone(),
17781 projection_sha256: None,
17782 files: Default::default(),
17783 local_policy_digest: None,
17784 local_eligibility: Default::default(),
17785 remote_copy_remains: Default::default(),
17786 },
17787 )
17788 .unwrap();
17789
17790 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
17791
17792 assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
17793 assert!(!root.join(V2_PULL_JOURNAL).exists());
17794 assert!(!root.join(backup_dir).exists());
17795 assert!(!root.join(orphan).exists());
17796 }
17797}