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;
135const MAX_STAGED_CHANGE_BYTES: usize = 64 * 1024 * 1024;
138
139const MAX_PUSH_FILES: usize = u16::MAX as usize;
141const MAX_STORE_PATH_BYTES: usize = 1_024;
142const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
143const MAX_PACK_BYTES: u64 =
146 MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
147const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
156const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
157
158const MAX_IDENTITY_ROTATIONS: usize = 1_024;
161
162fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
165 let mut batches: Vec<Vec<Value>> = Vec::new();
166 let mut current: Vec<Value> = Vec::new();
167 let mut current_bytes = 0usize;
168 for declaration in declarations {
169 let declared_bytes = serde_json::to_string(&declaration)
170 .map(|text| text.len())
171 .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
172 + 1;
173 if !current.is_empty()
174 && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
175 || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
176 {
177 batches.push(std::mem::take(&mut current));
178 current_bytes = 0;
179 }
180 current_bytes += declared_bytes;
181 current.push(declaration);
182 }
183 if !current.is_empty() {
184 batches.push(current);
185 }
186 batches
187}
188const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
192const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
193const FEED_PAGE_LIMIT: usize = 100;
194
195pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
200
201const CONNECT_TIMEOUT_SECS: u64 = 10;
204const READ_TIMEOUT_SECS: u64 = 120;
205const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
209const COMMIT_REQUEST_TIMEOUT_SECS: u64 = 900;
216const COMMIT_ATTEMPTS: usize = 4;
220const COMMIT_RETRY_BACKOFF_MS: [u64; COMMIT_ATTEMPTS - 1] = [5_000, 20_000, 45_000];
221const CONNECT_ATTEMPTS: usize = 3;
222const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
223
224const UPLOAD_ATTEMPTS: usize = 6;
228const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
229const UPLOAD_TOTAL_TIMEOUT_SECS: u64 = 300;
233
234fn upload_retry_backoff_ms(attempt: usize) -> u64 {
235 UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
236}
237
238fn upload_deadline_error() -> LinkError {
239 LinkError::Transport {
240 hub: "the object store".to_string(),
241 message: "network error (upload deadline exceeded)".to_string(),
242 }
243}
244
245fn upload_attempt_timeout(deadline: std::time::Instant) -> LinkResult<std::time::Duration> {
246 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
247 if remaining.is_zero() {
248 return Err(upload_deadline_error());
249 }
250 Ok(remaining.min(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS)))
251}
252
253fn wait_for_upload_retry(deadline: std::time::Instant, attempt: usize) -> bool {
254 if attempt + 1 >= UPLOAD_ATTEMPTS {
255 return false;
256 }
257 let pause = std::time::Duration::from_millis(upload_retry_backoff_ms(attempt));
258 if deadline.saturating_duration_since(std::time::Instant::now()) <= pause {
259 return false;
260 }
261 std::thread::sleep(pause);
262 true
263}
264
265const RESERVATION_ATTEMPTS: usize = 7;
270const RESERVATION_BACKOFF_MS: [u64; RESERVATION_ATTEMPTS - 1] =
271 [500, 2_000, 5_000, 15_000, 30_000, 60_000];
272
273fn is_retryable_hub_status(status: u16) -> bool {
277 matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
278}
279
280fn is_retryable_upload_status(status: u16) -> bool {
284 matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
285}
286const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
290#[cfg(unix)]
294const V2_PULL_INSTALL_WORKERS: usize = 16;
295const V2_BULK_STREAM_FILES: usize = 256;
299const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
300const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
301const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
302
303#[derive(Debug, thiserror::Error)]
307pub enum LinkError {
308 #[error(
310 "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
311 )]
312 NoHub,
313
314 #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
316 NoCredential,
317
318 #[error(
321 "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
322 )]
323 BadKey,
324
325 #[error(
331 "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}"
332 )]
333 UnboundCredential,
334
335 #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
339 BadAgentKey {
340 message: String,
342 },
343
344 #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
346 UnsafeHub {
347 hub: String,
349 },
350
351 #[error("hub unreachable at {hub}: {message}")]
353 Transport {
354 hub: String,
356 message: String,
358 },
359
360 #[error("{what} failed (HTTP {status}): {message}")]
362 Http {
363 what: &'static str,
365 status: u16,
367 message: String,
369 code: Option<String>,
371 details: Option<Value>,
373 },
374
375 #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
378 NotJson {
379 what: &'static str,
381 status: u16,
383 },
384
385 #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
387 ResponseTooLarge {
388 limit_bytes: u64,
390 },
391
392 #[error("invalid address `{given}`: {reason}")]
394 BadAddress {
395 given: String,
397 reason: String,
399 },
400
401 #[error(
403 "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
404 )]
405 BadGrantId {
406 given: String,
408 },
409
410 #[error("refusing unsafe path from the hub: `{path}`")]
414 UnsafePath {
415 path: String,
417 },
418
419 #[error(
421 "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
422 MAX_STORE_BYTES / (1024 * 1024),
423 MAX_PACK_BYTES / (1024 * 1024)
424 )]
425 PushTooLarge {
426 detail: String,
428 },
429
430 #[error(
432 "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
433 MAX_PROPOSE_BYTES / 1024
434 )]
435 ProposeTooLarge {
436 bytes: u64,
438 },
439
440 #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
442 NotUtf8 {
443 path: String,
445 },
446
447 #[error("invalid store pack: {message}")]
449 InvalidPack {
450 message: String,
452 },
453
454 #[error("invalid signed feed: {message}")]
456 InvalidFeed {
457 message: String,
459 },
460
461 #[error(
465 "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}`"
466 )]
467 AliasRebindRequired {
468 alias: String,
469 from: String,
470 to: String,
471 },
472
473 #[error("sync conflict on {paths:?} — resolve the named files and retry")]
476 Conflict {
477 paths: Vec<String>,
479 },
480
481 #[error(
485 "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
486 )]
487 ConflictBundle {
488 bundle: String,
490 paths: Vec<String>,
492 },
493
494 #[error(
498 "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
499 )]
500 LocalPolicyTransition {
501 paths: Vec<String>,
503 },
504
505 #[error(
510 "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
511 )]
512 BulkPreviewRequired {
513 preview: Value,
515 },
516
517 #[error(
520 "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
521 )]
522 ScopedProjectionModified,
523
524 #[error(
528 "the checkout's permission scope changed — clone into a new directory to accept the new view"
529 )]
530 ScopedViewChanged,
531
532 #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
535 BrainUnavailable,
536
537 #[error(
540 "the remote brain advanced during sync — retry to converge from the new verified head"
541 )]
542 RemoteAdvancedDuringSync,
543
544 #[error(
547 "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
548 )]
549 UnsupportedPlatform {
550 operation: &'static str,
552 },
553
554 #[error(transparent)]
556 Io(#[from] std::io::Error),
557
558 #[error(transparent)]
560 Store(#[from] crate::StoreError),
561}
562
563pub type LinkResult<T> = std::result::Result<T, LinkError>;
565
566#[derive(Debug, Clone, PartialEq, Eq)]
568pub struct V2BulkConfirmation {
569 pub id: String,
571 pub digest: String,
574}
575
576impl V2BulkConfirmation {
577 pub fn parse(value: &str) -> LinkResult<Self> {
580 let (id, digest) = value
581 .split_once(':')
582 .ok_or_else(|| LinkError::InvalidPack {
583 message: "bulk confirmation must be <id>:<digest>".to_string(),
584 })?;
585 if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
586 return Err(LinkError::InvalidPack {
587 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
588 .to_string(),
589 });
590 }
591 Ok(Self {
592 id: id.to_string(),
593 digest: digest.to_string(),
594 })
595 }
596}
597
598fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
603 #[cfg(any(target_os = "linux", target_os = "macos", windows))]
604 {
605 let _ = operation;
606 Ok(())
607 }
608 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
609 {
610 Err(LinkError::UnsupportedPlatform { operation })
611 }
612}
613
614#[derive(Debug, Clone, PartialEq, Eq)]
620pub enum AddressTarget {
621 Id(String),
623 Path(String),
627}
628
629const BAD_BRAIN_REASON: &str =
632 "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
633
634const BAD_TARGET_REASON: &str =
637 "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
638
639#[derive(Debug, Clone, PartialEq, Eq)]
644pub struct Address {
645 pub brain: String,
647 pub target: Option<AddressTarget>,
649}
650
651impl Address {
652 pub fn parse(raw: &str) -> LinkResult<Address> {
656 let bad = |reason: &str| LinkError::BadAddress {
657 given: raw.to_string(),
658 reason: reason.to_string(),
659 };
660
661 let trimmed = raw.trim();
662 let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
663 if body.is_empty() {
664 return Err(bad("empty address"));
665 }
666
667 let (brain, rest) = match body.split_once('/') {
668 Some((b, r)) => (b, Some(r)),
669 None => (body, None),
670 };
671
672 if brain.is_empty() {
673 return Err(bad("missing brain reference before `/`"));
674 }
675 if !is_safe_ref(brain) {
676 return Err(bad(BAD_BRAIN_REASON));
677 }
678
679 let target = match rest {
680 None => None,
681 Some("") => return Err(bad("trailing `/` with no record id or path")),
682 Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
683 Some(r) => {
684 if !safe_store_rel_path(r) || !r.ends_with(".md") {
685 return Err(bad(BAD_TARGET_REASON));
686 }
687 Some(AddressTarget::Path(r.to_string()))
688 }
689 };
690
691 Ok(Address {
692 brain: brain.to_string(),
693 target,
694 })
695 }
696}
697
698fn is_safe_ref(s: &str) -> bool {
701 !s.is_empty()
702 && s.len() <= 64
703 && s.bytes()
704 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
705}
706
707pub fn is_valid_handle(s: &str) -> bool {
710 is_safe_ref(s)
711}
712
713pub fn safe_store_rel_path(p: &str) -> bool {
719 if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
720 return false;
721 }
722 if !p
723 .bytes()
724 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
725 {
726 return false;
727 }
728 p.split('/')
729 .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
730}
731
732fn require_safe_ref(brain: &str) -> LinkResult<()> {
740 if is_safe_ref(brain) {
741 Ok(())
742 } else {
743 Err(LinkError::BadAddress {
744 given: brain.to_string(),
745 reason: BAD_BRAIN_REASON.to_string(),
746 })
747 }
748}
749
750fn require_valid_handle(handle: &str) -> LinkResult<()> {
752 if is_valid_handle(handle) {
753 Ok(())
754 } else {
755 Err(LinkError::BadAddress {
756 given: handle.to_string(),
757 reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
758 })
759 }
760}
761
762fn require_safe_grant_id(id: &str) -> LinkResult<()> {
766 if is_safe_ref(id) {
767 Ok(())
768 } else {
769 Err(LinkError::BadGrantId {
770 given: id.to_string(),
771 })
772 }
773}
774
775#[derive(Debug, Clone)]
781pub struct HubConfig {
782 pub hub: String,
784 pub key: Option<String>,
786 pub agent_key: Option<AgentSigningKey>,
789 pub brain_key: Option<AgentSigningKey>,
792 pub state_dir: PathBuf,
795 store_selected: bool,
798}
799
800#[derive(Clone)]
803pub struct AgentSigningKey {
804 pkcs8: Vec<u8>,
805 pub multikey: String,
807 pub public_key_spki: String,
809}
810
811impl std::fmt::Debug for AgentSigningKey {
812 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
813 f.debug_struct("AgentSigningKey")
814 .field("multikey", &self.multikey)
815 .field("pkcs8", &"<redacted>")
816 .finish()
817 }
818}
819
820impl HubConfig {
821 pub fn require_key(&self) -> LinkResult<&str> {
824 self.key.as_deref().ok_or(LinkError::NoCredential)
825 }
826}
827
828pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
833 let explicit_hub = flag_hub
834 .map(str::to_string)
835 .or_else(|| env_nonempty(HUB_URL_ENV));
836 let selected_by_store = explicit_hub.is_none();
837 let hub = explicit_hub
838 .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
839 .ok_or(LinkError::NoHub)?;
840 let hub = hub.trim().trim_end_matches('/').to_string();
841 assert_safe_hub(&hub)?;
842 if selected_by_store {
843 let parsed =
844 url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
845 if !parsed.scheme().eq_ignore_ascii_case("https")
849 || (parsed.path() != "/" && !parsed.path().is_empty())
850 {
851 return Err(LinkError::UnsafeHub { hub });
852 }
853 }
854
855 let key = match env_nonempty(HUB_KEY_ENV) {
856 Some(raw) => Some(clean_key(&raw)?),
857 None => None,
858 };
859
860 let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
861 Some(path) => Some(load_agent_key(Path::new(&path))?),
862 None => None,
863 };
864
865 let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
866 Some(path) => Some(load_agent_key(Path::new(&path))?),
867 None => None,
868 };
869
870 if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
877 let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
878 .and_then(|value| normalized_origin(&value).ok());
879 let selected_origin = normalized_origin(&hub)?;
880 if bound.as_deref() != Some(selected_origin.as_str()) {
881 return Err(LinkError::UnboundCredential);
882 }
883 }
884
885 Ok(HubConfig {
886 hub,
887 key,
888 agent_key,
889 brain_key,
890 state_dir: toolkit_state_dir()?,
891 store_selected: selected_by_store,
892 })
893}
894
895fn toolkit_state_dir() -> LinkResult<PathBuf> {
896 if let Some(path) = env_nonempty(STATE_DIR_ENV) {
897 let path = PathBuf::from(path);
898 if !path.is_absolute() {
899 return Err(LinkError::UnsafePath {
900 path: path.display().to_string(),
901 });
902 }
903 return Ok(path);
904 }
905 #[cfg(windows)]
906 if let Some(base) = env_nonempty("LOCALAPPDATA") {
907 let base = PathBuf::from(base);
908 if base.is_absolute() {
909 return Ok(base.join("dbmd").join("state"));
910 }
911 }
912 #[cfg(windows)]
913 {
914 Err(LinkError::Io(std::io::Error::new(
915 std::io::ErrorKind::NotFound,
916 format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
917 )))
918 }
919 #[cfg(not(windows))]
920 if let Some(base) = env_nonempty("XDG_STATE_HOME") {
921 let base = PathBuf::from(base);
922 if base.is_absolute() {
923 return Ok(base.join("dbmd"));
924 }
925 }
926 #[cfg(not(windows))]
927 let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
928 LinkError::Io(std::io::Error::new(
929 std::io::ErrorKind::NotFound,
930 format!("cannot locate user state; set {STATE_DIR_ENV}"),
931 ))
932 })?);
933 #[cfg(not(windows))]
934 if !home.is_absolute() {
935 return Err(LinkError::UnsafePath {
936 path: home.display().to_string(),
937 });
938 }
939 #[cfg(target_os = "macos")]
940 {
941 Ok(home
942 .join("Library")
943 .join("Application Support")
944 .join("dbmd")
945 .join("state"))
946 }
947 #[cfg(all(not(target_os = "macos"), not(windows)))]
948 {
949 Ok(home.join(".local").join("state").join("dbmd"))
950 }
951}
952
953fn normalized_origin(value: &str) -> LinkResult<String> {
954 let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
955 hub: value.to_string(),
956 })?;
957 if !(parsed.scheme().eq_ignore_ascii_case("https")
958 || parsed.scheme().eq_ignore_ascii_case("http"))
959 || !parsed.username().is_empty()
960 || parsed.password().is_some()
961 || (parsed.path() != "/" && !parsed.path().is_empty())
962 || parsed.query().is_some()
963 || parsed.fragment().is_some()
964 {
965 return Err(LinkError::UnsafeHub {
966 hub: value.to_string(),
967 });
968 }
969 let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
970 hub: value.to_string(),
971 })?;
972 let host = if host.contains(':') {
973 format!("[{host}]")
974 } else {
975 host.to_ascii_lowercase()
976 };
977 let port = parsed
978 .port_or_known_default()
979 .ok_or_else(|| LinkError::UnsafeHub {
980 hub: value.to_string(),
981 })?;
982 let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
983 || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
984 Ok(format!(
985 "{}://{}{}",
986 parsed.scheme().to_ascii_lowercase(),
987 host,
988 if default {
989 String::new()
990 } else {
991 format!(":{port}")
992 }
993 ))
994}
995
996const ED25519_SPKI_PREFIX: [u8; 12] = [
1003 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1004];
1005
1006fn bad_agent_key(message: &str) -> LinkError {
1007 LinkError::BadAgentKey {
1008 message: message.to_string(),
1009 }
1010}
1011
1012fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
1013 ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
1017 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
1018 .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
1019}
1020
1021fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
1023 use ring::signature::KeyPair as _;
1024 let mut spki = Vec::with_capacity(44);
1025 spki.extend_from_slice(&ED25519_SPKI_PREFIX);
1026 spki.extend_from_slice(pair.public_key().as_ref());
1027 (
1028 URL_SAFE_NO_PAD.encode(&spki),
1029 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
1030 )
1031}
1032
1033pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1037 load_agent_key(path)
1038}
1039
1040fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1042 #[cfg(unix)]
1043 let file = {
1044 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1045 use std::os::unix::ffi::OsStrExt as _;
1046 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1047 .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1048 let leaf = path
1049 .file_name()
1050 .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1051 let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1052 let fd = unsafe {
1053 libc::openat(
1054 parent.as_raw_fd(),
1055 leaf.as_ptr(),
1056 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1057 )
1058 };
1059 if fd < 0 {
1060 return Err(bad_agent_key(
1061 "the key path must be an existing regular file without symlink ancestors",
1062 ));
1063 }
1064 unsafe { std::fs::File::from_raw_fd(fd) }
1065 };
1066 #[cfg(not(unix))]
1067 let file = std::fs::File::open(path)
1068 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1069 let metadata = file
1070 .metadata()
1071 .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1072 if !metadata.is_file() {
1073 return Err(bad_agent_key("the key path must be a regular file"));
1074 }
1075 #[cfg(unix)]
1076 {
1077 use std::os::unix::fs::PermissionsExt as _;
1078 if metadata.permissions().mode() & 0o077 != 0 {
1079 return Err(bad_agent_key(
1080 "the key file is accessible to group/other; set mode 0600",
1081 ));
1082 }
1083 }
1084 let mut text = String::new();
1085 file.take(1024 * 1024 + 1)
1086 .read_to_string(&mut text)
1087 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1088 if text.len() > 1024 * 1024 {
1089 return Err(bad_agent_key("the key file exceeds the size limit"));
1090 }
1091 let pkcs8 = URL_SAFE_NO_PAD
1092 .decode(text.trim())
1093 .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1094 let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1095 Ok(AgentSigningKey {
1096 pkcs8,
1097 multikey,
1098 public_key_spki,
1099 })
1100}
1101
1102fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1108 #[cfg(unix)]
1109 let (mut file, parent, leaf) = {
1110 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1111 use std::os::unix::ffi::OsStrExt as _;
1112 let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1113 let leaf_name = path
1114 .file_name()
1115 .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1116 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1117 let fd = unsafe {
1118 libc::openat(
1119 parent.as_raw_fd(),
1120 leaf.as_ptr(),
1121 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1122 0o600,
1123 )
1124 };
1125 if fd < 0 {
1126 let error = std::io::Error::last_os_error();
1127 if error.kind() == std::io::ErrorKind::AlreadyExists {
1128 return Err(bad_agent_key(
1129 "the output file already exists — refusing to overwrite a key",
1130 ));
1131 }
1132 return Err(error.into());
1133 }
1134 (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1135 };
1136 #[cfg(not(unix))]
1137 let mut file = std::fs::OpenOptions::new()
1138 .write(true)
1139 .create_new(true)
1140 .open(path)
1141 .map_err(|error| {
1142 if error.kind() == std::io::ErrorKind::AlreadyExists {
1143 bad_agent_key("the output file already exists — refusing to overwrite a key")
1144 } else {
1145 LinkError::Io(error)
1146 }
1147 })?;
1148 if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1149 drop(file);
1150 #[cfg(unix)]
1151 let _ =
1152 unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1153 #[cfg(not(unix))]
1154 let _ = std::fs::remove_file(path);
1155 return Err(LinkError::Io(error));
1156 }
1157 drop(file);
1158 #[cfg(unix)]
1159 parent.sync_all()?;
1160 Ok(())
1161}
1162
1163#[derive(Debug, Serialize)]
1166pub struct GeneratedAgentKey {
1167 pub multikey: String,
1169 #[serde(rename = "publicKeySpki")]
1171 pub public_key_spki: String,
1172 #[serde(rename = "keyFile")]
1174 pub key_file: String,
1175}
1176
1177pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1182 require_hardened_filesystem("key generation")?;
1183 let rng = ring::rand::SystemRandom::new();
1184 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1185 .map_err(|_| bad_agent_key("key generation failed"))?;
1186 let pair = agent_keypair(pkcs8.as_ref())?;
1187 let (spki_b64u, multikey) = public_identity_for(&pair);
1188
1189 write_secret_new(
1190 out,
1191 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1192 )?;
1193
1194 Ok(GeneratedAgentKey {
1195 multikey,
1196 public_key_spki: spki_b64u,
1197 key_file: out.display().to_string(),
1198 })
1199}
1200
1201fn linkmd_sig_header(
1210 key: &AgentSigningKey,
1211 origin: &str,
1212 method: &str,
1213 path: &str,
1214 body: Option<&str>,
1215) -> LinkResult<String> {
1216 let ts = std::time::SystemTime::now()
1217 .duration_since(std::time::UNIX_EPOCH)
1218 .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1219 .as_secs();
1220 let body_hash = match body {
1221 Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1222 None => "-".to_string(),
1223 };
1224 let canonical = format!(
1225 "v2\n{}\n{}\n{}\n{}\n{}",
1226 origin,
1227 method.to_uppercase(),
1228 path,
1229 ts,
1230 body_hash
1231 );
1232 let pair = agent_keypair(&key.pkcs8)?;
1233 let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1234 let fingerprint = key.multikey.trim_start_matches("ed25519:");
1235 Ok(format!(
1236 "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1237 ))
1238}
1239
1240#[derive(Serialize)]
1247struct WireFeedFile {
1248 path: String,
1249 sha256: String,
1250 bytes: u64,
1251}
1252
1253#[derive(Serialize)]
1256struct UnsignedWireEntry<'a> {
1257 v: u8,
1258 seq: u64,
1259 ts: String,
1260 brain: &'a str,
1261 public_key: &'a str,
1262 kind: &'a str,
1263 op: &'a str,
1264 pack_sha256: &'a str,
1265 files: &'a [WireFeedFile],
1266 removed: &'a [String],
1267 prev_entry_hash: Option<&'a str>,
1268}
1269
1270fn self_custody_entry(
1276 key: &AgentSigningKey,
1277 seq: u64,
1278 ts: String,
1279 pack_sha256: &str,
1280 files: &[WireFeedFile],
1281 prev_entry_hash: Option<&str>,
1282) -> LinkResult<String> {
1283 let removed: [String; 0] = [];
1284 let unsigned = serde_json::to_string(&UnsignedWireEntry {
1285 v: 1,
1286 seq,
1287 ts,
1288 brain: &key.multikey,
1289 public_key: &key.public_key_spki,
1290 kind: "push",
1291 op: "snapshot",
1292 pack_sha256,
1293 files,
1294 removed: &removed,
1295 prev_entry_hash,
1296 })
1297 .expect("serialize feed entry");
1298 let pair = agent_keypair(&key.pkcs8)?;
1299 let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1300 Ok(format!(
1301 "{},\"sig\":\"{}\"}}",
1302 &unsigned[..unsigned.len() - 1],
1303 sig
1304 ))
1305}
1306
1307fn env_nonempty(name: &str) -> Option<String> {
1310 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1311}
1312
1313fn config_file_hub(path: &Path) -> Option<String> {
1318 const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1319 #[cfg(unix)]
1320 let file = {
1321 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1322 use std::os::unix::ffi::OsStrExt as _;
1323 let parent =
1324 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1325 let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1326 let fd = unsafe {
1327 libc::openat(
1328 parent.as_raw_fd(),
1329 leaf.as_ptr(),
1330 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1331 )
1332 };
1333 if fd < 0 {
1334 return None;
1335 }
1336 unsafe { std::fs::File::from_raw_fd(fd) }
1337 };
1338 #[cfg(not(unix))]
1339 let file = std::fs::File::open(path).ok()?;
1340 let metadata = file.metadata().ok()?;
1341 if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1342 return None;
1343 }
1344 let mut bytes = Vec::with_capacity(metadata.len() as usize);
1345 file.take(MAX_CONFIG_BYTES + 1)
1346 .read_to_end(&mut bytes)
1347 .ok()?;
1348 if bytes.len() as u64 > MAX_CONFIG_BYTES {
1349 return None;
1350 }
1351 let text = String::from_utf8(bytes).ok()?;
1352 for line in text.lines() {
1353 let line = line.trim();
1354 if line.is_empty() || line.starts_with('#') {
1355 continue;
1356 }
1357 if let Some((k, v)) = line.split_once('=') {
1358 if k.trim() == "hub" {
1359 let v = v.trim();
1360 if !v.is_empty() {
1361 return Some(v.to_string());
1362 }
1363 }
1364 }
1365 }
1366 None
1367}
1368
1369fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1372 let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1373 hub: hub.to_string(),
1374 })?;
1375 if !(parsed.scheme().eq_ignore_ascii_case("https")
1376 || parsed.scheme().eq_ignore_ascii_case("http"))
1377 || !parsed.username().is_empty()
1378 || parsed.password().is_some()
1379 || (parsed.path() != "/" && !parsed.path().is_empty())
1380 || parsed.query().is_some()
1381 || parsed.fragment().is_some()
1382 {
1383 return Err(LinkError::UnsafeHub {
1384 hub: hub.to_string(),
1385 });
1386 }
1387 let loopback = match parsed.host() {
1388 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1389 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1390 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1391 None => false,
1392 };
1393 if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1394 Ok(())
1395 } else {
1396 Err(LinkError::UnsafeHub {
1397 hub: hub.to_string(),
1398 })
1399 }
1400}
1401
1402fn clean_key(raw: &str) -> LinkResult<String> {
1407 let k = raw.trim();
1408 if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1409 return Err(LinkError::BadKey);
1410 }
1411 Ok(k.to_string())
1412}
1413
1414#[derive(Debug)]
1420pub struct HubResponse {
1421 pub status: u16,
1423 pub body: Option<Value>,
1425}
1426
1427struct RawHubResponse {
1428 status: u16,
1429 body: Vec<u8>,
1430}
1431
1432#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1434enum Auth {
1435 Required,
1437 None,
1439 Optional,
1443}
1444
1445fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1446 ureq::AgentBuilder::new()
1447 .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1448 .redirects(0)
1452 .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1453 .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1454 .timeout_write(overall)
1455 .timeout(overall)
1456}
1457
1458fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1459 hub_agent_with_timeout(
1460 cfg,
1461 std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1462 )
1463}
1464
1465fn hub_agent_with_timeout(
1466 cfg: &HubConfig,
1467 overall: std::time::Duration,
1468) -> LinkResult<ureq::Agent> {
1469 if !cfg.store_selected {
1470 return Ok(agent_builder_with_timeout(overall).build());
1471 }
1472 let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1473 hub: cfg.hub.clone(),
1474 })?;
1475 pinned_public_agent_pooled(
1476 &parsed,
1477 false,
1478 "store-selected hub",
1479 AgentShape {
1480 overall,
1481 ..AgentShape::default()
1482 },
1483 )
1484}
1485
1486fn request_raw(
1491 cfg: &HubConfig,
1492 method: &str,
1493 path: &str,
1494 body: Option<&Value>,
1495 auth: Auth,
1496 max_response_bytes: u64,
1497) -> LinkResult<RawHubResponse> {
1498 let http = hub_agent(cfg)?;
1499 request_raw_with_agent(
1500 cfg,
1501 &http,
1502 method,
1503 path,
1504 body,
1505 RawRequestOptions {
1506 auth,
1507 max_response_bytes,
1508 request_id: None,
1509 },
1510 )
1511}
1512
1513struct RawRequestOptions<'a> {
1514 auth: Auth,
1515 max_response_bytes: u64,
1516 request_id: Option<&'a str>,
1517}
1518
1519fn request_raw_with_agent(
1520 cfg: &HubConfig,
1521 http: &ureq::Agent,
1522 method: &str,
1523 path: &str,
1524 body: Option<&Value>,
1525 options: RawRequestOptions<'_>,
1526) -> LinkResult<RawHubResponse> {
1527 let url = format!("{}{}", cfg.hub, path);
1528 let encoded_body = body.map(Value::to_string);
1529 let origin = normalized_origin(&cfg.hub)?;
1530 let credential = match options.auth {
1533 Auth::Required => Some(match &cfg.agent_key {
1534 Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1535 None => format!("Bearer {}", cfg.require_key()?),
1536 }),
1537 Auth::Optional => match &cfg.agent_key {
1538 Some(key) => Some(linkmd_sig_header(
1539 key,
1540 &origin,
1541 method,
1542 path,
1543 encoded_body.as_deref(),
1544 )?),
1545 None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1546 },
1547 Auth::None => None,
1548 };
1549 let result = with_connect_retries(|| {
1550 let mut req = http.request(method, &url);
1551 if let Some(value) = &credential {
1552 req = req.set("authorization", value);
1553 }
1554 if let Some(value) = options.request_id {
1555 req = req.set("x-request-id", value);
1556 }
1557 match &encoded_body {
1558 Some(value) => req
1559 .set("content-type", "application/json")
1560 .send_string(value)
1561 .map_err(Box::new),
1562 None => req.call().map_err(Box::new),
1563 }
1564 });
1565 let resp = match result {
1566 Ok(resp) => resp,
1567 Err(error) => match *error {
1568 ureq::Error::Status(_, resp) => resp,
1569 ureq::Error::Transport(error) => {
1570 return Err(LinkError::Transport {
1571 hub: cfg.hub.clone(),
1572 message: error.to_string(),
1573 });
1574 }
1575 },
1576 };
1577
1578 let status = resp.status();
1579 let buf = read_response_body(resp, options.max_response_bytes + 1, &cfg.hub)?;
1580 if buf.len() as u64 > options.max_response_bytes {
1581 return Err(LinkError::ResponseTooLarge {
1582 limit_bytes: options.max_response_bytes,
1583 });
1584 }
1585 Ok(RawHubResponse { status, body: buf })
1586}
1587
1588fn request_capped(
1589 cfg: &HubConfig,
1590 method: &str,
1591 path: &str,
1592 body: Option<&Value>,
1593 auth: Auth,
1594 max_response_bytes: u64,
1595) -> LinkResult<HubResponse> {
1596 let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1597 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1598 Ok(HubResponse {
1599 status: raw.status,
1600 body: parsed,
1601 })
1602}
1603
1604fn request_patient(
1616 cfg: &HubConfig,
1617 method: &str,
1618 path: &str,
1619 body: Option<&Value>,
1620 auth: Auth,
1621) -> LinkResult<HubResponse> {
1622 let http = hub_agent_with_timeout(
1623 cfg,
1624 std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1625 )?;
1626 let mut attempt = 0;
1627 loop {
1628 let sent = request_raw_with_agent(
1629 cfg,
1630 &http,
1631 method,
1632 path,
1633 body,
1634 RawRequestOptions {
1635 auth,
1636 max_response_bytes: MAX_RESPONSE_BYTES,
1637 request_id: None,
1638 },
1639 );
1640 match sent {
1641 Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1642 std::thread::sleep(std::time::Duration::from_millis(
1643 COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1644 ));
1645 attempt += 1;
1646 }
1647 Err(error) => return Err(error),
1648 Ok(raw) => {
1649 return Ok(HubResponse {
1650 status: raw.status,
1651 body: serde_json::from_slice(&raw.body).ok(),
1652 })
1653 }
1654 }
1655 }
1656}
1657
1658fn request(
1659 cfg: &HubConfig,
1660 method: &str,
1661 path: &str,
1662 body: Option<&Value>,
1663 auth: Auth,
1664) -> LinkResult<HubResponse> {
1665 request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1666}
1667
1668fn request_with_request_id(
1673 cfg: &HubConfig,
1674 method: &str,
1675 path: &str,
1676 body: Option<&Value>,
1677 auth: Auth,
1678 request_id: &str,
1679) -> LinkResult<HubResponse> {
1680 if request_id.is_empty()
1681 || request_id.len() > 128
1682 || !request_id
1683 .bytes()
1684 .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1685 {
1686 return Err(invalid_feed("hub returned an unsafe request id"));
1687 }
1688 let http = hub_agent_with_timeout(
1691 cfg,
1692 std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1693 )?;
1694 let raw = request_raw_with_agent(
1695 cfg,
1696 &http,
1697 method,
1698 path,
1699 body,
1700 RawRequestOptions {
1701 auth,
1702 max_response_bytes: MAX_RESPONSE_BYTES,
1703 request_id: Some(request_id),
1704 },
1705 )?;
1706 Ok(HubResponse {
1707 status: raw.status,
1708 body: serde_json::from_slice(&raw.body).ok(),
1709 })
1710}
1711
1712fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1713 if (200..300).contains(&r.status) {
1714 return Ok(r.body);
1715 }
1716 ensure_ok(
1717 HubResponse {
1718 status: r.status,
1719 body: serde_json::from_slice(&r.body).ok(),
1720 },
1721 what,
1722 )
1723 .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1724}
1725
1726fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1731 matches!(
1732 kind,
1733 ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1734 )
1735}
1736
1737fn with_connect_retries(
1738 mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1739) -> Result<ureq::Response, Box<ureq::Error>> {
1740 let mut attempt = 0;
1741 loop {
1742 match send() {
1743 Err(error)
1744 if matches!(
1745 error.as_ref(),
1746 ureq::Error::Transport(transport)
1747 if is_pre_request_transport(transport.kind())
1748 ) && attempt + 1 < CONNECT_ATTEMPTS =>
1749 {
1750 std::thread::sleep(std::time::Duration::from_millis(
1751 CONNECT_RETRY_BACKOFF_MS[attempt],
1752 ));
1753 attempt += 1;
1754 }
1755 result => return result,
1756 }
1757 }
1758}
1759
1760fn hub_is_loopback(hub: &str) -> bool {
1761 url::Url::parse(hub).ok().is_some_and(|parsed| {
1762 parsed.host().is_some_and(|host| match host {
1763 url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1764 url::Host::Ipv4(ip) => ip.is_loopback(),
1765 url::Host::Ipv6(ip) => ip.is_loopback(),
1766 })
1767 })
1768}
1769
1770fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1774 let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1775 message: "the hub returned an invalid object-store URL".to_string(),
1776 })?;
1777 let allow_private = hub_is_loopback(&cfg.hub)
1778 || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1779 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1780 || !parsed.username().is_empty()
1781 || parsed.password().is_some()
1782 || parsed.fragment().is_some()
1783 {
1784 return Err(LinkError::InvalidPack {
1785 message: "the hub returned an unsafe object-store URL".to_string(),
1786 });
1787 }
1788 Ok((parsed, allow_private))
1789}
1790
1791fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1792 let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1793 pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1794 LinkError::InvalidPack {
1795 message: "the hub returned an object-store URL with an unsafe network target"
1796 .to_string(),
1797 }
1798 })
1799}
1800
1801fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1810 let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1811 let authority = (
1812 first.host_str()?.to_string(),
1813 first.port_or_known_default()?,
1814 );
1815 for raw in &urls[1..] {
1816 let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1817 if (parsed.host_str()?, parsed.port_or_known_default()?)
1818 != (authority.0.as_str(), authority.1)
1819 {
1820 return None;
1821 }
1822 }
1823 pinned_public_agent_pooled(
1824 &first,
1825 allow_private,
1826 "object-store URL",
1827 AgentShape {
1828 idle_per_host: V2_UPLOAD_CONCURRENCY,
1829 ..AgentShape::default()
1830 },
1831 )
1832 .ok()
1833}
1834
1835fn object_store_transport_error(error: ureq::Transport) -> LinkError {
1841 LinkError::Transport {
1842 hub: "the object store".to_string(),
1843 message: format!("network error ({:?})", error.kind()),
1844 }
1845}
1846
1847fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1848 let http = presigned_agent(cfg, raw)?;
1849 let deadline = std::time::Instant::now()
1850 .checked_add(std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS))
1851 .ok_or_else(upload_deadline_error)?;
1852 let mut attempt = 0;
1853 let result = loop {
1854 let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
1858 if let Some(map) = headers.as_object() {
1859 for (name, value) in map {
1860 if let Some(value) = value.as_str() {
1861 req = req.set(name, value);
1862 }
1863 }
1864 }
1865 match req.send_bytes(bytes) {
1866 Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
1872 attempt += 1;
1873 }
1874 Err(ureq::Error::Status(status, _))
1875 if status != 412
1876 && is_retryable_upload_status(status)
1877 && wait_for_upload_retry(deadline, attempt) =>
1878 {
1879 attempt += 1;
1880 }
1881 result => break result,
1882 }
1883 };
1884 match result {
1885 Ok(resp) if (200..300).contains(&resp.status()) => {
1886 drain_presigned_response(resp);
1887 Ok(())
1888 }
1889 Ok(resp) => Err(presigned_upload_refusal(resp)),
1890 Err(error) => match error {
1891 ureq::Error::Status(412, _) => Ok(()),
1896 ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
1897 ureq::Error::Transport(err) => Err(object_store_transport_error(err)),
1898 },
1899 }
1900}
1901
1902fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
1911 let mut buf = Vec::new();
1912 response
1913 .into_reader()
1914 .take(limit)
1915 .read_to_end(&mut buf)
1916 .map_err(|error| LinkError::Transport {
1917 hub: peer.to_string(),
1918 message: error.to_string(),
1919 })?;
1920 Ok(buf)
1921}
1922
1923fn drain_presigned_response(response: ureq::Response) {
1928 let mut reader = response.into_reader().take(64 * 1024);
1929 let _ = std::io::copy(&mut reader, &mut std::io::sink());
1930}
1931
1932fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
1935 let status = response.status();
1936 let detail = response
1937 .into_string()
1938 .ok()
1939 .map(|body| body.chars().take(400).collect::<String>())
1940 .filter(|body| !body.trim().is_empty());
1941 LinkError::Http {
1942 what: "pack upload",
1943 status,
1944 message: match detail {
1945 Some(body) => format!(
1946 "object store rejected the upload: {}",
1947 body.replace('\n', " ")
1948 ),
1949 None => "object store rejected the upload".to_string(),
1950 },
1951 code: None,
1952 details: None,
1953 }
1954}
1955
1956fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1957 max_bytes.checked_add(1)
1958}
1959
1960fn presigned_download_read_limit() -> u64 {
1961 one_past_bounded_limit(MAX_PACK_BYTES)
1962 .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1963}
1964
1965fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1966 let http = presigned_agent(cfg, raw)?;
1967 let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1968 Ok(resp) => resp,
1969 Err(error) => match *error {
1970 ureq::Error::Status(_, resp) => {
1971 return Err(LinkError::Http {
1972 what: "pack download",
1973 status: resp.status(),
1974 message: "object store rejected the download".to_string(),
1975 code: None,
1976 details: None,
1977 });
1978 }
1979 ureq::Error::Transport(err) => {
1980 return Err(LinkError::Transport {
1981 hub: "the object store".to_string(),
1982 message: err.to_string(),
1983 });
1984 }
1985 },
1986 };
1987 if !(200..300).contains(&resp.status()) {
1988 return Err(LinkError::Http {
1989 what: "pack download",
1990 status: resp.status(),
1991 message: "object store rejected the download".to_string(),
1992 code: None,
1993 details: None,
1994 });
1995 }
1996 let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
1997 if bytes.len() as u64 > MAX_PACK_BYTES {
1998 return Err(LinkError::InvalidPack {
1999 message: "download exceeds the compressed-size limit".to_string(),
2000 });
2001 }
2002 Ok(bytes)
2003}
2004
2005fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
2009 if !(200..300).contains(&r.status) {
2010 let message = r
2011 .body
2012 .as_ref()
2013 .and_then(|b| b.get("error"))
2014 .and_then(Value::as_str)
2015 .unwrap_or("unknown error")
2016 .to_string();
2017 let code = r
2018 .body
2019 .as_ref()
2020 .and_then(|b| b.get("code"))
2021 .and_then(Value::as_str)
2022 .map(str::to_string);
2023 let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
2024 return Err(LinkError::Http {
2025 what,
2026 status: r.status,
2027 message,
2028 code,
2029 details,
2030 });
2031 }
2032 r.body.ok_or(LinkError::NotJson {
2033 what,
2034 status: r.status,
2035 })
2036}
2037
2038fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2047 match ip {
2048 std::net::IpAddr::V4(ip) => {
2049 let [a, b, c, _] = ip.octets();
2050 !(a == 0
2051 || a == 10
2052 || a == 127
2053 || (a == 100 && (64..=127).contains(&b))
2054 || (a == 169 && b == 254)
2055 || (a == 172 && (16..=31).contains(&b))
2056 || (a == 192 && b == 0 && c == 0)
2057 || (a == 192 && b == 0 && c == 2)
2058 || (a == 192 && b == 88 && c == 99)
2059 || (a == 192 && b == 168)
2060 || (a == 198 && (b == 18 || b == 19))
2061 || (a == 198 && b == 51 && c == 100)
2062 || (a == 203 && b == 0 && c == 113)
2063 || a >= 224)
2064 }
2065 std::net::IpAddr::V6(ip) => {
2066 let segments = ip.segments();
2067 (segments[0] & 0xe000) == 0x2000
2072 && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2073 && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2074 && segments[0] != 0x2002
2075 && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2076 }
2077 }
2078}
2079
2080#[derive(Clone)]
2081struct PinnedRegistryResolver {
2082 netloc: String,
2083 addresses: Vec<std::net::SocketAddr>,
2084}
2085
2086impl ureq::Resolver for PinnedRegistryResolver {
2087 fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2088 if requested == self.netloc {
2089 Ok(self.addresses.clone())
2090 } else {
2091 Err(std::io::Error::new(
2092 std::io::ErrorKind::PermissionDenied,
2093 "registry request attempted to resolve an unvalidated authority",
2094 ))
2095 }
2096 }
2097}
2098
2099fn pinned_public_agent(
2100 url: &url::Url,
2101 allow_private: bool,
2102 label: &str,
2103) -> LinkResult<ureq::Agent> {
2104 pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2105}
2106
2107struct AgentShape {
2112 idle_per_host: usize,
2113 overall: std::time::Duration,
2114}
2115
2116impl Default for AgentShape {
2117 fn default() -> Self {
2118 Self {
2119 idle_per_host: 1,
2120 overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2121 }
2122 }
2123}
2124
2125fn pinned_public_agent_pooled(
2126 url: &url::Url,
2127 allow_private: bool,
2128 label: &str,
2129 shape: AgentShape,
2130) -> LinkResult<ureq::Agent> {
2131 let host = url
2132 .host_str()
2133 .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2134 let port = url
2135 .port_or_known_default()
2136 .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2137 let addresses = resolve_addresses_with_deadline(
2138 host,
2139 port,
2140 std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2141 )
2142 .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2143 if addresses.is_empty() {
2144 return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2145 }
2146 if !allow_private
2147 && addresses
2148 .iter()
2149 .any(|address| !is_public_registry_ip(address.ip()))
2150 {
2151 return Err(invalid_feed(format!(
2152 "{label} resolves to a non-public address"
2153 )));
2154 }
2155 let netloc = if host.contains(':') {
2156 format!("[{host}]:{port}")
2157 } else {
2158 format!("{host}:{port}")
2159 };
2160 Ok(agent_builder_with_timeout(shape.overall)
2161 .max_idle_connections_per_host(shape.idle_per_host.max(1))
2162 .resolver(PinnedRegistryResolver { netloc, addresses })
2163 .build())
2164}
2165
2166fn resolve_addresses_with_deadline(
2171 host: &str,
2172 port: u16,
2173 timeout: std::time::Duration,
2174) -> std::io::Result<Vec<std::net::SocketAddr>> {
2175 use std::net::ToSocketAddrs as _;
2176
2177 let host = host.to_string();
2178 let (send, receive) = std::sync::mpsc::sync_channel(1);
2179 std::thread::Builder::new()
2180 .name("dbmd-dns".to_string())
2181 .spawn(move || {
2182 let result = (host.as_str(), port)
2183 .to_socket_addrs()
2184 .map(|addresses| addresses.collect());
2185 let _ = send.send(result);
2186 })
2187 .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2188 match receive.recv_timeout(timeout) {
2189 Ok(result) => result,
2190 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2191 std::io::ErrorKind::TimedOut,
2192 "resolution exceeded its deadline",
2193 )),
2194 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2195 "resolver stopped without returning a result",
2196 )),
2197 }
2198}
2199
2200fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2201 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2202 pinned_public_agent(url, allow_private, "registry home")
2203}
2204
2205fn get_json_absolute(url: &str) -> LinkResult<Value> {
2210 let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2211 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2212 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2213 || !parsed.username().is_empty()
2214 || parsed.password().is_some()
2215 || parsed.query().is_some()
2216 || parsed.fragment().is_some()
2217 {
2218 return Err(invalid_feed("unsafe registry home URL"));
2219 }
2220 let http = registry_agent(&parsed)?;
2221 let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2222 Ok(resp) => resp,
2223 Err(error) => match *error {
2224 ureq::Error::Status(status, resp) => {
2225 let _ = resp;
2226 return Err(LinkError::Http {
2227 what: "registry home fetch",
2228 status,
2229 message: "the home node rejected the card request".to_string(),
2230 code: None,
2231 details: None,
2232 });
2233 }
2234 ureq::Error::Transport(err) => {
2235 return Err(LinkError::Transport {
2236 hub: url.to_string(),
2237 message: err.to_string(),
2238 });
2239 }
2240 },
2241 };
2242 if !(200..300).contains(&resp.status()) {
2243 return Err(LinkError::Http {
2244 what: "registry home fetch",
2245 status: resp.status(),
2246 message: "the home node returned a redirect or error".to_string(),
2247 code: None,
2248 details: None,
2249 });
2250 }
2251 let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2252 if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2253 return Err(LinkError::ResponseTooLarge {
2254 limit_bytes: MAX_REGISTRY_CARD_BYTES,
2255 });
2256 }
2257 serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2258 message: "the home node returned invalid JSON".to_string(),
2259 })
2260}
2261
2262pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2269 require_safe_ref(handle)?;
2270 let trust_directory = open_trust_dir(cfg)?;
2274 let reg = request_capped(
2275 cfg,
2276 "GET",
2277 &format!("/api/hub/registry/{handle}"),
2278 None,
2279 Auth::None,
2280 MAX_REGISTRY_CARD_BYTES,
2281 )?;
2282 if reg.status == 404 {
2283 return Ok(None);
2284 }
2285 let body = ensure_ok(reg, "registry resolve")?;
2286 let home = body
2287 .get("home")
2288 .and_then(Value::as_str)
2289 .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2290 let brain = body
2291 .get("brain")
2292 .and_then(Value::as_str)
2293 .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2294 if !crate::ulid::is_ulid(brain) {
2295 return Err(invalid_feed(
2296 "registry entry brain is not a canonical lowercase ULID",
2297 ));
2298 }
2299 let want_fp = body
2300 .get("identity")
2301 .and_then(|i| i.get("fingerprint"))
2302 .and_then(Value::as_str)
2303 .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2304
2305 let home = home.trim_end_matches('/');
2306 let origin = normalized_origin(home)?;
2307 if origin != home {
2308 return Err(invalid_feed(
2309 "registry home must be an origin without a path, query, or fragment",
2310 ));
2311 }
2312 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2313 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2314 if let Some(binding) = &alias_binding {
2315 if binding
2316 .home
2317 .as_deref()
2318 .is_some_and(|pinned_home| pinned_home != home)
2319 {
2320 return Err(invalid_feed(
2321 "registry relocated a pinned handle to a different home",
2322 ));
2323 }
2324 }
2325 let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2326 if card.get("id").and_then(Value::as_str) != Some(brain) {
2327 return Err(invalid_feed(
2328 "the home node served a card for a different brain",
2329 ));
2330 }
2331 let identity: FeedIdentity = serde_json::from_value(
2332 card.get("identity")
2333 .cloned()
2334 .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2335 )
2336 .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2337 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2338 let got_fp = card
2339 .get("identity")
2340 .and_then(|i| i.get("fingerprint"))
2341 .and_then(Value::as_str)
2342 .unwrap_or_default();
2343 if got_fp != want_fp {
2344 return Err(invalid_feed(
2345 "the home node served an identity that does not match the registry — refusing",
2346 ));
2347 }
2348 let current = format!("ed25519:{}", identity.fingerprint);
2349 let advertised_seq = card
2350 .get("headSeq")
2351 .and_then(Value::as_u64)
2352 .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2353 let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2354 if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2355 || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2356 {
2357 return Err(invalid_feed(
2358 "the home node served an invalid feed head boundary",
2359 ));
2360 }
2361 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2365 let registry_alias = AliasBinding {
2366 v: 1,
2367 origin: normalized_origin(&cfg.hub)?,
2368 requested: handle.to_string(),
2369 brain: brain.to_string(),
2370 home: Some(home.to_string()),
2371 };
2372 save_canonical_pin_and_alias(
2373 cfg,
2374 &trust_directory,
2375 handle,
2376 brain,
2377 TrustState {
2378 v: 2,
2379 origin: normalized_origin(&cfg.hub)?,
2380 requested: brain.to_string(),
2381 brain: brain.to_string(),
2382 home: None,
2383 anchor,
2384 current,
2385 head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2386 feed_hash: pinned
2387 .as_ref()
2388 .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2389 rotations: identity.rotations.clone(),
2390 hub_signer: None,
2391 protocol_profile: None,
2392 },
2393 Some(®istry_alias),
2394 )?;
2395 let mut out = card;
2396 if let Value::Object(map) = &mut out {
2397 map.insert("home".to_string(), Value::String(home.to_string()));
2398 map.insert(
2399 "resolvedVia".to_string(),
2400 Value::String("registry".to_string()),
2401 );
2402 }
2403 Ok(Some(out))
2404}
2405
2406pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2407 require_safe_ref(&addr.brain)?;
2411 if let Some(target) = &addr.target {
2412 let (given, ok) = match target {
2413 AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2414 AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2415 };
2416 if !ok {
2417 return Err(LinkError::BadAddress {
2418 given: given.clone(),
2419 reason: BAD_TARGET_REASON.to_string(),
2420 });
2421 }
2422 }
2423
2424 if let Some(target) = &addr.target {
2430 if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2431 let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2432 what: "resolve",
2433 status: 404,
2434 message: "record not found".to_string(),
2435 code: Some("NOT_FOUND".to_string()),
2436 details: None,
2437 })?;
2438 let (path, file) = match target {
2439 AddressTarget::Path(path) => {
2440 let file =
2441 v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2442 LinkError::Http {
2443 what: "resolve",
2444 status: 404,
2445 message: "record not found".to_string(),
2446 code: Some("NOT_FOUND".to_string()),
2447 details: None,
2448 }
2449 })?;
2450 (path.clone(), file)
2451 }
2452 AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2453 };
2454 let mut downloaded =
2455 download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2456 let (_, bytes) = downloaded
2457 .pop()
2458 .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2459 let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2460 accept_v2_head(cfg, &head)?;
2461 return Ok(resolved);
2462 }
2463 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2464 if !remote.head.verified {
2465 return Err(invalid_feed(
2466 "a path-scoped feed cannot prove a record against the full signed snapshot",
2467 ));
2468 }
2469 if remote.head.seq == 0 {
2470 return Err(LinkError::Http {
2471 what: "resolve",
2472 status: 404,
2473 message: "record not found".to_string(),
2474 code: Some("NOT_FOUND".to_string()),
2475 details: None,
2476 });
2477 }
2478 let brain = remote.head.brain.clone();
2479 let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2480 return resolve_from_verified_pack(&brain, target, pack);
2481 }
2482
2483 let path = format!("/api/hub/brains/{}", addr.brain);
2484 let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2489 if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2490 if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2491 return Ok(card);
2492 }
2493 }
2494 let mut resolved = ensure_ok(direct, "resolve")?;
2495 if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2496 let v2 = v2_verified_head(cfg, &addr.brain)?
2497 .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2498 if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2499 return Err(invalid_feed(
2500 "resolve card is not bound to the verified v2 brain",
2501 ));
2502 }
2503 let card_identity: FeedIdentity = serde_json::from_value(
2504 resolved
2505 .get("identity")
2506 .cloned()
2507 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2508 )
2509 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2510 if card_identity != v2_identity(&v2.identity) {
2511 return Err(invalid_feed(
2512 "resolve card identity differs from the verified v2 identity",
2513 ));
2514 }
2515 accept_v2_head(cfg, &v2)?;
2516 if let Value::Object(card) = &mut resolved {
2517 card.insert(
2518 "headSeq".to_string(),
2519 json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2520 );
2521 card.insert(
2522 "feedHash".to_string(),
2523 v2.pointer
2524 .as_ref()
2525 .map(|pointer| Value::String(pointer.feed_hash.clone()))
2526 .unwrap_or(Value::Null),
2527 );
2528 card.insert(
2529 "storageProfile".to_string(),
2530 Value::String("v2".to_string()),
2531 );
2532 if let Some(pointer) = &v2.pointer {
2533 card.insert(
2534 "updatedAt".to_string(),
2535 Value::String(pointer.signed_at.clone()),
2536 );
2537 }
2538 }
2539 return Ok(resolved);
2540 }
2541 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2545 if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2546 || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2547 || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2548 {
2549 return Err(invalid_feed(
2550 "resolve card is not bound to the exact verified feed checkpoint",
2551 ));
2552 }
2553 let card_identity: FeedIdentity = serde_json::from_value(
2554 resolved
2555 .get("identity")
2556 .cloned()
2557 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2558 )
2559 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2560 if remote.identity.as_ref() != Some(&card_identity) {
2561 return Err(invalid_feed(
2562 "resolve card identity differs from the verified feed identity",
2563 ));
2564 }
2565 Ok(resolved)
2566}
2567
2568fn resolve_from_verified_pack(
2573 brain: &str,
2574 target: &AddressTarget,
2575 pack: Vec<u8>,
2576) -> LinkResult<Value> {
2577 let entries = parse_store_pack(pack)?;
2578 let mut matched: Option<(String, Vec<u8>)> = None;
2579
2580 for (path, bytes) in entries {
2581 let is_candidate = match target {
2582 AddressTarget::Path(want) => &path == want,
2583 AddressTarget::Id(_) => {
2584 path.ends_with(".md")
2585 && (path.starts_with("records/") || path.starts_with("sources/"))
2586 }
2587 };
2588 if !is_candidate {
2589 continue;
2590 }
2591 let text = std::str::from_utf8(&bytes)
2592 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2593 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2594 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2595 if let AddressTarget::Id(want) = target {
2596 let frontmatter =
2597 crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2598 .map_err(|_| {
2599 invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2600 })?;
2601 if frontmatter.id.as_deref() != Some(want) {
2602 continue;
2603 }
2604 }
2605 if matched.is_some() {
2606 return Err(invalid_feed(
2607 "signed snapshot contains more than one record for the requested target",
2608 ));
2609 }
2610 matched = Some((path, bytes));
2611 }
2612
2613 let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2614 what: "resolve",
2615 status: 404,
2616 message: "record not found".to_string(),
2617 code: Some("NOT_FOUND".to_string()),
2618 details: None,
2619 })?;
2620 resolve_from_verified_record_bytes(brain, target, path, bytes)
2621}
2622
2623fn resolve_from_verified_record_bytes(
2624 brain: &str,
2625 target: &AddressTarget,
2626 path: String,
2627 bytes: Vec<u8>,
2628) -> LinkResult<Value> {
2629 match target {
2630 AddressTarget::Path(expected) if expected != &path => {
2631 return Err(invalid_feed(
2632 "verified record path differs from the requested path",
2633 ));
2634 }
2635 AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2636 return Err(invalid_feed(
2637 "verified id resolved outside records or sources",
2638 ));
2639 }
2640 _ => {}
2641 }
2642 let text = std::str::from_utf8(&bytes)
2643 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2644 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2645 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2646 let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2647 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2648 let Value::Object(fields) = frontmatter else {
2649 return Err(invalid_feed(format!(
2650 "signed snapshot record `{path}` frontmatter is not a mapping"
2651 )));
2652 };
2653 if let AddressTarget::Id(expected) = target {
2654 if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2655 return Err(invalid_feed(
2656 "verified record id differs from the requested id",
2657 ));
2658 }
2659 }
2660 let mut document = serde_json::Map::new();
2661 document.insert("path".to_string(), Value::String(path));
2662 for (key, value) in fields {
2663 document.insert(key, value);
2664 }
2665 document.insert("body".to_string(), Value::String(parsed.body));
2666 document.insert(
2667 "contentSha".to_string(),
2668 Value::String(content_sha256(&bytes)),
2669 );
2670 Ok(json!({
2671 "brain": brain,
2672 "document": Value::Object(document),
2673 }))
2674}
2675
2676#[derive(Debug, Clone, serde::Serialize)]
2682pub struct PullReport {
2683 pub brain: String,
2685 pub slug: String,
2687 #[serde(rename = "headSeq")]
2689 pub head_seq: u64,
2690 pub files: usize,
2692 pub dest: String,
2694 #[serde(rename = "extraLocal")]
2697 pub extra_local: Vec<String>,
2698 #[serde(rename = "syncStatus")]
2700 pub sync_status: String,
2701}
2702
2703struct V2PulledSnapshot {
2704 report: PullReport,
2705 head: V2VerifiedHead,
2706 files: std::collections::BTreeMap<String, V2BaselineFile>,
2707 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2708 local: V2LocalView,
2709 local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2710}
2711
2712fn download_verified_snapshot_pack(
2713 cfg: &HubConfig,
2714 brain: &str,
2715 remote: &VerifiedRemote,
2716) -> LinkResult<Vec<u8>> {
2717 let feed_hash = remote
2718 .head
2719 .feed_hash
2720 .as_deref()
2721 .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2722 let signed_head = remote
2723 .head_entry
2724 .as_ref()
2725 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2726 let expected = &signed_head.entry.pack_sha256;
2727 if !is_sha256(expected) {
2728 return Err(invalid_feed(
2729 "signed head carries an invalid snapshot pack digest",
2730 ));
2731 }
2732 let path = format!(
2733 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2734 remote.head.seq
2735 );
2736 let body = ensure_ok(
2737 request(cfg, "GET", &path, None, Auth::Required)?,
2738 "sync pull",
2739 )?;
2740 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2741 || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2742 || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2743 || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2744 {
2745 return Err(invalid_feed(
2746 "export response is not bound to the exact verified snapshot",
2747 ));
2748 }
2749 let url = body
2750 .get("url")
2751 .and_then(Value::as_str)
2752 .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2753 let bytes = get_presigned(cfg, url)?;
2754 if content_sha256(&bytes) != *expected {
2755 return Err(LinkError::InvalidPack {
2756 message: "downloaded pack does not match the signed snapshot digest".to_string(),
2757 });
2758 }
2759 let entries = parse_store_pack(bytes.clone())?;
2760 if signed_head.entry.kind == "push" {
2761 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2762 }
2763 Ok(bytes)
2764}
2765
2766#[derive(Debug, Clone, Deserialize, Serialize)]
2767struct V2PointerBody {
2768 v: u8,
2769 brain: String,
2770 seq: u64,
2771 commit_hash: String,
2772 feed_hash: String,
2773 content_root: Option<String>,
2774 asset_root: Option<String>,
2775 materializer: String,
2776 signer_epoch: u64,
2777 control_revision: String,
2778 backup_preparation: String,
2779 prior_pointer_hash: Option<String>,
2780 signed_at: String,
2781}
2782
2783#[derive(Debug, Clone, Deserialize)]
2784struct V2SignedPointer {
2785 pointer: V2PointerBody,
2786 hub_public_key: String,
2787 hub_fingerprint: String,
2788 sig: String,
2789}
2790
2791#[derive(Debug, Clone, Deserialize)]
2792struct V2HeadIdentity {
2793 #[serde(default)]
2794 custody: String,
2795 fingerprint: String,
2796 public_key_spki: String,
2797 #[serde(default)]
2798 previous: Vec<V2PreviousIdentity>,
2799 #[serde(default)]
2800 rotations: Vec<String>,
2801}
2802
2803#[derive(Debug, Clone, Deserialize)]
2804struct V2PreviousIdentity {
2805 fingerprint: String,
2806 public_key_spki: String,
2807}
2808
2809#[derive(Debug, Deserialize)]
2810struct V2HeadResponse {
2811 v: u8,
2812 brain_id: String,
2813 profile: String,
2814 view: Option<V2HeadView>,
2815 pointer: Option<V2SignedPointer>,
2816 identity: Option<V2HeadIdentity>,
2817}
2818
2819#[derive(Debug, Clone, Deserialize)]
2820struct V2HeadView {
2821 kind: String,
2822 #[serde(default)]
2823 id: Option<String>,
2824 control_revision: String,
2825}
2826
2827#[derive(Debug, Clone)]
2828struct V2VerifiedHead {
2829 requested: String,
2830 brain_id: String,
2831 view_kind: String,
2832 view_revision: String,
2834 control_revision: String,
2836 identity: V2HeadIdentity,
2837 pointer: Option<V2PointerBody>,
2838 trust: TrustState,
2839 alias: Option<AliasBinding>,
2840}
2841
2842fn verify_v2_spki_signature(
2843 public_key: &str,
2844 message: &[u8],
2845 signature: &str,
2846) -> LinkResult<Vec<u8>> {
2847 let der = URL_SAFE_NO_PAD
2848 .decode(public_key)
2849 .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2850 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2851 return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2852 }
2853 let sig = URL_SAFE_NO_PAD
2854 .decode(signature)
2855 .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2856 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2857 .verify(message, &sig)
2858 .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2859 Ok(der)
2860}
2861
2862fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2863 if pointer.pointer.v != 2
2864 || pointer.pointer.brain != expected_brain
2865 || pointer.pointer.seq == 0
2866 || !is_sha256(&pointer.pointer.commit_hash)
2867 || !is_sha256(&pointer.pointer.feed_hash)
2868 || pointer
2869 .pointer
2870 .content_root
2871 .as_deref()
2872 .is_some_and(|hash| !is_sha256(hash))
2873 || !is_sha256(&pointer.pointer.backup_preparation)
2874 {
2875 return Err(invalid_feed("v2 pointer fields are invalid"));
2876 }
2877 let value = serde_json::to_value(&pointer.pointer)
2878 .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2879 let message = crate::linkmd_v2::canonical_bytes(&value)
2880 .map_err(|error| invalid_feed(error.to_string()))?;
2881 let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2882 let fingerprint = format!("{:x}", Sha256::digest(&der));
2883 if fingerprint != pointer.hub_fingerprint {
2884 return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2885 }
2886 Ok(format!(
2887 "{}:{}",
2888 pointer.hub_fingerprint, pointer.hub_public_key
2889 ))
2890}
2891
2892fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2893 FeedIdentity {
2894 fingerprint: identity.fingerprint.clone(),
2895 public_key_spki: identity.public_key_spki.clone(),
2896 previous: identity
2897 .previous
2898 .iter()
2899 .map(|previous| PreviousIdentity {
2900 fingerprint: previous.fingerprint.clone(),
2901 public_key_spki: previous.public_key_spki.clone(),
2902 })
2903 .collect(),
2904 rotations: identity.rotations.clone(),
2905 }
2906}
2907
2908fn verified_v2_commit_object(
2909 raw: &[u8],
2910 identity: &V2HeadIdentity,
2911) -> LinkResult<serde_json::Map<String, Value>> {
2912 let mut value: Value =
2913 serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2914 let canonical = crate::linkmd_v2::canonical_bytes(&value)
2915 .map_err(|error| invalid_feed(error.to_string()))?;
2916 if canonical != raw {
2917 return Err(invalid_feed("v2 commit is not canonical JSON"));
2918 }
2919 let object = value
2920 .as_object_mut()
2921 .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
2922 let sig = object
2923 .remove("sig")
2924 .and_then(|value| value.as_str().map(str::to_string))
2925 .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
2926 const FIELDS: [&str; 18] = [
2927 "actor_ref",
2928 "asset_root",
2929 "brain",
2930 "changes_sha256",
2931 "control_revision",
2932 "materializer",
2933 "op",
2934 "parent_asset_root",
2935 "parent_commit",
2936 "parent_root",
2937 "prev_entry_hash",
2938 "public_key",
2939 "seq",
2940 "signer_epoch",
2941 "state_root",
2942 "ts",
2943 "v",
2944 "v1_bridge",
2945 ];
2946 if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
2947 return Err(invalid_feed("v2 commit has a non-normative field set"));
2948 }
2949 let seq = object
2950 .get("seq")
2951 .and_then(Value::as_u64)
2952 .filter(|seq| *seq > 0)
2953 .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
2954 let signer_epoch = object
2955 .get("signer_epoch")
2956 .and_then(Value::as_u64)
2957 .filter(|epoch| *epoch > 0)
2958 .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
2959 let hash_or_null = |field: &str| {
2960 object
2961 .get(field)
2962 .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
2963 };
2964 if object.get("v").and_then(Value::as_u64) != Some(2)
2965 || object.get("op").and_then(Value::as_str) != Some("changeset")
2966 || !object
2967 .get("changes_sha256")
2968 .and_then(Value::as_str)
2969 .is_some_and(is_sha256)
2970 || !object
2971 .get("actor_ref")
2972 .and_then(Value::as_str)
2973 .is_some_and(is_sha256)
2974 || !object
2975 .get("control_revision")
2976 .and_then(Value::as_str)
2977 .is_some_and(is_sha256)
2978 || !object
2979 .get("state_root")
2980 .and_then(Value::as_str)
2981 .is_some_and(is_sha256)
2982 || !hash_or_null("parent_commit")
2983 || !hash_or_null("parent_root")
2984 || !hash_or_null("parent_asset_root")
2985 || !hash_or_null("asset_root")
2986 || !hash_or_null("prev_entry_hash")
2987 || !object
2988 .get("materializer")
2989 .and_then(Value::as_str)
2990 .is_some_and(|value| !value.is_empty() && value.len() <= 128)
2991 || !object
2992 .get("ts")
2993 .and_then(Value::as_str)
2994 .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
2995 {
2996 return Err(invalid_feed("v2 commit fields are invalid"));
2997 }
2998 if (seq == 1
2999 && [
3000 "parent_commit",
3001 "parent_root",
3002 "parent_asset_root",
3003 "prev_entry_hash",
3004 ]
3005 .iter()
3006 .any(|field| !object.get(*field).is_some_and(Value::is_null)))
3007 || (seq > 1
3008 && ["parent_commit", "parent_root", "prev_entry_hash"]
3009 .iter()
3010 .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
3011 {
3012 return Err(invalid_feed("v2 commit parent shape is invalid"));
3013 }
3014 match object.get("v1_bridge") {
3015 Some(Value::Null) => {}
3016 Some(Value::Object(bridge))
3017 if seq == 1
3018 && bridge.len() == 3
3019 && bridge
3020 .get("head_seq")
3021 .and_then(Value::as_u64)
3022 .is_some_and(|v| v > 0)
3023 && bridge
3024 .get("feed_hash")
3025 .and_then(Value::as_str)
3026 .is_some_and(is_sha256)
3027 && bridge
3028 .get("pack_sha256")
3029 .and_then(Value::as_str)
3030 .is_some_and(is_sha256) => {}
3031 _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
3032 }
3033 let public_key = object
3034 .get("public_key")
3035 .and_then(Value::as_str)
3036 .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3037 let der = URL_SAFE_NO_PAD
3038 .decode(public_key)
3039 .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3040 let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3041 if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3042 return Err(invalid_feed("v2 commit brain identity mismatch"));
3043 }
3044 verify_identity_chain(&v2_identity(identity), None)?;
3046 let mut chain: Vec<(&str, &str)> = identity
3049 .previous
3050 .iter()
3051 .rev()
3052 .map(|previous| {
3053 (
3054 previous.fingerprint.as_str(),
3055 previous.public_key_spki.as_str(),
3056 )
3057 })
3058 .collect();
3059 chain.push((&identity.fingerprint, &identity.public_key_spki));
3060 let signer_index = chain.iter().position(|(fingerprint, spki)| {
3061 *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3062 });
3063 let Some(signer_index) = signer_index else {
3064 return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3065 };
3066 if signer_epoch != signer_index as u64 + 1 {
3067 return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3068 }
3069 let lower_boundary = if signer_index == 0 {
3070 None
3071 } else {
3072 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3073 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3074 Some(prior.prior_head_seq)
3075 };
3076 let upper_boundary = if signer_index == identity.rotations.len() {
3077 None
3078 } else {
3079 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3080 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3081 Some(next.prior_head_seq)
3082 };
3083 if lower_boundary.is_some_and(|boundary| seq <= boundary)
3084 || upper_boundary.is_some_and(|boundary| seq > boundary)
3085 {
3086 return Err(invalid_feed(
3087 "v2 commit signer is outside its authenticated rotation epoch",
3088 ));
3089 }
3090 let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3091 .map_err(|error| invalid_feed(error.to_string()))?;
3092 verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3093 Ok(object.clone())
3094}
3095
3096#[derive(Debug, Deserialize)]
3097struct V2FeedWireEntry {
3098 seq: u64,
3099 commit_hash: String,
3100 feed_hash: String,
3101 bytes_base64: String,
3102}
3103
3104#[derive(Debug, Deserialize)]
3105struct V2FeedPage {
3106 v: u8,
3107 head_seq: u64,
3108 head_commit_hash: String,
3109 head_feed_hash: String,
3110 entries: Vec<V2FeedWireEntry>,
3111 next_after: u64,
3112 complete: bool,
3113}
3114
3115fn replay_v2_feed(
3116 cfg: &HubConfig,
3117 brain: &str,
3118 pointer: &V2PointerBody,
3119 identity: &V2HeadIdentity,
3120 start_after: u64,
3121 start_feed: Option<String>,
3122) -> LinkResult<()> {
3123 let mut after = start_after;
3124 let mut prior_feed = start_feed;
3125 let mut final_object = None;
3126 let mut replayed_entries = 0_u64;
3127 let mut replayed_bytes = 0_u64;
3128 while after < pointer.seq {
3129 let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3130 let value = ensure_ok(
3131 request_capped(
3132 cfg,
3133 "GET",
3134 &path,
3135 None,
3136 Auth::Required,
3137 MAX_FEED_REPLAY_BYTES,
3138 )?,
3139 "v2 feed replay",
3140 )?;
3141 let page: V2FeedPage = serde_json::from_value(value)
3142 .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3143 if page.v != 2
3144 || page.head_seq != pointer.seq
3145 || page.head_commit_hash != pointer.commit_hash
3146 || page.head_feed_hash != pointer.feed_hash
3147 || page.entries.is_empty()
3148 || page.entries.len() > FEED_PAGE_LIMIT
3149 {
3150 return Err(invalid_feed("v2 feed page differs from the signed head"));
3151 }
3152 for entry in page.entries {
3153 if entry.seq != after + 1
3154 || !is_sha256(&entry.commit_hash)
3155 || !is_sha256(&entry.feed_hash)
3156 {
3157 return Err(invalid_feed("v2 feed sequence is not contiguous"));
3158 }
3159 let raw = base64::engine::general_purpose::STANDARD
3160 .decode(&entry.bytes_base64)
3161 .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3162 replayed_entries = replayed_entries
3163 .checked_add(1)
3164 .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3165 replayed_bytes = replayed_bytes
3166 .checked_add(raw.len() as u64)
3167 .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3168 if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3169 {
3170 return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3171 }
3172 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3173 .map_err(|error| invalid_feed(error.to_string()))?
3174 != entry.commit_hash
3175 || content_sha256(&raw) != entry.feed_hash
3176 {
3177 return Err(invalid_feed("v2 feed entry address mismatch"));
3178 }
3179 let object = verified_v2_commit_object(&raw, identity)?;
3180 if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3181 || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3182 {
3183 return Err(invalid_feed(
3184 "v2 feed entry does not extend its predecessor",
3185 ));
3186 }
3187 after = entry.seq;
3188 prior_feed = Some(entry.feed_hash);
3189 final_object = Some((entry.commit_hash, object));
3190 }
3191 if page.next_after != after || (page.complete != (after == pointer.seq)) {
3192 return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3193 }
3194 }
3195 let (final_hash, object) =
3196 final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3197 if final_hash != pointer.commit_hash
3198 || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3199 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3200 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3201 || object.get("control_revision").and_then(Value::as_str)
3202 != Some(pointer.control_revision.as_str())
3203 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3204 {
3205 return Err(invalid_feed(
3206 "v2 replay did not converge on the signed pointer",
3207 ));
3208 }
3209 Ok(())
3210}
3211
3212fn verify_v1_to_v2_bridge(
3213 cfg: &HubConfig,
3214 brain: &str,
3215 pointer: &V2PointerBody,
3216 identity: &V2HeadIdentity,
3217 checkpoint: &TrustState,
3218) -> LinkResult<()> {
3219 let value = ensure_ok(
3220 request_capped(
3221 cfg,
3222 "GET",
3223 &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3224 None,
3225 Auth::Required,
3226 MAX_FEED_RESPONSE_BYTES,
3227 )?,
3228 "v2 genesis bridge",
3229 )?;
3230 let page: V2FeedPage = serde_json::from_value(value)
3231 .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3232 if page.v != 2
3233 || page.head_seq != pointer.seq
3234 || page.head_commit_hash != pointer.commit_hash
3235 || page.head_feed_hash != pointer.feed_hash
3236 || page.entries.len() != 1
3237 || page.entries[0].seq != 1
3238 || !is_sha256(&page.entries[0].commit_hash)
3239 || !is_sha256(&page.entries[0].feed_hash)
3240 {
3241 return Err(invalid_feed(
3242 "v2 genesis bridge page differs from the signed head",
3243 ));
3244 }
3245 let first = &page.entries[0];
3246 let raw = STANDARD
3247 .decode(&first.bytes_base64)
3248 .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3249 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3250 .map_err(|error| invalid_feed(error.to_string()))?
3251 != first.commit_hash
3252 || content_sha256(&raw) != first.feed_hash
3253 {
3254 return Err(invalid_feed("v2 genesis bridge address mismatch"));
3255 }
3256 let object = verified_v2_commit_object(&raw, identity)?;
3257 if checkpoint.head_seq == 0 {
3258 if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3259 return Err(invalid_feed(
3260 "empty v1 checkpoint did not transition through an empty v2 genesis",
3261 ));
3262 }
3263 return Ok(());
3264 }
3265 let bridge = object
3266 .get("v1_bridge")
3267 .and_then(Value::as_object)
3268 .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3269 let checkpoint_feed = checkpoint
3270 .feed_hash
3271 .as_deref()
3272 .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3273 if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3274 || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3275 {
3276 return Err(invalid_feed(
3277 "v2 genesis bridge differs from the pinned v1 checkpoint",
3278 ));
3279 }
3280 let legacy_raw = ensure_raw_ok(
3281 request_raw(
3282 cfg,
3283 "GET",
3284 &format!(
3285 "/api/hub/brains/{brain}/feed?after={}&limit=1",
3286 checkpoint.head_seq - 1
3287 ),
3288 None,
3289 Auth::Required,
3290 MAX_FEED_RESPONSE_BYTES,
3291 )?,
3292 "v1 bridge boundary",
3293 )?;
3294 let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3295 .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3296 let legacy_identity = legacy
3297 .identity
3298 .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3299 let item = legacy
3300 .entries
3301 .first()
3302 .filter(|_| legacy.entries.len() == 1)
3303 .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3304 if legacy.scope_limited
3305 || legacy.head_seq != checkpoint.head_seq
3306 || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3307 || item.entry.seq != checkpoint.head_seq
3308 || item.hash != checkpoint_feed
3309 || legacy_identity != v2_identity(identity)
3310 || bridge.get("pack_sha256").and_then(Value::as_str)
3311 != Some(item.entry.pack_sha256.as_str())
3312 {
3313 return Err(invalid_feed(
3314 "v1 bridge boundary differs from its signed legacy head",
3315 ));
3316 }
3317 let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3318 if anchor != checkpoint.anchor {
3319 return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3320 }
3321 verify_feed_item(item, &legacy_identity)?;
3322 verify_rotation_feed_boundaries(
3323 &legacy_identity,
3324 Some(checkpoint),
3325 std::slice::from_ref(item),
3326 checkpoint.head_seq,
3327 )?;
3328 Ok(())
3329}
3330
3331fn verify_v2_commit(
3332 cfg: &HubConfig,
3333 brain: &str,
3334 pointer: &V2PointerBody,
3335 identity: &V2HeadIdentity,
3336 pinned: Option<&TrustState>,
3337) -> LinkResult<()> {
3338 let path = format!(
3339 "/api/hub/brains/{brain}/v2/commit?commit={}",
3340 pointer.commit_hash
3341 );
3342 let raw = ensure_raw_ok(
3343 request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3344 "v2 commit",
3345 )?;
3346 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3347 .map_err(|error| invalid_feed(error.to_string()))?
3348 != pointer.commit_hash
3349 || content_sha256(&raw) != pointer.feed_hash
3350 {
3351 return Err(invalid_feed("v2 commit address differs from the pointer"));
3352 }
3353 let object = verified_v2_commit_object(&raw, identity)?;
3354 if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3355 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3356 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3357 || object.get("control_revision").and_then(Value::as_str)
3358 != Some(pointer.control_revision.as_str())
3359 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3360 {
3361 return Err(invalid_feed("v2 commit fields differ from the pointer"));
3362 }
3363 if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3364 if pointer.seq == checkpoint.head_seq + 1
3365 && object.get("prev_entry_hash").and_then(Value::as_str)
3366 != checkpoint.feed_hash.as_deref()
3367 {
3368 return Err(invalid_feed(
3369 "v2 commit does not extend the pinned feed hash",
3370 ));
3371 }
3372 if pointer.seq > checkpoint.head_seq + 1 {
3373 return replay_v2_feed(
3374 cfg,
3375 brain,
3376 pointer,
3377 identity,
3378 checkpoint.head_seq,
3379 checkpoint.feed_hash.clone(),
3380 );
3381 }
3382 } else {
3383 if let Some(checkpoint) = pinned {
3384 verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3385 }
3386 if pointer.seq > 1 {
3387 return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3388 }
3389 }
3390 Ok(())
3391}
3392
3393fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3394 require_hardened_filesystem("verified link.md v2 state")?;
3395 require_safe_ref(brain)?;
3396 let trust_directory = open_trust_dir(cfg)?;
3400 let path = format!("/api/hub/brains/{brain}/v2/head");
3401 let response = request(cfg, "GET", &path, None, Auth::Required)?;
3402 if response.status == 404 {
3403 if has_accepted_v2_ref(cfg, brain)? {
3404 return Err(LinkError::BrainUnavailable);
3405 }
3406 return Ok(None);
3407 }
3408 let body = ensure_ok(response, "v2 head")?;
3409 let head: V2HeadResponse = serde_json::from_value(body)
3410 .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3411 if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3412 return Err(invalid_feed("v2 head has no canonical brain id"));
3413 }
3414 if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3415 return Err(invalid_feed("v2 head resolved a different brain id"));
3416 }
3417 if head.profile == "v1" {
3418 return Ok(None);
3419 }
3420 if head.profile != "v2" && head.profile != "v2-empty" {
3421 return Err(invalid_feed("v2 head advertised an unknown profile"));
3422 }
3423 let view = head
3424 .view
3425 .as_ref()
3426 .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3427 if !matches!(view.kind.as_str(), "full" | "scoped")
3428 || !is_sha256(&view.control_revision)
3429 || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3430 {
3431 return Err(invalid_feed("v2 head has an invalid permission view"));
3432 }
3433 let view_kind = view.kind.clone();
3434 let view_revision = view
3437 .id
3438 .clone()
3439 .unwrap_or_else(|| view.control_revision.clone());
3440 let control_revision = view.control_revision.clone();
3441 let identity = head
3442 .identity
3443 .as_ref()
3444 .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3445 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3446 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3447 let feed_identity = v2_identity(identity);
3448 let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3449 let (seq, feed_hash, hub_signer) = match &head.pointer {
3450 None => {
3451 if head.profile != "v2-empty" {
3452 return Err(invalid_feed("initialized v2 head has no pointer"));
3453 }
3454 (
3455 0,
3456 None,
3457 pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3458 )
3459 }
3460 Some(signed) => {
3461 let signer = verify_v2_pointer(signed, &head.brain_id)?;
3462 if pinned
3463 .as_ref()
3464 .and_then(|state| state.hub_signer.as_ref())
3465 .is_some_and(|known| known != &signer)
3466 {
3467 return Err(invalid_feed(
3468 "v2 hub pointer signer changed without a trust transition",
3469 ));
3470 }
3471 if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3472 if signed.pointer.seq < checkpoint.head_seq
3473 || (signed.pointer.seq == checkpoint.head_seq
3474 && checkpoint.feed_hash.as_deref()
3475 != Some(signed.pointer.feed_hash.as_str()))
3476 {
3477 return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3478 }
3479 }
3480 verify_v2_commit(
3481 cfg,
3482 &head.brain_id,
3483 &signed.pointer,
3484 identity,
3485 pinned.as_ref(),
3486 )?;
3487 (
3488 signed.pointer.seq,
3489 Some(signed.pointer.feed_hash.clone()),
3490 Some(signer),
3491 )
3492 }
3493 };
3494 let trust = TrustState {
3495 v: 2,
3496 origin: normalized_origin(&cfg.hub)?,
3497 requested: head.brain_id.clone(),
3498 brain: head.brain_id.clone(),
3499 home: None,
3500 anchor,
3501 current: format!("ed25519:{}", identity.fingerprint),
3502 head_seq: seq,
3503 feed_hash,
3504 rotations: identity.rotations.clone(),
3505 hub_signer,
3506 protocol_profile: Some("link-v2".to_string()),
3507 };
3508 Ok(Some(V2VerifiedHead {
3509 requested: brain.to_string(),
3510 brain_id: head.brain_id,
3511 view_kind,
3512 view_revision,
3513 control_revision,
3514 identity: identity.clone(),
3515 pointer: head.pointer.map(|signed| signed.pointer),
3516 trust,
3517 alias: alias_binding,
3518 }))
3519}
3520
3521fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3522 let directory = open_trust_dir(cfg)?;
3523 let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3524 let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3525 if let Some(current) = current {
3526 let common_invalid = head.trust.anchor != current.anchor
3527 || !head.trust.rotations.starts_with(¤t.rotations);
3528 let profile_invalid = if accepted_as_v2(¤t) {
3529 head.trust.head_seq < current.head_seq
3530 || (head.trust.head_seq == current.head_seq
3531 && head.trust.feed_hash != current.feed_hash)
3532 || current
3533 .hub_signer
3534 .as_ref()
3535 .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3536 } else {
3537 head.trust.protocol_profile.as_deref() != Some("link-v2")
3538 || head.trust.hub_signer.is_none()
3539 };
3540 if common_invalid || profile_invalid {
3541 return Err(invalid_feed(
3542 "v2 head cannot advance the currently accepted trust checkpoint",
3543 ));
3544 }
3545 }
3546 save_canonical_pin_and_alias(
3547 cfg,
3548 &directory,
3549 &head.requested,
3550 &head.brain_id,
3551 head.trust.clone(),
3552 alias.as_ref().or(head.alias.as_ref()),
3553 )
3554}
3555
3556#[derive(Debug, Clone, Deserialize, Serialize)]
3557struct V2BaselineFile {
3558 sha256: String,
3559 bytes: u64,
3560 #[serde(skip)]
3561 proof: Option<Vec<V2ProofStep>>,
3562}
3563
3564#[derive(Debug, Clone, Deserialize, Serialize)]
3565struct V2SyncBaseline {
3566 v: u8,
3567 origin: String,
3568 brain: String,
3569 #[serde(default)]
3570 checkout_id: Option<String>,
3571 #[serde(default)]
3572 head_seq: Option<u64>,
3573 commit_hash: Option<String>,
3574 content_root: Option<String>,
3575 #[serde(default)]
3576 asset_root: Option<String>,
3577 #[serde(default)]
3578 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3579 #[serde(default)]
3580 view_kind: Option<String>,
3581 #[serde(default)]
3582 view_revision: Option<String>,
3583 #[serde(default)]
3584 projection_sha256: Option<String>,
3585 files: std::collections::BTreeMap<String, V2BaselineFile>,
3586 #[serde(default)]
3587 local_policy_digest: Option<String>,
3588 #[serde(default)]
3589 local_eligibility: std::collections::BTreeMap<String, bool>,
3590 #[serde(default)]
3591 remote_copy_remains: std::collections::BTreeMap<String, String>,
3592}
3593
3594struct V2LocalView {
3595 riding: std::collections::BTreeMap<String, (String, u64)>,
3596 eligibility: std::collections::BTreeMap<String, bool>,
3597 policy: crate::linkmd_sync_policy::SyncPolicy,
3598 withheld_links: Vec<V2WithheldLink>,
3599}
3600
3601#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3602struct V2WithheldLink {
3603 source: String,
3604 target: String,
3605}
3606
3607#[derive(Debug, Clone, Deserialize, Serialize)]
3608struct V2ProofStep {
3609 directory_root: String,
3610 component: String,
3611 proof: crate::linkmd_v2::HamtProof,
3612}
3613
3614#[derive(Debug, Deserialize)]
3615struct V2ManifestFile {
3616 path: String,
3617 sha256: String,
3618 bytes: u64,
3619 proof: Vec<V2ProofStep>,
3620}
3621
3622#[derive(Debug, Deserialize)]
3623struct V2ManifestPage {
3624 v: u8,
3625 commit: String,
3626 content_root: Option<String>,
3627 files: Vec<V2ManifestFile>,
3628 next_cursor: Option<String>,
3629}
3630
3631#[derive(Debug, Clone, Deserialize, Serialize)]
3632struct V2BaselineAsset {
3633 blob_sha256: String,
3634 bytes: u64,
3635 media_type: String,
3636 wrappers: Vec<String>,
3637 required: bool,
3638 disposition: String,
3639 leaf_hash: String,
3640}
3641
3642#[derive(Debug, Deserialize)]
3643struct V2AssetManifestItem {
3644 path: String,
3645 blob_sha256: String,
3646 bytes: u64,
3647 media_type: String,
3648 wrappers: Vec<String>,
3649 required: bool,
3650 disposition: String,
3651 leaf_hash: String,
3652 proof: crate::linkmd_v2::HamtProof,
3653}
3654
3655#[derive(Debug, Deserialize)]
3656struct V2AssetManifestPage {
3657 v: u8,
3658 commit: String,
3659 asset_root: Option<String>,
3660 assets: Vec<V2AssetManifestItem>,
3661 next_cursor: Option<String>,
3662}
3663
3664#[derive(Debug, Deserialize)]
3665struct V2SigningCandidate {
3666 seq: u64,
3667 content_root: Option<String>,
3668 asset_root: Option<String>,
3669 signing_bytes_base64: String,
3670 changes_base64: String,
3671 actor_claim_base64: String,
3672}
3673
3674#[derive(Debug, Deserialize)]
3675struct V2SigningCandidatePage {
3676 v: u8,
3677 challenge_id: String,
3678 mutation_id: String,
3679 request_hash: String,
3680 parent: V2SigningParent,
3681 candidate: V2SigningCandidate,
3682 files: Vec<V2ManifestFile>,
3683 #[serde(default)]
3684 assets: Vec<V2AssetManifestItem>,
3685 next_cursor: Option<String>,
3686 expires_at: String,
3687}
3688
3689#[derive(Debug, Deserialize)]
3690struct V2SigningParent {
3691 seq: u64,
3692 commit_hash: Option<String>,
3693}
3694
3695fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3696 let normalized = crate::linkmd_v2::normalize_path(&file.path)
3697 .map_err(|error| invalid_feed(error.to_string()))?;
3698 let components = normalized.split('/').collect::<Vec<_>>();
3699 if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3700 return Err(invalid_feed("v2 file proof has the wrong shape"));
3701 }
3702 let mut directory_root = root.to_string();
3703 for (index, step) in file.proof.iter().enumerate() {
3704 if step.directory_root != directory_root || step.component != components[index] {
3705 return Err(invalid_feed(
3706 "v2 file proof path chain differs from its manifest",
3707 ));
3708 }
3709 if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3710 .map_err(|error| invalid_feed(error.to_string()))?
3711 {
3712 return Err(invalid_feed("v2 file proof failed verification"));
3713 }
3714 let entry = match &step.proof {
3715 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3716 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3717 return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3718 }
3719 };
3720 if index + 1 == components.len() {
3721 if entry.kind != crate::linkmd_v2::EntryKind::Blob
3722 || entry.child_hash != file.sha256
3723 || entry.bytes != Some(file.bytes)
3724 {
3725 return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3726 }
3727 } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3728 return Err(invalid_feed("v2 file proof traversed a non-directory"));
3729 } else {
3730 directory_root = entry.child_hash.clone();
3731 }
3732 }
3733 Ok(())
3734}
3735
3736fn v2_manifest(
3737 cfg: &HubConfig,
3738 brain: &str,
3739 pointer: Option<&V2PointerBody>,
3740) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3741 let Some(pointer) = pointer else {
3742 return Ok(std::collections::BTreeMap::new());
3743 };
3744 let Some(root) = pointer.content_root.as_deref() else {
3745 return Ok(std::collections::BTreeMap::new());
3746 };
3747 let mut files = std::collections::BTreeMap::new();
3748 let mut after = String::new();
3749 loop {
3750 let encoded_after: String =
3751 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3752 let path = format!(
3753 "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3754 pointer.commit_hash
3755 );
3756 let value = ensure_ok(
3757 request_capped(
3758 cfg,
3759 "GET",
3760 &path,
3761 None,
3762 Auth::Required,
3763 MAX_FEED_RESPONSE_BYTES,
3764 )?,
3765 "v2 file manifest",
3766 )?;
3767 let page: V2ManifestPage = serde_json::from_value(value)
3768 .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3769 if page.v != 2
3770 || page.commit != pointer.commit_hash
3771 || page.content_root.as_deref() != Some(root)
3772 || page.files.len() > 500
3773 {
3774 return Err(invalid_feed(
3775 "v2 file manifest is not bound to the verified head",
3776 ));
3777 }
3778 for file in page.files {
3779 verify_v2_file_proof(root, &file)?;
3780 if files
3781 .insert(
3782 file.path.clone(),
3783 V2BaselineFile {
3784 sha256: file.sha256,
3785 bytes: file.bytes,
3786 proof: Some(file.proof),
3787 },
3788 )
3789 .is_some()
3790 {
3791 return Err(invalid_feed("v2 file manifest repeats a path"));
3792 }
3793 if files.len() > MAX_PUSH_FILES {
3794 return Err(invalid_feed(
3795 "v2 file manifest exceeds the file-count bound",
3796 ));
3797 }
3798 }
3799 match page.next_cursor {
3800 None => break,
3801 Some(next) if next > after => after = next,
3802 Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3803 }
3804 }
3805 Ok(files)
3806}
3807
3808fn v2_manifest_file(
3813 cfg: &HubConfig,
3814 brain: &str,
3815 pointer: &V2PointerBody,
3816 path: &str,
3817) -> LinkResult<Option<V2BaselineFile>> {
3818 let Some(root) = pointer.content_root.as_deref() else {
3819 return Ok(None);
3820 };
3821 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
3822 path: error.to_string(),
3823 })?;
3824 let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
3825 let value = ensure_ok(
3826 request_capped(
3827 cfg,
3828 "GET",
3829 &format!(
3830 "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
3831 pointer.commit_hash
3832 ),
3833 None,
3834 Auth::Required,
3835 MAX_FEED_RESPONSE_BYTES,
3836 )?,
3837 "v2 exact file proof",
3838 )?;
3839 let mut page: V2ManifestPage = serde_json::from_value(value)
3840 .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
3841 if page.v != 2
3842 || page.commit != pointer.commit_hash
3843 || page.content_root.as_deref() != Some(root)
3844 || page.next_cursor.is_some()
3845 || page.files.len() != 1
3846 || page.files[0].path != path
3847 {
3848 return Err(invalid_feed(
3849 "v2 exact file proof is not bound to the requested signed path",
3850 ));
3851 }
3852 let file = page.files.pop().expect("exactly one file was checked");
3853 verify_v2_file_proof(root, &file)?;
3854 Ok(Some(V2BaselineFile {
3855 sha256: file.sha256,
3856 bytes: file.bytes,
3857 proof: Some(file.proof),
3858 }))
3859}
3860
3861fn v2_manifest_file_by_id(
3866 cfg: &HubConfig,
3867 brain: &str,
3868 pointer: &V2PointerBody,
3869 id: &str,
3870) -> LinkResult<(String, V2BaselineFile)> {
3871 let root = pointer
3872 .content_root
3873 .as_deref()
3874 .ok_or_else(|| LinkError::Http {
3875 what: "resolve",
3876 status: 404,
3877 message: "record not found".to_string(),
3878 code: Some("NOT_FOUND".to_string()),
3879 details: None,
3880 })?;
3881 if !crate::ulid::is_ulid(id) {
3882 return Err(LinkError::BadAddress {
3883 given: id.to_string(),
3884 reason: BAD_TARGET_REASON.to_string(),
3885 });
3886 }
3887 let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
3888 let value = ensure_ok(
3889 request_capped(
3890 cfg,
3891 "GET",
3892 &format!(
3893 "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
3894 pointer.commit_hash
3895 ),
3896 None,
3897 Auth::Required,
3898 MAX_FEED_RESPONSE_BYTES,
3899 )?,
3900 "v2 exact id proof",
3901 )?;
3902 let mut page: V2ManifestPage = serde_json::from_value(value)
3903 .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
3904 if page.v != 2
3905 || page.commit != pointer.commit_hash
3906 || page.content_root.as_deref() != Some(root)
3907 || page.next_cursor.is_some()
3908 || page.files.len() != 1
3909 {
3910 return Err(invalid_feed(
3911 "v2 exact id proof is not bound to one signed path",
3912 ));
3913 }
3914 let file = page.files.pop().expect("exactly one file was checked");
3915 if !safe_store_rel_path(&file.path)
3916 || !file.path.ends_with(".md")
3917 || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
3918 {
3919 return Err(invalid_feed("v2 exact id proof has an invalid record path"));
3920 }
3921 verify_v2_file_proof(root, &file)?;
3922 Ok((
3923 file.path,
3924 V2BaselineFile {
3925 sha256: file.sha256,
3926 bytes: file.bytes,
3927 proof: Some(file.proof),
3928 },
3929 ))
3930}
3931
3932fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
3933 crate::linkmd_v2::normalize_path(&item.path)
3934 .map_err(|error| invalid_feed(error.to_string()))?;
3935 if !is_sha256(&item.blob_sha256)
3936 || !is_sha256(&item.leaf_hash)
3937 || item.wrappers.is_empty()
3938 || !matches!(item.disposition.as_str(), "hosted" | "withheld")
3939 || item
3940 .wrappers
3941 .iter()
3942 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
3943 {
3944 return Err(invalid_feed("v2 asset manifest item is invalid"));
3945 }
3946 let leaf = json!({
3947 "blob_sha256": item.blob_sha256,
3948 "bytes": item.bytes,
3949 "disposition": item.disposition,
3950 "media_type": item.media_type,
3951 "path": item.path,
3952 "required": item.required,
3953 "v": 2,
3954 "wrappers": item.wrappers,
3955 });
3956 if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
3957 .map_err(|error| invalid_feed(error.to_string()))?
3958 != item.leaf_hash
3959 || !crate::linkmd_v2::verify_proof_with_domain(
3960 root,
3961 &item.path,
3962 &item.proof,
3963 crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
3964 )
3965 .map_err(|error| invalid_feed(error.to_string()))?
3966 {
3967 return Err(invalid_feed("v2 asset inclusion proof failed"));
3968 }
3969 match &item.proof {
3970 crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
3971 if entry.name == item.path
3972 && entry.kind == crate::linkmd_v2::EntryKind::Blob
3973 && entry.child_hash == item.leaf_hash
3974 && entry.bytes == Some(item.bytes) =>
3975 {
3976 Ok(())
3977 }
3978 _ => Err(invalid_feed(
3979 "v2 asset proof leaf differs from its manifest",
3980 )),
3981 }
3982}
3983
3984fn v2_asset_manifest(
3985 cfg: &HubConfig,
3986 brain: &str,
3987 pointer: Option<&V2PointerBody>,
3988) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
3989 let Some(pointer) = pointer else {
3990 return Ok(std::collections::BTreeMap::new());
3991 };
3992 let Some(root) = pointer.asset_root.as_deref() else {
3993 return Ok(std::collections::BTreeMap::new());
3994 };
3995 let mut assets = std::collections::BTreeMap::new();
3996 let mut after = String::new();
3997 loop {
3998 let encoded_after: String =
3999 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4000 let path = format!(
4001 "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4002 pointer.commit_hash
4003 );
4004 let value = ensure_ok(
4005 request_capped(
4006 cfg,
4007 "GET",
4008 &path,
4009 None,
4010 Auth::Required,
4011 MAX_FEED_RESPONSE_BYTES,
4012 )?,
4013 "v2 asset manifest",
4014 )?;
4015 let page: V2AssetManifestPage = serde_json::from_value(value)
4016 .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4017 if page.v != 2
4018 || page.commit != pointer.commit_hash
4019 || page.asset_root.as_deref() != Some(root)
4020 || page.assets.len() > 500
4021 {
4022 return Err(invalid_feed(
4023 "v2 asset manifest is not bound to the verified head",
4024 ));
4025 }
4026 for item in page.assets {
4027 verify_v2_asset_proof(root, &item)?;
4028 let path = item.path.clone();
4029 if assets
4030 .insert(
4031 path,
4032 V2BaselineAsset {
4033 blob_sha256: item.blob_sha256,
4034 bytes: item.bytes,
4035 media_type: item.media_type,
4036 wrappers: item.wrappers,
4037 required: item.required,
4038 disposition: item.disposition,
4039 leaf_hash: item.leaf_hash,
4040 },
4041 )
4042 .is_some()
4043 {
4044 return Err(invalid_feed("v2 asset manifest repeats a path"));
4045 }
4046 if assets.len() > MAX_PUSH_FILES {
4047 return Err(invalid_feed(
4048 "v2 asset manifest exceeds the item-count bound",
4049 ));
4050 }
4051 }
4052 match page.next_cursor {
4053 None => break,
4054 Some(next) if next > after => after = next,
4055 Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4056 }
4057 }
4058 Ok(assets)
4059}
4060
4061fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4062 crate::AssetRecord {
4063 path: path.to_string(),
4064 sha256: asset.blob_sha256.clone(),
4065 bytes: asset.bytes,
4066 media_type: asset.media_type.clone(),
4067 wrappers: asset.wrappers.clone(),
4068 required: asset.required,
4069 }
4070}
4071
4072fn v2_asset_resumes_hosting(
4073 remote: Option<&V2BaselineAsset>,
4074 path: &str,
4075 record: &crate::AssetRecord,
4076 disposition: &str,
4077) -> bool {
4078 remote.is_some_and(|asset| {
4079 asset.disposition == "withheld"
4080 && disposition == "hosted"
4081 && v2_asset_record(asset, path) == *record
4082 })
4083}
4084
4085fn v2_asset_record_manifest_bytes(
4086 assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4087) -> LinkResult<Vec<u8>> {
4088 let mut bytes = Vec::new();
4089 for (path, asset) in assets {
4090 if asset.path != *path {
4091 return Err(invalid_feed(
4092 "local asset manifest key differs from its record path",
4093 ));
4094 }
4095 serde_json::to_writer(&mut bytes, asset)
4096 .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4097 bytes.push(b'\n');
4098 }
4099 Ok(bytes)
4100}
4101
4102fn v2_local_asset_records(
4103 store: &Store,
4104) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4105 Ok(crate::assets::read_manifest(store)
4106 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
4107 .into_iter()
4108 .map(|asset| (asset.path.clone(), asset))
4109 .collect())
4110}
4111
4112fn v2_asset_records_match_remote(
4113 local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4114 remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4115) -> bool {
4116 local.len() == remote.len()
4117 && remote
4118 .iter()
4119 .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4120}
4121
4122#[derive(Debug, Clone, PartialEq, Eq)]
4123struct V2PulledMerge<T> {
4124 records: std::collections::BTreeMap<String, T>,
4125 accept_remote: std::collections::BTreeSet<String>,
4126 conflicts: Vec<String>,
4127}
4128
4129fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4135 base: &std::collections::BTreeMap<String, Base>,
4136 remote: &std::collections::BTreeMap<String, Remote>,
4137 local: &std::collections::BTreeMap<String, Record>,
4138 base_record: BaseRecord,
4139 remote_record: RemoteRecord,
4140 keep_local: KeepLocal,
4141) -> V2PulledMerge<Record>
4142where
4143 Record: Clone + Eq,
4144 BaseRecord: Fn(&Base, &str) -> Record,
4145 RemoteRecord: Fn(&Remote, &str) -> Record,
4146 KeepLocal: Fn(&str) -> bool,
4147{
4148 let paths = base
4149 .keys()
4150 .chain(remote.keys())
4151 .chain(local.keys())
4152 .cloned()
4153 .collect::<std::collections::BTreeSet<_>>();
4154 let mut records = local.clone();
4155 let mut accept_remote = std::collections::BTreeSet::new();
4156 let mut conflicts = Vec::new();
4157 for path in paths {
4158 if keep_local(&path) {
4159 continue;
4160 }
4161 let base_value = base.get(&path).map(|value| base_record(value, &path));
4162 let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4163 let local_value = local.get(&path).cloned();
4164 if local_value != base_value && remote_value != base_value && local_value != remote_value {
4165 conflicts.push(path);
4166 continue;
4167 }
4168 if local_value == base_value || local_value == remote_value {
4169 accept_remote.insert(path.clone());
4170 match remote_value {
4171 Some(value) => {
4172 records.insert(path, value);
4173 }
4174 None => {
4175 records.remove(&path);
4176 }
4177 }
4178 }
4179 }
4180 V2PulledMerge {
4181 records,
4182 accept_remote,
4183 conflicts,
4184 }
4185}
4186
4187fn sign_verified_v2_candidate(
4188 cfg: &HubConfig,
4189 head: &V2VerifiedHead,
4190 expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4191 expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4192 mutation_id: &str,
4193 request_body: &Value,
4194 challenge_value: &Value,
4195) -> LinkResult<(String, String, String)> {
4196 if head.view_kind != "full" {
4197 return Err(invalid_feed(
4198 "a scoped self-custody writer must use the proposal workflow",
4199 ));
4200 }
4201 if head.identity.custody != "self" {
4202 return Err(invalid_feed(
4203 "a hub-custodied brain unexpectedly requested an external signature",
4204 ));
4205 }
4206 let key = cfg
4207 .brain_key
4208 .as_ref()
4209 .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4210 if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4211 || key.public_key_spki != head.identity.public_key_spki
4212 {
4213 return Err(bad_agent_key(
4214 "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4215 ));
4216 }
4217 let challenge_id = challenge_value
4218 .get("id")
4219 .and_then(Value::as_str)
4220 .filter(|id| crate::ulid::is_ulid(id))
4221 .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4222 let expected_endpoint = format!(
4223 "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4224 head.brain_id
4225 );
4226 if challenge_value
4227 .get("candidate_endpoint")
4228 .and_then(Value::as_str)
4229 != Some(expected_endpoint.as_str())
4230 {
4231 return Err(invalid_feed(
4232 "self-custody challenge candidate endpoint is not origin-bound",
4233 ));
4234 }
4235
4236 let mut files = std::collections::BTreeMap::new();
4237 let mut after = String::new();
4238 type CandidateCoordinate = (
4239 String,
4240 String,
4241 String,
4242 String,
4243 Option<String>,
4244 Option<String>,
4245 u64,
4246 Option<String>,
4247 );
4248 let mut pinned: Option<CandidateCoordinate> = None;
4249 loop {
4250 let encoded_after: String =
4251 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4252 let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4253 let value = ensure_ok(
4254 request_capped(
4255 cfg,
4256 "GET",
4257 &path,
4258 None,
4259 Auth::Required,
4260 MAX_FEED_RESPONSE_BYTES,
4261 )?,
4262 "v2 self-custody candidate",
4263 )?;
4264 let page: V2SigningCandidatePage = serde_json::from_value(value)
4265 .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4266 if page.v != 2
4267 || page.challenge_id != challenge_id
4268 || page.mutation_id != mutation_id
4269 || page.candidate.seq != page.parent.seq + 1
4270 || page.files.len() > 500
4271 || page.expires_at.is_empty()
4272 {
4273 return Err(invalid_feed(
4274 "self-custody candidate is not bound to this mutation",
4275 ));
4276 }
4277 let coordinate = (
4278 page.request_hash.clone(),
4279 page.candidate.signing_bytes_base64.clone(),
4280 page.candidate.changes_base64.clone(),
4281 page.candidate.actor_claim_base64.clone(),
4282 page.candidate.content_root.clone(),
4283 page.candidate.asset_root.clone(),
4284 page.parent.seq,
4285 page.parent.commit_hash.clone(),
4286 );
4287 if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4288 return Err(invalid_feed(
4289 "self-custody candidate changed between manifest pages",
4290 ));
4291 }
4292 pinned = Some(coordinate);
4293 let root = page
4294 .candidate
4295 .content_root
4296 .as_deref()
4297 .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4298 for file in page.files {
4299 verify_v2_file_proof(root, &file)?;
4300 if files
4301 .insert(
4302 file.path.clone(),
4303 V2BaselineFile {
4304 sha256: file.sha256,
4305 bytes: file.bytes,
4306 proof: Some(file.proof),
4307 },
4308 )
4309 .is_some()
4310 {
4311 return Err(invalid_feed(
4312 "self-custody candidate repeats a manifest path",
4313 ));
4314 }
4315 if files.len() > MAX_PUSH_FILES {
4316 return Err(invalid_feed(
4317 "self-custody candidate exceeds the file-count bound",
4318 ));
4319 }
4320 }
4321 match page.next_cursor {
4322 None => break,
4323 Some(next) if next > after => after = next,
4324 Some(_) => {
4325 return Err(invalid_feed(
4326 "self-custody candidate cursor did not advance",
4327 ))
4328 }
4329 }
4330 }
4331 if files.len() != expected.len()
4332 || files.iter().any(|(path, file)| {
4333 expected.get(path).is_none_or(|expected| {
4334 expected.sha256 != file.sha256 || expected.bytes != file.bytes
4335 })
4336 })
4337 {
4338 return Err(invalid_feed(
4339 "self-custody candidate contains an unexpected file mutation",
4340 ));
4341 }
4342 let mut assets = std::collections::BTreeMap::new();
4343 after.clear();
4344 loop {
4345 let encoded_after: String =
4346 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4347 let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4348 let value = ensure_ok(
4349 request_capped(
4350 cfg,
4351 "GET",
4352 &path,
4353 None,
4354 Auth::Required,
4355 MAX_FEED_RESPONSE_BYTES,
4356 )?,
4357 "v2 self-custody asset candidate",
4358 )?;
4359 let page: V2SigningCandidatePage = serde_json::from_value(value)
4360 .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4361 let coordinate = (
4362 page.request_hash.clone(),
4363 page.candidate.signing_bytes_base64.clone(),
4364 page.candidate.changes_base64.clone(),
4365 page.candidate.actor_claim_base64.clone(),
4366 page.candidate.content_root.clone(),
4367 page.candidate.asset_root.clone(),
4368 page.parent.seq,
4369 page.parent.commit_hash.clone(),
4370 );
4371 if page.v != 2
4372 || page.challenge_id != challenge_id
4373 || page.mutation_id != mutation_id
4374 || page.assets.len() > 500
4375 || pinned.as_ref() != Some(&coordinate)
4376 {
4377 return Err(invalid_feed(
4378 "self-custody asset candidate changed or is not bound",
4379 ));
4380 }
4381 let root = page.candidate.asset_root.as_deref();
4382 if !page.assets.is_empty() && root.is_none() {
4383 return Err(invalid_feed("asset candidate has no asset root"));
4384 }
4385 for item in page.assets {
4386 verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4387 if assets
4388 .insert(
4389 item.path.clone(),
4390 V2BaselineAsset {
4391 blob_sha256: item.blob_sha256,
4392 bytes: item.bytes,
4393 media_type: item.media_type,
4394 wrappers: item.wrappers,
4395 required: item.required,
4396 disposition: item.disposition,
4397 leaf_hash: item.leaf_hash,
4398 },
4399 )
4400 .is_some()
4401 {
4402 return Err(invalid_feed("self-custody candidate repeats an asset"));
4403 }
4404 }
4405 match page.next_cursor {
4406 None => break,
4407 Some(next) if next > after => after = next,
4408 Some(_) => {
4409 return Err(invalid_feed(
4410 "self-custody asset candidate cursor did not advance",
4411 ))
4412 }
4413 }
4414 }
4415 if assets.len() != expected_assets.len()
4416 || assets.iter().any(|(path, asset)| {
4417 expected_assets.get(path).is_none_or(|expected| {
4418 asset.blob_sha256 != expected.blob_sha256
4419 || asset.bytes != expected.bytes
4420 || asset.media_type != expected.media_type
4421 || asset.wrappers != expected.wrappers
4422 || asset.required != expected.required
4423 || asset.disposition != expected.disposition
4424 })
4425 })
4426 {
4427 return Err(invalid_feed(
4428 "self-custody candidate contains an unexpected asset mutation",
4429 ));
4430 }
4431 let Some((
4432 request_hash,
4433 signing_b64,
4434 changes_b64,
4435 actor_b64,
4436 root,
4437 asset_root,
4438 parent_seq,
4439 parent,
4440 )) = pinned
4441 else {
4442 return Err(invalid_feed("self-custody candidate has no manifest"));
4443 };
4444 let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4445 let current_commit = head
4446 .pointer
4447 .as_ref()
4448 .map(|pointer| pointer.commit_hash.clone());
4449 if parent_seq != current_seq || parent != current_commit {
4450 return Err(LinkError::RemoteAdvancedDuringSync);
4451 }
4452 let changes = STANDARD
4453 .decode(changes_b64)
4454 .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4455 let mut expected_changes = json!({
4456 "mutation_id": mutation_id,
4457 "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4458 "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4459 "v": 2,
4460 });
4461 if let Some(withheld_links) = request_body.get("withheld_links") {
4462 expected_changes["withheld_links"] = withheld_links.clone();
4463 }
4464 if let Some(checkout_id) = request_body.get("checkout_id") {
4465 expected_changes["checkout_id"] = checkout_id.clone();
4466 }
4467 let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4468 .map_err(|error| invalid_feed(error.to_string()))?;
4469 if changes != expected_changes_bytes {
4470 return Err(invalid_feed(
4471 "self-custody changeset differs from the requested mutation",
4472 ));
4473 }
4474 let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4475 .map_err(|error| invalid_feed(error.to_string()))?;
4476 let request_value = json!({
4477 "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4478 "brain": head.brain_id,
4479 "changes_sha256": changes_hash,
4480 "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4481 "v": 2,
4482 "v1_bridge": Value::Null,
4483 });
4484 let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4485 .map_err(|error| invalid_feed(error.to_string()))?;
4486 if request_hash != expected_request_hash {
4487 return Err(invalid_feed(
4488 "self-custody request hash differs from the requested mutation",
4489 ));
4490 }
4491 let actor = STANDARD
4492 .decode(actor_b64)
4493 .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4494 let actor_value: Value = serde_json::from_slice(&actor)
4495 .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4496 if crate::linkmd_v2::canonical_bytes(&actor_value)
4497 .map_err(|error| invalid_feed(error.to_string()))?
4498 != actor
4499 {
4500 return Err(invalid_feed("self-custody actor claim is not canonical"));
4501 }
4502 let actor_object = actor_value
4503 .as_object()
4504 .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4505 let actor_claim = actor_object
4506 .get("claim")
4507 .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4508 let actor_public_key = actor_object
4509 .get("public_key")
4510 .and_then(Value::as_str)
4511 .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4512 let actor_fingerprint = actor_object
4513 .get("fingerprint")
4514 .and_then(Value::as_str)
4515 .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4516 let actor_signature = actor_object
4517 .get("sig")
4518 .and_then(Value::as_str)
4519 .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4520 let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4521 .map_err(|error| invalid_feed(error.to_string()))?;
4522 let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4523 let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4524 let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4525 let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4526 let impact = actor_claim
4527 .get("result")
4528 .and_then(|result| result.get("impact"))
4529 .and_then(Value::as_object);
4530 let impact_fields = [
4531 "creates",
4532 "updates",
4533 "deletes",
4534 "withdrawals",
4535 "renames",
4536 "restores",
4537 "asset_changes",
4538 "public_expansions",
4539 "executable_activations",
4540 ];
4541 let impact_is_valid = impact.is_some_and(|impact| {
4542 impact.len() == impact_fields.len() + 1
4543 && impact.get("v").and_then(Value::as_u64) == Some(1)
4544 && impact_fields
4545 .iter()
4546 .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4547 });
4548 if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4549 || head
4550 .trust
4551 .hub_signer
4552 .as_ref()
4553 .is_some_and(|known| known != &expected_actor_signer)
4554 || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4555 || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4556 || actor_claim
4557 .get("candidate")
4558 .and_then(|candidate| candidate.get("changes_sha256"))
4559 .and_then(Value::as_str)
4560 != Some(changes_hash.as_str())
4561 || actor_claim
4562 .get("candidate")
4563 .and_then(|candidate| candidate.get("state_root"))
4564 != Some(&expected_actor_root)
4565 || actor_claim
4566 .get("candidate")
4567 .and_then(|candidate| candidate.get("asset_root"))
4568 != Some(&expected_actor_asset_root)
4569 || actor_claim
4570 .get("candidate")
4571 .and_then(|candidate| candidate.get("control_revision"))
4572 .and_then(Value::as_str)
4573 != Some(head.control_revision.as_str())
4574 || !impact_is_valid
4575 {
4576 return Err(invalid_feed(
4577 "self-custody actor claim does not bind the verified authority",
4578 ));
4579 }
4580 let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4581 .map_err(|error| invalid_feed(error.to_string()))?;
4582 let signing = STANDARD
4583 .decode(signing_b64)
4584 .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4585 let signing_value: Value = serde_json::from_slice(&signing)
4586 .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4587 if crate::linkmd_v2::canonical_bytes(&signing_value)
4588 .map_err(|error| invalid_feed(error.to_string()))?
4589 != signing
4590 {
4591 return Err(invalid_feed("self-custody signing bytes are not canonical"));
4592 }
4593 let pointer = head.pointer.as_ref();
4594 let expected_materializer = pointer
4595 .map(|value| value.materializer.as_str())
4596 .unwrap_or("dbmd-projection-v1");
4597 let expected_parent_commit = request_body
4598 .get("base")
4599 .and_then(|base| base.get("commit_hash"))
4600 .cloned()
4601 .unwrap_or(Value::Null);
4602 let expected_parent_root = request_body
4603 .get("base")
4604 .and_then(|base| base.get("content_root"))
4605 .cloned()
4606 .unwrap_or(Value::Null);
4607 let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4608 let expected_parent_asset_root = request_body
4609 .get("base")
4610 .and_then(|base| base.get("asset_root"))
4611 .cloned()
4612 .unwrap_or(Value::Null);
4613 let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4614 let expected_prev_entry = pointer
4615 .map(|value| Value::String(value.feed_hash.clone()))
4616 .unwrap_or(Value::Null);
4617 let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4618 .map_err(|_| invalid_feed("brain identity history is too large"))?
4619 + 1;
4620 if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4621 || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4622 || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4623 || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4624 || signing_value.get("public_key").and_then(Value::as_str)
4625 != Some(key.public_key_spki.as_str())
4626 || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4627 || signing_value.get("parent_root") != Some(&expected_parent_root)
4628 || signing_value.get("state_root") != Some(&expected_state_root)
4629 || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4630 || signing_value.get("asset_root") != Some(&expected_asset_root)
4631 || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4632 || signing_value.get("changes_sha256").and_then(Value::as_str)
4633 != Some(changes_hash.as_str())
4634 || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4635 || signing_value
4636 .get("control_revision")
4637 .and_then(Value::as_str)
4638 != Some(head.control_revision.as_str())
4639 || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4640 || signing_value.get("v1_bridge") != Some(&Value::Null)
4641 || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4642 {
4643 return Err(invalid_feed(
4644 "self-custody signing bytes do not bind the verified candidate",
4645 ));
4646 }
4647 let pair = agent_keypair(&key.pkcs8)?;
4648 let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4649 Ok((challenge_id.to_string(), signature, expected_actor_signer))
4650}
4651
4652fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4653 let origin = normalized_origin(&cfg.hub)?;
4654 let absolute = if checkout.is_absolute() {
4655 checkout.to_path_buf()
4656 } else {
4657 std::env::current_dir()?.join(checkout)
4658 };
4659 Ok(format!(
4660 "sync-{}.json",
4661 content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4662 ))
4663}
4664
4665fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4666 if let Some(value) = existing {
4667 if !is_sha256(value) {
4668 return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4669 }
4670 return Ok(value.to_string());
4671 }
4672 use ring::rand::SecureRandom as _;
4673 let mut random = [0_u8; 32];
4674 ring::rand::SystemRandom::new()
4675 .fill(&mut random)
4676 .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4677 Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4678}
4679
4680#[cfg(any(unix, windows))]
4681fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4682 let directory = open_trust_dir(cfg)?;
4683 let origin = normalized_origin(&cfg.hub)?;
4684 let name = format!(
4685 "operation-{}.lock",
4686 content_sha256(format!("{origin}\0{brain}").as_bytes())
4687 );
4688 lock_trust_name(&directory, &name)
4689}
4690
4691#[cfg(not(any(unix, windows)))]
4692fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4693 Err(LinkError::UnsupportedPlatform {
4694 operation: "serialized link.md v2 sync",
4695 })
4696}
4697
4698fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4699 left.brain_id == right.brain_id
4700 && left.view_kind == right.view_kind
4701 && left.view_revision == right.view_revision
4702 && left.control_revision == right.control_revision
4703 && match (&left.pointer, &right.pointer) {
4704 (None, None) => true,
4705 (Some(left), Some(right)) => {
4706 left.seq == right.seq
4707 && left.commit_hash == right.commit_hash
4708 && left.content_root == right.content_root
4709 && left.asset_root == right.asset_root
4710 && left.feed_hash == right.feed_hash
4711 }
4712 _ => false,
4713 }
4714}
4715
4716fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4717 format!(
4718 "---\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"
4719 )
4720 .into_bytes()
4721}
4722
4723fn scoped_projection_sha256(brain: &str) -> String {
4724 content_sha256(&scoped_projection_bytes(brain))
4725}
4726
4727#[derive(Deserialize)]
4728struct LocalScopedViewMarker {
4729 v: u8,
4730 kind: String,
4731 authoritative: bool,
4732 brain: String,
4733 projection_sha256: String,
4734}
4735
4736pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4740 let marker = store
4741 .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4742 .ok()
4743 .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4744 let Some(marker) = marker else {
4745 return false;
4746 };
4747 if marker.v != 1
4748 || marker.kind != "link.md-scoped-view"
4749 || marker.authoritative
4750 || !crate::ulid::is_ulid(&marker.brain)
4751 || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4752 {
4753 return false;
4754 }
4755 store
4756 .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4757 .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4758}
4759
4760fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4761 let mut bytes = serde_json::to_vec_pretty(&json!({
4762 "v": 1,
4763 "kind": "link.md-scoped-view",
4764 "authoritative": false,
4765 "brain": head.brain_id,
4766 "view_revision": head.view_revision,
4767 "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4768 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4769 "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4770 "visible_files": files,
4771 "projection_sha256": scoped_projection_sha256(&head.brain_id),
4772 }))
4773 .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4774 bytes.push(b'\n');
4775 Ok(bytes)
4776}
4777
4778fn refresh_scoped_view_marker(
4779 store: &Store,
4780 head: &V2VerifiedHead,
4781 files: usize,
4782) -> LinkResult<()> {
4783 if head.view_kind == "scoped" {
4784 store.write_atomic(
4785 Path::new(".dbmd/view.json"),
4786 &scoped_view_metadata(head, files)?,
4787 )?;
4788 }
4789 Ok(())
4790}
4791
4792fn ensure_v2_view_compatible(
4793 head: &V2VerifiedHead,
4794 baseline: Option<&V2SyncBaseline>,
4795) -> LinkResult<()> {
4796 let Some(baseline) = baseline else {
4797 return Ok(());
4798 };
4799 match (
4800 baseline.view_kind.as_deref(),
4801 baseline.view_revision.as_deref(),
4802 ) {
4803 (None, None) if head.view_kind == "full" => Ok(()),
4804 (Some(kind), Some(revision))
4805 if kind == head.view_kind && revision == head.view_revision =>
4806 {
4807 Ok(())
4808 }
4809 _ => Err(LinkError::ScopedViewChanged),
4810 }
4811}
4812
4813fn ensure_established_v2_checkout_opened(
4814 head: &V2VerifiedHead,
4815 baseline: Option<&V2SyncBaseline>,
4816 opened: bool,
4817) -> LinkResult<()> {
4818 if baseline.is_none() || opened {
4819 return Ok(());
4820 }
4821 if head.view_kind == "scoped" {
4822 return Err(LinkError::ScopedProjectionModified);
4823 }
4824 Err(LinkError::InvalidPack {
4825 message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4826 })
4827}
4828
4829fn remove_scoped_projection(
4830 head: &V2VerifiedHead,
4831 baseline: Option<&V2SyncBaseline>,
4832 view: &mut V2LocalView,
4833) -> LinkResult<()> {
4834 if head.view_kind != "scoped" {
4835 return Ok(());
4836 }
4837 let expected = scoped_projection_sha256(&head.brain_id);
4838 if baseline
4839 .and_then(|state| state.projection_sha256.as_deref())
4840 .is_some_and(|pinned| pinned != expected)
4841 {
4842 return Err(LinkError::ScopedViewChanged);
4843 }
4844 if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4845 return Err(LinkError::ScopedProjectionModified);
4846 }
4847 view.riding.remove("DB.md");
4848 view.eligibility.remove("DB.md");
4849 Ok(())
4850}
4851
4852fn local_view_for_v2_push(
4853 store: &Store,
4854 head: &V2VerifiedHead,
4855 baseline: Option<&V2SyncBaseline>,
4856 carried: Option<V2LocalView>,
4857) -> LinkResult<V2LocalView> {
4858 match carried {
4859 Some(view) => Ok(view),
4864 None => {
4865 let mut view = v2_local_files(store)?;
4866 remove_scoped_projection(head, baseline, &mut view)?;
4867 Ok(view)
4868 }
4869 }
4870}
4871
4872fn files_for_v2_view(
4873 head: &V2VerifiedHead,
4874 mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4875) -> std::collections::BTreeMap<String, V2BaselineFile> {
4876 if head.view_kind == "scoped" {
4877 files.remove("DB.md");
4881 }
4882 files
4883}
4884
4885fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4886 let baseline: V2SyncBaseline =
4887 serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
4888 if baseline.v != 2
4889 || baseline.origin != normalized_origin(&cfg.hub)?
4890 || baseline.brain != brain
4891 || baseline
4892 .commit_hash
4893 .as_deref()
4894 .is_some_and(|hash| !is_sha256(hash))
4895 || baseline
4896 .content_root
4897 .as_deref()
4898 .is_some_and(|hash| !is_sha256(hash))
4899 || baseline
4900 .asset_root
4901 .as_deref()
4902 .is_some_and(|hash| !is_sha256(hash))
4903 || baseline
4904 .local_policy_digest
4905 .as_deref()
4906 .is_some_and(|hash| !is_sha256(hash))
4907 || baseline
4908 .view_kind
4909 .as_deref()
4910 .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
4911 || baseline
4912 .view_revision
4913 .as_deref()
4914 .is_some_and(|hash| !is_sha256(hash))
4915 || baseline
4916 .projection_sha256
4917 .as_deref()
4918 .is_some_and(|hash| !is_sha256(hash))
4919 || (baseline.view_kind.as_deref() == Some("scoped")
4920 && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
4921 || baseline.files.len() > MAX_PUSH_FILES
4922 || baseline.assets.len() > MAX_PUSH_FILES
4923 || baseline.local_eligibility.len() > MAX_PUSH_FILES
4924 || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
4925 || baseline.files.iter().any(|(path, file)| {
4926 crate::linkmd_v2::normalize_path(path).is_err()
4927 || !is_sha256(&file.sha256)
4928 || file.bytes > MAX_STORE_BYTES
4929 })
4930 || baseline.assets.iter().any(|(path, asset)| {
4931 crate::linkmd_v2::normalize_path(path).is_err()
4932 || !is_sha256(&asset.blob_sha256)
4933 || !is_sha256(&asset.leaf_hash)
4934 || asset.bytes > MAX_STORE_BYTES
4935 || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
4936 || asset.wrappers.is_empty()
4937 || asset
4938 .wrappers
4939 .iter()
4940 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4941 })
4942 || baseline
4943 .local_eligibility
4944 .keys()
4945 .chain(baseline.remote_copy_remains.keys())
4946 .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
4947 || baseline
4948 .remote_copy_remains
4949 .values()
4950 .any(|hash| !is_sha256(hash))
4951 || baseline
4952 .checkout_id
4953 .as_deref()
4954 .is_some_and(|checkout_id| !is_sha256(checkout_id))
4955 {
4956 return Err(invalid_feed("v2 sync baseline failed validation"));
4957 }
4958 Ok(baseline)
4959}
4960
4961#[cfg(unix)]
4962fn load_v2_baseline(
4963 cfg: &HubConfig,
4964 brain: &str,
4965 checkout: &Path,
4966) -> LinkResult<Option<V2SyncBaseline>> {
4967 use std::os::fd::{AsRawFd as _, FromRawFd as _};
4968 let directory = open_trust_dir(cfg)?;
4969 let name_string = v2_baseline_name(cfg, brain, checkout)?;
4970 let _lock = lock_trust_name(&directory, &name_string)?;
4971 let name = c_name(name_string.as_bytes(), &name_string)?;
4972 let fd = unsafe {
4973 libc::openat(
4974 directory.as_raw_fd(),
4975 name.as_ptr(),
4976 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4977 )
4978 };
4979 if fd < 0 {
4980 let error = std::io::Error::last_os_error();
4981 return if error.kind() == std::io::ErrorKind::NotFound {
4982 Ok(None)
4983 } else {
4984 Err(LinkError::UnsafePath { path: name_string })
4985 };
4986 }
4987 let file = unsafe { std::fs::File::from_raw_fd(fd) };
4988 let mut bytes = Vec::new();
4989 file.take(MAX_FEED_RESPONSE_BYTES + 1)
4990 .read_to_end(&mut bytes)?;
4991 if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
4992 return Err(invalid_feed("v2 sync baseline is oversized"));
4993 }
4994 Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
4995}
4996
4997#[cfg(windows)]
4998fn load_v2_baseline(
4999 cfg: &HubConfig,
5000 brain: &str,
5001 checkout: &Path,
5002) -> LinkResult<Option<V2SyncBaseline>> {
5003 let directory = open_trust_dir(cfg)?;
5004 let name = v2_baseline_name(cfg, brain, checkout)?;
5005 let _lock = lock_trust_name(&directory, &name)?;
5006 let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
5007 match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
5008 Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5009 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5010 Err(_) => Err(LinkError::UnsafePath { path: name }),
5011 }
5012}
5013
5014#[cfg(not(any(unix, windows)))]
5015fn load_v2_baseline(
5016 _cfg: &HubConfig,
5017 _brain: &str,
5018 _checkout: &Path,
5019) -> LinkResult<Option<V2SyncBaseline>> {
5020 Err(LinkError::UnsupportedPlatform {
5021 operation: "verified link.md v2 baseline",
5022 })
5023}
5024
5025#[cfg(unix)]
5026fn save_v2_baseline(
5027 cfg: &HubConfig,
5028 brain: &str,
5029 checkout: &Path,
5030 baseline: &V2SyncBaseline,
5031) -> LinkResult<()> {
5032 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5033 let directory = open_trust_dir(cfg)?;
5034 let name_string = v2_baseline_name(cfg, brain, checkout)?;
5035 let _lock = lock_trust_name(&directory, &name_string)?;
5036 let name = c_name(name_string.as_bytes(), &name_string)?;
5037 let mut bytes = serde_json::to_vec(baseline)
5038 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5039 bytes.push(b'\n');
5040 let temp_string = format!(
5041 ".{name_string}.tmp.{}-{}",
5042 std::process::id(),
5043 std::time::SystemTime::now()
5044 .duration_since(std::time::UNIX_EPOCH)
5045 .unwrap_or_default()
5046 .as_nanos()
5047 );
5048 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5049 let fd = unsafe {
5050 libc::openat(
5051 directory.as_raw_fd(),
5052 temp.as_ptr(),
5053 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5054 0o600,
5055 )
5056 };
5057 if fd < 0 {
5058 return Err(std::io::Error::last_os_error().into());
5059 }
5060 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5061 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5062 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5063 return Err(error.into());
5064 }
5065 drop(file);
5066 if unsafe {
5067 libc::renameat(
5068 directory.as_raw_fd(),
5069 temp.as_ptr(),
5070 directory.as_raw_fd(),
5071 name.as_ptr(),
5072 )
5073 } != 0
5074 {
5075 let error = std::io::Error::last_os_error();
5076 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5077 return Err(error.into());
5078 }
5079 directory.sync_all()?;
5080 Ok(())
5081}
5082
5083#[cfg(windows)]
5084fn save_v2_baseline(
5085 cfg: &HubConfig,
5086 brain: &str,
5087 checkout: &Path,
5088 baseline: &V2SyncBaseline,
5089) -> LinkResult<()> {
5090 let directory = open_trust_dir(cfg)?;
5091 let name = v2_baseline_name(cfg, brain, checkout)?;
5092 let _lock = lock_trust_name(&directory, &name)?;
5093 let mut bytes = serde_json::to_vec(baseline)
5094 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5095 bytes.push(b'\n');
5096 crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5097 Ok(())
5098}
5099
5100#[cfg(not(any(unix, windows)))]
5101fn save_v2_baseline(
5102 _cfg: &HubConfig,
5103 _brain: &str,
5104 _checkout: &Path,
5105 _baseline: &V2SyncBaseline,
5106) -> LinkResult<()> {
5107 Err(LinkError::UnsupportedPlatform {
5108 operation: "verified link.md v2 baseline",
5109 })
5110}
5111
5112fn v2_baseline_from_head(
5113 cfg: &HubConfig,
5114 head: &V2VerifiedHead,
5115 files: std::collections::BTreeMap<String, V2BaselineFile>,
5116 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5117 local: Option<&V2LocalView>,
5118 checkout_id: Option<&str>,
5119) -> LinkResult<V2SyncBaseline> {
5120 let mut local_eligibility = local
5121 .map(|view| view.eligibility.clone())
5122 .unwrap_or_default();
5123 if let Some(view) = local {
5124 for path in files.keys() {
5125 local_eligibility
5126 .entry(path.clone())
5127 .or_insert_with(|| !view.policy.keeps_home(path));
5128 }
5129 }
5130 let remote_copy_remains = local_eligibility
5131 .iter()
5132 .filter(|(_, riding)| !**riding)
5133 .filter_map(|(path, _)| {
5134 files
5135 .get(path)
5136 .map(|file| (path.clone(), file.sha256.clone()))
5137 })
5138 .collect();
5139 Ok(V2SyncBaseline {
5140 v: 2,
5141 origin: normalized_origin(&cfg.hub)?,
5142 brain: head.brain_id.clone(),
5143 checkout_id: Some(v2_checkout_id(checkout_id)?),
5144 head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5145 commit_hash: head
5146 .pointer
5147 .as_ref()
5148 .map(|pointer| pointer.commit_hash.clone()),
5149 content_root: head
5150 .pointer
5151 .as_ref()
5152 .and_then(|pointer| pointer.content_root.clone()),
5153 asset_root: head
5154 .pointer
5155 .as_ref()
5156 .and_then(|pointer| pointer.asset_root.clone()),
5157 assets,
5158 view_kind: Some(head.view_kind.clone()),
5159 view_revision: Some(head.view_revision.clone()),
5160 projection_sha256: (head.view_kind == "scoped")
5161 .then(|| scoped_projection_sha256(&head.brain_id)),
5162 files,
5163 local_policy_digest: local.map(|view| view.policy.digest.clone()),
5164 local_eligibility,
5165 remote_copy_remains,
5166 })
5167}
5168
5169fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5170 let policy = crate::linkmd_sync_policy::load(store)
5171 .map_err(|message| LinkError::InvalidPack { message })?;
5172 let asset_paths = crate::assets::read_manifest(store)
5173 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5174 .into_iter()
5175 .map(|asset| asset.path)
5176 .collect::<std::collections::BTreeSet<_>>();
5177 let mut result = std::collections::BTreeMap::new();
5178 let mut eligibility = std::collections::BTreeMap::new();
5179 let mut riding_links = Vec::<(String, Vec<String>)>::new();
5180 let mut total = 0_u64;
5181 let mut paths = vec![PathBuf::from("DB.md")];
5182 paths.extend(store.walk()?);
5183 for relative in paths {
5184 let path = relative.to_string_lossy().replace('\\', "/");
5185 if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5187 continue;
5188 }
5189 if asset_paths.contains(&path) {
5190 continue;
5191 }
5192 crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5193 path: error.to_string(),
5194 })?;
5195 let riding = !policy.keeps_home(&path);
5196 eligibility.insert(path.clone(), riding);
5197 if !riding {
5198 continue;
5199 }
5200 let remaining = MAX_STORE_BYTES.saturating_sub(total);
5201 let bytes = store.read_bounded(&relative, remaining)?;
5202 total = total
5203 .checked_add(bytes.len() as u64)
5204 .ok_or_else(|| LinkError::PushTooLarge {
5205 detail: "v2 local byte count overflow".to_string(),
5206 })?;
5207 if total > MAX_STORE_BYTES {
5208 return Err(LinkError::PushTooLarge {
5209 detail: format!("{total} uncompressed bytes"),
5210 });
5211 }
5212 if std::str::from_utf8(&bytes).is_err() {
5213 return Err(LinkError::NotUtf8 { path });
5214 }
5215 let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5216 riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
5217 result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
5218 }
5219 let kept_home = eligibility
5220 .iter()
5221 .filter(|(_, riding)| !**riding)
5222 .map(|(path, _)| path.clone())
5223 .collect::<std::collections::BTreeSet<_>>();
5224 let mut withheld_links = riding_links
5225 .into_iter()
5226 .flat_map(|(source, targets)| {
5227 let kept_home = &kept_home;
5228 let policy = &policy;
5229 targets.into_iter().filter_map(move |target| {
5230 let target = format!("{target}.md");
5231 (kept_home.contains(&target) || policy.keeps_home(&target)).then_some(
5241 V2WithheldLink {
5242 source: source.clone(),
5243 target,
5244 },
5245 )
5246 })
5247 })
5248 .collect::<Vec<_>>();
5249 withheld_links.sort();
5250 withheld_links.dedup();
5251 Ok(V2LocalView {
5252 riding: result,
5253 eligibility,
5254 policy,
5255 withheld_links,
5256 })
5257}
5258
5259#[derive(Debug, Deserialize)]
5260struct V2DownloadItem {
5261 path: String,
5262 sha256: String,
5263 bytes: u64,
5264 url: String,
5265 method: String,
5266}
5267
5268#[derive(Debug, Deserialize)]
5269struct V2DownloadWindow {
5270 v: u8,
5271 commit: String,
5272 downloads: Vec<V2DownloadItem>,
5273}
5274
5275#[derive(Debug, Deserialize)]
5276struct V2BulkStreamHeader {
5277 v: u8,
5278 path: String,
5279 sha256: String,
5280 bytes: u64,
5281}
5282
5283fn parse_v2_bulk_stream(
5284 bytes: &[u8],
5285 expected: &[(&String, &V2BaselineFile)],
5286) -> LinkResult<Vec<(String, Vec<u8>)>> {
5287 if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5288 return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5289 }
5290 let mut cursor = V2_BULK_STREAM_MAGIC.len();
5291 let mut result = Vec::with_capacity(expected.len());
5292 for (expected_path, expected_file) in expected {
5293 let length_bytes = bytes
5294 .get(cursor..cursor + 4)
5295 .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5296 cursor += 4;
5297 let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5298 if header_len == 0 || header_len > 4 * 1024 {
5299 return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5300 }
5301 let header_bytes = bytes
5302 .get(cursor..cursor + header_len)
5303 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5304 cursor += header_len;
5305 let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5306 .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5307 if header.v != 2
5308 || &header.path != *expected_path
5309 || header.sha256 != expected_file.sha256
5310 || header.bytes != expected_file.bytes
5311 || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5312 {
5313 return Err(invalid_feed(
5314 "v2 bulk stream frame differs from its proven manifest entry",
5315 ));
5316 }
5317 let body_len = usize::try_from(header.bytes)
5318 .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5319 let body = bytes
5320 .get(cursor..cursor + body_len)
5321 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5322 cursor += body_len;
5323 if content_sha256(body) != header.sha256 {
5324 return Err(invalid_feed(
5325 "v2 bulk stream file differs from its proven manifest entry",
5326 ));
5327 }
5328 result.push((header.path, body.to_vec()));
5329 }
5330 if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5331 return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5332 }
5333 cursor += 4;
5334 if cursor != bytes.len() {
5335 return Err(invalid_feed("v2 bulk stream carries trailing data"));
5336 }
5337 Ok(result)
5338}
5339
5340fn download_v2_bulk_stream(
5341 cfg: &HubConfig,
5342 brain: &str,
5343 pointer: &V2PointerBody,
5344 pending: &[(&String, &V2BaselineFile)],
5345) -> LinkResult<Vec<(String, Vec<u8>)>> {
5346 let claims = pending
5347 .iter()
5348 .map(|(path, file)| {
5349 Ok(json!({
5350 "path": path,
5351 "sha256": file.sha256,
5352 "bytes": file.bytes,
5353 "proof": file.proof.as_ref().ok_or_else(|| {
5354 invalid_feed("v2 manifest omitted a bulk-stream proof")
5355 })?,
5356 }))
5357 })
5358 .collect::<LinkResult<Vec<_>>>()?;
5359 let raw = request_raw(
5360 cfg,
5361 "POST",
5362 &format!("/api/hub/brains/{brain}/v2/stream"),
5363 Some(&json!({
5364 "commit": pointer.commit_hash,
5365 "files": claims,
5366 })),
5367 Auth::Required,
5368 V2_BULK_STREAM_RESPONSE_BYTES,
5369 )?;
5370 let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5371 parse_v2_bulk_stream(&body, pending)
5372}
5373
5374fn prepare_v2_downloads(
5375 cfg: &HubConfig,
5376 brain: &str,
5377 pointer: &V2PointerBody,
5378 pending: &[(&String, &V2BaselineFile)],
5379) -> LinkResult<Vec<V2DownloadItem>> {
5380 let mut result = Vec::with_capacity(pending.len());
5381 for chunk in pending.chunks(128) {
5382 let claims = chunk
5383 .iter()
5384 .map(|(path, file)| {
5385 Ok(json!({
5386 "path": path,
5387 "sha256": file.sha256,
5388 "bytes": file.bytes,
5389 "proof": file.proof.as_ref().ok_or_else(|| {
5390 invalid_feed("v2 manifest omitted a download proof")
5391 })?,
5392 }))
5393 })
5394 .collect::<LinkResult<Vec<_>>>()?;
5395 let value = ensure_ok(
5396 request_capped(
5397 cfg,
5398 "POST",
5399 &format!("/api/hub/brains/{brain}/v2/downloads"),
5400 Some(&json!({
5401 "commit": pointer.commit_hash,
5402 "files": claims,
5403 })),
5404 Auth::Required,
5405 MAX_FEED_RESPONSE_BYTES,
5406 )?,
5407 "prepare v2 blob downloads",
5408 )?;
5409 let window: V2DownloadWindow = serde_json::from_value(value)
5410 .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5411 if window.v != 2
5412 || window.commit != pointer.commit_hash
5413 || window.downloads.len() != chunk.len()
5414 {
5415 return Err(invalid_feed(
5416 "v2 download window is not bound to the requested files",
5417 ));
5418 }
5419 let mut by_path = window
5420 .downloads
5421 .into_iter()
5422 .map(|item| (item.path.clone(), item))
5423 .collect::<std::collections::BTreeMap<_, _>>();
5424 if by_path.len() != chunk.len() {
5425 return Err(invalid_feed("v2 download window repeats a path"));
5426 }
5427 for (path, file) in chunk {
5428 let item = by_path
5429 .remove(*path)
5430 .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5431 if item.method != "GET"
5432 || item.sha256 != file.sha256
5433 || item.bytes != file.bytes
5434 || item.url.is_empty()
5435 {
5436 return Err(invalid_feed(
5437 "v2 download capability differs from its proven file",
5438 ));
5439 }
5440 result.push(item);
5441 }
5442 }
5443 Ok(result)
5444}
5445
5446fn prepare_v2_asset_downloads(
5447 cfg: &HubConfig,
5448 brain: &str,
5449 pointer: &V2PointerBody,
5450 pending: &[(&String, &V2BaselineAsset)],
5451) -> LinkResult<Vec<V2DownloadItem>> {
5452 let mut result = Vec::with_capacity(pending.len());
5453 for chunk in pending.chunks(128) {
5454 let claims = chunk
5455 .iter()
5456 .map(|(path, asset)| {
5457 json!({
5458 "path": path,
5459 "sha256": asset.blob_sha256,
5460 "bytes": asset.bytes,
5461 "leaf_hash": asset.leaf_hash,
5462 })
5463 })
5464 .collect::<Vec<_>>();
5465 let value = ensure_ok(
5466 request_capped(
5467 cfg,
5468 "POST",
5469 &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5470 Some(&json!({
5471 "commit": pointer.commit_hash,
5472 "assets": claims,
5473 })),
5474 Auth::Required,
5475 MAX_FEED_RESPONSE_BYTES,
5476 )?,
5477 "prepare v2 asset downloads",
5478 )?;
5479 let window: V2DownloadWindow = serde_json::from_value(value)
5480 .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5481 if window.v != 2
5482 || window.commit != pointer.commit_hash
5483 || window.downloads.len() != chunk.len()
5484 {
5485 return Err(invalid_feed(
5486 "v2 asset download window is not bound to the requested assets",
5487 ));
5488 }
5489 let mut by_path = window
5490 .downloads
5491 .into_iter()
5492 .map(|item| (item.path.clone(), item))
5493 .collect::<std::collections::BTreeMap<_, _>>();
5494 if by_path.len() != chunk.len() {
5495 return Err(invalid_feed("v2 asset download window repeats a path"));
5496 }
5497 for (path, asset) in chunk {
5498 let item = by_path
5499 .remove(*path)
5500 .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5501 if item.method != "GET"
5502 || item.sha256 != asset.blob_sha256
5503 || item.bytes != asset.bytes
5504 || item.url.is_empty()
5505 {
5506 return Err(invalid_feed(
5507 "v2 asset download capability differs from its signed leaf",
5508 ));
5509 }
5510 result.push(item);
5511 }
5512 }
5513 Ok(result)
5514}
5515
5516fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5517 let bytes = get_presigned(cfg, &item.url)?;
5518 if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5519 return Err(invalid_feed("v2 blob differs from its proven path entry"));
5520 }
5521 Ok(bytes)
5522}
5523
5524#[derive(Debug, Clone)]
5525struct V2StagedFile {
5526 path: String,
5527 source: PathBuf,
5528 sha256: String,
5529 bytes: u64,
5530}
5531
5532#[cfg(unix)]
5533fn v2_download_cache_dir(
5534 cfg: &HubConfig,
5535 brain: &str,
5536 pointer: &V2PointerBody,
5537) -> LinkResult<PathBuf> {
5538 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5539}
5540
5541#[cfg(unix)]
5542fn v2_download_cache_dir_for(
5543 cfg: &HubConfig,
5544 brain: &str,
5545 transaction: &str,
5546) -> LinkResult<PathBuf> {
5547 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5548 return Err(invalid_feed("v2 download cache address is invalid"));
5549 }
5550 let path = cfg
5551 .state_dir
5552 .join("downloads")
5553 .join(brain)
5554 .join(transaction);
5555 let directory = open_or_create_dir_nofollow(&path)?;
5556 use std::os::fd::AsRawFd as _;
5557 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5558 return Err(std::io::Error::last_os_error().into());
5559 }
5560 directory.sync_all()?;
5561 Ok(path)
5562}
5563
5564#[cfg(windows)]
5565fn v2_download_cache_dir(
5566 cfg: &HubConfig,
5567 brain: &str,
5568 pointer: &V2PointerBody,
5569) -> LinkResult<PathBuf> {
5570 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5571}
5572
5573#[cfg(windows)]
5574fn v2_download_cache_dir_for(
5575 cfg: &HubConfig,
5576 brain: &str,
5577 transaction: &str,
5578) -> LinkResult<PathBuf> {
5579 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5580 return Err(invalid_feed("v2 download cache address is invalid"));
5581 }
5582 let path = cfg
5583 .state_dir
5584 .join("downloads")
5585 .join(brain)
5586 .join(transaction);
5587 crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5588 crate::fsx::open_directory_nofollow(&path)?;
5589 Ok(path)
5590}
5591
5592#[cfg(unix)]
5593fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5594 use std::os::fd::AsRawFd as _;
5595 let parent = cfg.state_dir.join("downloads").join(brain);
5596 let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5597 return;
5598 };
5599 let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5600 return;
5601 };
5602 let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5603 let _ = directory.sync_all();
5604}
5605
5606#[cfg(windows)]
5607fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5608 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5609 return;
5610 }
5611 let parent = cfg.state_dir.join("downloads").join(brain);
5612 let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5613 return;
5614 };
5615 let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5616}
5617
5618#[cfg(not(any(unix, windows)))]
5619fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5620
5621#[cfg(not(any(unix, windows)))]
5622fn v2_download_cache_dir_for(
5623 _cfg: &HubConfig,
5624 _brain: &str,
5625 _transaction: &str,
5626) -> LinkResult<PathBuf> {
5627 Err(LinkError::UnsupportedPlatform {
5628 operation: "resumable v2 download staging",
5629 })
5630}
5631
5632#[cfg(any(unix, windows))]
5633fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5634 let file = match crate::fsx::open_regular_nofollow(path) {
5635 Ok(file) => file,
5636 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5637 Err(error) => return Err(error.into()),
5638 };
5639 if file.metadata()?.len() != bytes {
5640 return Ok(false);
5641 }
5642 Ok(content_sha256_reader(file)? == sha256)
5643}
5644
5645#[cfg(any(unix, windows))]
5646fn cache_v2_blob_bytes(
5647 cache_dir: &Path,
5648 sha256: &str,
5649 expected_bytes: u64,
5650 bytes: &[u8],
5651) -> LinkResult<PathBuf> {
5652 if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5653 return Err(invalid_feed("v2 cached blob differs from its declaration"));
5654 }
5655 let path = cache_dir.join(sha256);
5656 if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5657 crate::fsx::write_atomic(&path, bytes)?;
5658 }
5659 Ok(path)
5660}
5661
5662#[cfg(not(any(unix, windows)))]
5663fn cache_v2_blob_bytes(
5664 _cache_dir: &Path,
5665 _sha256: &str,
5666 _expected_bytes: u64,
5667 _bytes: &[u8],
5668) -> LinkResult<PathBuf> {
5669 Err(LinkError::UnsupportedPlatform {
5670 operation: "resumable v2 download staging",
5671 })
5672}
5673
5674#[cfg(unix)]
5675fn download_presigned_to_cache(
5676 cfg: &HubConfig,
5677 url: &str,
5678 cache_dir: &Path,
5679 sha256: &str,
5680 expected_bytes: u64,
5681) -> LinkResult<PathBuf> {
5682 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5683
5684 let target = cache_dir.join(sha256);
5685 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5686 return Ok(target);
5687 }
5688 let directory = open_existing_dir_nofollow(cache_dir)?;
5689 let mut nonce = [0_u8; 16];
5690 ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5691 .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5692 let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5693 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5694 let fd = unsafe {
5695 libc::openat(
5696 directory.as_raw_fd(),
5697 temp.as_ptr(),
5698 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5699 0o600,
5700 )
5701 };
5702 if fd < 0 {
5703 return Err(std::io::Error::last_os_error().into());
5704 }
5705 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5706 let response = match presigned_agent(cfg, url)?.get(url).call() {
5707 Ok(response) => response,
5708 Err(ureq::Error::Status(_, response)) => {
5709 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5710 return Err(LinkError::Http {
5711 what: "v2 direct download",
5712 status: response.status(),
5713 message: "object store rejected the download".to_string(),
5714 code: None,
5715 details: None,
5716 });
5717 }
5718 Err(ureq::Error::Transport(error)) => {
5719 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5720 return Err(LinkError::Transport {
5721 hub: cfg.hub.clone(),
5722 message: error.to_string(),
5723 });
5724 }
5725 };
5726 let mut reader = response
5727 .into_reader()
5728 .take(expected_bytes.saturating_add(1));
5729 let mut digest = Sha256::new();
5730 let mut total = 0_u64;
5731 let mut buffer = [0_u8; 64 * 1024];
5732 let write_result = (|| -> LinkResult<()> {
5737 loop {
5738 let read = reader
5739 .read(&mut buffer)
5740 .map_err(|error| LinkError::Transport {
5741 hub: cfg.hub.clone(),
5742 message: error.to_string(),
5743 })?;
5744 if read == 0 {
5745 break;
5746 }
5747 total = total.saturating_add(read as u64);
5748 digest.update(&buffer[..read]);
5749 output.write_all(&buffer[..read])?;
5750 }
5751 output.sync_all().map_err(LinkError::from)
5752 })();
5753 if let Err(error) = write_result {
5754 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5755 return Err(error);
5756 }
5757 drop(output);
5758 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5759 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5760 return Err(invalid_feed(
5761 "v2 direct download failed integrity verification",
5762 ));
5763 }
5764 let target_name = c_name(sha256.as_bytes(), sha256)?;
5765 if unsafe {
5768 libc::renameat(
5769 directory.as_raw_fd(),
5770 temp.as_ptr(),
5771 directory.as_raw_fd(),
5772 target_name.as_ptr(),
5773 )
5774 } != 0
5775 {
5776 let error = std::io::Error::last_os_error();
5777 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5778 return Err(error.into());
5779 }
5780 directory.sync_all()?;
5781 Ok(target)
5782}
5783
5784#[cfg(windows)]
5785fn download_presigned_to_cache(
5786 cfg: &HubConfig,
5787 url: &str,
5788 cache_dir: &Path,
5789 sha256: &str,
5790 expected_bytes: u64,
5791) -> LinkResult<PathBuf> {
5792 use std::fs::OpenOptions;
5793
5794 let target = cache_dir.join(sha256);
5795 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5796 return Ok(target);
5797 }
5798 let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
5802 let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
5803 let mut output = OpenOptions::new()
5804 .write(true)
5805 .create_new(true)
5806 .open(&temp)?;
5807 let response = match presigned_agent(cfg, url)?.get(url).call() {
5808 Ok(response) => response,
5809 Err(ureq::Error::Status(_, response)) => {
5810 let _ = std::fs::remove_file(&temp);
5811 return Err(LinkError::Http {
5812 what: "v2 direct download",
5813 status: response.status(),
5814 message: "object store rejected the download".to_string(),
5815 code: None,
5816 details: None,
5817 });
5818 }
5819 Err(ureq::Error::Transport(error)) => {
5820 let _ = std::fs::remove_file(&temp);
5821 return Err(LinkError::Transport {
5822 hub: cfg.hub.clone(),
5823 message: error.to_string(),
5824 });
5825 }
5826 };
5827 let mut reader = response
5828 .into_reader()
5829 .take(expected_bytes.saturating_add(1));
5830 let mut digest = Sha256::new();
5831 let mut total = 0_u64;
5832 let mut buffer = [0_u8; 64 * 1024];
5833 let copied = (|| -> LinkResult<()> {
5835 loop {
5836 let read = reader
5837 .read(&mut buffer)
5838 .map_err(|error| LinkError::Transport {
5839 hub: cfg.hub.clone(),
5840 message: error.to_string(),
5841 })?;
5842 if read == 0 {
5843 break;
5844 }
5845 total = total.saturating_add(read as u64);
5846 digest.update(&buffer[..read]);
5847 output.write_all(&buffer[..read])?;
5848 }
5849 output.sync_all()?;
5850 Ok(())
5851 })();
5852 if let Err(error) = copied {
5853 let _ = std::fs::remove_file(&temp);
5854 return Err(error);
5855 }
5856 drop(output);
5857 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5858 let _ = std::fs::remove_file(&temp);
5859 return Err(invalid_feed(
5860 "v2 direct download failed integrity verification",
5861 ));
5862 }
5863 if target.exists() {
5864 std::fs::remove_file(&target)?;
5865 }
5866 if let Err(error) = std::fs::rename(&temp, &target) {
5867 let _ = std::fs::remove_file(&temp);
5868 return Err(error.into());
5869 }
5870 Ok(target)
5871}
5872
5873#[cfg(not(any(unix, windows)))]
5874fn download_presigned_to_cache(
5875 _cfg: &HubConfig,
5876 _url: &str,
5877 _cache_dir: &Path,
5878 _sha256: &str,
5879 _expected_bytes: u64,
5880) -> LinkResult<PathBuf> {
5881 Err(LinkError::UnsupportedPlatform {
5882 operation: "resumable v2 download staging",
5883 })
5884}
5885
5886fn download_v2_blobs(
5887 cfg: &HubConfig,
5888 brain: &str,
5889 pointer: &V2PointerBody,
5890 pending: Vec<(&String, &V2BaselineFile)>,
5891) -> LinkResult<Vec<(String, Vec<u8>)>> {
5892 if pending.is_empty() {
5893 return Ok(Vec::new());
5894 }
5895 let expected_order = pending
5896 .iter()
5897 .map(|(path, _)| (*path).clone())
5898 .collect::<Vec<_>>();
5899 let mut streamed = std::collections::BTreeMap::new();
5900 let mut direct = Vec::new();
5901 let mut window = Vec::new();
5902 let mut window_bytes = 0_u64;
5903 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5904 window_bytes: &mut u64,
5905 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
5906 -> LinkResult<()> {
5907 if window.is_empty() {
5908 return Ok(());
5909 }
5910 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
5911 if streamed.insert(path, bytes).is_some() {
5912 return Err(invalid_feed("v2 bulk streams repeated a path"));
5913 }
5914 }
5915 window.clear();
5916 *window_bytes = 0;
5917 Ok(())
5918 };
5919 for &(path, file) in &pending {
5920 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5921 flush(&mut window, &mut window_bytes, &mut streamed)?;
5922 direct.push((path, file));
5923 continue;
5924 }
5925 if window.len() == V2_BULK_STREAM_FILES
5926 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5927 {
5928 flush(&mut window, &mut window_bytes, &mut streamed)?;
5929 }
5930 window.push((path, file));
5931 window_bytes += file.bytes;
5932 }
5933 flush(&mut window, &mut window_bytes, &mut streamed)?;
5934
5935 let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
5936 let next = std::sync::atomic::AtomicUsize::new(0);
5937 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5938 let mut results = std::iter::repeat_with(|| None)
5939 .take(downloads.len())
5940 .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
5941 std::thread::scope(|scope| {
5942 let (sender, receiver) = std::sync::mpsc::channel();
5943 for _ in 0..worker_count {
5944 let sender = sender.clone();
5945 let downloads = &downloads;
5946 let next = &next;
5947 scope.spawn(move || loop {
5948 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5949 let Some(item) = downloads.get(index) else {
5950 break;
5951 };
5952 let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
5953 if sender.send((index, result)).is_err() {
5954 break;
5955 }
5956 });
5957 }
5958 drop(sender);
5959 for (index, result) in receiver {
5960 results[index] = Some(result);
5961 }
5962 });
5963 for result in results.into_iter().map(|result| {
5964 result.ok_or_else(|| LinkError::Transport {
5965 hub: cfg.hub.clone(),
5966 message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
5967 })?
5968 }) {
5969 let (path, bytes) = result?;
5970 if streamed.insert(path, bytes).is_some() {
5971 return Err(invalid_feed("v2 download lanes repeated a path"));
5972 }
5973 }
5974 expected_order
5975 .into_iter()
5976 .map(|path| {
5977 streamed
5978 .remove(&path)
5979 .map(|bytes| (path, bytes))
5980 .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
5981 })
5982 .collect()
5983}
5984
5985#[cfg(any(unix, windows))]
5989fn stage_v2_blobs(
5990 cfg: &HubConfig,
5991 brain: &str,
5992 pointer: &V2PointerBody,
5993 pending: Vec<(&String, &V2BaselineFile)>,
5994) -> LinkResult<Vec<V2StagedFile>> {
5995 let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
5996 let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
5997 let mut direct = Vec::new();
5998 let mut window = Vec::new();
5999 let mut window_bytes = 0_u64;
6000 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6001 window_bytes: &mut u64,
6002 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
6003 -> LinkResult<()> {
6004 if window.is_empty() {
6005 return Ok(());
6006 }
6007 let missing = window
6008 .iter()
6009 .filter_map(|(path, file)| {
6010 let target = cache_dir.join(&file.sha256);
6011 match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6012 Ok(true) => {
6013 staged.insert(
6014 (*path).clone(),
6015 V2StagedFile {
6016 path: (*path).clone(),
6017 source: target,
6018 sha256: file.sha256.clone(),
6019 bytes: file.bytes,
6020 },
6021 );
6022 None
6023 }
6024 Ok(false) => Some(Ok((*path, *file))),
6025 Err(error) => Some(Err(error)),
6026 }
6027 })
6028 .collect::<LinkResult<Vec<_>>>()?;
6029 if !missing.is_empty() {
6030 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
6031 let file = missing
6032 .iter()
6033 .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
6034 .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
6035 let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
6036 staged.insert(
6037 path.clone(),
6038 V2StagedFile {
6039 path,
6040 source,
6041 sha256: file.sha256.clone(),
6042 bytes: file.bytes,
6043 },
6044 );
6045 }
6046 }
6047 window.clear();
6048 *window_bytes = 0;
6049 Ok(())
6050 };
6051 for &(path, file) in &pending {
6052 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6053 flush(&mut window, &mut window_bytes, &mut staged)?;
6054 direct.push((path, file));
6055 continue;
6056 }
6057 if window.len() == V2_BULK_STREAM_FILES
6058 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6059 {
6060 flush(&mut window, &mut window_bytes, &mut staged)?;
6061 }
6062 window.push((path, file));
6063 window_bytes += file.bytes;
6064 }
6065 flush(&mut window, &mut window_bytes, &mut staged)?;
6066 for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6067 let source =
6068 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6069 staged.insert(
6070 item.path.clone(),
6071 V2StagedFile {
6072 path: item.path,
6073 source,
6074 sha256: item.sha256,
6075 bytes: item.bytes,
6076 },
6077 );
6078 }
6079 pending
6080 .into_iter()
6081 .map(|(path, _)| {
6082 staged
6083 .remove(path)
6084 .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6085 })
6086 .collect()
6087}
6088
6089#[cfg(not(any(unix, windows)))]
6090fn stage_v2_blobs(
6091 _cfg: &HubConfig,
6092 _brain: &str,
6093 _pointer: &V2PointerBody,
6094 _pending: Vec<(&String, &V2BaselineFile)>,
6095) -> LinkResult<Vec<V2StagedFile>> {
6096 Err(LinkError::UnsupportedPlatform {
6097 operation: "resumable v2 download staging",
6098 })
6099}
6100
6101const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6102const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6103const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6104
6105#[derive(Debug, Clone, Deserialize, Serialize)]
6106struct V2ConflictCoordinate {
6107 sha256: Option<String>,
6108 bytes: Option<u64>,
6109 file: Option<String>,
6110}
6111
6112#[derive(Debug, Clone, Deserialize, Serialize)]
6113struct V2ConflictFile {
6114 path: String,
6115 base: V2ConflictCoordinate,
6116 local: V2ConflictCoordinate,
6117 remote: V2ConflictCoordinate,
6118}
6119
6120#[derive(Debug, Clone, Deserialize, Serialize)]
6121struct V2ConflictPlan {
6122 v: u8,
6123 class: String,
6124 bundle: String,
6125 brain: String,
6126 origin: String,
6127 created_unix: u64,
6128 expires_unix: u64,
6129 base_seq: Option<u64>,
6130 base_commit: Option<String>,
6131 remote_seq: u64,
6132 remote_commit: Option<String>,
6133 remote_content_root: Option<String>,
6134 view_kind: String,
6135 view_revision: String,
6136 files: Vec<V2ConflictFile>,
6137}
6138
6139fn v2_take_remote_selection(
6140 files: &[V2ConflictFile],
6141 current: &std::collections::BTreeMap<String, V2BaselineFile>,
6142) -> LinkResult<(
6143 std::collections::BTreeMap<String, V2BaselineFile>,
6144 Vec<String>,
6145)> {
6146 let mut selected = std::collections::BTreeMap::new();
6147 let mut deleted = Vec::new();
6148 for file in files {
6149 match (&file.remote.sha256, file.remote.bytes) {
6150 (Some(sha256), Some(bytes)) => {
6151 let proven = current.get(&file.path).ok_or_else(|| {
6152 invalid_feed("conflict remote coordinate disappeared from the exact head")
6153 })?;
6154 if proven.sha256 != *sha256 || proven.bytes != bytes {
6155 return Err(invalid_feed(
6156 "conflict remote coordinate differs from the exact head",
6157 ));
6158 }
6159 if selected.insert(file.path.clone(), proven.clone()).is_some() {
6160 return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6161 }
6162 }
6163 (None, None) => {
6164 if current.contains_key(&file.path) {
6165 return Err(invalid_feed(
6166 "conflict remote deletion differs from the exact head",
6167 ));
6168 }
6169 deleted.push(file.path.clone());
6170 }
6171 _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6172 }
6173 }
6174 Ok((selected, deleted))
6175}
6176
6177fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6178 PathBuf::from(".dbmd")
6179 .join("conflicts")
6180 .join(bundle)
6181 .join(suffix)
6182}
6183
6184fn read_historical_conflict_blob(
6185 cfg: &HubConfig,
6186 brain: &str,
6187 baseline: &V2SyncBaseline,
6188 path: &str,
6189 file: &V2BaselineFile,
6190) -> LinkResult<Option<Vec<u8>>> {
6191 let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6192 return Ok(None);
6193 };
6194 if seq == 0 {
6195 return Ok(None);
6196 }
6197 let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6198 let endpoint = format!(
6199 "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6200 file.sha256
6201 );
6202 let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
6203 if raw.status == 404 || raw.status == 403 {
6204 return Ok(None);
6205 }
6206 let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6207 if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6208 return Err(invalid_feed(
6209 "v2 conflict base failed integrity verification",
6210 ));
6211 }
6212 Ok(Some(bytes))
6213}
6214
6215fn create_v2_conflict_bundle(
6218 cfg: &HubConfig,
6219 store: &Store,
6220 head: &V2VerifiedHead,
6221 baseline: Option<&V2SyncBaseline>,
6222 local: &std::collections::BTreeMap<String, (String, u64)>,
6223 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6224 paths: &[String],
6225) -> LinkResult<(String, Vec<String>)> {
6226 let conflicts_root = Path::new(".dbmd/conflicts");
6227 store.create_dir_all(conflicts_root)?;
6228 let completed = store
6229 .directory_names(conflicts_root)?
6230 .into_iter()
6231 .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6232 .count();
6233 if completed >= V2_CONFLICT_BUNDLE_MAX {
6234 return Err(LinkError::InvalidPack {
6235 message: format!(
6236 "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6237 ),
6238 });
6239 }
6240
6241 let mut selected_paths = Vec::new();
6245 let mut selected_remote_bytes = 0_u64;
6246 for path in paths {
6247 let bytes = remote.get(path).map_or(0, |file| file.bytes);
6248 if !selected_paths.is_empty()
6249 && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6250 {
6251 break;
6252 }
6253 selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6254 selected_paths.push(path.clone());
6255 if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6256 break;
6257 }
6258 }
6259 if selected_paths.is_empty() {
6260 return Err(invalid_feed("content conflict set is empty"));
6261 }
6262 let bundle = crate::ulid::mint();
6263 let bundle_root = v2_conflict_relative(&bundle, "");
6264 store.create_dir_all(&bundle_root.join("files"))?;
6265 let pointer = head.pointer.as_ref();
6266 let remote_bytes = match pointer {
6267 Some(pointer) => download_v2_blobs(
6268 cfg,
6269 &head.brain_id,
6270 pointer,
6271 selected_paths
6272 .iter()
6273 .filter_map(|path| {
6274 remote
6275 .get(path)
6276 .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6277 .map(|file| (path, file))
6278 })
6279 .collect(),
6280 )?
6281 .into_iter()
6282 .collect::<std::collections::BTreeMap<_, _>>(),
6283 None => std::collections::BTreeMap::new(),
6284 };
6285
6286 let mut files = Vec::with_capacity(selected_paths.len());
6287 for (index, path) in selected_paths.iter().enumerate() {
6288 let base_file = baseline.and_then(|state| state.files.get(path));
6289 let base_bytes = match (baseline, base_file) {
6290 (Some(state), Some(file)) => {
6291 read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
6292 }
6293 _ => None,
6294 };
6295 let local_file = local.get(path);
6296 let remote_file = remote.get(path);
6297 let remote_content = remote_bytes.get(path);
6298 let prefix = format!("files/{index:04}");
6299 let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6300 let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6301 let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6302 if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6303 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6304 }
6305 if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6306 let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6307 if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6308 return Err(LinkError::InvalidPack {
6309 message: format!("local conflict path `{path}` changed while bundling"),
6310 });
6311 }
6312 store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6313 }
6314 if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6315 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6316 }
6317 files.push(V2ConflictFile {
6318 path: path.clone(),
6319 base: V2ConflictCoordinate {
6320 sha256: base_file.map(|file| file.sha256.clone()),
6321 bytes: base_file.map(|file| file.bytes),
6322 file: base_name,
6323 },
6324 local: V2ConflictCoordinate {
6325 sha256: local_file.map(|(sha256, _)| sha256.clone()),
6326 bytes: local_file.map(|(_, bytes)| *bytes),
6327 file: local_name,
6328 },
6329 remote: V2ConflictCoordinate {
6330 sha256: remote_file.map(|file| file.sha256.clone()),
6331 bytes: remote_file.map(|file| file.bytes),
6332 file: remote_name,
6333 },
6334 });
6335 }
6336 let now = SystemTime::now()
6337 .duration_since(UNIX_EPOCH)
6338 .unwrap_or_default()
6339 .as_secs();
6340 let plan = V2ConflictPlan {
6341 v: 2,
6342 class: "content_resolution_required".to_string(),
6343 bundle: bundle.clone(),
6344 brain: head.brain_id.clone(),
6345 origin: normalized_origin(&cfg.hub)?,
6346 created_unix: now,
6347 expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6348 base_seq: baseline.and_then(|state| state.head_seq),
6349 base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6350 remote_seq: pointer.map_or(0, |value| value.seq),
6351 remote_commit: pointer.map(|value| value.commit_hash.clone()),
6352 remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6353 view_kind: head.view_kind.clone(),
6354 view_revision: head.view_revision.clone(),
6355 files,
6356 };
6357 let mut bytes = serde_json::to_vec_pretty(&plan)
6358 .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6359 bytes.push(b'\n');
6360 store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6361 Ok((bundle, selected_paths))
6362}
6363
6364fn v2_sync_pull_with_resolution(
6365 cfg: &HubConfig,
6366 requested_brain: &str,
6367 expected_head: V2VerifiedHead,
6368 out: Option<&Path>,
6369 take_remote: Option<&std::collections::BTreeSet<String>>,
6370) -> LinkResult<V2PulledSnapshot> {
6371 let dest = out
6372 .map(Path::to_path_buf)
6373 .unwrap_or_else(|| PathBuf::from(requested_brain));
6374 let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6375 recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6376 let head = v2_verified_head(cfg, requested_brain)?
6377 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6378 if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6379 return Err(LinkError::RemoteAdvancedDuringSync);
6380 }
6381 let remote = files_for_v2_view(
6382 &head,
6383 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6384 );
6385 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
6386 let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6387 ensure_v2_view_compatible(&head, baseline.as_ref())?;
6388 let local_store = Store::open_strict(&dest).ok();
6389 ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6394 let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6395 if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6396 return Err(LinkError::ScopedViewChanged);
6397 }
6398 if let Some(view) = local_view.as_mut() {
6399 remove_scoped_projection(&head, baseline.as_ref(), view)?;
6400 }
6401 let empty_local = std::collections::BTreeMap::new();
6402 let local = local_view
6403 .as_ref()
6404 .map_or(&empty_local, |view| &view.riding);
6405 let kept_home = |path: &str| {
6406 local_view
6407 .as_ref()
6408 .is_some_and(|view| view.policy.keeps_home(path))
6409 };
6410 let empty_base = std::collections::BTreeMap::new();
6411 let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6412 let empty_base_assets = std::collections::BTreeMap::new();
6413 let base_assets = baseline
6414 .as_ref()
6415 .map_or(&empty_base_assets, |state| &state.assets);
6416 let mut local_assets = local_store
6417 .as_ref()
6418 .map(v2_local_asset_records)
6419 .transpose()?
6420 .unwrap_or_default();
6421 let mut content_merge = merge_v2_pulled_records(
6422 base,
6423 &remote,
6424 local,
6425 |file, _| (file.sha256.clone(), file.bytes),
6426 |file, _| (file.sha256.clone(), file.bytes),
6427 kept_home,
6428 );
6429 if let Some(selected) = take_remote {
6430 for path in selected {
6431 if let Some(position) = content_merge
6432 .conflicts
6433 .iter()
6434 .position(|conflict| conflict == path)
6435 {
6436 content_merge.conflicts.remove(position);
6437 content_merge.accept_remote.insert(path.clone());
6438 match remote.get(path) {
6439 Some(file) => {
6440 content_merge
6441 .records
6442 .insert(path.clone(), (file.sha256.clone(), file.bytes));
6443 }
6444 None => {
6445 content_merge.records.remove(path);
6446 }
6447 }
6448 } else if !content_merge.accept_remote.contains(path) {
6449 return Err(LinkError::InvalidPack {
6450 message: format!(
6451 "take-remote path `{path}` is no longer at its conflict coordinate"
6452 ),
6453 });
6454 }
6455 }
6456 }
6457 if !content_merge.conflicts.is_empty() {
6458 let mut conflicts = content_merge.conflicts.clone();
6459 conflicts.truncate(100);
6460 if let Some(store) = local_store.as_ref() {
6461 let (bundle, paths) = create_v2_conflict_bundle(
6462 cfg,
6463 store,
6464 &head,
6465 baseline.as_ref(),
6466 local,
6467 &remote,
6468 &conflicts,
6469 )?;
6470 return Err(LinkError::ConflictBundle { bundle, paths });
6471 }
6472 return Err(LinkError::Conflict { paths: conflicts });
6473 }
6474 let asset_merge = merge_v2_pulled_records(
6475 base_assets,
6476 &remote_assets,
6477 &local_assets,
6478 v2_asset_record,
6479 v2_asset_record,
6480 |_| false,
6481 );
6482 if !asset_merge.conflicts.is_empty() {
6483 let mut conflicts = asset_merge.conflicts.clone();
6484 conflicts.truncate(100);
6485 return Err(LinkError::Conflict { paths: conflicts });
6486 }
6487 let pointer = head.pointer.as_ref();
6488 let cache_transaction = pointer.map_or_else(
6489 || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6490 |value| value.commit_hash.clone(),
6491 );
6492 let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6493 let mut changed = match pointer {
6494 Some(pointer) => stage_v2_blobs(
6495 cfg,
6496 &head.brain_id,
6497 pointer,
6498 remote
6499 .iter()
6500 .filter(|(path, file)| {
6501 content_merge.accept_remote.contains(*path)
6502 && local.get(*path).map(|value| value.0.as_str())
6503 != Some(file.sha256.as_str())
6504 })
6505 .collect(),
6506 )?,
6507 None => Vec::new(),
6508 };
6509 let mut deleted = content_merge
6510 .accept_remote
6511 .iter()
6512 .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6513 .cloned()
6514 .collect::<Vec<_>>();
6515 if local_assets != asset_merge.records {
6516 if asset_merge.records.is_empty() {
6517 deleted.push("assets.jsonl".to_string());
6518 } else {
6519 let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6520 let sha256 = content_sha256(&bytes);
6521 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6522 changed.push(V2StagedFile {
6523 path: "assets.jsonl".to_string(),
6524 source,
6525 sha256,
6526 bytes: bytes.len() as u64,
6527 });
6528 }
6529 }
6530 if let Some(pointer) = pointer {
6531 let mut pending_assets = Vec::new();
6532 for (path, asset) in &remote_assets {
6533 if asset.disposition != "hosted"
6534 || kept_home(path)
6535 || !asset_merge.accept_remote.contains(path)
6536 {
6537 continue;
6538 }
6539 let already_current = local_store.as_ref().is_some_and(|store| {
6540 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6541 && store
6542 .read_bounded(Path::new(path), asset.bytes)
6543 .ok()
6544 .is_some_and(|bytes| {
6545 bytes.len() as u64 == asset.bytes
6546 && content_sha256(&bytes) == asset.blob_sha256
6547 })
6548 });
6549 if !already_current {
6550 pending_assets.push((path, asset));
6551 }
6552 }
6553 for item in prepare_v2_asset_downloads(cfg, &head.brain_id, pointer, &pending_assets)? {
6554 let source =
6555 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6556 changed.push(V2StagedFile {
6557 path: item.path,
6558 source,
6559 sha256: item.sha256,
6560 bytes: item.bytes,
6561 });
6562 }
6563 }
6564 for (path, prior) in base_assets {
6565 if remote_assets.contains_key(path)
6566 || kept_home(path)
6567 || !asset_merge.accept_remote.contains(path)
6568 {
6569 continue;
6570 }
6571 let unchanged = local_store.as_ref().is_some_and(|store| {
6572 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6573 && store
6574 .read_bounded(Path::new(path), prior.bytes)
6575 .ok()
6576 .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6577 });
6578 if unchanged {
6579 deleted.push(path.clone());
6580 }
6581 }
6582 let extra_local = content_merge
6583 .records
6584 .keys()
6585 .filter(|path| !remote.contains_key(*path))
6586 .cloned()
6587 .collect::<Vec<_>>();
6588 if head.view_kind == "scoped" {
6589 for (path, bytes) in [
6590 ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6591 (
6592 ".dbmd/view.json".to_string(),
6593 scoped_view_metadata(&head, remote.len())?,
6594 ),
6595 ] {
6596 let sha256 = content_sha256(&bytes);
6597 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6598 changed.push(V2StagedFile {
6599 path,
6600 source,
6601 sha256,
6602 bytes: bytes.len() as u64,
6603 });
6604 }
6605 }
6606 let install_changed = !changed.is_empty() || !deleted.is_empty();
6607 install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6608 let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6609 let installed_store =
6610 Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6611 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6612 })?;
6613 let installed_local = if install_changed {
6614 let mut scanned = v2_local_files(&installed_store)?;
6615 remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6616 scanned
6617 } else {
6618 local_view
6619 .take()
6620 .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6621 };
6622 if installed_local.riding != content_merge.records {
6623 return Err(LinkError::InvalidPack {
6624 message: "local content changed while installing the v2 pull".to_string(),
6625 });
6626 }
6627 let installed_assets = if install_changed {
6628 v2_local_asset_records(&installed_store)?
6629 } else {
6630 std::mem::take(&mut local_assets)
6631 };
6632 if installed_assets != asset_merge.records {
6633 return Err(LinkError::InvalidPack {
6634 message: "local assets changed while installing the v2 pull".to_string(),
6635 });
6636 }
6637 let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6638 installed_local.policy.keeps_home(path)
6639 })
6640 || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6641 let final_head = v2_verified_head(cfg, requested_brain)?
6642 .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6643 if !same_v2_head(&head, &final_head) {
6644 return Err(LinkError::RemoteAdvancedDuringSync);
6645 }
6646 accept_v2_head(cfg, &final_head)?;
6647 save_v2_baseline(
6648 cfg,
6649 &head.brain_id,
6650 &dest,
6651 &v2_baseline_from_head(
6652 cfg,
6653 &head,
6654 remote.clone(),
6655 remote_assets.clone(),
6656 Some(&installed_local),
6657 baseline
6658 .as_ref()
6659 .and_then(|current| current.checkout_id.as_deref()),
6660 )?,
6661 )?;
6662 complete_v2_pull(&dest)?;
6663 Ok((local_dirty, installed_local, installed_assets))
6664 })();
6665 let (local_dirty, installed_local, installed_assets) = match finalized {
6666 Ok(value) => value,
6667 Err(error) => {
6668 if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6669 return Err(LinkError::InvalidPack {
6670 message: format!("{error}; durable pull recovery also failed: {recovery}"),
6671 });
6672 }
6673 return Err(error);
6674 }
6675 };
6676 cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6677 let report = PullReport {
6678 brain: head.brain_id.clone(),
6679 slug: requested_brain.to_string(),
6680 head_seq: pointer.map_or(0, |value| value.seq),
6681 files: remote.len() + remote_assets.len(),
6682 dest: dest.to_string_lossy().into_owned(),
6683 extra_local,
6684 sync_status: if local_dirty {
6685 "local_dirty_after_install".to_string()
6686 } else {
6687 "synced".to_string()
6688 },
6689 };
6690 Ok(V2PulledSnapshot {
6691 report,
6692 head,
6693 files: remote,
6694 assets: remote_assets,
6695 local: installed_local,
6696 local_assets: installed_assets,
6697 })
6698}
6699
6700fn v2_sync_pull(
6701 cfg: &HubConfig,
6702 requested_brain: &str,
6703 head: V2VerifiedHead,
6704 out: Option<&Path>,
6705) -> LinkResult<PullReport> {
6706 Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
6707}
6708
6709fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
6710 match remote {
6711 Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
6712 None => json!({ "kind": "absent" }),
6713 }
6714}
6715
6716fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
6717 match remote {
6718 Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
6719 None => json!({ "kind": "absent" }),
6720 }
6721}
6722
6723fn v2_content_withdrawal_operation(
6724 store: &Store,
6725 local_view: &V2LocalView,
6726 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6727 path: &str,
6728 reason: &str,
6729) -> LinkResult<Value> {
6730 if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
6731 || path == "DB.md"
6732 {
6733 return Err(LinkError::InvalidPack {
6734 message: format!("content withdrawal path `{path}` is not a record or source"),
6735 });
6736 }
6737 if !local_view.policy.keeps_home(path)
6738 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6739 {
6740 return Err(LinkError::InvalidPack {
6741 message: format!(
6742 "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6743 ),
6744 });
6745 }
6746 let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
6747 message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
6748 })?;
6749 verify_v2_upload_source(store, path, ¤t.sha256, current.bytes)?;
6750 Ok(json!({
6751 "op": "withdraw_from_hosting",
6752 "path": path,
6753 "expected": { "kind": "blob", "hash": current.sha256 },
6754 "reason": reason,
6755 }))
6756}
6757
6758fn v2_asset_withdrawal_operation(
6759 store: &Store,
6760 local_view: &V2LocalView,
6761 path: &str,
6762 local: &crate::AssetRecord,
6763 current: &V2BaselineAsset,
6764 reason: &str,
6765) -> LinkResult<Value> {
6766 if !local_view.policy.keeps_home(path)
6767 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6768 {
6769 return Err(LinkError::InvalidPack {
6770 message: format!(
6771 "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6772 ),
6773 });
6774 }
6775 verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
6776 if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
6777 return Err(LinkError::InvalidPack {
6778 message: format!(
6779 "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
6780 ),
6781 });
6782 }
6783 Ok(json!({
6784 "op": "asset_withdraw",
6785 "path": path,
6786 "expected": v2_asset_expected(Some(current)),
6787 "reason": reason,
6788 }))
6789}
6790
6791fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
6798 let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
6799 let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
6800 for (index, operation) in operations.iter().enumerate() {
6801 match operation.get("op").and_then(Value::as_str) {
6802 Some("delete") => {
6803 let Some(path) = operation.get("path").and_then(Value::as_str) else {
6804 continue;
6805 };
6806 let Some(hash) = operation
6807 .get("expected")
6808 .and_then(|value| value.get("hash"))
6809 .and_then(Value::as_str)
6810 else {
6811 continue;
6812 };
6813 if path.starts_with("sources/") {
6814 deletes
6815 .entry(hash.to_string())
6816 .or_default()
6817 .push((index, path.to_string()));
6818 }
6819 }
6820 Some("put") => {
6821 let Some(path) = operation.get("path").and_then(Value::as_str) else {
6822 continue;
6823 };
6824 let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
6825 continue;
6826 };
6827 let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
6828 continue;
6829 };
6830 let destination_absent = operation
6831 .get("expected")
6832 .and_then(|value| value.get("kind"))
6833 .and_then(Value::as_str)
6834 == Some("absent");
6835 if path.starts_with("sources/") && destination_absent {
6836 puts.entry(hash.to_string()).or_default().push((
6837 index,
6838 path.to_string(),
6839 bytes,
6840 ));
6841 }
6842 }
6843 _ => {}
6844 }
6845 }
6846 let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
6847 let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
6848 for (hash, source) in deletes {
6849 let Some(destination) = puts.get(&hash) else {
6850 continue;
6851 };
6852 if source.len() != 1 || destination.len() != 1 {
6853 continue;
6854 }
6855 let (delete_index, from) = &source[0];
6856 let (put_index, to, bytes) = &destination[0];
6857 if from == to {
6858 continue;
6859 }
6860 rename_at.insert(
6861 *delete_index,
6862 json!({
6863 "op": "rename",
6864 "from": from,
6865 "to": to,
6866 "expected_from": { "kind": "blob", "hash": hash },
6867 "expected_to": { "kind": "absent" },
6868 "blob": hash,
6869 "bytes": bytes,
6870 }),
6871 );
6872 consumed_puts.insert(*put_index);
6873 }
6874 operations
6875 .into_iter()
6876 .enumerate()
6877 .filter_map(|(index, operation)| {
6878 if let Some(rename) = rename_at.remove(&index) {
6879 Some(rename)
6880 } else if consumed_puts.contains(&index) {
6881 None
6882 } else {
6883 Some(operation)
6884 }
6885 })
6886 .collect()
6887}
6888
6889fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
6890 json!({
6891 "blob_sha256": record.sha256,
6892 "bytes": record.bytes,
6893 "media_type": record.media_type,
6894 "wrappers": record.wrappers,
6895 "required": record.required,
6896 "disposition": disposition,
6897 })
6898}
6899
6900fn apply_generated_v2_operations(
6904 operations: &[Value],
6905 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
6906 candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
6907 candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
6908) -> LinkResult<bool> {
6909 let mut asset_changed = false;
6910 for operation in operations {
6911 match operation.get("op").and_then(Value::as_str) {
6912 Some("put") => {
6913 let path = operation
6914 .get("path")
6915 .and_then(Value::as_str)
6916 .ok_or_else(|| invalid_feed("v2 put has no path"))?;
6917 let sha256 = operation
6918 .get("blob")
6919 .and_then(Value::as_str)
6920 .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
6921 let bytes = operation
6922 .get("bytes")
6923 .and_then(Value::as_u64)
6924 .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
6925 candidate.insert(
6926 path.to_string(),
6927 V2BaselineFile {
6928 sha256: sha256.to_string(),
6929 bytes,
6930 proof: None,
6931 },
6932 );
6933 }
6934 Some("rename") => {
6935 let from = operation
6936 .get("from")
6937 .and_then(Value::as_str)
6938 .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
6939 let to = operation
6940 .get("to")
6941 .and_then(Value::as_str)
6942 .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
6943 let sha256 = operation
6944 .get("blob")
6945 .and_then(Value::as_str)
6946 .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
6947 let bytes = operation
6948 .get("bytes")
6949 .and_then(Value::as_u64)
6950 .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
6951 let expected_from = operation
6952 .get("expected_from")
6953 .and_then(|expected| expected.get("hash"))
6954 .and_then(Value::as_str);
6955 let expected_to_absent = operation
6956 .get("expected_to")
6957 .and_then(|expected| expected.get("kind"))
6958 .and_then(Value::as_str)
6959 == Some("absent");
6960 if from == to
6961 || !from.starts_with("sources/")
6962 || !to.starts_with("sources/")
6963 || expected_from != Some(sha256)
6964 || !expected_to_absent
6965 || candidate.contains_key(to)
6966 {
6967 return Err(invalid_feed("generated v2 source rename is malformed"));
6968 }
6969 let source = candidate
6970 .remove(from)
6971 .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
6972 if source.sha256 != sha256 || source.bytes != bytes {
6973 return Err(invalid_feed(
6974 "v2 rename source differs from its exact-byte claim",
6975 ));
6976 }
6977 candidate.insert(
6978 to.to_string(),
6979 V2BaselineFile {
6980 sha256: sha256.to_string(),
6981 bytes,
6982 proof: None,
6983 },
6984 );
6985 }
6986 Some("delete" | "withdraw_from_hosting") => {
6987 let path = operation
6988 .get("path")
6989 .and_then(Value::as_str)
6990 .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
6991 candidate.remove(path);
6992 }
6993 Some("asset_delete") => {
6994 let path = operation
6995 .get("path")
6996 .and_then(Value::as_str)
6997 .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
6998 candidate_assets.remove(path);
6999 asset_changed = true;
7000 }
7001 Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7002 let path = operation
7003 .get("path")
7004 .and_then(Value::as_str)
7005 .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7006 let record = local_assets
7007 .get(path)
7008 .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7009 let disposition =
7010 if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7011 "withheld"
7012 } else {
7013 operation
7014 .get("asset")
7015 .and_then(|asset| asset.get("disposition"))
7016 .and_then(Value::as_str)
7017 .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7018 };
7019 candidate_assets.insert(
7020 path.to_string(),
7021 V2BaselineAsset {
7022 blob_sha256: record.sha256.clone(),
7023 bytes: record.bytes,
7024 media_type: record.media_type.clone(),
7025 wrappers: record.wrappers.clone(),
7026 required: record.required,
7027 disposition: disposition.to_string(),
7028 leaf_hash: String::new(),
7031 },
7032 );
7033 asset_changed = true;
7034 }
7035 _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7036 }
7037 }
7038 Ok(asset_changed)
7039}
7040
7041fn v2_riding_matches_remote(
7042 local: &std::collections::BTreeMap<String, (String, u64)>,
7043 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7044 keeps_home: impl Fn(&str) -> bool,
7045) -> bool {
7046 remote.iter().all(|(path, file)| {
7047 keeps_home(path)
7048 || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7049 }) && local.iter().all(|(path, (hash, _))| {
7050 remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7051 })
7052}
7053
7054#[derive(Debug, Clone)]
7055struct V2ResolutionOverride {
7056 expected_remote: Option<String>,
7057 selected_local: Option<String>,
7058}
7059
7060#[derive(Debug, Clone)]
7061struct V2UploadSource {
7062 path: String,
7063 bytes: u64,
7064}
7065
7066struct V2SyncPushOptions<'a> {
7067 resume_local_policy: bool,
7068 bulk_confirmation: Option<&'a V2BulkConfirmation>,
7069 resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7070 pulled: Option<V2PulledSnapshot>,
7071 withdrawal_paths: &'a [String],
7072 withdrawal_reason: Option<&'a str>,
7073}
7074
7075fn verify_v2_upload_source(
7076 store: &Store,
7077 path: &str,
7078 sha256: &str,
7079 expected_bytes: u64,
7080) -> LinkResult<()> {
7081 let file = store.open_regular(Path::new(path))?;
7082 if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7083 return Err(LinkError::InvalidPack {
7084 message: format!("local path `{path}` changed during sync planning"),
7085 });
7086 }
7087 Ok(())
7088}
7089
7090struct V2PendingUpload<'a> {
7093 url: String,
7094 headers: Value,
7095 sha256: String,
7096 source: &'a V2UploadSource,
7097}
7098
7099const V2_UPLOAD_CONCURRENCY: usize = 16;
7106
7107fn upload_v2_batch_concurrently(
7111 cfg: &HubConfig,
7112 store: &Store,
7113 pending: &[V2PendingUpload<'_>],
7114) -> LinkResult<()> {
7115 if pending.is_empty() {
7116 return Ok(());
7117 }
7118 let urls = pending
7119 .iter()
7120 .map(|task| task.url.as_str())
7121 .collect::<Vec<_>>();
7122 let shared = shared_staging_agent(cfg, &urls);
7123 if pending.len() == 1 {
7124 let task = &pending[0];
7125 put_presigned_source(
7126 cfg,
7127 &task.url,
7128 &task.headers,
7129 store,
7130 task.source,
7131 shared.as_ref(),
7132 )?;
7133 return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7134 }
7135 let next = std::sync::atomic::AtomicUsize::new(0);
7136 let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7137 let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7138 std::thread::scope(|scope| {
7139 for _ in 0..workers {
7140 scope.spawn(|| loop {
7141 if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7142 return;
7143 }
7144 let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7145 let Some(task) = pending.get(index) else {
7146 return;
7147 };
7148 let outcome = put_presigned_source(
7149 cfg,
7150 &task.url,
7151 &task.headers,
7152 store,
7153 task.source,
7154 shared.as_ref(),
7155 )
7156 .and_then(|()| {
7157 verify_v2_upload_source(
7158 store,
7159 &task.source.path,
7160 &task.sha256,
7161 task.source.bytes,
7162 )
7163 });
7164 if let Err(error) = outcome {
7165 if let Ok(mut guard) = failure.lock() {
7166 guard.get_or_insert(error);
7167 }
7168 return;
7169 }
7170 });
7171 }
7172 });
7173 match failure.into_inner() {
7174 Ok(Some(error)) => Err(error),
7175 Ok(None) => Ok(()),
7176 Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7177 }
7178}
7179
7180fn put_presigned_source(
7181 cfg: &HubConfig,
7182 raw: &str,
7183 headers: &Value,
7184 store: &Store,
7185 source: &V2UploadSource,
7186 shared: Option<&ureq::Agent>,
7187) -> LinkResult<()> {
7188 put_presigned_source_with_budget(
7189 cfg,
7190 raw,
7191 headers,
7192 store,
7193 source,
7194 shared,
7195 std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
7196 )
7197}
7198
7199fn put_presigned_source_with_budget(
7200 cfg: &HubConfig,
7201 raw: &str,
7202 headers: &Value,
7203 store: &Store,
7204 source: &V2UploadSource,
7205 shared: Option<&ureq::Agent>,
7206 total_budget: std::time::Duration,
7207) -> LinkResult<()> {
7208 let owned = match shared {
7211 Some(_) => {
7212 checked_presigned_url(cfg, raw)?;
7213 None
7214 }
7215 None => Some(presigned_agent(cfg, raw)?),
7216 };
7217 let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7218 let deadline = std::time::Instant::now()
7219 .checked_add(total_budget)
7220 .ok_or_else(upload_deadline_error)?;
7221 let mut attempt = 0;
7222 let result = loop {
7223 let file = store.open_regular(Path::new(&source.path))?;
7224 if file.metadata()?.len() != source.bytes {
7225 return Err(LinkError::InvalidPack {
7226 message: format!("local path `{}` changed before upload", source.path),
7227 });
7228 }
7229 let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
7234 let mut has_content_length = false;
7235 if let Some(map) = headers.as_object() {
7236 for (name, value) in map {
7237 if let Some(value) = value.as_str() {
7238 has_content_length |= name.eq_ignore_ascii_case("content-length");
7239 req = req.set(name, value);
7240 }
7241 }
7242 }
7243 if !has_content_length {
7244 req = req.set("Content-Length", &source.bytes.to_string());
7245 }
7246 match req.send(file) {
7247 Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
7253 attempt += 1;
7254 }
7255 Err(ureq::Error::Status(status, _))
7261 if status != 412
7262 && is_retryable_upload_status(status)
7263 && wait_for_upload_retry(deadline, attempt) =>
7264 {
7265 attempt += 1;
7266 }
7267 result => break result,
7268 }
7269 };
7270 match result {
7271 Ok(response) if (200..300).contains(&response.status()) => {
7272 drain_presigned_response(response);
7273 Ok(())
7274 }
7275 Ok(response) => {
7276 let status = response.status();
7281 let detail = response
7282 .into_string()
7283 .ok()
7284 .map(|body| body.chars().take(400).collect::<String>())
7285 .filter(|body| !body.trim().is_empty());
7286 Err(LinkError::Http {
7287 what: "v2 changed-byte upload",
7288 status,
7289 message: match detail {
7290 Some(body) => format!(
7291 "object store rejected the upload of `{}`: {}",
7292 source.path,
7293 body.replace('\n', " ")
7294 ),
7295 None => format!("object store rejected the upload of `{}`", source.path),
7296 },
7297 code: None,
7298 details: None,
7299 })
7300 }
7301 Err(error) => match error {
7302 ureq::Error::Status(412, _) => Ok(()),
7303 ureq::Error::Status(_, response) => {
7304 let status = response.status();
7305 let detail = response
7306 .into_string()
7307 .ok()
7308 .map(|body| body.chars().take(400).collect::<String>())
7309 .filter(|body| !body.trim().is_empty());
7310 Err(LinkError::Http {
7311 what: "v2 changed-byte upload",
7312 status,
7313 message: match detail {
7314 Some(body) => format!(
7315 "object store rejected the upload of `{}`: {}",
7316 source.path,
7317 body.replace('\n', " ")
7318 ),
7319 None => {
7320 format!("object store rejected the upload of `{}`", source.path)
7321 }
7322 },
7323 code: None,
7324 details: None,
7325 })
7326 }
7327 ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
7328 },
7329 }
7330}
7331
7332fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
7336 if body.get("operations").is_some() {
7337 return body.clone();
7338 }
7339 let mut value = body.clone();
7340 if let Some(map) = value.as_object_mut() {
7341 map.remove("staged_change");
7342 map.insert("operations".to_string(), Value::Array(operations.to_vec()));
7343 }
7344 value
7345}
7346
7347fn reserve_upload_window(
7351 cfg: &HubConfig,
7352 path: &str,
7353 body: &Value,
7354 what: &'static str,
7355) -> LinkResult<Value> {
7356 let mut attempt = 0;
7357 loop {
7358 let pause = |attempt: usize| {
7359 std::thread::sleep(std::time::Duration::from_millis(
7360 RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
7361 ));
7362 };
7363 match request(cfg, "POST", path, Some(body), Auth::Required) {
7364 Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
7369 pause(attempt);
7370 attempt += 1;
7371 }
7372 Err(error) => return Err(error),
7373 Ok(response) => {
7374 if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
7375 pause(attempt);
7376 attempt += 1;
7377 continue;
7378 }
7379 return ensure_ok(response, what);
7380 }
7381 }
7382 }
7383}
7384
7385fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
7389 let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
7390 .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
7391 if bytes.len() > MAX_STAGED_CHANGE_BYTES {
7392 return Err(LinkError::PushTooLarge {
7393 detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
7394 });
7395 }
7396 Ok(bytes)
7397}
7398
7399fn stage_v2_change(
7409 cfg: &HubConfig,
7410 requested_brain: &str,
7411 operations: &[Value],
7412 blobs: Value,
7413) -> LinkResult<Value> {
7414 let bytes = v2_change_manifest(operations, blobs)?;
7415 let sha256 = content_sha256(&bytes);
7416 let reserved = reserve_upload_window(
7417 cfg,
7418 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7419 &json!({
7420 "blobs": [{
7421 "sha256": sha256,
7422 "bytes": bytes.len(),
7423 "kind": "staged_change",
7424 }],
7425 }),
7426 "stage the v2 change",
7427 )?;
7428 let items = reserved
7429 .get("uploads")
7430 .and_then(Value::as_array)
7431 .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
7432 let [item] = items.as_slice() else {
7433 return Err(invalid_feed(
7434 "v2 change staging response changed the requested set",
7435 ));
7436 };
7437 let reservation_id = item
7438 .get("reservation_id")
7439 .and_then(Value::as_str)
7440 .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
7441 if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
7442 || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
7443 || !crate::ulid::is_ulid(reservation_id)
7444 {
7445 return Err(invalid_feed("v2 change staging item is inconsistent"));
7446 }
7447 match item.get("status").and_then(Value::as_str) {
7448 Some("upload") => put_presigned(
7449 cfg,
7450 item.get("url")
7451 .and_then(Value::as_str)
7452 .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
7453 item.get("headers").unwrap_or(&Value::Null),
7454 &bytes,
7455 )?,
7456 Some("already_present") => {}
7457 _ => return Err(invalid_feed("v2 change staging has an unknown status")),
7458 }
7459 Ok(json!({
7460 "sha256": sha256,
7461 "bytes": bytes.len(),
7462 "reservation_id": reservation_id,
7463 }))
7464}
7465
7466fn stage_oversized_v2_change(
7470 cfg: &HubConfig,
7471 requested_brain: &str,
7472 operations: &[Value],
7473 body: &mut Value,
7474) -> LinkResult<()> {
7475 if body.to_string().len() <= MAX_PUSH_BYTES {
7476 return Ok(());
7477 }
7478 let staged = stage_v2_change(
7479 cfg,
7480 requested_brain,
7481 operations,
7482 body.get("blobs")
7483 .cloned()
7484 .unwrap_or(Value::Array(Vec::new())),
7485 )?;
7486 let map = body
7487 .as_object_mut()
7488 .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
7489 map.remove("operations");
7490 map.remove("blobs");
7491 map.insert("staged_change".to_string(), staged);
7492 Ok(())
7493}
7494
7495fn v2_sync_push(
7496 cfg: &HubConfig,
7497 requested_brain: &str,
7498 store: &Store,
7499 head: V2VerifiedHead,
7500 options: V2SyncPushOptions<'_>,
7501) -> LinkResult<Value> {
7502 let V2SyncPushOptions {
7503 resume_local_policy,
7504 bulk_confirmation,
7505 resolution,
7506 pulled,
7507 withdrawal_paths,
7508 withdrawal_reason,
7509 } = options;
7510 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
7511 let head = v2_verified_head(cfg, requested_brain)?
7512 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7513 let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
7514 match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
7515 Some(snapshot) => (
7516 snapshot.files,
7517 snapshot.assets,
7518 Some(snapshot.local),
7519 Some(snapshot.local_assets),
7520 ),
7521 None => (
7522 files_for_v2_view(
7523 &head,
7524 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7525 ),
7526 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7527 None,
7528 None,
7529 ),
7530 };
7531 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
7532 ensure_v2_view_compatible(&head, baseline.as_ref())?;
7533 if head.view_kind == "scoped" && baseline.is_none() {
7534 return Err(LinkError::ScopedViewChanged);
7535 }
7536 let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
7537 let local = &local_view.riding;
7538 let local_assets = match carried_local_assets {
7539 Some(assets) => assets,
7540 None => v2_local_asset_records(store)?,
7541 };
7542 if withdrawal_paths.len() > MAX_PUSH_FILES {
7543 return Err(LinkError::PushTooLarge {
7544 detail: "too many explicit withdrawal paths".to_string(),
7545 });
7546 }
7547 let withdrawal_reason = if withdrawal_paths.is_empty() {
7548 None
7549 } else {
7550 let reason = withdrawal_reason
7551 .map(str::trim)
7552 .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
7553 .ok_or_else(|| LinkError::InvalidPack {
7554 message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
7555 })?;
7556 Some(reason)
7557 };
7558 let mut withdrawals = withdrawal_paths
7559 .iter()
7560 .map(|path| {
7561 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
7562 path: error.to_string(),
7563 })
7564 })
7565 .collect::<LinkResult<Vec<_>>>()?;
7566 withdrawals.sort();
7567 withdrawals.dedup();
7568 if withdrawals.len() != withdrawal_paths.len() {
7569 return Err(LinkError::InvalidPack {
7570 message: "explicit withdrawal paths must be unique".to_string(),
7571 });
7572 }
7573 let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
7574 let mut consumed_withdrawals = BTreeSet::new();
7575 if let Some(previous) = baseline.as_ref() {
7576 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
7577 && !resume_local_policy
7578 {
7579 let mut newly_eligible = previous
7580 .local_eligibility
7581 .iter()
7582 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7583 .map(|(path, _)| path.clone())
7584 .collect::<Vec<_>>();
7585 if !newly_eligible.is_empty() {
7586 newly_eligible.truncate(100);
7587 return Err(LinkError::LocalPolicyTransition {
7588 paths: newly_eligible,
7589 });
7590 }
7591 }
7592 }
7593 let base = match baseline.as_ref() {
7594 Some(state) => &state.files,
7595 None if remote.is_empty() => &remote,
7596 None => {
7597 let mut conflicts = remote
7598 .iter()
7599 .filter(|(path, file)| {
7600 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7601 })
7602 .map(|(path, _)| path.clone())
7603 .collect::<Vec<_>>();
7604 if !conflicts.is_empty() {
7605 conflicts.truncate(100);
7606 let (bundle, paths) =
7607 create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7608 return Err(LinkError::ConflictBundle { bundle, paths });
7609 }
7610 &remote
7611 }
7612 };
7613 let all_paths = base
7614 .keys()
7615 .chain(remote.keys())
7616 .chain(local.keys())
7617 .cloned()
7618 .collect::<std::collections::BTreeSet<_>>();
7619 let mut conflicts = Vec::new();
7620 let mut operations = Vec::new();
7621 let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7622 for path in all_paths {
7623 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7624 let remote_file = remote.get(&path);
7625 let remote_hash = remote_file.map(|file| file.sha256.as_str());
7626 let local_file = local.get(&path);
7627 let local_hash = local_file.map(|file| file.0.as_str());
7628 if local_hash == base_hash {
7629 continue;
7630 }
7631 if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
7632 continue;
7633 }
7634 if local_view.policy.keeps_home(&path) {
7635 continue;
7638 }
7639 if remote_hash != base_hash && local_hash != remote_hash {
7640 let explicitly_resolved = resolution
7641 .and_then(|allowed| allowed.get(&path))
7642 .is_some_and(|selected| {
7643 selected.expected_remote.as_deref() == remote_hash
7644 && selected.selected_local.as_deref() == local_hash
7645 });
7646 if !explicitly_resolved {
7647 conflicts.push(path);
7648 continue;
7649 }
7650 }
7651 match local_file {
7652 Some((sha256, byte_count)) => {
7653 verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7654 operations.push(json!({
7655 "op": "put",
7656 "path": path,
7657 "expected": v2_expected(remote_file),
7658 "blob": sha256,
7659 "bytes": byte_count,
7660 }));
7661 upload_sources
7662 .entry(sha256.clone())
7663 .or_insert_with(|| V2UploadSource {
7664 path: path.clone(),
7665 bytes: *byte_count,
7666 });
7667 }
7668 None => {
7669 let Some(current) = remote_file else {
7670 continue;
7671 };
7672 operations.push(json!({
7673 "op": "delete",
7674 "path": path,
7675 "expected": { "kind": "blob", "hash": current.sha256 },
7676 }));
7677 }
7678 }
7679 }
7680 operations = infer_exact_source_promotions(operations);
7681 for path in &withdrawals {
7682 if local_assets.contains_key(path) {
7683 continue;
7684 }
7685 operations.push(v2_content_withdrawal_operation(
7686 store,
7687 &local_view,
7688 &remote,
7689 path,
7690 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7691 )?);
7692 consumed_withdrawals.insert(path.clone());
7693 }
7694 if !conflicts.is_empty() {
7695 conflicts.truncate(100);
7696 let (bundle, paths) = create_v2_conflict_bundle(
7697 cfg,
7698 store,
7699 &head,
7700 baseline.as_ref(),
7701 local,
7702 &remote,
7703 &conflicts,
7704 )?;
7705 return Err(LinkError::ConflictBundle { bundle, paths });
7706 }
7707 let base_assets = match baseline.as_ref() {
7708 Some(state) => &state.assets,
7709 None if remote_assets.is_empty() => &remote_assets,
7710 None => {
7711 let mismatched = remote_assets.iter().any(|(path, remote)| {
7712 local_assets.get(path) != Some(&v2_asset_record(remote, path))
7713 }) || local_assets.len() != remote_assets.len();
7714 if mismatched {
7715 return Err(LinkError::Conflict {
7716 paths: vec!["assets.jsonl".to_string()],
7717 });
7718 }
7719 &remote_assets
7720 }
7721 };
7722 let asset_paths = base_assets
7723 .keys()
7724 .chain(remote_assets.keys())
7725 .chain(local_assets.keys())
7726 .cloned()
7727 .collect::<std::collections::BTreeSet<_>>();
7728 let mut asset_policy_transitions = Vec::new();
7729 for path in asset_paths {
7730 let base_record = base_assets
7731 .get(&path)
7732 .map(|asset| v2_asset_record(asset, &path));
7733 let remote = remote_assets.get(&path);
7734 let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
7735 let local_record = local_assets.get(&path);
7736 if withdrawal_set.contains(&path) {
7737 let record = local_record.ok_or_else(|| LinkError::InvalidPack {
7738 message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
7739 })?;
7740 let current = remote.ok_or_else(|| LinkError::InvalidPack {
7741 message: format!(
7742 "asset withdrawal path `{path}` has no readable hosted coordinate"
7743 ),
7744 })?;
7745 operations.push(v2_asset_withdrawal_operation(
7746 store,
7747 &local_view,
7748 &path,
7749 record,
7750 current,
7751 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7752 )?);
7753 consumed_withdrawals.insert(path.clone());
7754 continue;
7755 }
7756 let mut raw_present = false;
7757 let mut disposition = "withheld";
7758 let mut resumes_hosting = false;
7759 if let Some(record) = local_record {
7760 crate::linkmd_v2::normalize_path(&record.path)
7761 .map_err(|error| invalid_feed(error.to_string()))?;
7762 let kept_home = local_view.policy.keeps_home(&path);
7763 raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
7764 disposition = if kept_home || !raw_present {
7765 "withheld"
7766 } else {
7767 "hosted"
7768 };
7769 if !raw_present && record.required && !kept_home {
7770 return Err(LinkError::InvalidPack {
7771 message: format!("required asset {path} is missing"),
7772 });
7773 }
7774 resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
7775 }
7776 if local_record == base_record.as_ref() && !resumes_hosting {
7777 continue;
7778 }
7779 if remote_record != base_record && local_record != remote_record.as_ref() {
7780 conflicts.push(path);
7781 continue;
7782 }
7783 let Some(record) = local_record else {
7784 if let Some(remote) = remote {
7785 operations.push(json!({
7786 "op": "asset_delete",
7787 "path": path,
7788 "expected": v2_asset_expected(Some(remote)),
7789 }));
7790 }
7791 continue;
7792 };
7793 let raw = if raw_present {
7794 verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
7795 Some(())
7796 } else {
7797 None
7798 };
7799 let op = if resumes_hosting {
7800 if !resume_local_policy {
7801 asset_policy_transitions.push(path);
7802 continue;
7803 }
7804 "asset_resume"
7805 } else {
7806 "asset_put"
7807 };
7808 operations.push(json!({
7809 "op": op,
7810 "path": path,
7811 "expected": v2_asset_expected(remote),
7812 "asset": v2_asset_value(record, disposition),
7813 }));
7814 if disposition == "hosted" {
7815 raw.expect("hosted asset was checked present");
7816 upload_sources
7817 .entry(record.sha256.clone())
7818 .or_insert_with(|| V2UploadSource {
7819 path: path.clone(),
7820 bytes: record.bytes,
7821 });
7822 }
7823 }
7824 if consumed_withdrawals != withdrawal_set {
7825 let missing = withdrawal_set
7826 .difference(&consumed_withdrawals)
7827 .next()
7828 .expect("different withdrawal sets have one member");
7829 return Err(LinkError::InvalidPack {
7830 message: format!(
7831 "withdrawal path `{missing}` is not a readable content or asset coordinate"
7832 ),
7833 });
7834 }
7835 if !conflicts.is_empty() {
7836 conflicts.truncate(100);
7837 return Err(LinkError::Conflict { paths: conflicts });
7838 }
7839 if !asset_policy_transitions.is_empty() {
7840 asset_policy_transitions.truncate(100);
7841 return Err(LinkError::LocalPolicyTransition {
7842 paths: asset_policy_transitions,
7843 });
7844 }
7845 let touched_sources = operations
7846 .iter()
7847 .filter_map(
7848 |operation| match operation.get("op").and_then(Value::as_str) {
7849 Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
7850 Some("rename") => operation.get("to").and_then(Value::as_str),
7851 _ => None,
7852 },
7853 )
7854 .collect::<std::collections::BTreeSet<_>>();
7855 let withheld_links = local_view
7856 .withheld_links
7857 .iter()
7858 .filter(|link| touched_sources.contains(link.source.as_str()))
7859 .collect::<Vec<_>>();
7860 let checkout_pseudonym = v2_checkout_id(
7861 baseline
7862 .as_ref()
7863 .and_then(|current| current.checkout_id.as_deref()),
7864 )?;
7865 let checkout_id = if withheld_links.is_empty() {
7866 None
7867 } else {
7868 Some(checkout_pseudonym.clone())
7869 };
7870 if operations.is_empty() {
7871 let final_head = v2_verified_head(cfg, requested_brain)?
7872 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
7873 if !same_v2_head(&head, &final_head) {
7874 return Err(LinkError::RemoteAdvancedDuringSync);
7875 }
7876 let mut final_local = v2_local_files(store)?;
7877 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
7878 let final_assets = v2_local_asset_records(store)?;
7879 let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
7880 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
7881 final_local.policy.keeps_home(path)
7882 }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
7883 let next = v2_baseline_from_head(
7884 cfg,
7885 &head,
7886 remote,
7887 remote_assets,
7888 Some(&final_local),
7889 Some(&checkout_pseudonym),
7890 )?;
7891 let split_count = next.remote_copy_remains.len();
7892 accept_v2_head(cfg, &final_head)?;
7893 if !local_changed && !remote_ahead {
7894 refresh_scoped_view_marker(store, &head, next.files.len())?;
7895 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
7896 }
7897 return Ok(json!({
7898 "v": 2,
7899 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
7900 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
7901 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
7902 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
7903 "local_policy": {
7904 "remote_copy_remains": split_count,
7905 },
7906 }));
7907 }
7908 let includes_contract = operations
7909 .iter()
7910 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
7911 let rebase = if head.pointer.is_none() || includes_contract {
7912 "strict"
7913 } else {
7914 "disjoint"
7915 };
7916 let base_value = head.pointer.as_ref().map(|pointer| {
7917 json!({
7918 "seq": pointer.seq,
7919 "commit_hash": pointer.commit_hash,
7920 "content_root": pointer.content_root,
7921 "asset_root": pointer.asset_root,
7922 })
7923 });
7924 let entropy = format!(
7928 "{}\0{}\0{}\0{}\0{}\0{}",
7929 normalized_origin(&cfg.hub)?,
7930 head.brain_id,
7931 serde_json::to_string(&base_value).unwrap_or_default(),
7932 serde_json::to_string(&operations).unwrap_or_default(),
7933 serde_json::to_string(&withheld_links).unwrap_or_default(),
7934 checkout_id.as_deref().unwrap_or("")
7935 );
7936 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
7937 let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
7938 total
7939 .checked_add(source.bytes)
7940 .ok_or_else(|| LinkError::PushTooLarge {
7941 detail: "v2 changed-byte total overflow".to_string(),
7942 })
7943 })?;
7944 let inline = changed_bytes <= 3 * 1024 * 1024;
7945 let inline_blobs = if inline {
7946 upload_sources
7947 .iter()
7948 .map(|(sha256, source)| {
7949 let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
7950 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
7951 return Err(LinkError::InvalidPack {
7952 message: format!("local path `{}` changed before upload", source.path),
7953 });
7954 }
7955 Ok(json!({
7956 "sha256": sha256,
7957 "bytes": source.bytes,
7958 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
7959 }))
7960 })
7961 .collect::<LinkResult<Vec<_>>>()?
7962 } else {
7963 Vec::new()
7964 };
7965 let mut body = json!({
7966 "mutation_id": mutation_id,
7967 "base": base_value,
7968 "rebase": rebase,
7969 "reason": "dbmd sync",
7970 "operations": operations,
7971 "blobs": inline_blobs,
7972 });
7973 if !withheld_links.is_empty() {
7974 body["withheld_links"] = serde_json::to_value(&withheld_links)
7975 .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
7976 body["checkout_id"] =
7977 Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
7978 }
7979 if let Some(confirmation) = bulk_confirmation {
7980 if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
7981 return Err(LinkError::InvalidPack {
7982 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
7983 .to_string(),
7984 });
7985 }
7986 body["rebase"] = Value::String("strict".to_string());
7990 body["bulk_preview_id"] = Value::String(confirmation.id.clone());
7991 body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
7992 }
7993 if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
7994 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
7995 for operation in &operations {
7996 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
7997 return Err(invalid_feed("v2 upload operation has no kind"));
7998 };
7999 let hash = match kind {
8000 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
8001 "asset_put" | "asset_resume" => operation
8002 .get("asset")
8003 .and_then(|asset| asset.get("blob_sha256"))
8004 .and_then(Value::as_str),
8005 _ => None,
8006 };
8007 let Some(hash) = hash else { continue };
8008 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8009 if kind == "rename" {
8010 for field in ["from", "to"] {
8011 coordinates.insert(
8012 operation
8013 .get(field)
8014 .and_then(Value::as_str)
8015 .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8016 .to_string(),
8017 );
8018 }
8019 } else {
8020 let path = operation
8021 .get("path")
8022 .and_then(Value::as_str)
8023 .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8024 coordinates.insert(if kind.starts_with("asset_") {
8025 format!("assets/{path}")
8026 } else {
8027 path.to_string()
8028 });
8029 }
8030 }
8031 let declarations = upload_sources
8032 .iter()
8033 .map(|(sha256, source)| {
8034 json!({
8035 "sha256": sha256,
8036 "bytes": source.bytes,
8037 "coordinates": coordinates_by_hash
8038 .get(sha256)
8039 .into_iter()
8040 .flatten()
8041 .collect::<Vec<_>>(),
8042 })
8043 })
8044 .collect::<Vec<_>>();
8045 let mut references = Vec::with_capacity(upload_sources.len());
8046 let mut seen = std::collections::BTreeSet::new();
8047 let mut reserved_count = 0usize;
8048 let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8049 for batch in batch_upload_declarations(declarations) {
8053 let batch_len = batch.len();
8054 let reserved = reserve_upload_window(
8055 cfg,
8056 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8057 &json!({ "blobs": batch }),
8058 "prepare v2 changed-byte uploads",
8059 )?;
8060 let items = reserved
8061 .get("uploads")
8062 .and_then(Value::as_array)
8063 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8064 if items.len() != batch_len {
8065 return Err(invalid_feed(
8066 "v2 upload reservation response changed the requested set",
8067 ));
8068 }
8069 reserved_count += items.len();
8070 for item in items {
8071 let sha256 = item
8072 .get("sha256")
8073 .and_then(Value::as_str)
8074 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8075 let source = upload_sources
8076 .get(sha256)
8077 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8078 let declared_bytes = item
8079 .get("bytes")
8080 .and_then(Value::as_u64)
8081 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8082 let reservation_id = item
8083 .get("reservation_id")
8084 .and_then(Value::as_str)
8085 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8086 let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8087 invalid_feed("v2 upload reservation has no coordinate binding")
8088 })?;
8089 let returned_coordinates = item
8090 .get("coordinates")
8091 .and_then(Value::as_array)
8092 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8093 if declared_bytes != source.bytes
8094 || !crate::ulid::is_ulid(reservation_id)
8095 || !seen.insert(sha256.to_string())
8096 || returned_coordinates.len() != expected_coordinates.len()
8097 || returned_coordinates
8098 .iter()
8099 .zip(expected_coordinates)
8100 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8101 {
8102 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8103 }
8104 match item.get("status").and_then(Value::as_str) {
8105 Some("upload") => {
8106 let url = item
8107 .get("url")
8108 .and_then(Value::as_str)
8109 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8110 pending_uploads.push(V2PendingUpload {
8111 url: url.to_string(),
8112 headers: item.get("headers").cloned().unwrap_or(Value::Null),
8113 sha256: sha256.to_string(),
8114 source,
8115 });
8116 }
8117 Some("already_present") => {}
8118 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
8119 }
8120 references.push(json!({
8121 "sha256": sha256,
8122 "bytes": source.bytes,
8123 "reservation_id": reservation_id,
8124 }));
8125 }
8126 upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
8132 pending_uploads.clear();
8133 }
8134 if reserved_count != upload_sources.len() {
8135 return Err(invalid_feed(
8136 "v2 upload reservation response changed the requested set",
8137 ));
8138 }
8139 body["blobs"] = Value::Array(references);
8140 }
8141 stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
8142 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
8143 let mut candidate_hub_signer: Option<String> = None;
8144 let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8145 let bulk_preview_required = !(200..300).contains(&response.status)
8146 && response.body.as_ref().is_some_and(|value| {
8147 value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
8148 || value
8149 .get("details")
8150 .and_then(|details| details.get("code"))
8151 .and_then(Value::as_str)
8152 == Some("bulk_preview_required")
8153 });
8154 if bulk_preview_required && bulk_confirmation.is_none() {
8155 body["rebase"] = Value::String("strict".to_string());
8156 body["preview_only"] = Value::Bool(true);
8157 let preview = ensure_ok(
8158 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
8159 "v2 bulk preview",
8160 )?;
8161 let preview_code = preview.get("code").and_then(Value::as_str);
8162 let required = preview.get("required").and_then(Value::as_bool);
8163 if preview.get("v").and_then(Value::as_u64) != Some(2)
8164 || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
8165 || !matches!(
8166 preview_code,
8167 Some("bulk_preview_created" | "bulk_preview_not_required")
8168 )
8169 || required.is_none()
8170 {
8171 return Err(invalid_feed(
8172 "bulk preview response is not bound to the requested mutation",
8173 ));
8174 }
8175 if required == Some(true) {
8176 let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
8177 let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
8178 if preview_code != Some("bulk_preview_created")
8179 || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
8180 || preview_digest.is_none_or(|value| !is_sha256(value))
8181 || preview.get("expires_at").and_then(Value::as_str).is_none()
8182 || !preview.get("impact").is_some_and(Value::is_object)
8183 {
8184 return Err(invalid_feed("bulk preview receipt is malformed"));
8185 }
8186 return Err(LinkError::BulkPreviewRequired { preview });
8187 }
8188 if preview_code != Some("bulk_preview_not_required") {
8189 return Err(invalid_feed("bulk preview requirement is inconsistent"));
8190 }
8191 body.as_object_mut()
8194 .expect("v2 commit request is an object")
8195 .remove("preview_only");
8196 response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8197 }
8198 let mut result = ensure_ok(response, "v2 sync push")?;
8199 if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
8200 if let Some(object) = result.as_object_mut() {
8201 object.insert(
8202 "sync_status".to_string(),
8203 Value::String("proposal_pending".to_string()),
8204 );
8205 }
8206 return Ok(result);
8207 }
8208 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
8209 let request_id = result
8210 .get("request_id")
8211 .and_then(Value::as_str)
8212 .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
8213 .to_string();
8214 let challenge = result
8215 .get("signing_challenge")
8216 .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
8217 let mut expected_candidate = remote.clone();
8218 let mut expected_candidate_assets = remote_assets.clone();
8219 apply_generated_v2_operations(
8220 &operations,
8221 &local_assets,
8222 &mut expected_candidate,
8223 &mut expected_candidate_assets,
8224 )?;
8225 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
8226 cfg,
8227 &head,
8228 &expected_candidate,
8229 &expected_candidate_assets,
8230 &mutation_id,
8231 &v2_signed_request_view(&body, &operations),
8232 challenge,
8233 )?;
8234 body["signing_challenge_id"] = Value::String(challenge_id);
8235 body["signature_base64url"] = Value::String(signature);
8236 candidate_hub_signer = Some(actor_signer);
8237 result = ensure_ok(
8238 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
8239 "v2 self-custody commit",
8240 )?;
8241 }
8242 let refreshed = v2_verified_head(cfg, requested_brain)?
8243 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
8244 if candidate_hub_signer
8245 .as_ref()
8246 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
8247 {
8248 return Err(invalid_feed(
8249 "self-custody actor signer differs from the committed hub pointer signer",
8250 ));
8251 }
8252 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
8253 if refreshed
8254 .pointer
8255 .as_ref()
8256 .map(|pointer| pointer.commit_hash.as_str())
8257 != accepted_hash
8258 {
8259 return Err(LinkError::RemoteAdvancedDuringSync);
8260 }
8261 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
8262 let rebased = result
8263 .get("rebased")
8264 .and_then(Value::as_bool)
8265 .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
8266 let (refreshed_files, refreshed_assets) = if rebased {
8267 (
8268 files_for_v2_view(
8269 &refreshed,
8270 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8271 ),
8272 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8273 )
8274 } else {
8275 let asset_changed = apply_generated_v2_operations(
8276 &operations,
8277 &local_assets,
8278 &mut remote,
8279 &mut remote_assets,
8280 )?;
8281 let assets = if asset_changed {
8282 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
8285 } else {
8286 remote_assets
8287 };
8288 (remote, assets)
8289 };
8290 let mut final_local = v2_local_files(store)?;
8291 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
8292 let final_assets = v2_local_asset_records(store)?;
8293 let local_dirty = final_local.riding != local_view.riding
8294 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
8295 final_local.policy.keeps_home(path)
8296 })
8297 || final_assets != local_assets
8298 || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
8299 let next = v2_baseline_from_head(
8300 cfg,
8301 &refreshed,
8302 refreshed_files,
8303 refreshed_assets,
8304 Some(&final_local),
8305 Some(&checkout_pseudonym),
8306 )?;
8307 let split_count = next.remote_copy_remains.len();
8308 accept_v2_head(cfg, &refreshed)?;
8309 if !local_dirty {
8310 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
8311 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
8312 }
8313 if let Some(object) = result.as_object_mut() {
8314 object.insert(
8315 "local_policy".to_string(),
8316 json!({ "remote_copy_remains": split_count }),
8317 );
8318 object.insert(
8319 "sync_status".to_string(),
8320 Value::String(if local_dirty {
8321 "remote_committed_local_dirty".to_string()
8322 } else {
8323 "synced".to_string()
8324 }),
8325 );
8326 }
8327 Ok(result)
8328}
8329
8330pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
8333 sync_push_incremental_with_policy(cfg, brain, store, false)
8334}
8335
8336pub fn sync_push_incremental_with_policy(
8339 cfg: &HubConfig,
8340 brain: &str,
8341 store: &Store,
8342 resume_local_policy: bool,
8343) -> LinkResult<Value> {
8344 sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
8345}
8346
8347pub fn sync_push_incremental_with_options(
8350 cfg: &HubConfig,
8351 brain: &str,
8352 store: &Store,
8353 resume_local_policy: bool,
8354 bulk_confirmation: Option<&V2BulkConfirmation>,
8355) -> LinkResult<Value> {
8356 sync_push_incremental_with_controls(
8357 cfg,
8358 brain,
8359 store,
8360 resume_local_policy,
8361 bulk_confirmation,
8362 &[],
8363 None,
8364 )
8365}
8366
8367pub fn sync_push_incremental_with_controls(
8369 cfg: &HubConfig,
8370 brain: &str,
8371 store: &Store,
8372 resume_local_policy: bool,
8373 bulk_confirmation: Option<&V2BulkConfirmation>,
8374 withdrawal_paths: &[String],
8375 withdrawal_reason: Option<&str>,
8376) -> LinkResult<Value> {
8377 require_safe_ref(brain)?;
8378 if let Some(head) = v2_verified_head(cfg, brain)? {
8379 return v2_sync_push(
8380 cfg,
8381 brain,
8382 store,
8383 head,
8384 V2SyncPushOptions {
8385 resume_local_policy,
8386 bulk_confirmation,
8387 resolution: None,
8388 pulled: None,
8389 withdrawal_paths,
8390 withdrawal_reason,
8391 },
8392 );
8393 }
8394 if !withdrawal_paths.is_empty() {
8395 return Err(LinkError::InvalidPack {
8396 message: "explicit withdrawal requires a link.md v2 brain".to_string(),
8397 });
8398 }
8399 legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
8400}
8401
8402pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
8406 require_safe_ref(brain)?;
8407 Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
8408}
8409
8410#[cfg(windows)]
8411fn legacy_sync_push_incremental(
8412 _cfg: &HubConfig,
8413 _brain: &str,
8414 _store: &Store,
8415 _resume_local_policy: bool,
8416 _bulk_confirmation: Option<&V2BulkConfirmation>,
8417) -> LinkResult<Value> {
8418 Err(LinkError::UnsupportedPlatform {
8419 operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
8420 })
8421}
8422
8423#[cfg(not(windows))]
8424fn legacy_sync_push_incremental(
8425 cfg: &HubConfig,
8426 brain: &str,
8427 store: &Store,
8428 resume_local_policy: bool,
8429 bulk_confirmation: Option<&V2BulkConfirmation>,
8430) -> LinkResult<Value> {
8431 if resume_local_policy || bulk_confirmation.is_some() {
8432 return Err(LinkError::InvalidPack {
8433 message: "v2 sync options require a link.md v2 brain".to_string(),
8434 });
8435 }
8436 let files = collect_push_files(store)?;
8437 sync_push(cfg, brain, &files)
8438}
8439
8440#[derive(Debug, Clone)]
8442pub enum V2ConflictChoice {
8443 KeepLocal,
8444 TakeRemote,
8445 From(PathBuf),
8446}
8447
8448fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
8449 if !crate::ulid::is_ulid(bundle) {
8450 return Err(LinkError::InvalidPack {
8451 message: "conflict bundle must be a lowercase ULID".to_string(),
8452 });
8453 }
8454 let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
8455 let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
8456 .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
8457 if plan.v != 2
8458 || plan.class != "content_resolution_required"
8459 || plan.bundle != bundle
8460 || !crate::ulid::is_ulid(&plan.brain)
8461 || plan.files.is_empty()
8462 || plan.files.len() > 100
8463 || plan.files.iter().any(|file| {
8464 crate::linkmd_v2::normalize_path(&file.path).is_err()
8465 || [&file.base, &file.local, &file.remote]
8466 .into_iter()
8467 .any(|coordinate| {
8468 coordinate
8469 .sha256
8470 .as_deref()
8471 .is_some_and(|hash| !is_sha256(hash))
8472 || coordinate.file.as_deref().is_some_and(|name| {
8473 name.starts_with('/')
8474 || name
8475 .split('/')
8476 .any(|part| part.is_empty() || part == "." || part == "..")
8477 })
8478 })
8479 })
8480 {
8481 return Err(invalid_feed("private conflict plan failed validation"));
8482 }
8483 Ok(plan)
8484}
8485
8486pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
8491 require_hardened_filesystem("private conflict maintenance")?;
8492 if all && !prune {
8493 return Err(LinkError::InvalidPack {
8494 message: "discarding all conflict bundles requires prune=true".to_string(),
8495 });
8496 }
8497 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8498 message: format!("conflict checkout is not a valid db.md store: {error}"),
8499 })?;
8500 let _transaction = store.transaction()?;
8501 let root = Path::new(".dbmd/conflicts");
8502 let names = match store.directory_names(root) {
8503 Ok(names) => names,
8504 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8505 Err(error) => return Err(error.into()),
8506 };
8507 let now = SystemTime::now()
8508 .duration_since(UNIX_EPOCH)
8509 .unwrap_or_default()
8510 .as_secs();
8511 let mut bundles = Vec::new();
8512 let mut pruned = 0_u64;
8513 for name in names {
8514 let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
8515 continue;
8516 };
8517 let plan_path = v2_conflict_relative(bundle, "plan.json");
8518 let plan_exists = store.regular_file_exists(&plan_path)?;
8519 let expired = if plan_exists {
8520 match load_v2_conflict_plan(&store, bundle) {
8521 Ok(plan) => plan.expires_unix < now,
8522 Err(error) if all => {
8523 let _ = error;
8524 true
8525 }
8526 Err(error) => return Err(error),
8527 }
8528 } else {
8529 true
8530 };
8531 if prune && (all || expired) {
8532 store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8533 pruned += 1;
8534 continue;
8535 }
8536 bundles.push(json!({
8537 "bundle": bundle,
8538 "complete": plan_exists,
8539 "expired": expired,
8540 }));
8541 }
8542 Ok(json!({
8543 "v": 2,
8544 "class": "private_conflict_state",
8545 "bundles": bundles.len(),
8546 "pruned": pruned,
8547 "items": bundles,
8548 }))
8549}
8550
8551pub fn sync_resolve_conflict(
8555 cfg: &HubConfig,
8556 checkout: &Path,
8557 bundle: &str,
8558 choice: V2ConflictChoice,
8559 bulk_confirmation: Option<&V2BulkConfirmation>,
8560) -> LinkResult<Value> {
8561 require_hardened_filesystem("conflict resolution")?;
8562 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8563 message: format!("conflict checkout is not a valid db.md store: {error}"),
8564 })?;
8565 let plan = load_v2_conflict_plan(&store, bundle)?;
8566 if plan.origin != normalized_origin(&cfg.hub)? {
8567 return Err(invalid_feed(
8568 "conflict bundle belongs to another hub origin",
8569 ));
8570 }
8571 let now = SystemTime::now()
8572 .duration_since(UNIX_EPOCH)
8573 .unwrap_or_default()
8574 .as_secs();
8575 if now > plan.expires_unix {
8576 return Err(LinkError::InvalidPack {
8577 message: "conflict bundle expired; rerun sync to obtain current coordinates"
8578 .to_string(),
8579 });
8580 }
8581 let head = v2_verified_head(cfg, &plan.brain)?
8582 .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8583 let pointer = head.pointer.as_ref();
8584 if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8585 || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8586 || pointer.and_then(|value| value.content_root.as_deref())
8587 != plan.remote_content_root.as_deref()
8588 || head.view_kind != plan.view_kind
8589 || head.view_revision != plan.view_revision
8590 {
8591 return Err(LinkError::RemoteAdvancedDuringSync);
8592 }
8593
8594 for file in &plan.files {
8596 let actual = match store.regular_file_exists(Path::new(&file.path))? {
8597 true => Some(content_sha256(&store.read_bounded(
8598 Path::new(&file.path),
8599 file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8600 )?)),
8601 false => None,
8602 };
8603 if actual.as_deref() != file.local.sha256.as_deref() {
8604 return Err(LinkError::InvalidPack {
8605 message: format!(
8606 "local conflict path `{}` changed after the bundle was created",
8607 file.path
8608 ),
8609 });
8610 }
8611 }
8612
8613 let from_source = match &choice {
8614 V2ConflictChoice::From(source) => Some(source.clone()),
8615 _ => None,
8616 };
8617 let result = match choice {
8618 V2ConflictChoice::TakeRemote => {
8619 if bulk_confirmation.is_some() {
8620 return Err(LinkError::InvalidPack {
8621 message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8622 });
8623 }
8624 let current_remote =
8628 files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8629 let _ = v2_take_remote_selection(&plan.files, ¤t_remote)?;
8630 let selected = plan
8631 .files
8632 .iter()
8633 .map(|file| file.path.clone())
8634 .collect::<std::collections::BTreeSet<_>>();
8635 serde_json::to_value(
8636 v2_sync_pull_with_resolution(
8637 cfg,
8638 &plan.brain,
8639 head,
8640 Some(checkout),
8641 Some(&selected),
8642 )?
8643 .report,
8644 )
8645 .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
8646 }
8647 V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
8648 if let Some(source) = from_source.as_ref() {
8649 if plan.files.len() != 1 {
8650 return Err(LinkError::InvalidPack {
8651 message: "--from requires a bundle with exactly one conflict".to_string(),
8652 });
8653 }
8654 let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
8655 if std::str::from_utf8(&candidate).is_err() {
8656 return Err(LinkError::NotUtf8 {
8657 path: source.display().to_string(),
8658 });
8659 }
8660 store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
8661 }
8662 let refreshed_store =
8663 Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8664 message: format!("resolved checkout is not a valid db.md store: {error}"),
8665 })?;
8666 let mut overrides = std::collections::BTreeMap::new();
8667 for file in &plan.files {
8668 let selected_local = match refreshed_store
8669 .regular_file_exists(Path::new(&file.path))?
8670 {
8671 true => Some(content_sha256(
8672 &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
8673 )),
8674 false => None,
8675 };
8676 overrides.insert(
8677 file.path.clone(),
8678 V2ResolutionOverride {
8679 expected_remote: file.remote.sha256.clone(),
8680 selected_local,
8681 },
8682 );
8683 }
8684 v2_sync_push(
8685 cfg,
8686 &plan.brain,
8687 &refreshed_store,
8688 head,
8689 V2SyncPushOptions {
8690 resume_local_policy: true,
8691 bulk_confirmation,
8692 resolution: Some(&overrides),
8693 pulled: None,
8694 withdrawal_paths: &[],
8695 withdrawal_reason: None,
8696 },
8697 )?
8698 }
8699 };
8700
8701 if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
8702 let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8703 message: format!("resolved checkout is not a valid db.md store: {error}"),
8704 })?;
8705 installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8706 }
8707 Ok(json!({
8708 "v": 2,
8709 "class": "auto_converged",
8710 "bundle": bundle,
8711 "receipt": result,
8712 }))
8713}
8714
8715pub fn sync_converge(
8726 cfg: &HubConfig,
8727 brain: &str,
8728 checkout: &Path,
8729 resume_local_policy: bool,
8730) -> LinkResult<Value> {
8731 sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
8732}
8733
8734pub fn sync_converge_with_options(
8736 cfg: &HubConfig,
8737 brain: &str,
8738 checkout: &Path,
8739 resume_local_policy: bool,
8740 bulk_confirmation: Option<&V2BulkConfirmation>,
8741) -> LinkResult<Value> {
8742 sync_converge_with_controls(
8743 cfg,
8744 brain,
8745 checkout,
8746 resume_local_policy,
8747 bulk_confirmation,
8748 &[],
8749 None,
8750 )
8751}
8752
8753pub fn sync_converge_with_controls(
8755 cfg: &HubConfig,
8756 brain: &str,
8757 checkout: &Path,
8758 resume_local_policy: bool,
8759 bulk_confirmation: Option<&V2BulkConfirmation>,
8760 withdrawal_paths: &[String],
8761 withdrawal_reason: Option<&str>,
8762) -> LinkResult<Value> {
8763 require_hardened_filesystem("bidirectional sync")?;
8764 require_safe_ref(brain)?;
8765 let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
8766 message:
8767 "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
8768 .to_string(),
8769 })?;
8770 let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
8771 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8772 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
8773 })?;
8774 let _transaction = store.transaction()?;
8775 let pulled_report = pulled.report.clone();
8776 let pulled_head = pulled.head.clone();
8777 let mut result = v2_sync_push(
8778 cfg,
8779 brain,
8780 &store,
8781 pulled_head,
8782 V2SyncPushOptions {
8783 resume_local_policy,
8784 bulk_confirmation,
8785 resolution: None,
8786 pulled: Some(pulled),
8787 withdrawal_paths,
8788 withdrawal_reason,
8789 },
8790 )?;
8791 if let Some(object) = result.as_object_mut() {
8792 object.insert("pulled_files".to_string(), json!(pulled_report.files));
8793 object.insert("checkout".to_string(), Value::String(pulled_report.dest));
8794 object.insert(
8795 "mode".to_string(),
8796 Value::String("bidirectional".to_string()),
8797 );
8798 }
8799 Ok(result)
8800}
8801
8802pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
8808 require_hardened_filesystem("sync pull")?;
8809 require_safe_ref(brain)?;
8810 if let Some(head) = v2_verified_head(cfg, brain)? {
8811 return v2_sync_pull(cfg, brain, head, out);
8812 }
8813 legacy_sync_pull(cfg, brain, out)
8814}
8815
8816#[cfg(windows)]
8817fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
8818 Err(LinkError::UnsupportedPlatform {
8819 operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
8820 })
8821}
8822
8823#[cfg(not(windows))]
8824fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
8825 let remote = verified_remote_head(cfg, brain, false)?;
8826 if !remote.head.verified {
8827 return Err(invalid_feed(
8828 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
8829 ));
8830 }
8831 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
8832 let path = format!(
8833 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
8834 remote.head.seq
8835 );
8836 let body = ensure_ok(
8837 request(cfg, "GET", &path, None, Auth::Required)?,
8838 "sync pull",
8839 )?;
8840 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
8841 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
8842 {
8843 return Err(invalid_feed(
8844 "export response is not bound to the verified snapshot token",
8845 ));
8846 }
8847
8848 let remote_slug = body
8849 .get("slug")
8850 .and_then(Value::as_str)
8851 .filter(|slug| is_safe_slug(slug));
8852 let slug = remote_slug
8853 .or_else(|| is_safe_slug(brain).then_some(brain))
8854 .unwrap_or("brain")
8855 .to_string();
8856 let brain_id = body
8857 .get("brain")
8858 .and_then(Value::as_str)
8859 .unwrap_or(&remote.head.brain)
8860 .to_string();
8861 if brain_id != remote.head.brain {
8862 return Err(invalid_feed(
8863 "export response names a different brain than the verified head",
8864 ));
8865 }
8866 let head_seq = remote.head.seq;
8867 let dest: PathBuf = match out {
8868 Some(p) => p.to_path_buf(),
8869 None => PathBuf::from(&slug),
8870 };
8871 let entries = if head_seq == 0 {
8872 let files = body
8873 .get("files")
8874 .and_then(Value::as_array)
8875 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
8876 if !files.is_empty() || body.get("url").is_some() {
8877 return Err(invalid_feed(
8878 "empty signed feed cannot authorize non-empty exported content",
8879 ));
8880 }
8881 Vec::new()
8882 } else {
8883 let signed_head = remote
8884 .head_entry
8885 .as_ref()
8886 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
8887 let expected = &signed_head.entry.pack_sha256;
8888 if !is_sha256(expected) {
8889 return Err(invalid_feed(
8890 "signed head carries an invalid snapshot pack digest",
8891 ));
8892 }
8893 if let Some(url) = body.get("url").and_then(Value::as_str) {
8894 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
8895 return Err(invalid_feed(
8896 "export pack digest does not match the signed head entry",
8897 ));
8898 }
8899 let bytes = get_presigned(cfg, url)?;
8900 let actual = format!("{:x}", Sha256::digest(&bytes));
8901 if actual != *expected {
8902 return Err(LinkError::InvalidPack {
8903 message: "downloaded pack does not match the signed snapshot digest"
8904 .to_string(),
8905 });
8906 }
8907 let entries = parse_store_pack(bytes)?;
8908 if signed_head.entry.kind == "push" {
8909 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
8910 }
8911 entries
8912 } else {
8913 if signed_head.entry.kind != "push" {
8914 return Err(invalid_feed(
8915 "delta snapshots must export the exact signed pack",
8916 ));
8917 }
8918 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
8919 invalid_feed("verified snapshot export carried neither a pack nor files")
8920 })?;
8921 let mut entries = Vec::with_capacity(files.len());
8922 for file in files {
8923 let path = file
8924 .get("path")
8925 .and_then(Value::as_str)
8926 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
8927 let content = file
8928 .get("content")
8929 .and_then(Value::as_str)
8930 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
8931 entries.push((path.to_string(), content.as_bytes().to_vec()));
8932 }
8933 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
8934 entries
8935 }
8936 };
8937
8938 let mut seen = std::collections::HashSet::new();
8940 for (path, _) in &entries {
8941 if !safe_store_rel_path(path) {
8942 return Err(LinkError::UnsafePath { path: path.clone() });
8943 }
8944 if !seen.insert(path) {
8945 return Err(LinkError::InvalidPack {
8946 message: format!("duplicate path `{path}`"),
8947 });
8948 }
8949 }
8950 let pulled: std::collections::BTreeSet<&str> =
8953 entries.iter().map(|(p, _)| p.as_str()).collect();
8954 let mut extra_local = Vec::new();
8955 if let Ok(store) = Store::open(&dest) {
8956 if let Ok(walked) = store.walk() {
8957 for rel in walked {
8958 let rel_str = rel.to_string_lossy().replace('\\', "/");
8959 if !pulled.contains(rel_str.as_str()) {
8960 extra_local.push(rel_str);
8961 }
8962 }
8963 }
8964 }
8965 #[cfg(unix)]
8966 install_pulled_snapshot(&dest, &entries)?;
8967
8968 Ok(PullReport {
8969 brain: brain_id,
8970 slug,
8971 head_seq,
8972 files: entries.len(),
8973 dest: dest.to_string_lossy().into_owned(),
8974 extra_local,
8975 sync_status: "synced".to_string(),
8976 })
8977}
8978
8979#[cfg(unix)]
8980fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
8981 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
8982 path: display.to_string(),
8983 })
8984}
8985
8986#[cfg(unix)]
8987fn open_dir_at(
8988 parent: std::os::fd::RawFd,
8989 name: &std::ffi::CStr,
8990 display: &str,
8991) -> LinkResult<std::fs::File> {
8992 use std::os::fd::FromRawFd as _;
8993 let fd = unsafe {
8994 libc::openat(
8995 parent,
8996 name.as_ptr(),
8997 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8998 )
8999 };
9000 if fd < 0 {
9001 return Err(LinkError::UnsafePath {
9002 path: display.to_string(),
9003 });
9004 }
9005 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
9006}
9007
9008#[cfg(unix)]
9012fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
9013 use std::os::fd::AsRawFd as _;
9014
9015 #[cfg(target_os = "macos")]
9019 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
9020 .into_iter()
9021 .find_map(|(alias, real)| {
9022 path.strip_prefix(alias)
9023 .ok()
9024 .map(|rest| Path::new(real).join(rest))
9025 })
9026 .unwrap_or_else(|| path.to_path_buf());
9027 #[cfg(not(target_os = "macos"))]
9028 let normalized = path.to_path_buf();
9029
9030 let start = if normalized.is_absolute() {
9031 std::fs::File::open("/")?
9032 } else {
9033 std::fs::File::open(".")?
9034 };
9035 let mut directory = start;
9036 for component in normalized.components() {
9037 use std::path::Component;
9038 let name = match component {
9039 Component::RootDir | Component::CurDir => continue,
9040 Component::Normal(name) => name,
9041 Component::ParentDir | Component::Prefix(_) => {
9042 return Err(LinkError::UnsafePath {
9043 path: path.display().to_string(),
9044 });
9045 }
9046 };
9047 use std::os::unix::ffi::OsStrExt as _;
9048 let name = c_name(name.as_bytes(), &path.display().to_string())?;
9049 if create {
9050 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9051 if made != 0 {
9052 let error = std::io::Error::last_os_error();
9053 if error.raw_os_error() != Some(libc::EEXIST) {
9054 return Err(error.into());
9055 }
9056 }
9057 }
9058 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
9059 }
9060 Ok(directory)
9061}
9062
9063#[cfg(unix)]
9064fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9065 open_dir_path_nofollow(path, true)
9066}
9067
9068#[cfg(unix)]
9069fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9070 open_dir_path_nofollow(path, false)
9071}
9072
9073#[cfg(unix)]
9074fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
9075 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9076 let result =
9077 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
9078 if result == 0 {
9079 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
9080 }
9081 let error = std::io::Error::last_os_error();
9082 if error.kind() == std::io::ErrorKind::NotFound {
9083 Ok(None)
9084 } else {
9085 Err(error.into())
9086 }
9087}
9088
9089#[cfg(unix)]
9090fn create_dir_exclusive_at(
9091 parent: std::os::fd::RawFd,
9092 name: &std::ffi::CStr,
9093 display: &str,
9094) -> LinkResult<std::fs::File> {
9095 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
9096 if made != 0 {
9097 return Err(LinkError::UnsafePath {
9098 path: display.to_string(),
9099 });
9100 }
9101 open_dir_at(parent, name, display)
9102}
9103
9104#[cfg(unix)]
9105fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
9106 use std::os::fd::AsRawFd as _;
9107
9108 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
9109 if duplicate < 0 {
9110 return Err(std::io::Error::last_os_error().into());
9111 }
9112 let stream = unsafe { libc::fdopendir(duplicate) };
9113 if stream.is_null() {
9114 let error = std::io::Error::last_os_error();
9115 unsafe {
9116 libc::close(duplicate);
9117 }
9118 return Err(error.into());
9119 }
9120 let mut names = Vec::new();
9121 loop {
9122 let entry = unsafe { libc::readdir(stream) };
9123 if entry.is_null() {
9124 break;
9125 }
9126 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
9127 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
9128 names.push(raw.to_owned());
9129 }
9130 }
9131 if unsafe { libc::closedir(stream) } != 0 {
9132 return Err(std::io::Error::last_os_error().into());
9133 }
9134 Ok(names)
9135}
9136
9137#[cfg(unix)]
9140fn remove_tree_at(
9141 parent: std::os::fd::RawFd,
9142 name: &std::ffi::CStr,
9143 display: &str,
9144) -> LinkResult<()> {
9145 use std::os::fd::AsRawFd as _;
9146
9147 match entry_is_dir_at(parent, name)? {
9148 None => return Ok(()),
9149 Some(false) => {
9150 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
9151 return Err(std::io::Error::last_os_error().into());
9152 }
9153 }
9154 Some(true) => {
9155 let directory = open_dir_at(parent, name, display)?;
9156 for child in directory_entry_names(&directory)? {
9157 let child_display =
9158 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
9159 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
9160 }
9161 drop(directory);
9162 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
9163 return Err(std::io::Error::last_os_error().into());
9164 }
9165 }
9166 }
9167 Ok(())
9168}
9169
9170#[cfg(unix)]
9174fn clone_tree_contents(
9175 source: &std::fs::File,
9176 destination: &std::fs::File,
9177 display: &str,
9178) -> LinkResult<()> {
9179 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9180
9181 for name in directory_entry_names(source)? {
9182 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9183 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9184 if unsafe {
9185 libc::fstatat(
9186 source.as_raw_fd(),
9187 name.as_ptr(),
9188 &mut stat,
9189 libc::AT_SYMLINK_NOFOLLOW,
9190 )
9191 } != 0
9192 {
9193 return Err(std::io::Error::last_os_error().into());
9194 }
9195 match stat.st_mode & libc::S_IFMT {
9196 libc::S_IFDIR => {
9197 if unsafe {
9198 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
9199 } != 0
9200 {
9201 return Err(std::io::Error::last_os_error().into());
9202 }
9203 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
9204 let destination_child =
9205 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
9206 clone_tree_contents(&source_child, &destination_child, &child_display)?;
9207 destination_child.sync_all()?;
9208 }
9209 libc::S_IFREG => {
9210 let source_fd = unsafe {
9211 libc::openat(
9212 source.as_raw_fd(),
9213 name.as_ptr(),
9214 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9215 )
9216 };
9217 if source_fd < 0 {
9218 return Err(std::io::Error::last_os_error().into());
9219 }
9220 let destination_fd = unsafe {
9221 libc::openat(
9222 destination.as_raw_fd(),
9223 name.as_ptr(),
9224 libc::O_WRONLY
9225 | libc::O_CREAT
9226 | libc::O_EXCL
9227 | libc::O_CLOEXEC
9228 | libc::O_NOFOLLOW,
9229 (stat.st_mode & 0o777) as libc::c_uint,
9230 )
9231 };
9232 if destination_fd < 0 {
9233 unsafe {
9234 libc::close(source_fd);
9235 }
9236 return Err(std::io::Error::last_os_error().into());
9237 }
9238 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
9239 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
9240 std::io::copy(&mut input, &mut output)?;
9241 output.sync_all()?;
9242 }
9243 libc::S_IFLNK => {
9244 let mut target = vec![0_u8; 4097];
9245 let length = unsafe {
9246 libc::readlinkat(
9247 source.as_raw_fd(),
9248 name.as_ptr(),
9249 target.as_mut_ptr().cast(),
9250 target.len(),
9251 )
9252 };
9253 if length < 0 || length as usize >= target.len() {
9254 return Err(LinkError::UnsafePath {
9255 path: child_display,
9256 });
9257 }
9258 target.truncate(length as usize);
9259 let target = c_name(&target, &child_display)?;
9260 if unsafe {
9261 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
9262 } != 0
9263 {
9264 return Err(std::io::Error::last_os_error().into());
9265 }
9266 }
9267 _ => {
9268 return Err(LinkError::UnsafePath {
9269 path: child_display,
9270 });
9271 }
9272 }
9273 }
9274 destination.sync_all()?;
9275 Ok(())
9276}
9277
9278#[cfg(target_os = "linux")]
9279fn install_stage_at(
9280 parent: std::os::fd::RawFd,
9281 stage: &std::ffi::CStr,
9282 dest: &std::ffi::CStr,
9283 dest_exists: bool,
9284) -> LinkResult<()> {
9285 let flags = if dest_exists {
9286 libc::RENAME_EXCHANGE
9287 } else {
9288 libc::RENAME_NOREPLACE
9289 };
9290 let result = unsafe {
9294 libc::syscall(
9295 libc::SYS_renameat2,
9296 parent,
9297 stage.as_ptr(),
9298 parent,
9299 dest.as_ptr(),
9300 flags,
9301 )
9302 };
9303 if result == 0 {
9304 Ok(())
9305 } else {
9306 Err(std::io::Error::last_os_error().into())
9307 }
9308}
9309
9310#[cfg(target_os = "macos")]
9311fn install_stage_at(
9312 parent: std::os::fd::RawFd,
9313 stage: &std::ffi::CStr,
9314 dest: &std::ffi::CStr,
9315 dest_exists: bool,
9316) -> LinkResult<()> {
9317 let flags = if dest_exists {
9318 libc::RENAME_SWAP
9319 } else {
9320 libc::RENAME_EXCL
9321 };
9322 let result =
9323 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
9324 if result == 0 {
9325 Ok(())
9326 } else {
9327 Err(std::io::Error::last_os_error().into())
9328 }
9329}
9330
9331#[cfg(unix)]
9332fn write_pull_entries_beneath_dir(
9333 root: &std::fs::File,
9334 entries: &[(String, Vec<u8>)],
9335) -> LinkResult<()> {
9336 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9337
9338 for (path, content) in entries {
9339 let components: Vec<&str> = path.split('/').collect();
9340 let (leaf, parents) = components
9341 .split_last()
9342 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9343 let mut directory = root.try_clone()?;
9344 for component in parents {
9345 let name = c_name(component.as_bytes(), path)?;
9346 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9347 if made != 0 {
9348 let error = std::io::Error::last_os_error();
9349 if error.raw_os_error() != Some(libc::EEXIST) {
9350 return Err(error.into());
9351 }
9352 }
9353 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9354 }
9355
9356 let leaf_name = c_name(leaf.as_bytes(), path)?;
9357 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9358 let inspected = unsafe {
9359 libc::fstatat(
9360 directory.as_raw_fd(),
9361 leaf_name.as_ptr(),
9362 &mut existing,
9363 libc::AT_SYMLINK_NOFOLLOW,
9364 )
9365 };
9366 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
9367 return Err(LinkError::UnsafePath { path: path.clone() });
9368 }
9369
9370 let nonce = std::time::SystemTime::now()
9371 .duration_since(std::time::UNIX_EPOCH)
9372 .unwrap_or_default()
9373 .as_nanos();
9374 let temp_name = format!(
9375 ".dbmd-pull-{}-{nonce}-{}",
9376 std::process::id(),
9377 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
9378 );
9379 let temp = c_name(temp_name.as_bytes(), path)?;
9380 let fd = unsafe {
9381 libc::openat(
9382 directory.as_raw_fd(),
9383 temp.as_ptr(),
9384 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9385 0o600,
9386 )
9387 };
9388 if fd < 0 {
9389 return Err(std::io::Error::last_os_error().into());
9390 }
9391 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9392 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
9393 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9394 return Err(error.into());
9395 }
9396 drop(file);
9397 let renamed = unsafe {
9398 libc::renameat(
9399 directory.as_raw_fd(),
9400 temp.as_ptr(),
9401 directory.as_raw_fd(),
9402 leaf_name.as_ptr(),
9403 )
9404 };
9405 if renamed != 0 {
9406 let error = std::io::Error::last_os_error();
9407 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9408 return Err(error.into());
9409 }
9410 directory.sync_all()?;
9411 }
9412 root.sync_all()?;
9413 Ok(())
9414}
9415
9416#[cfg(unix)]
9417fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9418 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9419
9420 let path = &entry.path;
9421 let components: Vec<&str> = path.split('/').collect();
9422 let (leaf, parents) = components
9423 .split_last()
9424 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9425 let mut directory = root.try_clone()?;
9426 for component in parents {
9427 let name = c_name(component.as_bytes(), path)?;
9428 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9429 if made != 0 {
9430 let error = std::io::Error::last_os_error();
9431 if error.raw_os_error() != Some(libc::EEXIST) {
9432 return Err(error.into());
9433 }
9434 }
9435 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9436 }
9437 let leaf_name = c_name(leaf.as_bytes(), path)?;
9438 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9439 if unsafe {
9440 libc::fstatat(
9441 directory.as_raw_fd(),
9442 leaf_name.as_ptr(),
9443 &mut existing,
9444 libc::AT_SYMLINK_NOFOLLOW,
9445 )
9446 } == 0
9447 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
9448 {
9449 return Err(LinkError::UnsafePath { path: path.clone() });
9450 }
9451 let nonce = SystemTime::now()
9452 .duration_since(UNIX_EPOCH)
9453 .unwrap_or_default()
9454 .as_nanos();
9455 let temp_name = format!(
9456 ".dbmd-pull-{}-{nonce}-{}",
9457 std::process::id(),
9458 content_sha256(path.as_bytes())
9459 );
9460 let temp = c_name(temp_name.as_bytes(), path)?;
9461 let fd = unsafe {
9462 libc::openat(
9463 directory.as_raw_fd(),
9464 temp.as_ptr(),
9465 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9466 0o600,
9467 )
9468 };
9469 if fd < 0 {
9470 return Err(std::io::Error::last_os_error().into());
9471 }
9472 let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
9473 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
9474 let mut digest = Sha256::new();
9475 let mut total = 0_u64;
9476 let mut buffer = [0_u8; 64 * 1024];
9477 let copied = (|| -> std::io::Result<()> {
9478 loop {
9479 let read = input.read(&mut buffer)?;
9480 if read == 0 {
9481 break;
9482 }
9483 total = total.saturating_add(read as u64);
9484 if total > entry.bytes {
9485 return Err(std::io::Error::new(
9486 std::io::ErrorKind::InvalidData,
9487 "staged sync source grew beyond its verified length",
9488 ));
9489 }
9490 digest.update(&buffer[..read]);
9491 output.write_all(&buffer[..read])?;
9492 }
9493 Ok(())
9494 })();
9495 if let Err(error) = copied {
9496 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9497 return Err(error.into());
9498 }
9499 drop(output);
9500 if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
9501 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9502 return Err(invalid_feed(
9503 "private staged sync source failed final integrity verification",
9504 ));
9505 }
9506 if unsafe {
9507 libc::renameat(
9508 directory.as_raw_fd(),
9509 temp.as_ptr(),
9510 directory.as_raw_fd(),
9511 leaf_name.as_ptr(),
9512 )
9513 } != 0
9514 {
9515 let error = std::io::Error::last_os_error();
9516 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9517 return Err(error.into());
9518 }
9519 Ok(())
9520}
9521
9522#[cfg(unix)]
9523fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9524 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9525
9526 let path = &entry.path;
9527 let components: Vec<&str> = path.split('/').collect();
9528 let (leaf, parents) = components
9529 .split_last()
9530 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9531 let mut directory = root.try_clone()?;
9532 for component in parents {
9533 directory = open_dir_at(
9534 directory.as_raw_fd(),
9535 &c_name(component.as_bytes(), path)?,
9536 path,
9537 )?;
9538 }
9539 let leaf = c_name(leaf.as_bytes(), path)?;
9540 let fd = unsafe {
9541 libc::openat(
9542 directory.as_raw_fd(),
9543 leaf.as_ptr(),
9544 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9545 )
9546 };
9547 if fd < 0 {
9548 return Err(std::io::Error::last_os_error().into());
9549 }
9550 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9551 if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
9552 return Err(invalid_feed(
9553 "private pull stage changed before its durability barrier",
9554 ));
9555 }
9556 file.sync_all()?;
9557 Ok(())
9558}
9559
9560#[cfg(unix)]
9561fn run_pull_source_workers(
9562 root: &std::fs::File,
9563 entries: &[V2StagedFile],
9564 operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
9565) -> LinkResult<()> {
9566 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9567
9568 let next = AtomicUsize::new(0);
9569 let failed = AtomicBool::new(false);
9570 let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
9571 let mut first_error = None;
9572 std::thread::scope(|scope| {
9573 let (sender, receiver) = std::sync::mpsc::channel();
9574 for _ in 0..worker_count {
9575 let sender = sender.clone();
9576 let next = &next;
9577 let failed = &failed;
9578 scope.spawn(move || {
9579 while !failed.load(Ordering::Acquire) {
9580 let index = next.fetch_add(1, Ordering::Relaxed);
9581 let Some(entry) = entries.get(index) else {
9582 break;
9583 };
9584 let result = operation(root, entry);
9585 if result.is_err() {
9586 failed.store(true, Ordering::Release);
9587 }
9588 if sender.send(result).is_err() {
9589 break;
9590 }
9591 }
9592 });
9593 }
9594 drop(sender);
9595 for result in receiver {
9596 if let Err(error) = result {
9597 if first_error.is_none() {
9598 first_error = Some(error);
9599 }
9600 }
9601 }
9602 });
9603 if let Some(error) = first_error {
9604 return Err(error);
9605 }
9606 if next.load(Ordering::Relaxed) < entries.len() {
9607 return Err(invalid_feed(
9608 "a bounded pull worker stopped before reporting every file",
9609 ));
9610 }
9611 Ok(())
9612}
9613
9614#[cfg(unix)]
9615fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9616 use std::os::fd::AsRawFd as _;
9617
9618 for name in directory_entry_names(root)? {
9619 if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9620 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9621 let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9622 sync_pull_directory_tree(&child, &child_display)?;
9623 }
9624 }
9625 root.sync_all()?;
9626 Ok(())
9627}
9628
9629#[cfg(unix)]
9630fn write_pull_sources_beneath_dir(
9631 root: &std::fs::File,
9632 entries: &[V2StagedFile],
9633) -> LinkResult<()> {
9634 run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
9641 run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
9642 sync_pull_directory_tree(root, "v2 pull stage")
9643}
9644
9645#[cfg(unix)]
9646fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
9647 use std::os::fd::AsRawFd as _;
9648 for path in paths {
9649 if !safe_store_rel_path(path) {
9650 return Err(LinkError::UnsafePath { path: path.clone() });
9651 }
9652 let components = path.split('/').collect::<Vec<_>>();
9653 let Some((leaf, parents)) = components.split_last() else {
9654 return Err(LinkError::UnsafePath { path: path.clone() });
9655 };
9656 let mut directory = root.try_clone()?;
9657 let mut missing = false;
9658 for component in parents {
9659 let name = c_name(component.as_bytes(), path)?;
9660 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
9661 None => {
9662 missing = true;
9663 break;
9664 }
9665 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
9666 Some(true) => {
9667 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9668 }
9669 }
9670 }
9671 if missing {
9672 continue;
9673 }
9674 let leaf = c_name(leaf.as_bytes(), path)?;
9675 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
9676 None => {}
9677 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
9678 Some(false) => {
9679 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
9680 return Err(std::io::Error::last_os_error().into());
9681 }
9682 directory.sync_all()?;
9683 }
9684 }
9685 }
9686 Ok(())
9687}
9688
9689#[cfg(unix)]
9690fn install_pulled_delta(
9691 dest: &Path,
9692 entries: &[(String, Vec<u8>)],
9693 deleted: &[String],
9694 rebuild_indexes: bool,
9695) -> LinkResult<()> {
9696 use ring::rand::SecureRandom as _;
9697 use std::os::fd::AsRawFd as _;
9698 use std::os::unix::ffi::OsStrExt as _;
9699
9700 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9701 let name = dest
9702 .file_name()
9703 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9704 .ok_or_else(|| LinkError::UnsafePath {
9705 path: dest.display().to_string(),
9706 })?;
9707 let parent_dir = open_or_create_dir_nofollow(parent)?;
9708 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9709 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9710 None => false,
9711 Some(true) => true,
9712 Some(false) => {
9713 return Err(LinkError::UnsafePath {
9714 path: dest.display().to_string(),
9715 });
9716 }
9717 };
9718
9719 let mut nonce = [0_u8; 16];
9720 ring::rand::SystemRandom::new()
9721 .fill(&mut nonce)
9722 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9723 let stage_label = format!(
9724 ".{}.dbmd-pull-stage-{}",
9725 name.to_string_lossy(),
9726 URL_SAFE_NO_PAD.encode(nonce)
9727 );
9728 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9729 let stage_dir = create_dir_exclusive_at(
9730 parent_dir.as_raw_fd(),
9731 &stage_name,
9732 &dest.display().to_string(),
9733 )?;
9734
9735 let prepared = (|| -> LinkResult<()> {
9736 if dest_exists {
9737 let live = open_dir_at(
9738 parent_dir.as_raw_fd(),
9739 &dest_name,
9740 &dest.display().to_string(),
9741 )?;
9742 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9743 }
9744 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9745 write_pull_entries_beneath_dir(&stage_dir, entries)?;
9746 if rebuild_indexes {
9747 let stage_store =
9748 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
9749 .map_err(|error| LinkError::InvalidPack {
9750 message: format!("v2 staging tree is not a valid db.md store: {error}"),
9751 })?;
9752 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
9753 LinkError::InvalidPack {
9754 message: format!("could not materialize v2 local catalogs: {error}"),
9755 }
9756 })?;
9757 }
9758 stage_dir.sync_all()?;
9759 Ok(())
9760 })();
9761 if let Err(error) = prepared {
9762 let _ = remove_tree_at(
9763 parent_dir.as_raw_fd(),
9764 &stage_name,
9765 &dest.display().to_string(),
9766 );
9767 return Err(error);
9768 }
9769
9770 if let Err(error) =
9771 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
9772 {
9773 let _ = remove_tree_at(
9774 parent_dir.as_raw_fd(),
9775 &stage_name,
9776 &dest.display().to_string(),
9777 );
9778 return Err(error);
9779 }
9780 parent_dir.sync_all()?;
9781 if dest_exists {
9782 let _ = remove_tree_at(
9786 parent_dir.as_raw_fd(),
9787 &stage_name,
9788 &dest.display().to_string(),
9789 );
9790 let _ = parent_dir.sync_all();
9791 }
9792 Ok(())
9793}
9794
9795#[cfg(unix)]
9796fn install_pulled_delta_sources(
9797 dest: &Path,
9798 entries: &[V2StagedFile],
9799 deleted: &[String],
9800 rebuild_indexes: bool,
9801 _previous: Option<&V2SyncBaseline>,
9802 _next: &V2VerifiedHead,
9803) -> LinkResult<()> {
9804 use ring::rand::SecureRandom as _;
9805 use std::os::fd::AsRawFd as _;
9806 use std::os::unix::ffi::OsStrExt as _;
9807
9808 if let Ok(store) = Store::open_strict(dest) {
9812 return install_established_v2_delta(
9813 store,
9814 entries,
9815 deleted,
9816 rebuild_indexes,
9817 _previous,
9818 _next,
9819 );
9820 }
9821
9822 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9823 let name = dest
9824 .file_name()
9825 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9826 .ok_or_else(|| LinkError::UnsafePath {
9827 path: dest.display().to_string(),
9828 })?;
9829 let parent_dir = open_or_create_dir_nofollow(parent)?;
9830 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9831 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9832 None => false,
9833 Some(true) => true,
9834 Some(false) => {
9835 return Err(LinkError::UnsafePath {
9836 path: dest.display().to_string(),
9837 })
9838 }
9839 };
9840 let mut nonce = [0_u8; 16];
9841 ring::rand::SystemRandom::new()
9842 .fill(&mut nonce)
9843 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9844 let stage_label = format!(
9845 ".{}.dbmd-pull-stage-{}",
9846 name.to_string_lossy(),
9847 URL_SAFE_NO_PAD.encode(nonce)
9848 );
9849 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9850 let stage_dir = create_dir_exclusive_at(
9851 parent_dir.as_raw_fd(),
9852 &stage_name,
9853 &dest.display().to_string(),
9854 )?;
9855 let prepared = (|| -> LinkResult<()> {
9856 if dest_exists {
9857 let live = open_dir_at(
9858 parent_dir.as_raw_fd(),
9859 &dest_name,
9860 &dest.display().to_string(),
9861 )?;
9862 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9863 }
9864 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9865 write_pull_sources_beneath_dir(&stage_dir, entries)?;
9866 if rebuild_indexes {
9867 let stage_store =
9868 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
9869 .map_err(|error| LinkError::InvalidPack {
9870 message: format!("v2 staging tree is not a valid db.md store: {error}"),
9871 })?;
9872 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
9873 LinkError::InvalidPack {
9874 message: format!("could not materialize v2 local catalogs: {error}"),
9875 }
9876 })?;
9877 }
9878 stage_dir.sync_all()?;
9879 Ok(())
9880 })();
9881 if let Err(error) = prepared {
9882 let _ = remove_tree_at(
9883 parent_dir.as_raw_fd(),
9884 &stage_name,
9885 &dest.display().to_string(),
9886 );
9887 return Err(error);
9888 }
9889 if let Err(error) =
9890 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
9891 {
9892 let _ = remove_tree_at(
9893 parent_dir.as_raw_fd(),
9894 &stage_name,
9895 &dest.display().to_string(),
9896 );
9897 return Err(error);
9898 }
9899 parent_dir.sync_all()?;
9900 if dest_exists {
9901 let _ = remove_tree_at(
9902 parent_dir.as_raw_fd(),
9903 &stage_name,
9904 &dest.display().to_string(),
9905 );
9906 let _ = parent_dir.sync_all();
9907 }
9908 Ok(())
9909}
9910
9911#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9912struct V2PullCoordinate {
9913 head_seq: Option<u64>,
9914 commit_hash: Option<String>,
9915 view_kind: Option<String>,
9916 view_revision: Option<String>,
9917}
9918
9919#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9920struct V2PullFileCoordinate {
9921 sha256: String,
9922 bytes: u64,
9923}
9924
9925#[derive(Debug, Clone, Deserialize, Serialize)]
9926struct V2PullJournalEntry {
9927 path: String,
9928 old: Option<V2PullFileCoordinate>,
9929 new: Option<V2PullFileCoordinate>,
9930 backup: Option<String>,
9931}
9932
9933#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9934#[serde(rename_all = "snake_case")]
9935enum V2PullPhase {
9936 Preparing,
9937 Ready,
9938}
9939
9940#[derive(Debug, Clone, Deserialize, Serialize)]
9941struct V2PullJournal {
9942 v: u8,
9943 phase: V2PullPhase,
9944 brain: String,
9945 previous: V2PullCoordinate,
9946 next: V2PullCoordinate,
9947 backup_dir: String,
9948 entries: Vec<V2PullJournalEntry>,
9949}
9950
9951const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
9952
9953fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
9954 V2PullCoordinate {
9955 head_seq: baseline.and_then(|value| value.head_seq),
9956 commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
9957 view_kind: baseline.and_then(|value| value.view_kind.clone()),
9958 view_revision: baseline.and_then(|value| value.view_revision.clone()),
9959 }
9960}
9961
9962fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
9963 V2PullCoordinate {
9964 head_seq: head.pointer.as_ref().map(|value| value.seq),
9965 commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
9966 view_kind: Some(head.view_kind.clone()),
9967 view_revision: Some(head.view_revision.clone()),
9968 }
9969}
9970
9971fn v2_pull_file_coordinate(
9972 store: &Store,
9973 path: &str,
9974 limit: u64,
9975) -> LinkResult<Option<V2PullFileCoordinate>> {
9976 let file = match store.open_regular(Path::new(path)) {
9977 Ok(file) => file,
9978 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9979 Err(error) => return Err(error.into()),
9980 };
9981 let bytes = file.metadata()?.len();
9982 if bytes > limit || bytes > MAX_STORE_BYTES {
9983 return Err(invalid_feed(
9984 "pull transaction file exceeds its declared bound",
9985 ));
9986 }
9987 Ok(Some(V2PullFileCoordinate {
9988 sha256: content_sha256_reader(file)?,
9989 bytes,
9990 }))
9991}
9992
9993fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
9994 let mut bytes = serde_json::to_vec_pretty(journal)
9995 .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
9996 bytes.push(b'\n');
9997 Ok(bytes)
9998}
9999
10000fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
10001 let backup_prefix = ".dbmd/pull-backup-";
10002 let suffix = journal
10003 .backup_dir
10004 .strip_prefix(backup_prefix)
10005 .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
10006 let mut paths = std::collections::BTreeSet::new();
10007 if journal.v != 1
10008 || !crate::ulid::is_ulid(&journal.brain)
10009 || !crate::ulid::is_ulid(suffix)
10010 || journal.entries.is_empty()
10011 || journal.entries.len() > MAX_PUSH_FILES + 4
10012 || journal.previous == journal.next
10013 {
10014 return Err(invalid_feed("v2 pull journal failed validation"));
10015 }
10016 for (index, entry) in journal.entries.iter().enumerate() {
10017 if !safe_store_rel_path(&entry.path)
10018 || entry.path == V2_PULL_JOURNAL
10019 || entry.path.starts_with(backup_prefix)
10020 || !paths.insert(entry.path.clone())
10021 || (entry.old.is_none() && entry.new.is_none())
10022 || entry
10023 .old
10024 .iter()
10025 .chain(entry.new.iter())
10026 .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
10027 || entry.backup.as_deref()
10028 != entry
10029 .old
10030 .as_ref()
10031 .map(|_| format!("{index:08x}"))
10032 .as_deref()
10033 {
10034 return Err(invalid_feed("v2 pull journal entry failed validation"));
10035 }
10036 }
10037 Ok(())
10038}
10039
10040fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
10041 #[cfg(unix)]
10042 {
10043 use std::os::unix::fs::PermissionsExt as _;
10044 match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
10045 Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
10046 return Err(invalid_feed(
10047 "v2 pull journal is accessible to group/other; set mode 0600",
10048 ));
10049 }
10050 Ok(_) => {}
10051 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10052 Err(error) => return Err(error.into()),
10053 }
10054 }
10055 let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
10056 Ok(bytes) => bytes,
10057 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10058 Err(error) => return Err(error.into()),
10059 };
10060 let journal: V2PullJournal =
10061 serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
10062 validate_v2_pull_journal(&journal)?;
10063 Ok(Some(journal))
10064}
10065
10066fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10067 match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
10071 Ok(()) => {}
10072 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
10073 Err(error) => return Err(error.into()),
10074 }
10075 match store.remove_private_tree(Path::new(&journal.backup_dir)) {
10076 Ok(()) => Ok(()),
10077 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
10078 Err(error) => Err(error.into()),
10079 }
10080}
10081
10082fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
10083 let names = match store.directory_names(Path::new(".dbmd")) {
10084 Ok(names) => names,
10085 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
10086 Err(error) => return Err(error.into()),
10087 };
10088 for name in names {
10089 let Some(name) = name.to_str() else {
10090 continue;
10091 };
10092 let Some(suffix) = name.strip_prefix("pull-backup-") else {
10093 continue;
10094 };
10095 if crate::ulid::is_ulid(suffix) {
10096 store.remove_private_tree(&Path::new(".dbmd").join(name))?;
10097 }
10098 }
10099 Ok(())
10100}
10101
10102fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10103 for entry in &journal.entries {
10105 let limit = entry
10106 .old
10107 .as_ref()
10108 .into_iter()
10109 .chain(entry.new.iter())
10110 .map(|value| value.bytes)
10111 .max()
10112 .unwrap_or(0);
10113 let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
10114 if current != entry.old && current != entry.new {
10115 return Err(LinkError::InvalidPack {
10116 message: format!(
10117 "cannot recover interrupted pull because `{}` changed afterward",
10118 entry.path
10119 ),
10120 });
10121 }
10122 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10123 let path = Path::new(&journal.backup_dir).join(backup);
10124 let file = store.open_regular(&path)?;
10125 if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
10126 return Err(invalid_feed("v2 pull recovery backup failed verification"));
10127 }
10128 }
10129 }
10130 for entry in journal.entries.iter().rev() {
10131 match (&entry.old, &entry.backup) {
10132 (Some(old), Some(backup)) => {
10133 let bytes =
10134 store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
10135 store.write_atomic(Path::new(&entry.path), &bytes)?;
10136 }
10137 (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
10138 store.remove_file(Path::new(&entry.path))?;
10139 }
10140 (None, None) => {}
10141 _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
10142 }
10143 }
10144 crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
10145 message: format!("could not rebuild catalogs after pull recovery: {error}"),
10146 })?;
10147 cleanup_v2_pull_journal(store, journal)
10148}
10149
10150fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
10151 let Ok(store) = Store::open_strict(dest) else {
10152 return Ok(());
10153 };
10154 if let Some(journal) = load_v2_pull_journal(&store)? {
10155 if journal.brain != brain {
10156 return Err(invalid_feed("v2 pull journal belongs to another brain"));
10157 }
10158 if journal.phase == V2PullPhase::Preparing {
10159 cleanup_v2_pull_journal(&store, &journal)?;
10160 } else {
10161 let baseline = load_v2_baseline(cfg, brain, dest)?;
10162 let current = v2_pull_baseline_coordinate(baseline.as_ref());
10163 if current == journal.next {
10164 cleanup_v2_pull_journal(&store, &journal)?;
10165 } else {
10166 if current != journal.previous {
10167 return Err(invalid_feed(
10168 "cannot recover interrupted pull because its baseline changed afterward",
10169 ));
10170 }
10171 rollback_v2_pull(&store, &journal)?;
10172 }
10173 }
10174 }
10175 prune_orphan_v2_pull_backups(&store)
10180}
10181
10182fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
10183 let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
10184 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
10185 })?;
10186 if let Some(journal) = load_v2_pull_journal(&store)? {
10187 cleanup_v2_pull_journal(&store, &journal)?;
10188 }
10189 Ok(())
10190}
10191
10192#[cfg(windows)]
10193fn install_windows_initial_sources(
10194 dest: &Path,
10195 entries: &[V2StagedFile],
10196 rebuild_indexes: bool,
10197) -> LinkResult<()> {
10198 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10199 let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
10200 path: dest.display().to_string(),
10201 })?;
10202 let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
10203 if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
10204 return Err(LinkError::UnsafePath {
10205 path: dest.display().to_string(),
10206 });
10207 }
10208 let stage_name = format!(
10209 ".{}.dbmd-pull-stage-{}",
10210 name.to_string_lossy(),
10211 crate::ulid::mint()
10212 );
10213 let stage_path = parent.join(&stage_name);
10214 let stage_capability =
10215 crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
10216 let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
10217 let prepared = (|| -> LinkResult<()> {
10218 for entry in entries {
10219 let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
10220 if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
10221 return Err(invalid_feed(
10222 "private staged sync source failed final integrity verification",
10223 ));
10224 }
10225 stage.write_atomic(Path::new(&entry.path), &bytes)?;
10226 }
10227 let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
10228 .map_err(|error| LinkError::InvalidPack {
10229 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10230 })?;
10231 if rebuild_indexes {
10232 crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
10233 message: format!("could not materialize v2 local catalogs: {error}"),
10234 })?;
10235 }
10236 Ok(())
10237 })();
10238 if let Err(error) = prepared {
10239 let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
10240 return Err(error);
10241 }
10242 crate::fsx::rename_directory_beneath(
10243 &parent_capability,
10244 Path::new(&stage_name),
10245 Path::new(name),
10246 )?;
10247 Ok(())
10248}
10249
10250fn install_established_v2_delta(
10251 store: Store,
10252 entries: &[V2StagedFile],
10253 deleted: &[String],
10254 rebuild_indexes: bool,
10255 previous: Option<&V2SyncBaseline>,
10256 next: &V2VerifiedHead,
10257) -> LinkResult<()> {
10258 if load_v2_pull_journal(&store)?.is_some() {
10259 return Err(invalid_feed(
10260 "an interrupted pull must be recovered before installing",
10261 ));
10262 }
10263 let mut sources = std::collections::BTreeMap::new();
10264 for entry in entries {
10265 if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
10266 return Err(invalid_feed("pull mutation repeats a path"));
10267 }
10268 }
10269 let mut paths = sources.keys().cloned().collect::<Vec<_>>();
10270 paths.extend(deleted.iter().cloned());
10271 paths.sort();
10272 paths.dedup();
10273 if paths.is_empty() {
10274 return Ok(());
10275 }
10276 let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
10277 let mut journal = V2PullJournal {
10278 v: 1,
10279 phase: V2PullPhase::Preparing,
10280 brain: next.brain_id.clone(),
10281 previous: v2_pull_baseline_coordinate(previous),
10282 next: v2_pull_head_coordinate(next),
10283 backup_dir: backup_dir.clone(),
10284 entries: Vec::with_capacity(paths.len()),
10285 };
10286 for path in &paths {
10287 let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
10288 let new = sources.get(path).map(|entry| V2PullFileCoordinate {
10289 sha256: entry.sha256.clone(),
10290 bytes: entry.bytes,
10291 });
10292 if old == new {
10293 continue;
10294 }
10295 let index = journal.entries.len();
10296 journal.entries.push(V2PullJournalEntry {
10297 path: path.clone(),
10298 backup: old.as_ref().map(|_| format!("{index:08x}")),
10299 old,
10300 new,
10301 });
10302 }
10303 if journal.entries.is_empty() {
10304 return Ok(());
10305 }
10306 let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
10307 entry
10308 .old
10309 .as_ref()
10310 .map_or(Some(total), |old| total.checked_add(old.bytes))
10311 });
10312 if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
10313 return Err(LinkError::InvalidPack {
10314 message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
10315 });
10316 }
10317 validate_v2_pull_journal(&journal)?;
10318 store.write_private_atomic_new(
10319 Path::new(V2_PULL_JOURNAL),
10320 &v2_pull_journal_bytes(&journal)?,
10321 )?;
10322 let prepared = (|| -> LinkResult<()> {
10323 store.create_private_dir_all(Path::new(&backup_dir))?;
10324 for entry in &journal.entries {
10325 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10326 let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
10327 if content_sha256(&bytes) != old.sha256 {
10328 return Err(invalid_feed("live pull source changed during backup"));
10329 }
10330 store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
10331 }
10332 }
10333 journal.phase = V2PullPhase::Ready;
10334 store.write_private_atomic(
10335 Path::new(V2_PULL_JOURNAL),
10336 &v2_pull_journal_bytes(&journal)?,
10337 )?;
10338 Ok(())
10339 })();
10340 if let Err(error) = prepared {
10341 let cleanup = cleanup_v2_pull_journal(&store, &journal);
10342 return match cleanup {
10343 Ok(()) => Err(error),
10344 Err(cleanup) => Err(LinkError::InvalidPack {
10345 message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
10346 }),
10347 };
10348 }
10349 let installed = (|| -> LinkResult<()> {
10350 for entry in &journal.entries {
10351 if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
10352 return Err(LinkError::InvalidPack {
10353 message: format!("local path `{}` changed during pull", entry.path),
10354 });
10355 }
10356 if let Some(source) = sources.get(&entry.path) {
10357 let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
10358 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
10359 return Err(invalid_feed(
10360 "private staged sync source failed final integrity verification",
10361 ));
10362 }
10363 store.write_atomic(Path::new(&entry.path), &bytes)?;
10364 } else if entry.old.is_some() {
10365 store.remove_file(Path::new(&entry.path))?;
10366 }
10367 }
10368 if rebuild_indexes {
10369 crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
10370 message: format!("could not materialize v2 local catalogs: {error}"),
10371 })?;
10372 }
10373 Ok(())
10374 })();
10375 if let Err(error) = installed {
10376 return match rollback_v2_pull(&store, &journal) {
10377 Ok(()) => Err(error),
10378 Err(rollback) => Err(LinkError::InvalidPack {
10379 message: format!("{error}; durable pull rollback also failed: {rollback}"),
10380 }),
10381 };
10382 }
10383 Ok(())
10384}
10385
10386#[cfg(windows)]
10387fn install_pulled_delta_sources(
10388 dest: &Path,
10389 entries: &[V2StagedFile],
10390 deleted: &[String],
10391 rebuild_indexes: bool,
10392 previous: Option<&V2SyncBaseline>,
10393 next: &V2VerifiedHead,
10394) -> LinkResult<()> {
10395 match Store::open_strict(dest) {
10396 Ok(store) => {
10397 install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
10398 }
10399 Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
10400 }
10401}
10402
10403#[cfg(not(any(unix, windows)))]
10404fn install_pulled_delta_sources(
10405 _dest: &Path,
10406 _entries: &[V2StagedFile],
10407 _deleted: &[String],
10408 _rebuild_indexes: bool,
10409 _previous: Option<&V2SyncBaseline>,
10410 _next: &V2VerifiedHead,
10411) -> LinkResult<()> {
10412 Err(LinkError::UnsupportedPlatform {
10413 operation: "atomic v2 pull install",
10414 })
10415}
10416
10417#[cfg(unix)]
10418fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
10419 install_pulled_delta(dest, entries, &[], false)
10420}
10421
10422#[cfg(not(windows))]
10423fn is_safe_slug(slug: &str) -> bool {
10424 !slug.is_empty()
10425 && slug.len() <= 63
10426 && !slug.starts_with('-')
10427 && !slug.ends_with('-')
10428 && slug
10429 .bytes()
10430 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
10431}
10432
10433fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
10434 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
10435}
10436
10437fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
10438 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
10439}
10440
10441fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
10442 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
10443}
10444
10445fn preflight_zip_central_directory(
10446 bytes: &[u8],
10447 offset: usize,
10448 size: usize,
10449 count: u64,
10450) -> LinkResult<()> {
10451 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
10452 let end = offset
10453 .checked_add(size)
10454 .filter(|end| *end <= bytes.len())
10455 .ok_or_else(|| LinkError::InvalidPack {
10456 message: "ZIP central directory is out of bounds".to_string(),
10457 })?;
10458 let mut cursor = offset;
10459 for _ in 0..count {
10460 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
10461 return Err(LinkError::InvalidPack {
10462 message: "ZIP central directory entry count is inconsistent".to_string(),
10463 });
10464 }
10465 if le_u16(bytes, cursor + 34) != Some(0) {
10466 return Err(LinkError::InvalidPack {
10467 message: "multi-disk ZIP archives are not supported".to_string(),
10468 });
10469 }
10470 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
10471 total.checked_add(le_u16(bytes, cursor + at)? as usize)
10472 });
10473 cursor = cursor
10474 .checked_add(46)
10475 .and_then(|fixed| fixed.checked_add(variable?))
10476 .filter(|cursor| *cursor <= end)
10477 .ok_or_else(|| LinkError::InvalidPack {
10478 message: "ZIP central directory entry is truncated".to_string(),
10479 })?;
10480 }
10481 if cursor != end {
10482 return Err(LinkError::InvalidPack {
10483 message: "ZIP central directory size is inconsistent".to_string(),
10484 });
10485 }
10486 Ok(())
10487}
10488
10489fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
10493 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
10494 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
10495 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
10496 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
10497 let eocd = bytes[search_start..]
10498 .windows(4)
10499 .rposition(|window| window == EOCD_SIG)
10500 .map(|offset| search_start + offset)
10501 .ok_or_else(|| LinkError::InvalidPack {
10502 message: "ZIP has no end-of-central-directory record".to_string(),
10503 })?;
10504 let invalid_end = || LinkError::InvalidPack {
10505 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
10506 };
10507 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
10508 if eocd
10509 .checked_add(22)
10510 .and_then(|end| end.checked_add(comment_len))
10511 != Some(bytes.len())
10512 {
10513 return Err(invalid_end());
10517 }
10518 let disk = le_u16(bytes, eocd + 4);
10519 let central_disk = le_u16(bytes, eocd + 6);
10520 if disk != Some(0) || central_disk != Some(0) {
10521 return Err(LinkError::InvalidPack {
10522 message: "multi-disk ZIP archives are not supported".to_string(),
10523 });
10524 }
10525 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
10526 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
10527 if entries_on_disk != ordinary {
10528 return Err(LinkError::InvalidPack {
10529 message: "multi-disk ZIP archives are not supported".to_string(),
10530 });
10531 }
10532 let zip64_locator = eocd
10533 .checked_sub(20)
10534 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
10535 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
10536 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
10537 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
10538 if central_offset
10539 .checked_add(central_size)
10540 .filter(|end| *end == eocd)
10541 .is_none()
10542 {
10543 return Err(invalid_end());
10544 }
10545 (ordinary as u64, central_offset, central_size)
10546 } else {
10547 let Some(locator) = zip64_locator else {
10548 return Err(invalid_end());
10549 };
10550 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
10551 return Err(LinkError::InvalidPack {
10552 message: "multi-disk ZIP64 archives are not supported".to_string(),
10553 });
10554 }
10555 let record = le_u64(bytes, locator + 8)
10556 .and_then(|offset| usize::try_from(offset).ok())
10557 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
10558 .ok_or_else(|| LinkError::InvalidPack {
10559 message: "ZIP64 archive has an invalid end record".to_string(),
10560 })?;
10561 let record_size = le_u64(bytes, record + 4)
10562 .and_then(|size| usize::try_from(size).ok())
10563 .filter(|size| *size >= 44)
10564 .ok_or_else(invalid_end)?;
10565 if record
10566 .checked_add(12)
10567 .and_then(|end| end.checked_add(record_size))
10568 != Some(locator)
10569 || le_u32(bytes, record + 16) != Some(0)
10570 || le_u32(bytes, record + 20) != Some(0)
10571 {
10572 return Err(invalid_end());
10573 }
10574 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
10575 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
10576 let central_size = le_u64(bytes, record + 40)
10577 .and_then(|size| usize::try_from(size).ok())
10578 .ok_or_else(invalid_end)?;
10579 let central_offset = le_u64(bytes, record + 48)
10580 .and_then(|offset| usize::try_from(offset).ok())
10581 .ok_or_else(invalid_end)?;
10582 if zip64_on_disk != zip64_total
10583 || central_offset
10584 .checked_add(central_size)
10585 .filter(|end| *end == record)
10586 .is_none()
10587 {
10588 return Err(invalid_end());
10589 }
10590 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10591 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10592 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10593 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10594 {
10595 return Err(invalid_end());
10596 }
10597 (zip64_total, central_offset, central_size)
10598 };
10599 if count == 0 || count > max_entries as u64 {
10600 return Err(LinkError::InvalidPack {
10601 message: format!("invalid file count {count}"),
10602 });
10603 }
10604 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10605 Ok(())
10606}
10607
10608fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10609 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10610 let mut archive =
10611 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10612 message: format!("ZIP parse failed: {err}"),
10613 })?;
10614 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10615 return Err(LinkError::InvalidPack {
10616 message: format!("invalid file count {}", archive.len()),
10617 });
10618 }
10619 let mut total = 0u64;
10620 let mut seen = std::collections::HashSet::new();
10621 let mut entries = Vec::with_capacity(archive.len());
10622 for index in 0..archive.len() {
10623 let mut file = archive
10624 .by_index(index)
10625 .map_err(|err| LinkError::InvalidPack {
10626 message: format!("ZIP entry failed: {err}"),
10627 })?;
10628 if file.is_dir() {
10629 continue;
10630 }
10631 let path = file.name().to_string();
10632 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10633 return Err(LinkError::UnsafePath { path });
10634 }
10635 if file
10636 .unix_mode()
10637 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
10638 {
10639 return Err(LinkError::InvalidPack {
10640 message: format!("non-file entry `{path}`"),
10641 });
10642 }
10643 if !seen.insert(path.clone()) {
10644 return Err(LinkError::InvalidPack {
10645 message: format!("duplicate path `{path}`"),
10646 });
10647 }
10648 let remaining = MAX_STORE_BYTES.saturating_sub(total);
10649 if file.size() > remaining {
10650 return Err(LinkError::InvalidPack {
10651 message: "expanded content exceeds the 512 MB limit".to_string(),
10652 });
10653 }
10654 let mut content = Vec::new();
10655 (&mut file)
10656 .take(remaining + 1)
10657 .read_to_end(&mut content)
10658 .map_err(|err| LinkError::InvalidPack {
10659 message: format!("could not decompress `{path}`: {err}"),
10660 })?;
10661 if content.len() as u64 > remaining {
10662 return Err(LinkError::InvalidPack {
10663 message: "expanded content exceeds the 512 MB limit".to_string(),
10664 });
10665 }
10666 if content.len() as u64 != file.size() {
10667 return Err(LinkError::InvalidPack {
10668 message: format!("length mismatch for `{path}`"),
10669 });
10670 }
10671 total += content.len() as u64;
10672 entries.push((path, content));
10673 }
10674 if entries.is_empty() {
10675 return Err(LinkError::InvalidPack {
10676 message: "pack contains no files".to_string(),
10677 });
10678 }
10679 Ok(entries)
10680}
10681
10682fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
10683 let mut expected = std::collections::BTreeMap::new();
10684 for file in signed {
10685 if !safe_store_rel_path(&file.path) {
10686 return Err(LinkError::UnsafePath {
10687 path: file.path.clone(),
10688 });
10689 }
10690 if !is_sha256(&file.sha256)
10691 || expected
10692 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10693 .is_some()
10694 {
10695 return Err(invalid_feed(
10696 "signed snapshot manifest contains an invalid or duplicate file",
10697 ));
10698 }
10699 }
10700 if expected.len() != entries.len() {
10701 return Err(invalid_feed(
10702 "downloaded pack file set differs from the signed snapshot manifest",
10703 ));
10704 }
10705 for (path, bytes) in entries {
10706 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
10707 return Err(invalid_feed(format!(
10708 "downloaded pack contains unsigned path `{path}`"
10709 )));
10710 };
10711 if *declared_bytes != bytes.len() as u64
10712 || *sha256 != format!("{:x}", Sha256::digest(bytes))
10713 {
10714 return Err(invalid_feed(format!(
10715 "downloaded file `{path}` differs from its signed manifest"
10716 )));
10717 }
10718 }
10719 Ok(())
10720}
10721
10722pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
10729 require_hardened_filesystem("sync push")?;
10730 preflight_push_ownership(store)?;
10731 let mut out: Vec<(String, String)> = Vec::new();
10732 let mut total = 0u64;
10733
10734 let mut read_text = |rel: &str| -> LinkResult<String> {
10735 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
10736 total = total
10737 .checked_add(bytes.len() as u64)
10738 .ok_or_else(|| LinkError::PushTooLarge {
10739 detail: "uncompressed byte count overflow".to_string(),
10740 })?;
10741 if total > MAX_STORE_BYTES {
10742 return Err(LinkError::PushTooLarge {
10743 detail: format!("{total} uncompressed bytes"),
10744 });
10745 }
10746 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
10747 path: rel.to_string(),
10748 })
10749 };
10750
10751 out.push(("DB.md".to_string(), read_text("DB.md")?));
10752 if store
10753 .regular_file_exists(Path::new("assets.jsonl"))
10754 .unwrap_or(false)
10755 {
10756 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
10757 }
10758
10759 for rel in store.walk()? {
10760 let rel_str = rel.to_string_lossy().replace('\\', "/");
10761 if !safe_store_rel_path(&rel_str) {
10762 return Err(LinkError::UnsafePath { path: rel_str });
10765 }
10766 let content = read_text(&rel_str)?;
10767 out.push((rel_str, content));
10768 }
10769
10770 out.sort_by(|a, b| a.0.cmp(&b.0));
10771 Ok(out)
10772}
10773
10774fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
10778 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
10779 return Err(LinkError::from(std::io::Error::new(
10780 std::io::ErrorKind::PermissionDenied,
10781 format!("cannot push: nested db.md store at {}", nested.display()),
10782 )));
10783 }
10784
10785 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
10786 return Err(LinkError::from(std::io::Error::new(
10787 std::io::ErrorKind::PermissionDenied,
10788 format!(
10789 "cannot push: {} is a symlink outside the store ownership model",
10790 symlink.display()
10791 ),
10792 )));
10793 }
10794 Ok(())
10795}
10796
10797pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
10803 require_safe_ref(brain)?;
10804 let remote = verified_remote_head(cfg, brain, false)?;
10805 if files.len() > MAX_PUSH_FILES {
10806 return Err(LinkError::PushTooLarge {
10807 detail: format!("{} files", files.len()),
10808 });
10809 }
10810 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
10811 if raw_total > MAX_STORE_BYTES {
10812 return Err(LinkError::PushTooLarge {
10813 detail: format!("{raw_total} uncompressed bytes"),
10814 });
10815 }
10816
10817 if cfg.brain_key.is_none() {
10821 let body = json!({
10822 "files": files
10823 .iter()
10824 .map(|(p, c)| json!({ "path": p, "content": c }))
10825 .collect::<Vec<_>>(),
10826 });
10827 if body.to_string().len() <= MAX_PUSH_BYTES {
10828 let path = format!("/api/hub/brains/{brain}/push");
10829 let pushed = ensure_ok(
10830 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10831 "sync push",
10832 )?;
10833 return Ok(pushed);
10834 }
10835 }
10836
10837 let pack = build_store_pack(files)?;
10838 if pack.len() as u64 > MAX_PACK_BYTES {
10839 return Err(LinkError::PushTooLarge {
10840 detail: format!("{} pack bytes", pack.len()),
10841 });
10842 }
10843 let sha256 = format!("{:x}", Sha256::digest(&pack));
10844 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
10845 if let Some(key) = &cfg.brain_key {
10846 if !remote.head.verified {
10847 return Err(invalid_feed(
10848 "self-custody push requires a fully verified, unscoped feed head",
10849 ));
10850 }
10851 let identity = remote
10852 .identity
10853 .as_ref()
10854 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
10855 let current_multikey = format!("ed25519:{}", identity.fingerprint);
10856 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
10857 return Err(invalid_feed(
10858 "configured brain key is not the verified current brain identity",
10859 ));
10860 }
10861 let next_seq = remote
10864 .head
10865 .seq
10866 .checked_add(1)
10867 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
10868 let mut manifest: Vec<WireFeedFile> = files
10869 .iter()
10870 .map(|(path, content)| WireFeedFile {
10871 path: path.clone(),
10872 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
10873 bytes: content.len() as u64,
10874 })
10875 .collect();
10876 manifest.sort_by(|a, b| a.path.cmp(&b.path));
10877 let ts = crate::now()
10878 .with_timezone(&chrono::Utc)
10879 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
10880 .to_string();
10881 let entry = self_custody_entry(
10882 key,
10883 next_seq,
10884 ts,
10885 &sha256,
10886 &manifest,
10887 remote.head.feed_hash.as_deref(),
10888 )?;
10889 meta["entry"] = Value::String(entry);
10890 }
10891 let presigned = ensure_ok(
10892 request(
10893 cfg,
10894 "POST",
10895 &format!("/api/hub/brains/{brain}/packs/presign"),
10896 Some(&meta),
10897 Auth::Required,
10898 )?,
10899 "prepare pack upload",
10900 )?;
10901 let url = presigned
10902 .get("url")
10903 .and_then(Value::as_str)
10904 .ok_or_else(|| LinkError::InvalidPack {
10905 message: "the hub returned no upload URL".to_string(),
10906 })?;
10907 put_presigned(
10908 cfg,
10909 url,
10910 presigned.get("headers").unwrap_or(&Value::Null),
10911 &pack,
10912 )?;
10913 let committed = ensure_ok(
10914 request(
10915 cfg,
10916 "POST",
10917 &format!("/api/hub/brains/{brain}/packs/commit"),
10918 Some(&meta),
10919 Auth::Required,
10920 )?,
10921 "commit pack",
10922 )?;
10923 Ok(committed)
10924}
10925
10926fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
10927 const LOCAL_HEADER: u32 = 0x0403_4b50;
10928 const CENTRAL_HEADER: u32 = 0x0201_4b50;
10929 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
10930 const VERSION_20: u16 = 20;
10931 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
10932 const UTF8_FLAG: u16 = 1 << 11;
10933 const STORED: u16 = 0;
10934 const DOS_TIME_MIDNIGHT: u16 = 0;
10935 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
10936 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
10937
10938 struct CentralEntry<'a> {
10939 name: &'a [u8],
10940 crc32: u32,
10941 size: u32,
10942 local_offset: u32,
10943 }
10944
10945 fn push_u16(out: &mut Vec<u8>, value: u16) {
10946 out.extend_from_slice(&value.to_le_bytes());
10947 }
10948
10949 fn push_u32(out: &mut Vec<u8>, value: u32) {
10950 out.extend_from_slice(&value.to_le_bytes());
10951 }
10952
10953 if files.is_empty() {
10954 return Err(LinkError::InvalidPack {
10955 message: "cannot create an empty snapshot pack".to_string(),
10956 });
10957 }
10958 if files.len() > u16::MAX as usize {
10959 return Err(LinkError::PushTooLarge {
10960 detail: format!(
10961 "{} files (canonical ZIP32 packs cap at {})",
10962 files.len(),
10963 u16::MAX
10964 ),
10965 });
10966 }
10967
10968 let mut sorted: Vec<_> = files.iter().collect();
10969 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
10970 let mut previous: Option<&str> = None;
10971 for (path, content) in &sorted {
10972 if !safe_store_rel_path(path) {
10973 return Err(LinkError::UnsafePath {
10974 path: (*path).clone(),
10975 });
10976 }
10977 if previous == Some(path.as_str()) {
10978 return Err(LinkError::InvalidPack {
10979 message: format!("duplicate path `{path}`"),
10980 });
10981 }
10982 previous = Some(path.as_str());
10983 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
10984 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
10985 })?;
10986 }
10987
10988 let mut out = Vec::new();
10989 let mut central = Vec::with_capacity(sorted.len());
10990 for (path, content) in sorted {
10991 let name = path.as_bytes();
10992 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
10993 message: format!("ZIP entry name is too long: `{path}`"),
10994 })?;
10995 let bytes = content.as_bytes();
10996 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
10997 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
10998 })?;
10999 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11000 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11001 })?;
11002 let crc32 = crc32fast::hash(bytes);
11003
11004 push_u32(&mut out, LOCAL_HEADER);
11007 push_u16(&mut out, VERSION_20);
11008 push_u16(&mut out, UTF8_FLAG);
11009 push_u16(&mut out, STORED);
11010 push_u16(&mut out, DOS_TIME_MIDNIGHT);
11011 push_u16(&mut out, DOS_DATE_1980_01_01);
11012 push_u32(&mut out, crc32);
11013 push_u32(&mut out, size);
11014 push_u32(&mut out, size);
11015 push_u16(&mut out, name_len);
11016 push_u16(&mut out, 0); out.extend_from_slice(name);
11018 out.extend_from_slice(bytes);
11019
11020 central.push(CentralEntry {
11021 name,
11022 crc32,
11023 size,
11024 local_offset,
11025 });
11026 }
11027
11028 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11029 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11030 })?;
11031 for entry in ¢ral {
11032 push_u32(&mut out, CENTRAL_HEADER);
11033 push_u16(&mut out, MADE_BY_UNIX_20);
11034 push_u16(&mut out, VERSION_20);
11035 push_u16(&mut out, UTF8_FLAG);
11036 push_u16(&mut out, STORED);
11037 push_u16(&mut out, DOS_TIME_MIDNIGHT);
11038 push_u16(&mut out, DOS_DATE_1980_01_01);
11039 push_u32(&mut out, entry.crc32);
11040 push_u32(&mut out, entry.size);
11041 push_u32(&mut out, entry.size);
11042 push_u16(&mut out, entry.name.len() as u16);
11043 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);
11048 push_u32(&mut out, entry.local_offset);
11049 out.extend_from_slice(entry.name);
11050 }
11051 let central_size = u32::try_from(out.len())
11052 .ok()
11053 .and_then(|end| end.checked_sub(central_offset))
11054 .ok_or_else(|| LinkError::PushTooLarge {
11055 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
11056 })?;
11057 let entry_count = central.len() as u16;
11058
11059 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
11060 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
11063 push_u16(&mut out, entry_count);
11064 push_u32(&mut out, central_size);
11065 push_u32(&mut out, central_offset);
11066 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
11069 return Err(LinkError::PushTooLarge {
11070 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
11071 });
11072 }
11073 Ok(out)
11074}
11075
11076#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11082pub enum Capability {
11083 Read,
11085 Write,
11087}
11088
11089impl Capability {
11090 pub fn as_str(self) -> &'static str {
11092 match self {
11093 Capability::Read => "read",
11094 Capability::Write => "write",
11095 }
11096 }
11097}
11098
11099pub fn grant_issue(
11105 cfg: &HubConfig,
11106 brain: &str,
11107 grantee: &str,
11108 can: Capability,
11109 scope: Option<&str>,
11110 until: Option<&str>,
11111) -> LinkResult<Value> {
11112 require_safe_ref(brain)?;
11113 let is_key_grantee = URL_SAFE_NO_PAD
11118 .decode(grantee)
11119 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
11120 .unwrap_or(false);
11121 if let Some(head) = v2_verified_head(cfg, brain)? {
11122 if is_key_grantee {
11123 let scope = scope.unwrap_or("");
11124 let preset = match can {
11125 Capability::Read => "viewer",
11126 Capability::Write => "editor",
11127 };
11128 let entropy = format!(
11129 "{}\0{}\0{}\0{}\0{}\0{}\0{}",
11130 normalized_origin(&cfg.hub)?,
11131 head.brain_id,
11132 head.control_revision,
11133 grantee,
11134 preset,
11135 scope,
11136 until.unwrap_or("")
11137 );
11138 let mut body = json!({
11139 "context": "external",
11140 "expected_control_revision": head.control_revision,
11141 "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
11142 "preset": preset,
11143 "principal_kind": "key",
11144 "public_key": grantee,
11145 "scope": scope,
11146 "scope_kind": "prefix",
11147 });
11148 if let Some(value) = until {
11149 body["expires_at"] = json!(value);
11150 }
11151 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11152 let response = ensure_ok(
11153 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11154 "v2 grant issue",
11155 )?;
11156 let expected_fingerprint = identity_fingerprint(grantee)?;
11157 if response.get("v").and_then(Value::as_u64) != Some(2)
11158 || response
11159 .get("id")
11160 .and_then(Value::as_str)
11161 .is_none_or(|id| !crate::ulid::is_ulid(id))
11162 || response.get("principal_kind").and_then(Value::as_str) != Some("key")
11163 || response.get("principal_id").and_then(Value::as_str)
11164 != Some(expected_fingerprint.as_str())
11165 || response
11166 .get("control_revision")
11167 .and_then(Value::as_str)
11168 .is_none_or(|value| !is_sha256(value))
11169 {
11170 return Err(invalid_feed(
11171 "v2 grant issue response is not authority-bound",
11172 ));
11173 }
11174 return Ok(response);
11175 }
11176 let mut body = json!({ "email": grantee, "capability": can.as_str() });
11182 if let Some(value) = scope {
11183 body["scopePrefix"] = json!(value);
11184 }
11185 if let Some(value) = until {
11186 body["expiresAt"] = json!(value);
11187 }
11188 let path = format!("/api/hub/brains/{}/grants", head.brain_id);
11189 return ensure_ok(
11190 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11191 "account grant issue",
11192 );
11193 }
11194 let _ = verified_remote_head(cfg, brain, false)?;
11195 let mut body = if is_key_grantee {
11196 json!({ "keySpki": grantee, "capability": can.as_str() })
11197 } else {
11198 json!({ "email": grantee, "capability": can.as_str() })
11199 };
11200 if let Some(s) = scope {
11201 body["scopePrefix"] = json!(s);
11202 }
11203 if let Some(u) = until {
11204 body["expiresAt"] = json!(u);
11205 }
11206 let path = format!("/api/hub/brains/{brain}/grants");
11207 ensure_ok(
11208 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11209 "grant issue",
11210 )
11211}
11212
11213pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
11215 require_safe_ref(brain)?;
11216 if let Some(head) = v2_verified_head(cfg, brain)? {
11217 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11218 let response = ensure_ok(
11219 request(cfg, "GET", &path, None, Auth::Required)?,
11220 "v2 grant list",
11221 )?;
11222 if response.get("v").and_then(Value::as_u64) != Some(2)
11223 || response.get("control_revision").and_then(Value::as_str)
11224 != Some(head.control_revision.as_str())
11225 || !response.get("grants").is_some_and(Value::is_array)
11226 {
11227 return Err(invalid_feed(
11228 "v2 grant list is not bound to the verified authority",
11229 ));
11230 }
11231 return Ok(response);
11232 }
11233 let _ = verified_remote_head(cfg, brain, false)?;
11234 let path = format!("/api/hub/brains/{brain}/grants");
11235 ensure_ok(
11236 request(cfg, "GET", &path, None, Auth::Required)?,
11237 "grant list",
11238 )
11239}
11240
11241pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
11244 require_safe_ref(brain)?;
11245 require_safe_grant_id(grant_id)?;
11246 if let Some(head) = v2_verified_head(cfg, brain)? {
11247 let entropy = format!(
11248 "{}\0{}\0{}\0{}",
11249 normalized_origin(&cfg.hub)?,
11250 head.brain_id,
11251 head.control_revision,
11252 grant_id
11253 );
11254 let body = json!({
11255 "expected_control_revision": head.control_revision,
11256 "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
11257 });
11258 let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
11259 let response = ensure_ok(
11260 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11261 "v2 grant revoke",
11262 )?;
11263 if response.get("v").and_then(Value::as_u64) != Some(2)
11264 || response.get("id").and_then(Value::as_str) != Some(grant_id)
11265 || response.get("revoked").and_then(Value::as_bool) != Some(true)
11266 || response
11267 .get("control_revision")
11268 .and_then(Value::as_str)
11269 .is_none_or(|value| !is_sha256(value))
11270 {
11271 return Err(invalid_feed(
11272 "v2 grant revocation response is not authority-bound",
11273 ));
11274 }
11275 return Ok(response);
11276 }
11277 let _ = verified_remote_head(cfg, brain, false)?;
11278 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
11279 ensure_ok(
11280 request(cfg, "DELETE", &path, None, Auth::Required)?,
11281 "grant revoke",
11282 )
11283}
11284
11285#[derive(Debug)]
11290struct VerifiedV2Proposal {
11291 value: Value,
11292 changes: Value,
11293 blobs: Vec<(String, u64, String)>,
11294}
11295
11296fn require_proposal_id(id: &str) -> LinkResult<()> {
11297 if crate::ulid::is_ulid(id) {
11298 Ok(())
11299 } else {
11300 Err(invalid_feed("proposal id is not a lowercase ULID"))
11301 }
11302}
11303
11304fn verified_v2_proposal(
11305 cfg: &HubConfig,
11306 head: &V2VerifiedHead,
11307 proposal_id: &str,
11308) -> LinkResult<VerifiedV2Proposal> {
11309 require_proposal_id(proposal_id)?;
11310 if head.view_kind != "full" {
11311 return Err(invalid_feed(
11312 "proposal review requires a full readable view",
11313 ));
11314 }
11315 let path = format!(
11316 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11317 head.brain_id
11318 );
11319 let value = ensure_ok(
11320 request_capped(
11321 cfg,
11322 "GET",
11323 &path,
11324 None,
11325 Auth::Required,
11326 MAX_FEED_RESPONSE_BYTES,
11327 )?,
11328 "v2 proposal",
11329 )?;
11330 verify_v2_proposal_value(head, proposal_id, value)
11331}
11332
11333fn verify_v2_proposal_value(
11334 head: &V2VerifiedHead,
11335 proposal_id: &str,
11336 value: Value,
11337) -> LinkResult<VerifiedV2Proposal> {
11338 if value.get("v").and_then(Value::as_u64) != Some(2) {
11339 return Err(invalid_feed("proposal response has an invalid version"));
11340 }
11341 let proposal = value
11342 .get("proposal")
11343 .and_then(Value::as_object)
11344 .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
11345 if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
11346 return Err(invalid_feed("proposal response changed its id"));
11347 }
11348 let payload_hash = proposal
11349 .get("payload_sha256")
11350 .and_then(Value::as_str)
11351 .filter(|hash| is_sha256(hash))
11352 .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
11353 let clear_hash = proposal
11354 .get("clear_sha256")
11355 .and_then(Value::as_str)
11356 .filter(|hash| is_sha256(hash))
11357 .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
11358 let submission_hash = proposal
11359 .get("submission_claim_sha256")
11360 .and_then(Value::as_str)
11361 .filter(|hash| is_sha256(hash))
11362 .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
11363 let submission = STANDARD
11364 .decode(
11365 proposal
11366 .get("submission_claim_base64")
11367 .and_then(Value::as_str)
11368 .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
11369 )
11370 .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
11371 let submission_value: Value = serde_json::from_slice(&submission)
11372 .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
11373 if crate::linkmd_v2::canonical_bytes(&submission_value)
11374 .map_err(|error| invalid_feed(error.to_string()))?
11375 != submission
11376 || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
11377 .map_err(|error| invalid_feed(error.to_string()))?
11378 != submission_hash
11379 {
11380 return Err(invalid_feed(
11381 "proposal submission claim is not canonical or addressed",
11382 ));
11383 }
11384 let envelope = submission_value
11385 .as_object()
11386 .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
11387 let claim = envelope
11388 .get("claim")
11389 .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
11390 let claim_object = claim
11391 .as_object()
11392 .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
11393 let actor_root = claim_object
11394 .get("actor_root")
11395 .and_then(Value::as_object)
11396 .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
11397 let public_key = envelope
11398 .get("public_key")
11399 .and_then(Value::as_str)
11400 .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
11401 let fingerprint = envelope
11402 .get("fingerprint")
11403 .and_then(Value::as_str)
11404 .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
11405 let signature = envelope
11406 .get("sig")
11407 .and_then(Value::as_str)
11408 .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
11409 let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
11410 .map_err(|error| invalid_feed(error.to_string()))?;
11411 let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
11412 let signer = format!("{fingerprint}:{public_key}");
11413 let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
11414 let grants = actor_root.get("grants").and_then(Value::as_array);
11415 let grants_are_canonical = grants.is_some_and(|items| {
11416 let mut prior: Option<&str> = None;
11417 items.iter().all(|item| {
11418 let Some(grant) = item.as_str() else {
11419 return false;
11420 };
11421 if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
11422 return false;
11423 }
11424 prior = Some(grant);
11425 true
11426 })
11427 });
11428 let optional_actor_field = |name: &str| {
11429 actor_root.get(name).is_some_and(|value| {
11430 value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
11431 })
11432 };
11433 let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
11434 if claim_object.get("v").and_then(Value::as_u64) != Some(2)
11435 || format!("{:x}", Sha256::digest(&der)) != fingerprint
11436 || head
11437 .trust
11438 .hub_signer
11439 .as_ref()
11440 .is_some_and(|known| known != &signer)
11441 || !matches!(
11442 actor_class,
11443 Some(
11444 "user"
11445 | "owned_agent"
11446 | "foreign_key"
11447 | "curation"
11448 | "inbox"
11449 | "restore"
11450 | "migration"
11451 | "operator_recovery"
11452 )
11453 )
11454 || actor_root
11455 .get("principal")
11456 .and_then(Value::as_str)
11457 .is_none_or(|value| value.is_empty())
11458 || actor_root
11459 .get("credential")
11460 .and_then(Value::as_str)
11461 .is_none_or(|value| value.is_empty())
11462 || !optional_actor_field("organization")
11463 || !optional_actor_field("role")
11464 || !grants_are_canonical
11465 || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
11466 || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11467 || !claim_object
11468 .get("mutation_id")
11469 .and_then(Value::as_str)
11470 .is_some_and(|value| {
11471 !value.is_empty()
11472 && value.len() <= 128
11473 && value.chars().enumerate().all(|(index, char)| {
11474 char.is_ascii_alphanumeric()
11475 || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
11476 })
11477 })
11478 || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
11479 || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
11480 || !claim_object
11481 .get("control_revision")
11482 .and_then(Value::as_str)
11483 .is_some_and(is_sha256)
11484 || submitted_at.is_none_or(|value| {
11485 chrono::DateTime::parse_from_rfc3339(value).is_err()
11486 || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
11487 })
11488 || !proposal
11489 .get("state")
11490 .and_then(Value::as_str)
11491 .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
11492 || proposal
11493 .get("expires_at")
11494 .and_then(Value::as_str)
11495 .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
11496 || proposal
11497 .get("proposer")
11498 .and_then(Value::as_object)
11499 .and_then(|value| value.get("class"))
11500 .and_then(Value::as_str)
11501 != actor_class
11502 {
11503 return Err(invalid_feed(
11504 "proposal submission claim does not bind the verified proposal",
11505 ));
11506 }
11507 let changes_b64 = proposal
11508 .get("changes_base64")
11509 .and_then(Value::as_str)
11510 .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
11511 let changes_bytes = STANDARD
11512 .decode(changes_b64)
11513 .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
11514 let changes: Value = serde_json::from_slice(&changes_bytes)
11515 .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
11516 if crate::linkmd_v2::canonical_bytes(&changes)
11517 .map_err(|error| invalid_feed(error.to_string()))?
11518 != changes_bytes
11519 || changes.get("v").and_then(Value::as_u64) != Some(2)
11520 || !changes.get("operations").is_some_and(Value::is_array)
11521 {
11522 return Err(invalid_feed("proposal changeset is not canonical v2"));
11523 }
11524 let blob_values = proposal
11525 .get("blobs")
11526 .and_then(Value::as_array)
11527 .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
11528 let mut blobs = Vec::with_capacity(blob_values.len());
11529 let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
11530 let mut prior_hash: Option<String> = None;
11531 for item in blob_values {
11532 let hash = item
11533 .get("sha256")
11534 .and_then(Value::as_str)
11535 .filter(|hash| is_sha256(hash))
11536 .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
11537 let bytes = item
11538 .get("bytes")
11539 .and_then(Value::as_u64)
11540 .filter(|bytes| *bytes <= MAX_STORE_BYTES)
11541 .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
11542 if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
11543 return Err(invalid_feed(
11544 "proposal blob declarations are not unique and sorted",
11545 ));
11546 }
11547 prior_hash = Some(hash.to_string());
11548 let endpoint = item
11549 .get("endpoint")
11550 .and_then(Value::as_str)
11551 .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
11552 let expected_endpoint = format!(
11553 "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
11554 head.brain_id
11555 );
11556 if endpoint != expected_endpoint {
11557 return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
11558 }
11559 descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
11560 blobs.push((hash.to_string(), bytes, endpoint.to_string()));
11561 }
11562 let descriptor = json!({
11563 "base": proposal.get("base").cloned().unwrap_or(Value::Null),
11564 "blobs": descriptor_blobs,
11565 "changes_base64": changes_b64,
11566 "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
11567 "v": 2,
11568 });
11569 let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
11570 .map_err(|error| invalid_feed(error.to_string()))?;
11571 if content_sha256(&descriptor_bytes) != clear_hash {
11572 return Err(invalid_feed(
11573 "proposal clear payload differs from its signed submission claim",
11574 ));
11575 }
11576 Ok(VerifiedV2Proposal {
11577 value,
11578 changes,
11579 blobs,
11580 })
11581}
11582
11583pub fn proposal_list(
11584 cfg: &HubConfig,
11585 brain: &str,
11586 state: &str,
11587 after: Option<&str>,
11588 limit: usize,
11589) -> LinkResult<Value> {
11590 require_safe_ref(brain)?;
11591 if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11592 return Err(invalid_feed("proposal state is invalid"));
11593 }
11594 if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11595 return Err(invalid_feed("proposal cursor is invalid"));
11596 }
11597 let head = v2_verified_head(cfg, brain)?
11598 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11599 let path = format!(
11600 "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11601 head.brain_id,
11602 limit.clamp(1, 100),
11603 after.map_or_else(String::new, |value| format!("&after={value}"))
11604 );
11605 ensure_ok(
11606 request_capped(
11607 cfg,
11608 "GET",
11609 &path,
11610 None,
11611 Auth::Required,
11612 MAX_FEED_RESPONSE_BYTES,
11613 )?,
11614 "v2 proposal list",
11615 )
11616}
11617
11618pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11619 require_safe_ref(brain)?;
11620 let head = v2_verified_head(cfg, brain)?
11621 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11622 Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11623}
11624
11625pub fn proposal_reject(
11626 cfg: &HubConfig,
11627 brain: &str,
11628 proposal_id: &str,
11629 mutation_id: &str,
11630 reason: &str,
11631) -> LinkResult<Value> {
11632 require_safe_ref(brain)?;
11633 require_proposal_id(proposal_id)?;
11634 let head = v2_verified_head(cfg, brain)?
11635 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11636 let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
11637 let body = json!({
11638 "mutation_id": mutation_id,
11639 "control_revision": head.control_revision,
11640 "reason": reason,
11641 });
11642 let path = format!(
11643 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11644 head.brain_id
11645 );
11646 ensure_ok(
11647 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11648 "v2 proposal rejection",
11649 )
11650}
11651
11652pub fn proposal_accept_exact(
11653 cfg: &HubConfig,
11654 brain: &str,
11655 proposal_id: &str,
11656 mutation_id: &str,
11657 reason: &str,
11658) -> LinkResult<Value> {
11659 require_safe_ref(brain)?;
11660 require_proposal_id(proposal_id)?;
11661 let head = v2_verified_head(cfg, brain)?
11662 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11663 let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
11664 let operations = proposal
11665 .changes
11666 .get("operations")
11667 .and_then(Value::as_array)
11668 .cloned()
11669 .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
11670 if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
11671 return Err(invalid_feed("proposal operation count is invalid"));
11672 }
11673 let mut downloaded = std::collections::BTreeMap::new();
11674 for (hash, bytes, endpoint) in &proposal.blobs {
11675 let body = ensure_raw_ok(
11676 request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
11677 "v2 proposal blob",
11678 )?;
11679 if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
11680 return Err(invalid_feed("proposal blob does not match its declaration"));
11681 }
11682 downloaded.insert(hash.clone(), body);
11683 }
11684 let remote = files_for_v2_view(
11685 &head,
11686 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
11687 );
11688 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
11689 let mut expected_candidate = remote.clone();
11690 let mut expected_candidate_assets = remote_assets;
11691 for operation in &operations {
11692 let op = operation
11693 .get("op")
11694 .and_then(Value::as_str)
11695 .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
11696 match op {
11697 "put" | "restore" => {
11698 let path = operation
11699 .get("path")
11700 .and_then(Value::as_str)
11701 .ok_or_else(|| invalid_feed("proposal write has no path"))?;
11702 crate::linkmd_v2::normalize_path(path)
11703 .map_err(|error| invalid_feed(error.to_string()))?;
11704 let hash = operation
11705 .get("blob")
11706 .and_then(Value::as_str)
11707 .filter(|hash| is_sha256(hash))
11708 .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
11709 let bytes = operation
11710 .get("bytes")
11711 .and_then(Value::as_u64)
11712 .ok_or_else(|| invalid_feed("proposal write has no size"))?;
11713 expected_candidate.insert(
11714 path.to_string(),
11715 V2BaselineFile {
11716 sha256: hash.to_string(),
11717 bytes,
11718 proof: None,
11719 },
11720 );
11721 }
11722 "delete" | "withdraw_from_hosting" => {
11723 let path = operation
11724 .get("path")
11725 .and_then(Value::as_str)
11726 .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
11727 crate::linkmd_v2::normalize_path(path)
11728 .map_err(|error| invalid_feed(error.to_string()))?;
11729 expected_candidate.remove(path);
11730 }
11731 "rename" => {
11732 let from = operation
11733 .get("from")
11734 .and_then(Value::as_str)
11735 .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
11736 let to = operation
11737 .get("to")
11738 .and_then(Value::as_str)
11739 .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
11740 crate::linkmd_v2::normalize_path(from)
11741 .and_then(|_| crate::linkmd_v2::normalize_path(to))
11742 .map_err(|error| invalid_feed(error.to_string()))?;
11743 let hash = operation
11744 .get("blob")
11745 .and_then(Value::as_str)
11746 .filter(|hash| is_sha256(hash))
11747 .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
11748 let bytes = operation
11749 .get("bytes")
11750 .and_then(Value::as_u64)
11751 .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
11752 expected_candidate.remove(from);
11753 expected_candidate.insert(
11754 to.to_string(),
11755 V2BaselineFile {
11756 sha256: hash.to_string(),
11757 bytes,
11758 proof: None,
11759 },
11760 );
11761 }
11762 "asset_delete" => {
11763 let path = operation
11764 .get("path")
11765 .and_then(Value::as_str)
11766 .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
11767 expected_candidate_assets.remove(path);
11768 }
11769 "asset_withdraw" => {
11770 let path = operation
11771 .get("path")
11772 .and_then(Value::as_str)
11773 .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
11774 let asset = expected_candidate_assets
11775 .get_mut(path)
11776 .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
11777 asset.disposition = "withheld".to_string();
11778 asset.leaf_hash.clear();
11779 }
11780 "asset_put" | "asset_resume" => {
11781 let path = operation
11782 .get("path")
11783 .and_then(Value::as_str)
11784 .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
11785 let asset = operation
11786 .get("asset")
11787 .and_then(Value::as_object)
11788 .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
11789 let blob_sha256 = asset
11790 .get("blob_sha256")
11791 .and_then(Value::as_str)
11792 .filter(|hash| is_sha256(hash))
11793 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
11794 let bytes = asset
11795 .get("bytes")
11796 .and_then(Value::as_u64)
11797 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
11798 let media_type = asset
11799 .get("media_type")
11800 .and_then(Value::as_str)
11801 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
11802 let wrappers = asset
11803 .get("wrappers")
11804 .and_then(Value::as_array)
11805 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
11806 .iter()
11807 .map(|wrapper| {
11808 wrapper
11809 .as_str()
11810 .map(str::to_string)
11811 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
11812 })
11813 .collect::<LinkResult<Vec<_>>>()?;
11814 let required = asset
11815 .get("required")
11816 .and_then(Value::as_bool)
11817 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
11818 let disposition = asset
11819 .get("disposition")
11820 .and_then(Value::as_str)
11821 .filter(|value| matches!(*value, "hosted" | "withheld"))
11822 .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
11823 expected_candidate_assets.insert(
11824 path.to_string(),
11825 V2BaselineAsset {
11826 blob_sha256: blob_sha256.to_string(),
11827 bytes,
11828 media_type: media_type.to_string(),
11829 wrappers,
11830 required,
11831 disposition: disposition.to_string(),
11832 leaf_hash: String::new(),
11833 },
11834 );
11835 }
11836 _ => return Err(invalid_feed("proposal operation kind is unsupported")),
11837 }
11838 }
11839 let base = head.pointer.as_ref().map(|pointer| {
11840 json!({
11841 "seq": pointer.seq,
11842 "commit_hash": pointer.commit_hash,
11843 "content_root": pointer.content_root,
11844 "asset_root": pointer.asset_root,
11845 })
11846 });
11847 let mut body = json!({
11848 "mutation_id": mutation_id,
11849 "base": base,
11850 "rebase": "strict",
11851 "reason": reason,
11852 "operations": operations,
11853 "blobs": downloaded
11854 .iter()
11855 .map(|(sha256, bytes)| json!({
11856 "sha256": sha256,
11857 "bytes": bytes.len(),
11858 "content_base64": STANDARD.encode(bytes),
11859 }))
11860 .collect::<Vec<_>>(),
11861 "proposal_id": proposal_id,
11862 "proposal_mode": "exact",
11863 });
11864 let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
11865 total
11866 .checked_add(bytes.len())
11867 .ok_or_else(|| LinkError::PushTooLarge {
11868 detail: "proposal changed-byte total overflow".to_string(),
11869 })
11870 })?;
11871 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
11872 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11873 for operation in &operations {
11874 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
11875 return Err(invalid_feed("proposal upload operation has no kind"));
11876 };
11877 let hash = match kind {
11878 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
11879 "asset_put" | "asset_resume" => operation
11880 .get("asset")
11881 .and_then(|asset| asset.get("blob_sha256"))
11882 .and_then(Value::as_str),
11883 _ => None,
11884 };
11885 let Some(hash) = hash else { continue };
11886 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
11887 if kind == "rename" {
11888 for field in ["from", "to"] {
11889 coordinates.insert(
11890 operation
11891 .get(field)
11892 .and_then(Value::as_str)
11893 .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
11894 .to_string(),
11895 );
11896 }
11897 } else {
11898 let path = operation
11899 .get("path")
11900 .and_then(Value::as_str)
11901 .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
11902 coordinates.insert(if kind.starts_with("asset_") {
11903 format!("assets/{path}")
11904 } else {
11905 path.to_string()
11906 });
11907 }
11908 }
11909 let declarations = downloaded
11910 .iter()
11911 .map(|(sha256, bytes)| {
11912 json!({
11913 "sha256": sha256,
11914 "bytes": bytes.len(),
11915 "coordinates": coordinates_by_hash
11916 .get(sha256)
11917 .into_iter()
11918 .flatten()
11919 .collect::<Vec<_>>(),
11920 })
11921 })
11922 .collect::<Vec<_>>();
11923 let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
11924 for batch in batch_upload_declarations(declarations) {
11925 let reserved = reserve_upload_window(
11926 cfg,
11927 &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
11928 &json!({ "blobs": batch }),
11929 "prepare proposal blob transport",
11930 )?;
11931 let reserved_items = reserved
11932 .get("uploads")
11933 .and_then(Value::as_array)
11934 .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
11935 items.extend(reserved_items.iter().cloned());
11936 }
11937 if items.len() != downloaded.len() {
11938 return Err(invalid_feed("proposal upload reservation changed the set"));
11939 }
11940 let mut references = Vec::with_capacity(items.len());
11941 for item in items {
11942 let hash = item
11943 .get("sha256")
11944 .and_then(Value::as_str)
11945 .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
11946 let bytes = downloaded
11947 .get(hash)
11948 .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
11949 let reservation_id = item
11950 .get("reservation_id")
11951 .and_then(Value::as_str)
11952 .filter(|id| crate::ulid::is_ulid(id))
11953 .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
11954 let expected_coordinates = coordinates_by_hash
11955 .get(hash)
11956 .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
11957 let returned_coordinates = item
11958 .get("coordinates")
11959 .and_then(Value::as_array)
11960 .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
11961 if returned_coordinates.len() != expected_coordinates.len()
11962 || returned_coordinates
11963 .iter()
11964 .zip(expected_coordinates)
11965 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
11966 {
11967 return Err(invalid_feed(
11968 "proposal upload reservation changed its coordinates",
11969 ));
11970 }
11971 match item.get("status").and_then(Value::as_str) {
11972 Some("upload") => put_presigned(
11973 cfg,
11974 item.get("url")
11975 .and_then(Value::as_str)
11976 .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
11977 item.get("headers").unwrap_or(&Value::Null),
11978 bytes,
11979 )?,
11980 Some("already_present") => {}
11981 _ => return Err(invalid_feed("proposal upload status is invalid")),
11982 }
11983 references.push(json!({
11984 "sha256": hash,
11985 "bytes": bytes.len(),
11986 "reservation_id": reservation_id,
11987 }));
11988 }
11989 body["blobs"] = Value::Array(references);
11990 }
11991 stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
11995 let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
11996 let mut result = ensure_ok(
11997 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
11998 "exact proposal acceptance",
11999 )?;
12000 let mut candidate_hub_signer = None;
12001 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
12002 let request_id = result
12003 .get("request_id")
12004 .and_then(Value::as_str)
12005 .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
12006 .to_string();
12007 let challenge = result
12008 .get("signing_challenge")
12009 .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
12010 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
12011 cfg,
12012 &head,
12013 &expected_candidate,
12014 &expected_candidate_assets,
12015 mutation_id,
12016 &v2_signed_request_view(&body, &operations),
12017 challenge,
12018 )?;
12019 body["signing_challenge_id"] = Value::String(challenge_id);
12020 body["signature_base64url"] = Value::String(signature);
12021 candidate_hub_signer = Some(actor_signer);
12022 result = ensure_ok(
12023 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
12024 "signed exact proposal acceptance",
12025 )?;
12026 }
12027 let refreshed = v2_verified_head(cfg, brain)?
12028 .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
12029 if candidate_hub_signer
12030 .as_ref()
12031 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
12032 || refreshed
12033 .pointer
12034 .as_ref()
12035 .map(|pointer| pointer.commit_hash.as_str())
12036 != result.get("commit_hash").and_then(Value::as_str)
12037 || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12038 || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
12039 {
12040 return Err(LinkError::RemoteAdvancedDuringSync);
12041 }
12042 accept_v2_head(cfg, &refreshed)?;
12043 Ok(result)
12044}
12045
12046pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
12057 require_valid_handle(handle)?;
12058 if body.len() as u64 > MAX_PROPOSE_BYTES {
12059 return Err(LinkError::ProposeTooLarge {
12060 bytes: body.len() as u64,
12061 });
12062 }
12063 let payload = json!({ "app": app, "body": body });
12064 let (path, auth) = if crate::ulid::is_ulid(handle) {
12069 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
12070 } else {
12071 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
12072 };
12073 ensure_ok(
12074 request(cfg, "POST", &path, Some(&payload), auth)?,
12075 "propose",
12076 )
12077}
12078
12079#[derive(Debug, serde::Serialize)]
12085pub struct Head {
12086 pub brain: String,
12088 pub seq: u64,
12090 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
12092 pub updated_at: Option<String>,
12093 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
12095 pub feed_hash: Option<String>,
12096 pub verified: bool,
12099}
12100
12101struct BoundedVecVisitor<T, const MAX: usize> {
12102 label: &'static str,
12103 marker: std::marker::PhantomData<T>,
12104}
12105
12106impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
12107where
12108 T: Deserialize<'de>,
12109{
12110 type Value = Vec<T>;
12111
12112 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12113 write!(formatter, "at most {MAX} {}", self.label)
12114 }
12115
12116 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
12117 where
12118 A: serde::de::SeqAccess<'de>,
12119 {
12120 if sequence.size_hint().is_some_and(|size| size > MAX) {
12121 return Err(serde::de::Error::custom(format!(
12122 "{} exceeds the {MAX}-item limit",
12123 self.label
12124 )));
12125 }
12126 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
12127 while let Some(value) = sequence.next_element()? {
12128 if values.len() == MAX {
12129 return Err(serde::de::Error::custom(format!(
12130 "{} exceeds the {MAX}-item limit",
12131 self.label
12132 )));
12133 }
12134 values.push(value);
12135 }
12136 Ok(values)
12137 }
12138}
12139
12140fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
12141 deserializer: D,
12142 label: &'static str,
12143) -> Result<Vec<T>, D::Error>
12144where
12145 D: serde::Deserializer<'de>,
12146 T: Deserialize<'de>,
12147{
12148 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
12149 label,
12150 marker: std::marker::PhantomData,
12151 })
12152}
12153
12154fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
12155where
12156 D: serde::Deserializer<'de>,
12157{
12158 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
12159}
12160
12161fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12162where
12163 D: serde::Deserializer<'de>,
12164{
12165 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
12166}
12167
12168fn deserialize_previous_identities<'de, D>(
12169 deserializer: D,
12170) -> Result<Vec<PreviousIdentity>, D::Error>
12171where
12172 D: serde::Deserializer<'de>,
12173{
12174 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
12175 deserializer,
12176 "previous identities",
12177 )
12178}
12179
12180fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12181where
12182 D: serde::Deserializer<'de>,
12183{
12184 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
12185 deserializer,
12186 "rotation statements",
12187 )
12188}
12189
12190fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
12191where
12192 D: serde::Deserializer<'de>,
12193{
12194 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
12195}
12196
12197#[derive(Debug, Clone, Deserialize, Serialize)]
12198struct FeedFile {
12199 path: String,
12200 sha256: String,
12201 bytes: u64,
12202}
12203
12204#[cfg(test)]
12205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12206enum V1DisclosureError {
12207 DuplicateFile,
12208 DuplicateRemoved,
12209 PushManifestMismatch,
12210 EditMissingChange,
12211 EditFalseFile,
12212 RemovedMismatch,
12213}
12214
12215#[cfg(test)]
12219fn verify_v1_manifest_disclosure(
12220 kind: &str,
12221 previous: &[FeedFile],
12222 resulting: &[FeedFile],
12223 files: &[FeedFile],
12224 removed: &[String],
12225) -> Result<(), V1DisclosureError> {
12226 fn as_map(
12227 files: &[FeedFile],
12228 ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
12229 let mut result = std::collections::BTreeMap::new();
12230 for file in files {
12231 if result
12232 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
12233 .is_some()
12234 {
12235 return Err(V1DisclosureError::DuplicateFile);
12236 }
12237 }
12238 Ok(result)
12239 }
12240 let previous = as_map(previous)?;
12241 let resulting = as_map(resulting)?;
12242 let disclosed = as_map(files)?;
12243 let removed_set: std::collections::BTreeSet<&str> =
12244 removed.iter().map(String::as_str).collect();
12245 if removed_set.len() != removed.len() {
12246 return Err(V1DisclosureError::DuplicateRemoved);
12247 }
12248 let expected_removed: std::collections::BTreeSet<&str> = previous
12249 .keys()
12250 .copied()
12251 .filter(|path| !resulting.contains_key(path))
12252 .collect();
12253 if removed_set != expected_removed {
12254 return Err(V1DisclosureError::RemovedMismatch);
12255 }
12256 if kind == "push" {
12257 return if disclosed == resulting {
12258 Ok(())
12259 } else {
12260 Err(V1DisclosureError::PushManifestMismatch)
12261 };
12262 }
12263 if kind != "edit" {
12264 return Err(V1DisclosureError::EditFalseFile);
12265 }
12266 if disclosed
12267 .iter()
12268 .any(|(path, value)| resulting.get(path) != Some(value))
12269 {
12270 return Err(V1DisclosureError::EditFalseFile);
12271 }
12272 for (path, value) in &resulting {
12273 if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
12274 return Err(V1DisclosureError::EditMissingChange);
12275 }
12276 }
12277 Ok(())
12278}
12279
12280#[derive(Debug, Clone, Deserialize, Serialize)]
12281struct FeedEntry {
12282 v: u8,
12283 seq: u64,
12284 ts: String,
12285 brain: String,
12286 public_key: String,
12287 kind: String,
12288 op: String,
12289 pack_sha256: String,
12290 #[serde(deserialize_with = "deserialize_feed_files")]
12291 files: Vec<FeedFile>,
12292 #[serde(deserialize_with = "deserialize_removed_paths")]
12293 removed: Vec<String>,
12294 prev_entry_hash: Option<String>,
12295 sig: String,
12296}
12297
12298#[derive(Serialize)]
12299struct UnsignedFeedEntry<'a> {
12300 v: u8,
12301 seq: u64,
12302 ts: &'a str,
12303 brain: &'a str,
12304 public_key: &'a str,
12305 kind: &'a str,
12306 op: &'a str,
12307 pack_sha256: &'a str,
12308 files: &'a [FeedFile],
12309 removed: &'a [String],
12310 prev_entry_hash: &'a Option<String>,
12311}
12312
12313#[derive(Debug, Clone, Deserialize, Serialize)]
12314struct FeedItem {
12315 hash: String,
12316 entry: FeedEntry,
12317}
12318
12319#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12320struct FeedIdentity {
12321 fingerprint: String,
12322 #[serde(rename = "publicKeySpki")]
12323 public_key_spki: String,
12324 #[serde(default, deserialize_with = "deserialize_previous_identities")]
12328 previous: Vec<PreviousIdentity>,
12329 #[serde(default, deserialize_with = "deserialize_rotations")]
12332 rotations: Vec<String>,
12333}
12334
12335#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12336struct PreviousIdentity {
12337 fingerprint: String,
12338 #[serde(rename = "publicKeySpki")]
12339 public_key_spki: String,
12340}
12341
12342#[derive(Debug, Deserialize)]
12343struct FeedResponse {
12344 #[serde(rename = "headSeq")]
12345 head_seq: u64,
12346 #[serde(rename = "feedHash")]
12347 feed_hash: Option<String>,
12348 identity: Option<FeedIdentity>,
12349 #[serde(deserialize_with = "deserialize_feed_items")]
12350 entries: Vec<FeedItem>,
12351 #[serde(rename = "scopeLimited")]
12352 scope_limited: bool,
12353}
12354
12355#[derive(Debug, Deserialize, Serialize)]
12356#[serde(deny_unknown_fields)]
12357struct RotationStatement {
12358 v: u8,
12359 op: String,
12360 brain: String,
12361 public_key: String,
12362 new_brain: String,
12363 new_public_key: String,
12364 prior_head_seq: u64,
12365 prior_feed_hash: Option<String>,
12366 ts: String,
12367 sig: String,
12368}
12369
12370#[derive(Debug, Clone, Deserialize, Serialize)]
12371struct TrustState {
12372 v: u8,
12373 origin: String,
12374 #[serde(default)]
12378 requested: String,
12379 brain: String,
12381 #[serde(default, skip_serializing_if = "Option::is_none")]
12384 home: Option<String>,
12385 anchor: String,
12386 current: String,
12387 #[serde(rename = "headSeq")]
12388 head_seq: u64,
12389 #[serde(rename = "feedHash")]
12390 feed_hash: Option<String>,
12391 #[serde(default)]
12395 rotations: Vec<String>,
12396 #[serde(default, skip_serializing_if = "Option::is_none")]
12399 hub_signer: Option<String>,
12400 #[serde(default, skip_serializing_if = "Option::is_none")]
12403 protocol_profile: Option<String>,
12404}
12405
12406fn accepted_as_v2(state: &TrustState) -> bool {
12407 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
12408}
12409
12410fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
12411 let directory = open_trust_dir(cfg)?;
12412 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
12413 return Ok(true);
12414 }
12415 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
12416 return Ok(false);
12417 };
12418 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
12419}
12420
12421#[derive(Debug, Clone, Deserialize, Serialize)]
12422struct AliasBinding {
12423 v: u8,
12424 origin: String,
12425 requested: String,
12426 brain: String,
12427 #[serde(default, skip_serializing_if = "Option::is_none")]
12428 home: Option<String>,
12429}
12430
12431struct VerifiedRemote {
12432 head: Head,
12433 identity: Option<FeedIdentity>,
12434 head_entry: Option<FeedItem>,
12435 entries: Vec<FeedItem>,
12437 anchor: Option<String>,
12438}
12439
12440fn invalid_feed(message: impl Into<String>) -> LinkError {
12441 LinkError::InvalidFeed {
12442 message: message.into(),
12443 }
12444}
12445
12446fn is_sha256(value: &str) -> bool {
12447 value.len() == 64
12448 && value
12449 .bytes()
12450 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
12451}
12452
12453fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
12454 let der = URL_SAFE_NO_PAD
12455 .decode(public_key_spki)
12456 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
12457 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
12458 return Err(invalid_feed(
12459 "identity public key is not a valid Ed25519 SPKI",
12460 ));
12461 }
12462 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
12463}
12464
12465fn verify_identity_chain(
12469 identity: &FeedIdentity,
12470 pinned: Option<&TrustState>,
12471) -> LinkResult<String> {
12472 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
12473 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
12474 {
12475 return Err(invalid_feed(
12476 "identity rotation history exceeds the client cap",
12477 ));
12478 }
12479 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
12480 return Err(invalid_feed(
12481 "current identity fingerprint does not match its public key",
12482 ));
12483 }
12484 for previous in &identity.previous {
12485 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
12486 return Err(invalid_feed(
12487 "previous identity fingerprint does not match its public key",
12488 ));
12489 }
12490 }
12491 if identity.rotations.len() != identity.previous.len() {
12492 return Err(invalid_feed(
12493 "identity history is missing an old-key-signed rotation statement",
12494 ));
12495 }
12496
12497 let mut chain: Vec<(&str, &str)> = identity
12501 .previous
12502 .iter()
12503 .rev()
12504 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
12505 .collect();
12506 chain.push((&identity.fingerprint, &identity.public_key_spki));
12507
12508 for (index, raw) in identity.rotations.iter().enumerate() {
12509 let statement: RotationStatement = serde_json::from_str(raw)
12510 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
12511 let (old_fingerprint, old_spki) = chain[index];
12512 let (new_fingerprint, new_spki) = chain[index + 1];
12513 if statement.v != 1
12514 || statement.op != "rotate"
12515 || statement.brain != format!("ed25519:{old_fingerprint}")
12516 || statement.public_key != old_spki
12517 || statement.new_brain != format!("ed25519:{new_fingerprint}")
12518 || statement.new_public_key != new_spki
12519 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
12520 || (statement.prior_head_seq > 0
12521 && statement
12522 .prior_feed_hash
12523 .as_deref()
12524 .is_none_or(|hash| !is_sha256(hash)))
12525 {
12526 return Err(invalid_feed(
12527 "rotation statement does not connect adjacent identities",
12528 ));
12529 }
12530 let unsigned = serde_json::to_string(&UnsignedRotation {
12531 v: statement.v,
12532 op: &statement.op,
12533 brain: &statement.brain,
12534 public_key: &statement.public_key,
12535 new_brain: &statement.new_brain,
12536 new_public_key: &statement.new_public_key,
12537 prior_head_seq: statement.prior_head_seq,
12538 prior_feed_hash: statement.prior_feed_hash.as_deref(),
12539 ts: statement.ts.clone(),
12540 })
12541 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
12542 let exact = format!(
12543 "{},\"sig\":\"{}\"}}",
12544 &unsigned[..unsigned.len() - 1],
12545 statement.sig
12546 );
12547 if exact != *raw {
12548 return Err(invalid_feed(
12549 "rotation statement is not in normative serialization",
12550 ));
12551 }
12552 let der = URL_SAFE_NO_PAD
12553 .decode(old_spki)
12554 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
12555 let signature = URL_SAFE_NO_PAD
12556 .decode(&statement.sig)
12557 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
12558 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
12559 .verify(unsigned.as_bytes(), &signature)
12560 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
12561 if index > 0 {
12562 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
12563 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
12564 if statement.prior_head_seq < prior.prior_head_seq {
12565 return Err(invalid_feed("rotation feed boundaries move backward"));
12566 }
12567 }
12568 }
12569
12570 let anchor = format!("ed25519:{}", chain[0].0);
12571 let current = format!("ed25519:{}", identity.fingerprint);
12572 if let Some(pin) = pinned {
12573 if pin.anchor != anchor {
12574 return Err(invalid_feed(
12575 "served identity chain does not descend from the pinned anchor",
12576 ));
12577 }
12578 if !chain
12579 .iter()
12580 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12581 {
12582 return Err(invalid_feed(
12583 "served identity chain forked away from the last pinned identity",
12584 ));
12585 }
12586 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
12587 return Err(invalid_feed("served identity discarded its rotation chain"));
12588 }
12589 if pin.v >= 2
12590 && (identity.rotations.len() < pin.rotations.len()
12591 || identity.rotations[..pin.rotations.len()] != pin.rotations)
12592 {
12593 return Err(invalid_feed(
12594 "served identity rewrote the locally accepted rotation history",
12595 ));
12596 }
12597 }
12598 Ok(anchor)
12599}
12600
12601fn verify_rotation_feed_boundaries(
12602 identity: &FeedIdentity,
12603 pinned: Option<&TrustState>,
12604 observed: &[FeedItem],
12605 advertised_seq: u64,
12606) -> LinkResult<()> {
12607 let mut chain: Vec<String> = identity
12608 .previous
12609 .iter()
12610 .rev()
12611 .map(|previous| format!("ed25519:{}", previous.fingerprint))
12612 .collect();
12613 chain.push(format!("ed25519:{}", identity.fingerprint));
12614 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
12615
12616 for (index, raw) in identity.rotations.iter().enumerate() {
12617 let rotation: RotationStatement = serde_json::from_str(raw)
12618 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12619 if rotation.prior_head_seq > advertised_seq {
12620 return Err(invalid_feed(
12621 "rotation claims a feed boundary beyond the advertised head",
12622 ));
12623 }
12624 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
12625 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
12626 return Err(invalid_feed(
12627 "newly disclosed rotation predates the local feed checkpoint",
12628 ));
12629 }
12630 }
12631 let actual = if rotation.prior_head_seq == 0 {
12632 None
12633 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
12634 pinned.and_then(|pin| pin.feed_hash.as_deref())
12635 } else {
12636 observed
12637 .iter()
12638 .find(|item| item.entry.seq == rotation.prior_head_seq)
12639 .map(|item| item.hash.as_str())
12640 };
12641 if let Some(actual) = actual {
12642 if rotation.prior_feed_hash.as_deref() != Some(actual) {
12643 return Err(invalid_feed(
12644 "rotation statement does not commit the verified feed boundary",
12645 ));
12646 }
12647 } else if rotation.prior_head_seq == 0 {
12648 } else if pinned.is_some_and(|pin| {
12651 pinned_index.is_some_and(|pin_index| index >= pin_index)
12652 || rotation.prior_head_seq >= pin.head_seq
12653 }) {
12654 return Err(invalid_feed(
12655 "rotation feed boundary was not present in the verified chain",
12656 ));
12657 }
12658 }
12659 Ok(())
12660}
12661
12662fn reject_retired_signer_after_checkpoint(
12667 identity: &FeedIdentity,
12668 pinned: Option<&TrustState>,
12669 item: &FeedItem,
12670) -> LinkResult<()> {
12671 let Some(pin) = pinned else {
12672 return Ok(());
12673 };
12674 if item.entry.seq <= pin.head_seq {
12675 return Ok(());
12676 }
12677 let mut chain: Vec<String> = identity
12678 .previous
12679 .iter()
12680 .rev()
12681 .map(|previous| format!("ed25519:{}", previous.fingerprint))
12682 .collect();
12683 chain.push(format!("ed25519:{}", identity.fingerprint));
12684 let pinned_index = chain
12685 .iter()
12686 .position(|key| key == &pin.current)
12687 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
12688 let signer_index = chain
12689 .iter()
12690 .position(|key| key == &item.entry.brain)
12691 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
12692 if signer_index < pinned_index {
12693 return Err(invalid_feed(
12694 "a retired identity attempted to sign after the local checkpoint",
12695 ));
12696 }
12697 Ok(())
12698}
12699
12700fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
12701 let origin = normalized_origin(&cfg.hub)?;
12702 let key = format!(
12703 "{:x}",
12704 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
12705 );
12706 Ok(format!("{key}.json"))
12707}
12708
12709fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
12710 let origin = normalized_origin(&cfg.hub)?;
12711 let key = format!(
12712 "{:x}",
12713 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
12714 );
12715 Ok(format!("alias-{key}.json"))
12716}
12717
12718#[cfg(any(unix, windows))]
12719struct TrustLock {
12720 _file: std::fs::File,
12721}
12722
12723#[cfg(unix)]
12724fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12725 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12726
12727 let lock_string = format!(".{state_name}.lock");
12728 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
12729 let fd = unsafe {
12730 libc::openat(
12731 directory.as_raw_fd(),
12732 lock_name.as_ptr(),
12733 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12734 0o600,
12735 )
12736 };
12737 if fd < 0 {
12738 return Err(std::io::Error::last_os_error().into());
12739 }
12740 let file = unsafe { std::fs::File::from_raw_fd(fd) };
12741 if !file.metadata()?.is_file() {
12742 return Err(LinkError::UnsafePath { path: lock_string });
12743 }
12744 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
12745 return Err(std::io::Error::last_os_error().into());
12746 }
12747 Ok(TrustLock { _file: file })
12748}
12749
12750#[cfg(windows)]
12751fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12752 let lock_name = format!(".{state_name}.lock");
12753 let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
12754 Ok(TrustLock { _file: file })
12755}
12756
12757#[cfg(any(unix, windows))]
12758fn lock_trust_many(
12759 cfg: &HubConfig,
12760 directory: &std::fs::File,
12761 refs: &[&str],
12762) -> LinkResult<Vec<TrustLock>> {
12763 let mut names = refs
12764 .iter()
12765 .map(|reference| trust_file_name(cfg, reference))
12766 .collect::<LinkResult<Vec<_>>>()?;
12767 names.sort();
12768 names.dedup();
12769 names
12770 .iter()
12771 .map(|name| lock_trust_name(directory, name))
12772 .collect()
12773}
12774
12775#[cfg(not(any(unix, windows)))]
12776fn lock_trust_many(
12777 _cfg: &HubConfig,
12778 _directory: &TrustDirectory,
12779 _refs: &[&str],
12780) -> LinkResult<Vec<()>> {
12781 Err(LinkError::UnsupportedPlatform {
12782 operation: "verified link.md state",
12783 })
12784}
12785
12786#[cfg(any(unix, windows))]
12787type TrustDirectory = std::fs::File;
12788
12789#[cfg(not(any(unix, windows)))]
12790struct TrustDirectory;
12791
12792#[cfg(unix)]
12793fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12794 use std::os::fd::AsRawFd as _;
12795
12796 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
12797 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
12798 return Err(std::io::Error::last_os_error().into());
12799 }
12800 directory.sync_all()?;
12801 Ok(directory)
12802}
12803
12804#[cfg(windows)]
12805fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12806 let marker = cfg.state_dir.join("trust").join(".directory");
12807 crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
12808 Ok(crate::fsx::open_directory_nofollow(
12809 marker.parent().expect("trust marker has a parent"),
12810 )?)
12811}
12812
12813#[cfg(not(any(unix, windows)))]
12814fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12815 Err(LinkError::UnsupportedPlatform {
12816 operation: "verified link.md state",
12817 })
12818}
12819
12820#[cfg(unix)]
12821fn load_trust_in(
12822 cfg: &HubConfig,
12823 directory: &TrustDirectory,
12824 requested: &str,
12825) -> LinkResult<Option<TrustState>> {
12826 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12827
12828 let name_string = trust_file_name(cfg, requested)?;
12829 let name = c_name(name_string.as_bytes(), &name_string)?;
12830 let fd = unsafe {
12831 libc::openat(
12832 directory.as_raw_fd(),
12833 name.as_ptr(),
12834 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12835 )
12836 };
12837 if fd < 0 {
12838 let error = std::io::Error::last_os_error();
12839 if error.kind() == std::io::ErrorKind::NotFound {
12840 return Ok(None);
12841 }
12842 return Err(LinkError::UnsafePath { path: name_string });
12843 }
12844 let file = unsafe { std::fs::File::from_raw_fd(fd) };
12845 if !file.metadata()?.is_file() {
12846 return Err(LinkError::UnsafePath { path: name_string });
12847 }
12848 let mut bytes = Vec::new();
12849 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
12850 if bytes.len() > 1024 * 1024 {
12851 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
12852 }
12853 let mut state: TrustState = serde_json::from_slice(&bytes)
12854 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
12855 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
12856 return Err(invalid_feed(
12857 "local identity/feed checkpoint does not match this hub and brain",
12858 ));
12859 }
12860 if state.v == 1 {
12861 if state.brain != requested {
12865 return Err(invalid_feed(
12866 "legacy checkpoint is not bound to the requested brain id",
12867 ));
12868 }
12869 state.requested = requested.to_string();
12870 } else if state.requested != requested {
12871 return Err(invalid_feed(
12872 "local identity/feed checkpoint is bound to a different requested ref",
12873 ));
12874 }
12875 Ok(Some(state))
12876}
12877
12878#[cfg(windows)]
12879fn load_trust_in(
12880 cfg: &HubConfig,
12881 directory: &TrustDirectory,
12882 requested: &str,
12883) -> LinkResult<Option<TrustState>> {
12884 let name = trust_file_name(cfg, requested)?;
12885 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
12886 let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
12887 Ok(bytes) => bytes,
12888 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
12889 Err(_) => return Err(LinkError::UnsafePath { path: name }),
12890 };
12891 let mut state: TrustState = serde_json::from_slice(&bytes)
12892 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
12893 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
12894 return Err(invalid_feed(
12895 "local identity/feed checkpoint does not match this hub and brain",
12896 ));
12897 }
12898 if state.v == 1 {
12899 if state.brain != requested {
12900 return Err(invalid_feed(
12901 "legacy checkpoint is not bound to the requested brain id",
12902 ));
12903 }
12904 state.requested = requested.to_string();
12905 } else if state.requested != requested {
12906 return Err(invalid_feed(
12907 "local identity/feed checkpoint is bound to a different requested ref",
12908 ));
12909 }
12910 Ok(Some(state))
12911}
12912
12913#[cfg(not(any(unix, windows)))]
12914fn load_trust_in(
12915 _cfg: &HubConfig,
12916 _directory: &TrustDirectory,
12917 _brain: &str,
12918) -> LinkResult<Option<TrustState>> {
12919 Err(LinkError::UnsupportedPlatform {
12920 operation: "verified link.md state",
12921 })
12922}
12923
12924#[cfg(all(test, any(unix, windows)))]
12925fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
12926 let directory = open_trust_dir(cfg)?;
12927 load_trust_in(cfg, &directory, requested)
12928}
12929
12930#[cfg(unix)]
12931fn save_trust_in(
12932 cfg: &HubConfig,
12933 directory: &TrustDirectory,
12934 state: &TrustState,
12935) -> LinkResult<()> {
12936 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12937
12938 let name_string = trust_file_name(cfg, &state.requested)?;
12939 let name = c_name(name_string.as_bytes(), &name_string)?;
12940 let mut bytes = serde_json::to_vec(state)
12941 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
12942 bytes.push(b'\n');
12943
12944 let nonce = std::time::SystemTime::now()
12945 .duration_since(std::time::UNIX_EPOCH)
12946 .unwrap_or_default()
12947 .as_nanos();
12948 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
12949 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
12950 let fd = unsafe {
12951 libc::openat(
12952 directory.as_raw_fd(),
12953 temp.as_ptr(),
12954 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12955 0o600,
12956 )
12957 };
12958 if fd < 0 {
12959 return Err(std::io::Error::last_os_error().into());
12960 }
12961 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
12962 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
12963 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12964 return Err(error.into());
12965 }
12966 drop(file);
12967 if unsafe {
12968 libc::renameat(
12969 directory.as_raw_fd(),
12970 temp.as_ptr(),
12971 directory.as_raw_fd(),
12972 name.as_ptr(),
12973 )
12974 } != 0
12975 {
12976 let error = std::io::Error::last_os_error();
12977 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12978 return Err(error.into());
12979 }
12980 directory.sync_all()?;
12981 Ok(())
12982}
12983
12984#[cfg(windows)]
12985fn save_trust_in(
12986 cfg: &HubConfig,
12987 directory: &TrustDirectory,
12988 state: &TrustState,
12989) -> LinkResult<()> {
12990 let name = trust_file_name(cfg, &state.requested)?;
12991 let mut bytes = serde_json::to_vec(state)
12992 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
12993 bytes.push(b'\n');
12994 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
12995 Ok(())
12996}
12997
12998#[cfg(not(any(unix, windows)))]
12999fn save_trust_in(
13000 _cfg: &HubConfig,
13001 _directory: &TrustDirectory,
13002 _state: &TrustState,
13003) -> LinkResult<()> {
13004 Err(LinkError::UnsupportedPlatform {
13005 operation: "verified link.md state",
13006 })
13007}
13008
13009#[cfg(unix)]
13010fn load_alias_in(
13011 cfg: &HubConfig,
13012 directory: &TrustDirectory,
13013 requested: &str,
13014) -> LinkResult<Option<AliasBinding>> {
13015 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13016
13017 let name_string = alias_file_name(cfg, requested)?;
13018 let name = c_name(name_string.as_bytes(), &name_string)?;
13019 let fd = unsafe {
13020 libc::openat(
13021 directory.as_raw_fd(),
13022 name.as_ptr(),
13023 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13024 )
13025 };
13026 if fd < 0 {
13027 let error = std::io::Error::last_os_error();
13028 if error.kind() == std::io::ErrorKind::NotFound {
13029 return Ok(None);
13030 }
13031 return Err(LinkError::UnsafePath { path: name_string });
13032 }
13033 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13034 if !file.metadata()?.is_file() {
13035 return Err(LinkError::UnsafePath { path: name_string });
13036 }
13037 let mut bytes = Vec::new();
13038 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
13039 if bytes.len() > 64 * 1024 {
13040 return Err(invalid_feed("local alias binding is oversized"));
13041 }
13042 let alias: AliasBinding = serde_json::from_slice(&bytes)
13043 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13044 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13045 {
13046 return Err(invalid_feed(
13047 "local alias binding does not match this hub and requested ref",
13048 ));
13049 }
13050 Ok(Some(alias))
13051}
13052
13053#[cfg(windows)]
13054fn load_alias_in(
13055 cfg: &HubConfig,
13056 directory: &TrustDirectory,
13057 requested: &str,
13058) -> LinkResult<Option<AliasBinding>> {
13059 let name = alias_file_name(cfg, requested)?;
13060 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13061 let bytes = match reader.read(Path::new(&name), 64 * 1024) {
13062 Ok(bytes) => bytes,
13063 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13064 Err(_) => return Err(LinkError::UnsafePath { path: name }),
13065 };
13066 let alias: AliasBinding = serde_json::from_slice(&bytes)
13067 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13068 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13069 {
13070 return Err(invalid_feed(
13071 "local alias binding does not match this hub and requested ref",
13072 ));
13073 }
13074 Ok(Some(alias))
13075}
13076
13077#[cfg(not(any(unix, windows)))]
13078fn load_alias_in(
13079 _cfg: &HubConfig,
13080 _directory: &TrustDirectory,
13081 _requested: &str,
13082) -> LinkResult<Option<AliasBinding>> {
13083 Err(LinkError::UnsupportedPlatform {
13084 operation: "verified link.md state",
13085 })
13086}
13087
13088#[cfg(unix)]
13089fn save_alias_in(
13090 cfg: &HubConfig,
13091 directory: &TrustDirectory,
13092 alias: &AliasBinding,
13093) -> LinkResult<()> {
13094 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13095
13096 let name_string = alias_file_name(cfg, &alias.requested)?;
13097 let name = c_name(name_string.as_bytes(), &name_string)?;
13098 let mut bytes = serde_json::to_vec(alias)
13099 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13100 bytes.push(b'\n');
13101 let nonce = std::time::SystemTime::now()
13102 .duration_since(std::time::UNIX_EPOCH)
13103 .unwrap_or_default()
13104 .as_nanos();
13105 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13106 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13107 let fd = unsafe {
13108 libc::openat(
13109 directory.as_raw_fd(),
13110 temp.as_ptr(),
13111 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13112 0o600,
13113 )
13114 };
13115 if fd < 0 {
13116 return Err(std::io::Error::last_os_error().into());
13117 }
13118 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13119 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13120 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13121 return Err(error.into());
13122 }
13123 drop(file);
13124 if unsafe {
13125 libc::renameat(
13126 directory.as_raw_fd(),
13127 temp.as_ptr(),
13128 directory.as_raw_fd(),
13129 name.as_ptr(),
13130 )
13131 } != 0
13132 {
13133 let error = std::io::Error::last_os_error();
13134 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13135 return Err(error.into());
13136 }
13137 directory.sync_all()?;
13138 Ok(())
13139}
13140
13141#[cfg(windows)]
13142fn save_alias_in(
13143 cfg: &HubConfig,
13144 directory: &TrustDirectory,
13145 alias: &AliasBinding,
13146) -> LinkResult<()> {
13147 let name = alias_file_name(cfg, &alias.requested)?;
13148 let mut bytes = serde_json::to_vec(alias)
13149 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13150 bytes.push(b'\n');
13151 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13152 Ok(())
13153}
13154
13155#[cfg(not(any(unix, windows)))]
13156fn save_alias_in(
13157 _cfg: &HubConfig,
13158 _directory: &TrustDirectory,
13159 _alias: &AliasBinding,
13160) -> LinkResult<()> {
13161 Err(LinkError::UnsupportedPlatform {
13162 operation: "verified link.md state",
13163 })
13164}
13165
13166fn load_canonical_pin(
13171 cfg: &HubConfig,
13172 directory: &TrustDirectory,
13173 requested: &str,
13174 resolved_brain: &str,
13175) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
13176 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
13177 if requested == resolved_brain {
13178 return Ok((canonical, None));
13179 }
13180
13181 let mut alias = load_alias_in(cfg, directory, requested)?;
13182 if let Some(binding) = &alias {
13183 if binding.brain != resolved_brain {
13184 return Err(LinkError::AliasRebindRequired {
13185 alias: requested.to_string(),
13186 from: binding.brain.clone(),
13187 to: resolved_brain.to_string(),
13188 });
13189 }
13190 return Ok((canonical, alias));
13191 }
13192
13193 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
13197 if legacy.brain != resolved_brain {
13198 return Err(invalid_feed(
13199 "legacy alias checkpoint names a different canonical brain",
13200 ));
13201 }
13202 if let Some(existing) = &canonical {
13203 if existing.brain != legacy.brain
13204 || existing.anchor != legacy.anchor
13205 || existing.current != legacy.current
13206 || existing.head_seq != legacy.head_seq
13207 || existing.feed_hash != legacy.feed_hash
13208 || existing.rotations != legacy.rotations
13209 {
13210 return Err(invalid_feed(
13211 "legacy alias checkpoint conflicts with the canonical checkpoint",
13212 ));
13213 }
13214 } else {
13215 let mut promoted = legacy.clone();
13216 promoted.requested = resolved_brain.to_string();
13217 promoted.home = None;
13218 save_trust_in(cfg, directory, &promoted)?;
13219 canonical = Some(promoted);
13220 }
13221 alias = Some(AliasBinding {
13222 v: 1,
13223 origin: normalized_origin(&cfg.hub)?,
13224 requested: requested.to_string(),
13225 brain: resolved_brain.to_string(),
13226 home: legacy.home,
13227 });
13228 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
13229 }
13230 Ok((canonical, alias))
13231}
13232
13233pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
13238 require_hardened_filesystem("verified alias rebind")?;
13239 require_safe_ref(alias)?;
13240 require_safe_ref(from)?;
13241 require_safe_ref(to)?;
13242 if crate::ulid::is_ulid(alias)
13243 || !crate::ulid::is_ulid(from)
13244 || !crate::ulid::is_ulid(to)
13245 || from == to
13246 {
13247 return Err(LinkError::InvalidPack {
13248 message:
13249 "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
13250 .to_string(),
13251 });
13252 }
13253
13254 let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
13255 message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
13256 })?;
13257 accept_v2_head(cfg, &verified)?;
13258
13259 let alias_response = ensure_ok(
13260 request(
13261 cfg,
13262 "GET",
13263 &format!("/api/hub/brains/{alias}/v2/head"),
13264 None,
13265 Auth::Required,
13266 )?,
13267 "resolve alias for explicit rebind",
13268 )?;
13269 let resolved: V2HeadResponse = serde_json::from_value(alias_response)
13270 .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
13271 if resolved.v != 2 || resolved.brain_id != to {
13272 return Err(LinkError::RemoteAdvancedDuringSync);
13273 }
13274
13275 let directory = open_trust_dir(cfg)?;
13276 let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
13277 let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
13278 message: "the requested alias has no existing local binding to replace".to_string(),
13279 })?;
13280 if binding.brain != from {
13281 return Err(LinkError::AliasRebindRequired {
13282 alias: alias.to_string(),
13283 from: binding.brain,
13284 to: to.to_string(),
13285 });
13286 }
13287 save_alias_in(
13288 cfg,
13289 &directory,
13290 &AliasBinding {
13291 v: 1,
13292 origin: normalized_origin(&cfg.hub)?,
13293 requested: alias.to_string(),
13294 brain: to.to_string(),
13295 home: binding.home,
13296 },
13297 )?;
13298 Ok(json!({
13299 "v": 2,
13300 "alias": alias,
13301 "from": from,
13302 "to": to,
13303 "outcome": "alias_rebound",
13304 }))
13305}
13306
13307fn save_canonical_pin_and_alias(
13308 cfg: &HubConfig,
13309 directory: &TrustDirectory,
13310 requested: &str,
13311 resolved_brain: &str,
13312 mut state: TrustState,
13313 existing_alias: Option<&AliasBinding>,
13314) -> LinkResult<()> {
13315 state.requested = resolved_brain.to_string();
13316 state.brain = resolved_brain.to_string();
13317 state.home = None;
13318 save_trust_in(cfg, directory, &state)?;
13319 if requested != resolved_brain {
13320 save_alias_in(
13321 cfg,
13322 directory,
13323 &AliasBinding {
13324 v: 1,
13325 origin: normalized_origin(&cfg.hub)?,
13326 requested: requested.to_string(),
13327 brain: resolved_brain.to_string(),
13328 home: existing_alias.and_then(|alias| alias.home.clone()),
13329 },
13330 )?;
13331 }
13332 Ok(())
13333}
13334
13335fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
13336 const ED25519_SPKI_PREFIX: &[u8] = &[
13337 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13338 ];
13339 let entry = &item.entry;
13340 let public_der = URL_SAFE_NO_PAD
13341 .decode(&entry.public_key)
13342 .map_err(|_| invalid_feed("public key is not base64url"))?;
13343 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
13344 || !public_der.starts_with(ED25519_SPKI_PREFIX)
13345 {
13346 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
13347 }
13348 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
13349 if entry.brain != format!("ed25519:{fingerprint}") {
13350 return Err(invalid_feed(
13351 "brain fingerprint does not match its public key",
13352 ));
13353 }
13354 let _ = verify_identity_chain(identity, None)?;
13356 let mut chain: Vec<(&str, &str)> = identity
13357 .previous
13358 .iter()
13359 .rev()
13360 .map(|previous| {
13361 (
13362 previous.fingerprint.as_str(),
13363 previous.public_key_spki.as_str(),
13364 )
13365 })
13366 .collect();
13367 chain.push((&identity.fingerprint, &identity.public_key_spki));
13368 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
13369 *known_fingerprint == fingerprint && *spki == entry.public_key
13370 });
13371 let Some(signer_index) = signer_index else {
13372 return Err(invalid_feed(
13373 "entry signer is not this brain's identity (current or rotated-from)",
13374 ));
13375 };
13376 let lower_boundary = if signer_index == 0 {
13377 None
13378 } else {
13379 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
13380 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13381 Some(prior.prior_head_seq)
13382 };
13383 let upper_boundary = if signer_index == identity.rotations.len() {
13384 None
13385 } else {
13386 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
13387 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13388 Some(next.prior_head_seq)
13389 };
13390 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
13391 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
13392 {
13393 return Err(invalid_feed(
13394 "entry signer is outside its authenticated rotation epoch",
13395 ));
13396 }
13397 let unsigned = UnsignedFeedEntry {
13398 v: entry.v,
13399 seq: entry.seq,
13400 ts: &entry.ts,
13401 brain: &entry.brain,
13402 public_key: &entry.public_key,
13403 kind: &entry.kind,
13404 op: &entry.op,
13405 pack_sha256: &entry.pack_sha256,
13406 files: &entry.files,
13407 removed: &entry.removed,
13408 prev_entry_hash: &entry.prev_entry_hash,
13409 };
13410 let message =
13411 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
13412 let signature = URL_SAFE_NO_PAD
13413 .decode(&entry.sig)
13414 .map_err(|_| invalid_feed("signature is not base64url"))?;
13415 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
13416 .verify(&message, &signature)
13417 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
13418
13419 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
13420 exact.push(b'\n');
13421 let actual_hash = format!("{:x}", Sha256::digest(&exact));
13422 if actual_hash != item.hash {
13423 return Err(invalid_feed("entry SHA-256 does not match"));
13424 }
13425 Ok(())
13426}
13427
13428#[derive(Serialize)]
13434struct UnsignedRotation<'a> {
13435 v: u8,
13436 op: &'a str,
13437 brain: &'a str,
13438 public_key: &'a str,
13439 new_brain: &'a str,
13440 new_public_key: &'a str,
13441 prior_head_seq: u64,
13442 prior_feed_hash: Option<&'a str>,
13443 ts: String,
13444}
13445
13446#[derive(Debug, Deserialize, Serialize)]
13451#[serde(deny_unknown_fields)]
13452struct RotationJournal {
13453 v: u8,
13454 origin: String,
13455 brain: String,
13456 old_brain: String,
13457 new_brain: String,
13458 prior_head_seq: u64,
13459 prior_feed_hash: Option<String>,
13460 statement: String,
13461}
13462
13463fn rotation_journal_path(key_path: &Path) -> PathBuf {
13464 let mut path = key_path.as_os_str().to_os_string();
13465 path.push(".rotation.json");
13466 PathBuf::from(path)
13467}
13468
13469fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
13470 #[cfg(unix)]
13471 let file = {
13472 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13473 use std::os::unix::ffi::OsStrExt as _;
13474 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13475 .map_err(|error| {
13476 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
13477 })?;
13478 let leaf_name = path
13479 .file_name()
13480 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
13481 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
13482 let fd = unsafe {
13483 libc::openat(
13484 parent.as_raw_fd(),
13485 leaf.as_ptr(),
13486 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13487 )
13488 };
13489 if fd < 0 {
13490 return Err(bad_agent_key(
13491 "the rotation journal must be an existing regular file without symlink ancestors",
13492 ));
13493 }
13494 unsafe { std::fs::File::from_raw_fd(fd) }
13495 };
13496 #[cfg(not(unix))]
13497 let file = std::fs::File::open(path)
13498 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
13499 let metadata = file
13500 .metadata()
13501 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
13502 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
13503 return Err(bad_agent_key(
13504 "the rotation journal must be a bounded regular file",
13505 ));
13506 }
13507 #[cfg(unix)]
13508 {
13509 use std::os::unix::fs::PermissionsExt as _;
13510 if metadata.permissions().mode() & 0o077 != 0 {
13511 return Err(bad_agent_key(
13512 "the rotation journal is accessible to group/other; set mode 0600",
13513 ));
13514 }
13515 }
13516 serde_json::from_reader(file)
13517 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
13518}
13519
13520fn remove_rotation_journal(path: &Path) {
13521 #[cfg(unix)]
13522 {
13523 use std::os::fd::AsRawFd as _;
13524 use std::os::unix::ffi::OsStrExt as _;
13525 let Ok(parent) =
13526 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13527 else {
13528 return;
13529 };
13530 let Some(leaf_name) = path.file_name() else {
13531 return;
13532 };
13533 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
13534 return;
13535 };
13536 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
13537 let _ = parent.sync_all();
13538 }
13539 }
13540 #[cfg(not(unix))]
13541 {
13542 let _ = std::fs::remove_file(path);
13543 }
13544}
13545
13546fn validate_rotation_journal(
13547 journal: &RotationJournal,
13548 cfg: &HubConfig,
13549 canonical_brain: &str,
13550 old_key: &AgentSigningKey,
13551 new_key: &AgentSigningKey,
13552 head: &Head,
13553) -> LinkResult<()> {
13554 if journal.v != 1
13555 || journal.origin != normalized_origin(&cfg.hub)?
13556 || journal.brain != canonical_brain
13557 || journal.old_brain != old_key.multikey
13558 || journal.new_brain != new_key.multikey
13559 || journal.prior_head_seq != head.seq
13560 || journal.prior_feed_hash != head.feed_hash
13561 {
13562 return Err(invalid_feed(
13563 "rotation journal does not match the verified key and feed boundary",
13564 ));
13565 }
13566 let statement: RotationStatement = serde_json::from_str(&journal.statement)
13567 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
13568 if statement.prior_head_seq != journal.prior_head_seq
13569 || statement.prior_feed_hash != journal.prior_feed_hash
13570 || statement.brain != old_key.multikey
13571 || statement.public_key != old_key.public_key_spki
13572 || statement.new_brain != new_key.multikey
13573 || statement.new_public_key != new_key.public_key_spki
13574 {
13575 return Err(invalid_feed(
13576 "rotation journal statement does not match its durable intent",
13577 ));
13578 }
13579 let identity = FeedIdentity {
13580 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13581 public_key_spki: new_key.public_key_spki.clone(),
13582 previous: vec![PreviousIdentity {
13583 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
13584 public_key_spki: old_key.public_key_spki.clone(),
13585 }],
13586 rotations: vec![journal.statement.clone()],
13587 };
13588 verify_identity_chain(&identity, None)?;
13589 Ok(())
13590}
13591
13592#[derive(Debug, Serialize)]
13594pub struct RotationReport {
13595 pub brain: String,
13597 pub multikey: String,
13599 #[serde(rename = "keyFile")]
13601 pub key_file: String,
13602 pub previous: Vec<String>,
13604}
13605
13606pub fn rotate_brain_key(
13612 cfg: &HubConfig,
13613 brain: &str,
13614 old_key: &AgentSigningKey,
13615 out: &Path,
13616) -> LinkResult<RotationReport> {
13617 require_hardened_filesystem("key rotation")?;
13618 require_safe_ref(brain)?;
13619 let new_key = if out.exists() {
13623 load_signing_key(out)?
13624 } else {
13625 let rng = ring::rand::SystemRandom::new();
13626 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
13627 .map_err(|_| bad_agent_key("key generation failed"))?;
13628 let pair = agent_keypair(pkcs8.as_ref())?;
13629 let (public_key_spki, multikey) = public_identity_for(&pair);
13630 write_secret_new(
13631 out,
13632 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
13633 )?;
13634 AgentSigningKey {
13635 pkcs8: pkcs8.as_ref().to_vec(),
13636 multikey,
13637 public_key_spki,
13638 }
13639 };
13640 let new_spki = new_key.public_key_spki.clone();
13641 let new_multikey = new_key.multikey.clone();
13642 let journal_path = rotation_journal_path(out);
13643 let before_v2 = v2_verified_head(cfg, brain)?;
13644 let (canonical_brain, served_identity, observed_head, v2_profile) =
13645 if let Some(head) = before_v2 {
13646 let observed = Head {
13647 brain: head.brain_id.clone(),
13648 seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13649 updated_at: head
13650 .pointer
13651 .as_ref()
13652 .map(|pointer| pointer.signed_at.clone()),
13653 feed_hash: head
13654 .pointer
13655 .as_ref()
13656 .map(|pointer| pointer.feed_hash.clone()),
13657 verified: true,
13658 };
13659 let identity = v2_identity(&head.identity);
13660 let canonical = head.brain_id.clone();
13661 accept_v2_head(cfg, &head)?;
13662 (canonical, identity, observed, true)
13663 } else {
13664 let remote = verified_remote_head(cfg, brain, false)?;
13665 let identity = remote
13666 .identity
13667 .clone()
13668 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
13669 (remote.head.brain.clone(), identity, remote.head, false)
13670 };
13671 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
13672 let already_rotated = served_multikey == new_multikey;
13673 if already_rotated && !journal_path.exists() {
13678 remove_rotation_journal(&journal_path);
13679 return Ok(RotationReport {
13680 brain: brain.to_string(),
13681 multikey: new_multikey,
13682 key_file: out.display().to_string(),
13683 previous: served_identity
13684 .previous
13685 .iter()
13686 .map(|identity| format!("ed25519:{}", identity.fingerprint))
13687 .collect(),
13688 });
13689 }
13690 if !already_rotated && served_multikey != old_key.multikey {
13691 return Err(invalid_feed(
13692 "the supplied old key is not the brain's verified current identity",
13693 ));
13694 }
13695
13696 let journal = if journal_path.exists() {
13697 read_rotation_journal(&journal_path)?
13698 } else {
13699 let ts = crate::now()
13700 .with_timezone(&chrono::Utc)
13701 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
13702 .to_string();
13703 let unsigned = serde_json::to_string(&UnsignedRotation {
13704 v: 1,
13705 op: "rotate",
13706 brain: &old_key.multikey,
13707 public_key: &old_key.public_key_spki,
13708 new_brain: &new_multikey,
13709 new_public_key: &new_spki,
13710 prior_head_seq: observed_head.seq,
13711 prior_feed_hash: observed_head.feed_hash.as_deref(),
13712 ts,
13713 })
13714 .expect("serialize rotation");
13715 let old_pair = agent_keypair(&old_key.pkcs8)?;
13716 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
13717 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
13718 let journal = RotationJournal {
13719 v: 1,
13720 origin: normalized_origin(&cfg.hub)?,
13721 brain: canonical_brain.clone(),
13722 old_brain: old_key.multikey.clone(),
13723 new_brain: new_multikey.clone(),
13724 prior_head_seq: observed_head.seq,
13725 prior_feed_hash: observed_head.feed_hash.clone(),
13726 statement,
13727 };
13728 let mut exact = serde_json::to_vec(&journal)
13729 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
13730 exact.push(b'\n');
13731 if write_secret_new(&journal_path, &exact).is_err() {
13732 read_rotation_journal(&journal_path)?
13735 } else {
13736 journal
13737 }
13738 };
13739 validate_rotation_journal(
13740 &journal,
13741 cfg,
13742 &canonical_brain,
13743 old_key,
13744 &new_key,
13745 &observed_head,
13746 )?;
13747
13748 let body = json!({ "statement": journal.statement });
13749 let path = format!("/api/hub/brains/{brain}/rotate");
13750 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
13751 let attempted_failure = match attempted {
13752 Ok(response) if (200..300).contains(&response.status) => None,
13753 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
13754 Err(error) => Some(error),
13755 };
13756
13757 let identity = if v2_profile {
13761 match v2_verified_head(cfg, brain) {
13762 Ok(Some(after)) => {
13763 let identity = v2_identity(&after.identity);
13764 accept_v2_head(cfg, &after)?;
13765 identity
13766 }
13767 Ok(None) => {
13768 return Err(attempted_failure.unwrap_or_else(|| {
13769 invalid_feed("rotated v2 brain no longer serves a v2 head")
13770 }));
13771 }
13772 Err(error) => return Err(attempted_failure.unwrap_or(error)),
13773 }
13774 } else {
13775 match verified_remote_head(cfg, brain, false) {
13776 Ok(after) => after
13777 .identity
13778 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
13779 Err(error) => return Err(attempted_failure.unwrap_or(error)),
13780 }
13781 };
13782 if format!("ed25519:{}", identity.fingerprint) != new_multikey
13783 || identity.public_key_spki != new_spki
13784 {
13785 return Err(attempted_failure.unwrap_or_else(|| {
13786 invalid_feed("hub acknowledged rotation without committing the verified new identity")
13787 }));
13788 }
13789 if v2_profile {
13790 if let Some(error) = attempted_failure {
13791 return Err(error);
13796 }
13797 }
13798 let previous = identity
13799 .previous
13800 .iter()
13801 .map(|prior| format!("ed25519:{}", prior.fingerprint))
13802 .collect();
13803 remove_rotation_journal(&journal_path);
13804
13805 Ok(RotationReport {
13806 brain: brain.to_string(),
13807 multikey: new_multikey,
13808 key_file: out.display().to_string(),
13809 previous,
13810 })
13811}
13812
13813#[derive(Debug, Serialize)]
13819pub struct MirrorReport {
13820 pub brain: String,
13822 #[serde(rename = "headSeq")]
13824 pub head_seq: u64,
13825 #[serde(rename = "feedHash")]
13827 pub feed_hash: Option<String>,
13828 pub entries: u64,
13830 pub pinned: String,
13832 pub files: usize,
13834}
13835
13836pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
13838
13839#[derive(Debug)]
13841pub struct VerifiedMirrorMaterial {
13842 pub brain: String,
13843 pub head_seq: u64,
13844 pub feed_hash: Option<String>,
13845 pub identity: serde_json::Value,
13846 pub entries: Vec<(u64, String, String)>,
13848 pub pack_sha256: Option<String>,
13849}
13850
13851#[derive(Deserialize)]
13852#[serde(deny_unknown_fields)]
13853struct StoredMirrorHead {
13854 brain: String,
13855 #[serde(rename = "headSeq")]
13856 head_seq: u64,
13857 #[serde(rename = "feedHash")]
13858 feed_hash: Option<String>,
13859}
13860
13861pub fn verify_mirror_material(
13864 head_bytes: &[u8],
13865 identity_bytes: &[u8],
13866 feed_bytes: &[Vec<u8>],
13867 snapshot_pack: Option<&[u8]>,
13868 expected_anchor: &str,
13869) -> LinkResult<VerifiedMirrorMaterial> {
13870 let snapshot_hash = snapshot_pack
13871 .filter(|pack| !pack.is_empty())
13872 .map(content_sha256);
13873 verify_mirror_material_with_pack_hash(
13874 head_bytes,
13875 identity_bytes,
13876 feed_bytes,
13877 snapshot_hash.as_deref(),
13878 expected_anchor,
13879 )
13880}
13881
13882pub fn verify_mirror_material_with_pack_hash(
13886 head_bytes: &[u8],
13887 identity_bytes: &[u8],
13888 feed_bytes: &[Vec<u8>],
13889 snapshot_pack_sha256: Option<&str>,
13890 expected_anchor: &str,
13891) -> LinkResult<VerifiedMirrorMaterial> {
13892 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
13893 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
13894 require_safe_ref(&head.brain)?;
13895 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
13896 return Err(invalid_feed(
13897 "stored mirror feed count does not match its bounded head sequence",
13898 ));
13899 }
13900 let aggregate = feed_bytes
13901 .iter()
13902 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
13903 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
13904 if aggregate > MAX_FEED_REPLAY_BYTES {
13905 return Err(invalid_feed(
13906 "stored mirror feed metadata exceeds the aggregate limit",
13907 ));
13908 }
13909 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
13910 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
13911 let anchor = verify_identity_chain(&identity, None)?;
13912 if anchor != expected_anchor {
13913 return Err(invalid_feed(
13914 "stored mirror identity does not descend from the explicitly trusted anchor",
13915 ));
13916 }
13917
13918 let mut entries = Vec::with_capacity(feed_bytes.len());
13919 let mut items = Vec::with_capacity(feed_bytes.len());
13920 let mut previous_hash = None;
13921 let mut pack_sha256 = None;
13922 for (index, bytes) in feed_bytes.iter().enumerate() {
13923 let exact = bytes
13924 .strip_suffix(b"\n")
13925 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
13926 if exact.ends_with(b"\n") {
13927 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
13928 }
13929 let entry: FeedEntry = serde_json::from_slice(exact)
13930 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
13931 let expected_seq = index as u64 + 1;
13932 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
13933 return Err(invalid_feed(
13934 "stored mirror feed is not contiguous and hash-chained",
13935 ));
13936 }
13937 let canonical = serde_json::to_vec(&entry)
13938 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
13939 if canonical != exact {
13940 return Err(invalid_feed(
13941 "stored feed entry is not in normative serialization",
13942 ));
13943 }
13944 let hash = content_sha256(bytes);
13945 let item = FeedItem {
13946 hash: hash.clone(),
13947 entry,
13948 };
13949 verify_feed_item(&item, &identity)?;
13950 previous_hash = Some(hash.clone());
13951 if expected_seq == head.head_seq {
13952 pack_sha256 = Some(item.entry.pack_sha256.clone());
13953 }
13954 entries.push((
13955 expected_seq,
13956 std::str::from_utf8(exact)
13957 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
13958 .to_string(),
13959 hash,
13960 ));
13961 items.push(item);
13962 }
13963 if previous_hash != head.feed_hash {
13964 return Err(invalid_feed(
13965 "stored mirror feed does not converge on its advertised head",
13966 ));
13967 }
13968 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
13969 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
13970 (0, None, None) => {}
13971 (_, Some(actual), Some(expected)) if actual == expected => {}
13972 _ => {
13973 return Err(LinkError::InvalidPack {
13974 message: "stored snapshot pack does not match the signed head digest".to_string(),
13975 });
13976 }
13977 }
13978 let identity_value = serde_json::to_value(&identity)
13979 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
13980 Ok(VerifiedMirrorMaterial {
13981 brain: head.brain,
13982 head_seq: head.head_seq,
13983 feed_hash: head.feed_hash,
13984 identity: identity_value,
13985 entries,
13986 pack_sha256,
13987 })
13988}
13989
13990pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
13993 format!(
13994 "{:x}",
13995 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
13996 )
13997}
13998
13999pub fn content_sha256(bytes: &[u8]) -> String {
14002 format!("{:x}", Sha256::digest(bytes))
14003}
14004
14005pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
14007 let mut digest = Sha256::new();
14008 let mut buffer = [0u8; 64 * 1024];
14009 loop {
14010 let read = reader.read(&mut buffer)?;
14011 if read == 0 {
14012 break;
14013 }
14014 digest.update(&buffer[..read]);
14015 }
14016 Ok(format!("{:x}", digest.finalize()))
14017}
14018
14019#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
14027pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
14028 require_hardened_filesystem("mirror")?;
14029 require_safe_ref(brain)?;
14030 #[cfg(windows)]
14031 {
14032 let _ = (cfg, dest);
14033 return Err(LinkError::UnsupportedPlatform {
14034 operation: "atomic whole-mirror replacement on Windows",
14035 });
14036 }
14037 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
14038 let name = dest
14039 .file_name()
14040 .and_then(|name| name.to_str())
14041 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
14042 .ok_or_else(|| LinkError::UnsafePath {
14043 path: dest.display().to_string(),
14044 })?;
14045 #[cfg(unix)]
14046 let parent_dir = open_or_create_dir_nofollow(parent)?;
14047 #[cfg(unix)]
14048 use std::os::fd::AsRawFd as _;
14049 #[cfg(unix)]
14050 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
14051 #[cfg(unix)]
14052 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
14053 None => false,
14054 Some(true) => true,
14055 Some(false) => {
14056 return Err(LinkError::UnsafePath {
14057 path: dest.display().to_string(),
14058 });
14059 }
14060 };
14061
14062 #[cfg(unix)]
14065 let legacy_backup_name = c_name(
14066 format!(".{name}.dbmd-backup").as_bytes(),
14067 &dest.display().to_string(),
14068 )?;
14069 #[cfg(unix)]
14070 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
14071 return Err(LinkError::UnsafePath {
14072 path: parent
14073 .join(format!(".{name}.dbmd-backup"))
14074 .display()
14075 .to_string(),
14076 });
14077 }
14078
14079 let nonce = std::time::SystemTime::now()
14080 .duration_since(std::time::UNIX_EPOCH)
14081 .unwrap_or_default()
14082 .as_nanos();
14083 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
14084 #[cfg(unix)]
14085 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
14086 #[cfg(unix)]
14087 let stage_dir = create_dir_exclusive_at(
14088 parent_dir.as_raw_fd(),
14089 &stage_name,
14090 &dest.display().to_string(),
14091 )?;
14092
14093 let assembled = (|| -> LinkResult<MirrorReport> {
14094 let remote = verified_remote_head(cfg, brain, true)?;
14095 let brain_id = remote.head.brain.clone();
14096 let identity = remote
14097 .identity
14098 .as_ref()
14099 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
14100 let anchor = remote
14101 .anchor
14102 .clone()
14103 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
14104 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
14105 let snapshot_entries = parse_store_pack(pack.clone())?;
14106 let snapshot_count = snapshot_entries.len();
14107 let mut staged_entries = snapshot_entries;
14108 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
14109 for item in &remote.entries {
14110 let mut exact = serde_json::to_vec(&item.entry)
14111 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
14112 exact.push(b'\n');
14113 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
14114 return Err(invalid_feed(
14115 "serialized mirror entry differs from its verified hash",
14116 ));
14117 }
14118 staged_entries.push((
14119 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
14120 exact,
14121 ));
14122 }
14123 let mut identity_bytes = serde_json::to_vec(identity)
14124 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
14125 identity_bytes.push(b'\n');
14126 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
14127 let mut head_bytes = serde_json::to_vec(&json!({
14128 "brain": brain_id,
14129 "headSeq": remote.head.seq,
14130 "feedHash": remote.head.feed_hash,
14131 }))
14132 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
14133 head_bytes.push(b'\n');
14134 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
14135 staged_entries.push((
14136 CONFIG_REL_PATH.to_string(),
14137 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
14138 ));
14139 #[cfg(unix)]
14140 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
14141
14142 Ok(MirrorReport {
14143 brain: brain_id,
14144 head_seq: remote.head.seq,
14145 feed_hash: remote.head.feed_hash,
14146 entries: remote.entries.len() as u64,
14147 pinned: anchor,
14148 files: snapshot_count,
14149 })
14150 })();
14151
14152 let report = match assembled {
14153 Ok(report) => report,
14154 Err(error) => {
14155 #[cfg(unix)]
14156 let _ = remove_tree_at(
14157 parent_dir.as_raw_fd(),
14158 &stage_name,
14159 &dest.display().to_string(),
14160 );
14161 return Err(error);
14162 }
14163 };
14164
14165 #[cfg(unix)]
14166 if let Err(error) =
14167 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
14168 {
14169 let _ = remove_tree_at(
14170 parent_dir.as_raw_fd(),
14171 &stage_name,
14172 &dest.display().to_string(),
14173 );
14174 return Err(error);
14175 }
14176 #[cfg(unix)]
14179 if dest_exists {
14180 remove_tree_at(
14181 parent_dir.as_raw_fd(),
14182 &stage_name,
14183 &dest.display().to_string(),
14184 )?;
14185 }
14186 #[cfg(unix)]
14187 parent_dir.sync_all()?;
14188 Ok(report)
14189}
14190
14191fn verified_remote_head(
14192 cfg: &HubConfig,
14193 brain: &str,
14194 require_full_chain: bool,
14195) -> LinkResult<VerifiedRemote> {
14196 require_hardened_filesystem("verified link.md state")?;
14197 require_safe_ref(brain)?;
14198 let trust_directory = open_trust_dir(cfg)?;
14202 let path = format!("/api/hub/brains/{brain}");
14203 let body = ensure_ok(
14204 request(cfg, "GET", &path, None, Auth::Required)?,
14205 "subscribe",
14206 )?;
14207 let resolved_brain = body
14208 .get("id")
14209 .and_then(Value::as_str)
14210 .filter(|id| crate::ulid::is_ulid(id))
14211 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
14212 .to_string();
14213 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
14214 return Err(invalid_feed(
14215 "brain card id differs from the explicitly requested brain id",
14216 ));
14217 }
14218 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
14223 let (pinned, alias_binding) =
14224 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
14225 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
14226 let advertised_hash = body
14227 .get("feedHash")
14228 .and_then(Value::as_str)
14229 .map(str::to_string);
14230 let updated_at = body
14231 .get("updatedAt")
14232 .and_then(Value::as_str)
14233 .map(str::to_string);
14234 if let Some(pin) = &pinned {
14235 if seq < pin.head_seq {
14236 return Err(invalid_feed(format!(
14237 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
14238 pin.head_seq
14239 )));
14240 }
14241 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
14242 return Err(invalid_feed(
14243 "feed equivocation: the checkpoint sequence now has a different hash",
14244 ));
14245 }
14246 }
14247 if seq == 0 {
14248 if advertised_hash.is_some() {
14249 return Err(invalid_feed("an empty feed advertised a head hash"));
14250 }
14251 let identity: FeedIdentity = serde_json::from_value(
14252 body.get("identity")
14253 .cloned()
14254 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
14255 )
14256 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
14257 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
14258 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
14263 save_canonical_pin_and_alias(
14264 cfg,
14265 &trust_directory,
14266 brain,
14267 &resolved_brain,
14268 TrustState {
14269 v: 2,
14270 origin: normalized_origin(&cfg.hub)?,
14271 requested: resolved_brain.clone(),
14272 brain: resolved_brain.clone(),
14273 home: None,
14274 anchor: anchor.clone(),
14275 current: format!("ed25519:{}", identity.fingerprint),
14276 head_seq: 0,
14277 feed_hash: None,
14278 rotations: identity.rotations.clone(),
14279 hub_signer: None,
14280 protocol_profile: None,
14281 },
14282 alias_binding.as_ref(),
14283 )?;
14284 return Ok(VerifiedRemote {
14285 head: Head {
14286 brain: resolved_brain,
14287 seq,
14288 updated_at,
14289 feed_hash: None,
14290 verified: true,
14291 },
14292 identity: Some(identity),
14293 head_entry: None,
14294 entries: Vec::new(),
14295 anchor: Some(anchor),
14296 });
14297 }
14298 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
14299 return Err(invalid_feed(
14300 "non-empty feed did not advertise a valid SHA-256 head",
14301 ));
14302 }
14303
14304 let replay_head_only = !require_full_chain
14308 && pinned
14309 .as_ref()
14310 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
14311 let mut after = if replay_head_only {
14312 seq - 1
14313 } else if require_full_chain || pinned.is_none() {
14314 0
14315 } else {
14316 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
14317 };
14318 let mut expected_seq = after + 1;
14319 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
14320 None
14321 } else {
14322 pinned
14323 .as_ref()
14324 .and_then(|checkpoint| checkpoint.feed_hash.clone())
14325 };
14326 let mut identity: Option<FeedIdentity> = None;
14327 let mut anchor: Option<String> = None;
14328 let mut head_entry: Option<FeedItem> = None;
14329 let mut all_entries = Vec::new();
14330 let mut observed_entries = Vec::new();
14331 let replay_count = seq
14332 .checked_sub(after)
14333 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
14334 if replay_count > MAX_FEED_REPLAY_ENTRIES {
14335 return Err(invalid_feed(format!(
14336 "feed replay requires {replay_count} entries, over the client cap"
14337 )));
14338 }
14339 let mut replay_bytes = 0u64;
14340
14341 loop {
14342 let feed_bytes = ensure_raw_ok(
14343 request_raw(
14344 cfg,
14345 "GET",
14346 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
14347 None,
14348 Auth::Required,
14349 MAX_FEED_RESPONSE_BYTES,
14350 )?,
14351 "subscribe feed",
14352 )?;
14353 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
14354 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
14355 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
14356 return Err(invalid_feed("brain card and feed head disagree"));
14357 }
14358 if feed.entries.len() > FEED_PAGE_LIMIT {
14359 return Err(invalid_feed("feed page exceeds the requested entry limit"));
14360 }
14361 if feed.scope_limited {
14362 if require_full_chain {
14363 return Err(invalid_feed(
14364 "path-scoped grants cannot verify a full snapshot chain",
14365 ));
14366 }
14367 return Ok(VerifiedRemote {
14368 head: Head {
14369 brain: resolved_brain,
14370 seq,
14371 updated_at,
14372 feed_hash: advertised_hash,
14373 verified: false,
14374 },
14375 identity: None,
14376 head_entry: None,
14377 entries: Vec::new(),
14378 anchor: None,
14379 });
14380 }
14381 let page_identity = feed
14382 .identity
14383 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14384 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
14385 if identity
14386 .as_ref()
14387 .is_some_and(|existing| existing != &page_identity)
14388 {
14389 return Err(invalid_feed("identity changed while reading the feed"));
14390 }
14391 if anchor
14392 .as_ref()
14393 .is_some_and(|existing| existing != &page_anchor)
14394 {
14395 return Err(invalid_feed(
14396 "identity anchor changed while reading the feed",
14397 ));
14398 }
14399 identity = Some(page_identity.clone());
14400 if anchor.is_none() {
14401 anchor = Some(page_anchor);
14402 }
14403 if feed.entries.is_empty() {
14404 return Err(invalid_feed("feed page was empty before the signed head"));
14405 }
14406
14407 for item in feed.entries {
14408 if item.entry.seq != expected_seq {
14409 return Err(invalid_feed(format!(
14410 "expected entry {expected_seq}, feed served {}",
14411 item.entry.seq
14412 )));
14413 }
14414 if item.entry.seq > seq {
14415 return Err(invalid_feed("feed advanced past the card snapshot"));
14416 }
14417 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
14418 return Err(invalid_feed(format!(
14419 "entry {} does not chain to the local checkpoint",
14420 item.entry.seq
14421 )));
14422 }
14423 verify_feed_item(&item, &page_identity)?;
14424 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
14425 replay_bytes = replay_bytes.saturating_add(
14426 serde_json::to_vec(&item)
14427 .map_err(|_| invalid_feed("could not size feed entry"))?
14428 .len() as u64,
14429 );
14430 if replay_bytes > MAX_FEED_REPLAY_BYTES {
14431 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
14432 }
14433 previous_hash = Some(item.hash.clone());
14434 after = item.entry.seq;
14435 expected_seq = expected_seq
14436 .checked_add(1)
14437 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
14438 if require_full_chain {
14439 all_entries.push(item.clone());
14440 }
14441 observed_entries.push(item.clone());
14442 head_entry = Some(item);
14443 }
14444 if after == seq {
14445 break;
14446 }
14447 }
14448
14449 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
14450 return Err(invalid_feed(
14451 "verified chain does not converge on the advertised head",
14452 ));
14453 }
14454 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14455 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
14456 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
14457 save_canonical_pin_and_alias(
14458 cfg,
14459 &trust_directory,
14460 brain,
14461 &resolved_brain,
14462 TrustState {
14463 v: 2,
14464 origin: normalized_origin(&cfg.hub)?,
14465 requested: resolved_brain.clone(),
14466 brain: resolved_brain.clone(),
14467 home: None,
14468 anchor: anchor.clone(),
14469 current: format!("ed25519:{}", identity.fingerprint),
14470 head_seq: seq,
14471 feed_hash: advertised_hash.clone(),
14472 rotations: identity.rotations.clone(),
14473 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
14474 protocol_profile: pinned
14475 .as_ref()
14476 .and_then(|state| state.protocol_profile.clone()),
14477 },
14478 alias_binding.as_ref(),
14479 )?;
14480 Ok(VerifiedRemote {
14481 head: Head {
14482 brain: resolved_brain,
14483 seq,
14484 updated_at,
14485 feed_hash: advertised_hash,
14486 verified: true,
14487 },
14488 identity: Some(identity),
14489 head_entry,
14490 entries: all_entries,
14491 anchor: Some(anchor),
14492 })
14493}
14494
14495pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
14500 if let Some(verified) = v2_verified_head(cfg, brain)? {
14501 let observation = Head {
14502 brain: verified.brain_id.clone(),
14503 seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14504 updated_at: verified
14505 .pointer
14506 .as_ref()
14507 .map(|pointer| pointer.signed_at.clone()),
14508 feed_hash: verified
14509 .pointer
14510 .as_ref()
14511 .map(|pointer| pointer.feed_hash.clone()),
14512 verified: true,
14513 };
14514 accept_v2_head(cfg, &verified)?;
14515 return Ok(observation);
14516 }
14517 Ok(verified_remote_head(cfg, brain, false)?.head)
14518}
14519
14520#[cfg(test)]
14521mod tests {
14522 use super::*;
14523
14524 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
14525
14526 fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
14527 json!({
14528 "sha256": "a".repeat(64),
14529 "bytes": 10,
14530 "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
14531 })
14532 }
14533
14534 #[test]
14535 fn upload_reservations_batch_by_count_and_by_size() {
14536 let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
14540 let batches = batch_upload_declarations(declarations.clone());
14541
14542 assert!(batches.len() > 1, "5,000 blobs must not ride one request");
14543 for batch in &batches {
14544 assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
14545 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14546 .expect("batch serializes")
14547 .len();
14548 assert!(
14549 bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
14550 "batch body {bytes} exceeds the reservation budget"
14551 );
14552 }
14553 let flattened: Vec<Value> = batches.into_iter().flatten().collect();
14554 assert_eq!(
14555 flattened, declarations,
14556 "batching must preserve the set and order"
14557 );
14558 }
14559
14560 #[test]
14561 fn only_load_shaped_hub_answers_are_worth_asking_again() {
14562 for status in [408, 429, 500, 502, 503, 504] {
14567 assert!(is_retryable_hub_status(status), "{status} is load-shaped");
14568 }
14569 for status in [400, 401, 403, 404, 409, 413, 422] {
14570 assert!(
14571 !is_retryable_hub_status(status),
14572 "{status} states something about the request"
14573 );
14574 }
14575 let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
14577 assert!(total >= 60_000, "backoff totals only {total}ms");
14578 }
14579
14580 #[test]
14581 fn a_batch_shares_a_connection_only_within_one_authority() {
14582 let cfg = HubConfig {
14587 hub: "https://www.sevrahq.com".to_string(),
14588 key: Some("k".to_string()),
14589 agent_key: None,
14590 brain_key: None,
14591 state_dir: PathBuf::from("."),
14592 store_selected: false,
14593 };
14594 assert!(shared_staging_agent(&cfg, &[]).is_none());
14595 assert!(
14596 shared_staging_agent(
14597 &cfg,
14598 &[
14599 "https://one.example.com/a?sig=1",
14600 "https://two.example.com/b?sig=2",
14601 ]
14602 )
14603 .is_none(),
14604 "two authorities must not share a pinned pool"
14605 );
14606 assert!(
14607 shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
14608 "an unsafe object-store URL must not produce an agent"
14609 );
14610 assert!(
14611 shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
14612 "credentials in the URL must not produce an agent"
14613 );
14614 }
14615
14616 #[test]
14617 fn a_staged_change_states_only_operations_and_blobs() {
14618 let operations = vec![json!({
14622 "op": "put",
14623 "path": "records/a.md",
14624 "blob": "a".repeat(64),
14625 "bytes": 3,
14626 })];
14627 let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
14628 let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
14629 let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
14630 let keys: Vec<&str> = parsed
14631 .as_object()
14632 .expect("manifest is an object")
14633 .keys()
14634 .map(String::as_str)
14635 .collect();
14636 assert_eq!(keys, ["blobs", "operations"]);
14637 assert_eq!(parsed["operations"], Value::Array(operations));
14638 assert_eq!(parsed["blobs"], blobs);
14639 }
14640
14641 #[test]
14642 fn a_staged_push_signs_the_change_not_the_transport() {
14643 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14648 let staged = json!({
14649 "mutation_id": "dbmd-1",
14650 "rebase": "strict",
14651 "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
14652 });
14653 let view = v2_signed_request_view(&staged, &operations);
14654 assert_eq!(view["operations"], Value::Array(operations.clone()));
14655 assert!(view.get("staged_change").is_none());
14656 assert_eq!(view["mutation_id"], staged["mutation_id"]);
14657
14658 let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
14659 assert_eq!(v2_signed_request_view(&inline, &[]), inline);
14660 }
14661
14662 #[test]
14663 fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
14664 let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
14665 let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
14666 .expect_err("an oversized change must not be staged");
14667 assert!(
14668 matches!(error, LinkError::PushTooLarge { .. }),
14669 "expected a size refusal, got {error:?}"
14670 );
14671 }
14672
14673 #[test]
14674 fn a_push_that_fits_the_request_is_left_inline() {
14675 let cfg = HubConfig {
14679 hub: "http://127.0.0.1:9".to_string(),
14680 key: Some("k".to_string()),
14681 agent_key: None,
14682 brain_key: None,
14683 state_dir: PathBuf::from("."),
14684 store_selected: false,
14685 };
14686 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14687 let mut body = json!({
14688 "mutation_id": "dbmd-1",
14689 "operations": operations,
14690 "blobs": [],
14691 });
14692 stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
14693 assert!(body.get("staged_change").is_none());
14694 assert_eq!(body["operations"], Value::Array(operations));
14695 }
14696
14697 #[test]
14698 fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
14699 let declarations: Vec<Value> = (0..2_000)
14703 .map(|index| {
14704 json!({
14705 "sha256": "a".repeat(64),
14706 "bytes": 10,
14707 "coordinates": (0..24)
14708 .map(|slot| format!(
14709 "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
14710 ))
14711 .collect::<Vec<_>>(),
14712 })
14713 })
14714 .collect();
14715 let batches = batch_upload_declarations(declarations);
14716 assert!(
14717 batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
14718 "wide coordinate sets must bound the batch by size"
14719 );
14720 for batch in &batches {
14721 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14722 .expect("batch serializes")
14723 .len();
14724 assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
14725 }
14726 }
14727
14728 #[test]
14729 fn a_small_push_still_rides_exactly_one_request() {
14730 let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
14731 assert_eq!(batch_upload_declarations(declarations).len(), 1);
14732 assert!(batch_upload_declarations(Vec::new()).is_empty());
14733 }
14734
14735 #[test]
14736 fn exact_source_move_becomes_one_provenance_preserving_rename() {
14737 let hash = "a".repeat(64);
14738 let operations = vec![
14739 json!({
14740 "op": "put",
14741 "path": "sources/curated/item.md",
14742 "expected": { "kind": "absent" },
14743 "blob": hash,
14744 "bytes": 19,
14745 }),
14746 json!({
14747 "op": "delete",
14748 "path": "sources/inbox/item.md",
14749 "expected": { "kind": "blob", "hash": hash },
14750 }),
14751 ];
14752
14753 assert_eq!(
14754 infer_exact_source_promotions(operations),
14755 vec![json!({
14756 "op": "rename",
14757 "from": "sources/inbox/item.md",
14758 "to": "sources/curated/item.md",
14759 "expected_from": { "kind": "blob", "hash": hash },
14760 "expected_to": { "kind": "absent" },
14761 "blob": hash,
14762 "bytes": 19,
14763 })]
14764 );
14765 }
14766
14767 #[test]
14768 fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
14769 let hash = "b".repeat(64);
14770 let operations = vec![
14771 json!({
14772 "op": "delete",
14773 "path": "sources/inbox/a.md",
14774 "expected": { "kind": "blob", "hash": hash },
14775 }),
14776 json!({
14777 "op": "delete",
14778 "path": "sources/inbox/b.md",
14779 "expected": { "kind": "blob", "hash": hash },
14780 }),
14781 json!({
14782 "op": "put",
14783 "path": "sources/curated/item.md",
14784 "expected": { "kind": "absent" },
14785 "blob": hash,
14786 "bytes": 19,
14787 }),
14788 ];
14789
14790 assert_eq!(
14791 infer_exact_source_promotions(operations.clone()),
14792 operations,
14793 "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
14794 );
14795 }
14796
14797 #[test]
14798 fn accepted_source_promotion_advances_the_local_baseline_exactly() {
14799 let hash = "c".repeat(64);
14800 let mut candidate = std::collections::BTreeMap::from([(
14801 "sources/inbox/item.md".to_string(),
14802 V2BaselineFile {
14803 sha256: hash.clone(),
14804 bytes: 19,
14805 proof: None,
14806 },
14807 )]);
14808 let mut candidate_assets = std::collections::BTreeMap::new();
14809 let operations = vec![
14810 json!({
14811 "op": "rename",
14812 "from": "sources/inbox/item.md",
14813 "to": "sources/curated/item.md",
14814 "expected_from": { "kind": "blob", "hash": hash },
14815 "expected_to": { "kind": "absent" },
14816 "blob": hash,
14817 "bytes": 19,
14818 }),
14819 json!({
14820 "op": "put",
14821 "path": "records/rsvps/item.md",
14822 "expected": { "kind": "absent" },
14823 "blob": "d".repeat(64),
14824 "bytes": 23,
14825 }),
14826 ];
14827
14828 assert!(!apply_generated_v2_operations(
14829 &operations,
14830 &std::collections::BTreeMap::new(),
14831 &mut candidate,
14832 &mut candidate_assets,
14833 )
14834 .unwrap());
14835 assert!(!candidate.contains_key("sources/inbox/item.md"));
14836 assert_eq!(
14837 candidate
14838 .get("sources/curated/item.md")
14839 .map(|file| (&file.sha256, file.bytes)),
14840 Some((&hash, 19))
14841 );
14842 assert_eq!(
14843 candidate
14844 .get("records/rsvps/item.md")
14845 .map(|file| (file.sha256.as_str(), file.bytes)),
14846 Some((
14847 "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
14848 23
14849 ))
14850 );
14851 }
14852
14853 fn merge_fixture(
14854 base: Option<&str>,
14855 remote: Option<&str>,
14856 local: Option<&str>,
14857 keep_local: bool,
14858 ) -> V2PulledMerge<String> {
14859 let map = |value: Option<&str>| {
14860 value
14861 .map(|value| [("records/a.md".to_string(), value.to_string())])
14862 .into_iter()
14863 .flatten()
14864 .collect::<std::collections::BTreeMap<_, _>>()
14865 };
14866 merge_v2_pulled_records(
14867 &map(base),
14868 &map(remote),
14869 &map(local),
14870 |value, _| value.clone(),
14871 |value, _| value.clone(),
14872 |_| keep_local,
14873 )
14874 }
14875
14876 #[test]
14877 fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
14878 let path = "records/a.md".to_string();
14879
14880 let local_add = merge_fixture(None, None, Some("local"), false);
14881 assert_eq!(
14882 local_add.records.get(&path).map(String::as_str),
14883 Some("local")
14884 );
14885 assert!(local_add.accept_remote.is_empty());
14886 assert!(local_add.conflicts.is_empty());
14887
14888 let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
14889 assert_eq!(
14890 local_edit.records.get(&path).map(String::as_str),
14891 Some("local")
14892 );
14893 assert!(local_edit.accept_remote.is_empty());
14894 assert!(local_edit.conflicts.is_empty());
14895
14896 let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
14897 assert!(!local_delete.records.contains_key(&path));
14898 assert!(local_delete.accept_remote.is_empty());
14899 assert!(local_delete.conflicts.is_empty());
14900
14901 let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
14902 assert_eq!(
14903 remote_edit.records.get(&path).map(String::as_str),
14904 Some("remote")
14905 );
14906 assert!(remote_edit.accept_remote.contains(&path));
14907 assert!(remote_edit.conflicts.is_empty());
14908
14909 let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
14910 assert!(!remote_delete.records.contains_key(&path));
14911 assert!(remote_delete.accept_remote.contains(&path));
14912 assert!(remote_delete.conflicts.is_empty());
14913
14914 let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
14915 assert_eq!(
14916 same_edit.records.get(&path).map(String::as_str),
14917 Some("same")
14918 );
14919 assert!(same_edit.accept_remote.contains(&path));
14920 assert!(same_edit.conflicts.is_empty());
14921
14922 let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
14923 assert_eq!(conflict.conflicts, vec![path.clone()]);
14924 assert_eq!(
14925 conflict.records.get(&path).map(String::as_str),
14926 Some("local")
14927 );
14928 assert!(conflict.accept_remote.is_empty());
14929
14930 let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
14931 assert_eq!(
14932 kept_home.records.get(&path).map(String::as_str),
14933 Some("local")
14934 );
14935 assert!(kept_home.accept_remote.is_empty());
14936 assert!(kept_home.conflicts.is_empty());
14937 }
14938
14939 #[test]
14940 fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
14941 let path = "sources/report.pdf";
14942 let record = crate::AssetRecord {
14943 path: path.to_string(),
14944 sha256: "a".repeat(64),
14945 bytes: 42,
14946 media_type: "application/pdf".to_string(),
14947 wrappers: vec!["gzip".to_string()],
14948 required: true,
14949 };
14950 let mut remote = V2BaselineAsset {
14951 blob_sha256: record.sha256.clone(),
14952 bytes: record.bytes,
14953 media_type: record.media_type.clone(),
14954 wrappers: record.wrappers.clone(),
14955 required: record.required,
14956 disposition: "withheld".to_string(),
14957 leaf_hash: "b".repeat(64),
14958 };
14959
14960 assert!(v2_asset_resumes_hosting(
14961 Some(&remote),
14962 path,
14963 &record,
14964 "hosted"
14965 ));
14966 assert!(!v2_asset_resumes_hosting(
14967 Some(&remote),
14968 path,
14969 &record,
14970 "withheld"
14971 ));
14972
14973 remote.disposition = "hosted".to_string();
14974 assert!(!v2_asset_resumes_hosting(
14975 Some(&remote),
14976 path,
14977 &record,
14978 "hosted"
14979 ));
14980
14981 remote.disposition = "withheld".to_string();
14982 remote.blob_sha256 = "c".repeat(64);
14983 assert!(!v2_asset_resumes_hosting(
14984 Some(&remote),
14985 path,
14986 &record,
14987 "hosted"
14988 ));
14989 assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
14990 }
14991
14992 #[test]
14993 fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
14994 let path = "records/team/alpha.md".to_string();
14995 let deleted_path = "records/team/deleted.md".to_string();
14996 let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
14997 sha256,
14998 bytes,
14999 file: None,
15000 };
15001 let files = vec![
15002 V2ConflictFile {
15003 path: path.clone(),
15004 base: coordinate(None, None),
15005 local: coordinate(Some("b".repeat(64)), Some(7)),
15006 remote: coordinate(Some("a".repeat(64)), Some(5)),
15007 },
15008 V2ConflictFile {
15009 path: deleted_path.clone(),
15010 base: coordinate(Some("c".repeat(64)), Some(9)),
15011 local: coordinate(Some("d".repeat(64)), Some(11)),
15012 remote: coordinate(None, None),
15013 },
15014 ];
15015 let proven = V2BaselineFile {
15016 sha256: "a".repeat(64),
15017 bytes: 5,
15018 proof: None,
15019 };
15020 let current = [(path.clone(), proven.clone())]
15021 .into_iter()
15022 .collect::<std::collections::BTreeMap<_, _>>();
15023
15024 let (selected, deleted) = v2_take_remote_selection(&files, ¤t).unwrap();
15025 assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
15026 assert_eq!(deleted, vec![deleted_path.clone()]);
15027
15028 let changed = [(
15029 path.clone(),
15030 V2BaselineFile {
15031 sha256: "e".repeat(64),
15032 bytes: 5,
15033 proof: None,
15034 },
15035 )]
15036 .into_iter()
15037 .collect::<std::collections::BTreeMap<_, _>>();
15038 assert!(v2_take_remote_selection(&files, &changed).is_err());
15039
15040 let resurrected = [
15041 (path, proven),
15042 (
15043 deleted_path,
15044 V2BaselineFile {
15045 sha256: "f".repeat(64),
15046 bytes: 13,
15047 proof: None,
15048 },
15049 ),
15050 ]
15051 .into_iter()
15052 .collect::<std::collections::BTreeMap<_, _>>();
15053 assert!(v2_take_remote_selection(&files, &resurrected).is_err());
15054 }
15055
15056 #[cfg(target_os = "linux")]
15057 #[test]
15058 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
15059 use std::os::fd::AsRawFd as _;
15060
15061 let sandbox = tempfile::TempDir::new().unwrap();
15062 let parent = std::fs::File::open(sandbox.path()).unwrap();
15063 let stage = std::ffi::CString::new("stage").unwrap();
15064 let destination = std::ffi::CString::new("brain").unwrap();
15065
15066 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15067 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
15068 install_stage_at(
15069 parent.as_raw_fd(),
15070 stage.as_c_str(),
15071 destination.as_c_str(),
15072 false,
15073 )
15074 .unwrap();
15075 assert!(!sandbox.path().join("stage").exists());
15076 assert_eq!(
15077 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15078 b"created"
15079 );
15080
15081 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15082 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
15083 install_stage_at(
15084 parent.as_raw_fd(),
15085 stage.as_c_str(),
15086 destination.as_c_str(),
15087 true,
15088 )
15089 .unwrap();
15090 assert_eq!(
15091 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15092 b"replacement"
15093 );
15094 assert_eq!(
15095 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
15096 b"created",
15097 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
15098 );
15099 }
15100
15101 struct SignedRemoteFixture {
15102 card: String,
15103 feed: String,
15104 key: AgentSigningKey,
15105 identity: FeedIdentity,
15106 }
15107
15108 fn signed_remote_fixture() -> SignedRemoteFixture {
15109 let rng = ring::rand::SystemRandom::new();
15110 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15111 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15112 let (public_key, multikey) = public_identity_for(&pair);
15113 let identity = FeedIdentity {
15114 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15115 public_key_spki: public_key.clone(),
15116 previous: Vec::new(),
15117 rotations: Vec::new(),
15118 };
15119 let mut entry = FeedEntry {
15120 v: 1,
15121 seq: 1,
15122 ts: "2026-07-30T12:00:00.000Z".to_string(),
15123 brain: multikey.clone(),
15124 public_key: public_key.clone(),
15125 kind: "push".to_string(),
15126 op: "snapshot".to_string(),
15127 pack_sha256: "a".repeat(64),
15128 files: Vec::new(),
15129 removed: Vec::new(),
15130 prev_entry_hash: None,
15131 sig: String::new(),
15132 };
15133 let unsigned = UnsignedFeedEntry {
15134 v: entry.v,
15135 seq: entry.seq,
15136 ts: &entry.ts,
15137 brain: &entry.brain,
15138 public_key: &entry.public_key,
15139 kind: &entry.kind,
15140 op: &entry.op,
15141 pack_sha256: &entry.pack_sha256,
15142 files: &entry.files,
15143 removed: &entry.removed,
15144 prev_entry_hash: &entry.prev_entry_hash,
15145 };
15146 entry.sig =
15147 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15148 let mut exact = serde_json::to_vec(&entry).unwrap();
15149 exact.push(b'\n');
15150 let hash = content_sha256(&exact);
15151 let card = json!({
15152 "id": TEST_BRAIN_ID,
15153 "headSeq": 1,
15154 "feedHash": hash,
15155 "identity": identity.clone(),
15156 })
15157 .to_string();
15158 let feed = json!({
15159 "headSeq": 1,
15160 "feedHash": hash,
15161 "identity": identity.clone(),
15162 "entries": [{"hash": hash, "entry": entry}],
15163 "scopeLimited": false,
15164 })
15165 .to_string();
15166 SignedRemoteFixture {
15167 card,
15168 feed,
15169 key: AgentSigningKey {
15170 pkcs8: pkcs8.as_ref().to_vec(),
15171 multikey,
15172 public_key_spki: public_key,
15173 },
15174 identity,
15175 }
15176 }
15177
15178 #[test]
15179 fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
15180 let file = |path: &str, byte: char| FeedFile {
15181 path: path.to_string(),
15182 sha256: byte.to_string().repeat(64),
15183 bytes: 1,
15184 };
15185 let a0 = file("records/a.md", 'a');
15186 let a1 = file("records/a.md", 'b');
15187 let stable = file("records/stable.md", 'c');
15188 let added = file("records/added.md", 'd');
15189 let removed_file = file("records/removed.md", 'e');
15190 let previous = vec![a0, stable.clone(), removed_file.clone()];
15191 let resulting = vec![a1.clone(), stable.clone(), added.clone()];
15192 let removed = vec![removed_file.path.clone()];
15193
15194 assert_eq!(
15195 verify_v1_manifest_disclosure(
15196 "edit",
15197 &previous,
15198 &resulting,
15199 &[a1.clone(), added.clone()],
15200 &removed,
15201 ),
15202 Ok(())
15203 );
15204 assert_eq!(
15205 verify_v1_manifest_disclosure(
15206 "edit",
15207 &previous,
15208 &resulting,
15209 &[stable.clone(), added.clone(), a1.clone()],
15210 &removed,
15211 ),
15212 Ok(())
15213 );
15214 assert_eq!(
15215 verify_v1_manifest_disclosure(
15216 "edit",
15217 &previous,
15218 &resulting,
15219 std::slice::from_ref(&added),
15220 &removed,
15221 ),
15222 Err(V1DisclosureError::EditMissingChange)
15223 );
15224 assert_eq!(
15225 verify_v1_manifest_disclosure(
15226 "edit",
15227 &previous,
15228 &resulting,
15229 &[file("records/a.md", 'f'), added.clone()],
15230 &removed,
15231 ),
15232 Err(V1DisclosureError::EditFalseFile)
15233 );
15234 assert_eq!(
15235 verify_v1_manifest_disclosure(
15236 "edit",
15237 &previous,
15238 &resulting,
15239 &[a1.clone(), added.clone()],
15240 &[],
15241 ),
15242 Err(V1DisclosureError::RemovedMismatch)
15243 );
15244 assert_eq!(
15245 verify_v1_manifest_disclosure(
15246 "push",
15247 &previous,
15248 &resulting,
15249 &[added.clone(), stable, a1],
15250 &removed,
15251 ),
15252 Ok(())
15253 );
15254 assert_eq!(
15255 verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
15256 Err(V1DisclosureError::PushManifestMismatch)
15257 );
15258 }
15259
15260 #[test]
15261 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
15262 let fixture = signed_remote_fixture();
15263 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
15264 let item = feed["entries"][0].to_string();
15265 let oversized_page = format!(
15266 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
15267 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
15268 .collect::<Vec<_>>()
15269 .join(",")
15270 );
15271 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
15272
15273 let oversized_identity = format!(
15274 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
15275 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
15276 .collect::<Vec<_>>()
15277 .join(",")
15278 );
15279 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
15280
15281 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
15282 let oversized_entry = format!(
15283 "{{\"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\"}}",
15284 "a".repeat(64),
15285 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
15286 .collect::<Vec<_>>()
15287 .join(",")
15288 );
15289 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
15290 }
15291
15292 #[test]
15293 fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
15294 let id = "01arz3ndektsv4rrffq69g5fav";
15295 let digest = "a".repeat(64);
15296 assert_eq!(
15297 V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
15298 V2BulkConfirmation {
15299 id: id.to_string(),
15300 digest,
15301 }
15302 );
15303 for invalid in [
15304 "",
15305 "01arz3ndektsv4rrffq69g5fav",
15306 "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15307 "01arz3ndektsv4rrffq69g5fav:ABCDEF",
15308 "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15309 ] {
15310 assert!(matches!(
15311 V2BulkConfirmation::parse(invalid),
15312 Err(LinkError::InvalidPack { .. })
15313 ));
15314 }
15315 }
15316
15317 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
15318 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15319 use std::net::TcpListener;
15320
15321 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15322 let url = format!("http://{}", listener.local_addr().unwrap());
15323 let handle = std::thread::spawn(move || {
15324 for (status, body) in responses {
15325 let (stream, _) = listener.accept().unwrap();
15326 let mut reader = BufReader::new(stream);
15327 let mut line = String::new();
15328 reader.read_line(&mut line).unwrap();
15329 let mut content_length = 0usize;
15330 loop {
15331 line.clear();
15332 reader.read_line(&mut line).unwrap();
15333 if line == "\r\n" || line == "\n" || line.is_empty() {
15334 break;
15335 }
15336 if let Some((name, value)) = line.split_once(':') {
15337 if name.eq_ignore_ascii_case("content-length") {
15338 content_length = value.trim().parse().unwrap();
15339 }
15340 }
15341 }
15342 let mut request_body = vec![0_u8; content_length];
15343 reader.read_exact(&mut request_body).unwrap();
15344 let response = format!(
15345 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15346 body.len()
15347 );
15348 reader.get_mut().write_all(response.as_bytes()).unwrap();
15349 }
15350 });
15351 (url, handle)
15352 }
15353
15354 fn routed_json_hub(
15355 requests: usize,
15356 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
15357 ) -> (String, std::thread::JoinHandle<()>) {
15358 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15359 use std::net::TcpListener;
15360
15361 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15362 let url = format!("http://{}", listener.local_addr().unwrap());
15363 let handle = std::thread::spawn(move || {
15364 for _ in 0..requests {
15365 let (stream, _) = listener.accept().unwrap();
15366 let mut reader = BufReader::new(stream);
15367 let mut line = String::new();
15368 reader.read_line(&mut line).unwrap();
15369 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
15370 let mut content_length = 0usize;
15371 loop {
15372 line.clear();
15373 reader.read_line(&mut line).unwrap();
15374 if line == "\r\n" || line == "\n" || line.is_empty() {
15375 break;
15376 }
15377 if let Some((name, value)) = line.split_once(':') {
15378 if name.eq_ignore_ascii_case("content-length") {
15379 content_length = value.trim().parse().unwrap();
15380 }
15381 }
15382 }
15383 let mut request_body = vec![0_u8; content_length];
15384 reader.read_exact(&mut request_body).unwrap();
15385 let (status, body) = respond(&path);
15386 let response = format!(
15387 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15388 body.len()
15389 );
15390 reader.get_mut().write_all(response.as_bytes()).unwrap();
15391 }
15392 });
15393 (url, handle)
15394 }
15395
15396 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
15397 HubConfig {
15398 hub,
15399 key: Some("test-key".to_string()),
15400 agent_key: None,
15401 brain_key: None,
15402 state_dir,
15403 store_selected: false,
15404 }
15405 }
15406
15407 #[test]
15408 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
15409 use ring::signature::KeyPair as _;
15410
15411 let rng = ring::rand::SystemRandom::new();
15412 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15413 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15414 let (spki, multikey) = public_identity_for(&pair);
15415 let key = AgentSigningKey {
15416 pkcs8: pkcs8.as_ref().to_vec(),
15417 multikey,
15418 public_key_spki: spki,
15419 };
15420 let header = linkmd_sig_header(
15421 &key,
15422 "https://hub-a.example",
15423 "post",
15424 "/api/hub/brains/brain/push?mode=exact",
15425 Some("{\"ok\":true}"),
15426 )
15427 .unwrap();
15428 assert!(header.starts_with("LinkMD-Sig v2,"));
15429 let ts = header
15430 .split(",ts=")
15431 .nth(1)
15432 .unwrap()
15433 .split(',')
15434 .next()
15435 .unwrap();
15436 let signature = URL_SAFE_NO_PAD
15437 .decode(header.rsplit(",sig=").next().unwrap())
15438 .unwrap();
15439 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
15440 let accepted = format!(
15441 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15442 );
15443 let replayed = format!(
15444 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15445 );
15446 let public = pair.public_key().as_ref();
15447 assert!(UnparsedPublicKey::new(&ED25519, public)
15448 .verify(accepted.as_bytes(), &signature)
15449 .is_ok());
15450 assert!(
15451 UnparsedPublicKey::new(&ED25519, public)
15452 .verify(replayed.as_bytes(), &signature)
15453 .is_err(),
15454 "a proof captured at hub A must not authenticate at hub B"
15455 );
15456 }
15457
15458 #[test]
15459 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
15460 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15461 let card = json!({
15462 "id": other,
15463 "headSeq": 0,
15464 "identity": signed_remote_fixture().identity,
15465 })
15466 .to_string();
15467 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15468 let state = tempfile::tempdir().unwrap();
15469 let cfg = test_hub_config(hub, state.path().to_path_buf());
15470 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15471 assert!(
15472 error.contains("differs from the explicitly requested"),
15473 "{error}"
15474 );
15475 server.join().unwrap();
15476 }
15477
15478 #[test]
15479 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
15480 let first = signed_remote_fixture().identity;
15481 let second = signed_remote_fixture().identity;
15482 let card = |identity: FeedIdentity| {
15483 json!({
15484 "id": TEST_BRAIN_ID,
15485 "headSeq": 0,
15486 "identity": identity,
15487 })
15488 .to_string()
15489 };
15490 let (hub, server) = scripted_json_hub(vec![
15491 (404, "{}".to_string()),
15492 (200, card(first)),
15493 (404, "{}".to_string()),
15494 (200, card(second)),
15495 ]);
15496 let state = tempfile::tempdir().unwrap();
15497 let cfg = test_hub_config(hub, state.path().to_path_buf());
15498 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15499 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15500 assert!(
15501 error.contains("pinned anchor") || error.contains("forked away"),
15502 "{error}"
15503 );
15504 server.join().unwrap();
15505 }
15506
15507 #[test]
15508 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
15509 let old = signed_remote_fixture();
15510 let new = signed_remote_fixture();
15511 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
15512 let unsigned = serde_json::to_string(&UnsignedRotation {
15513 v: 1,
15514 op: "rotate",
15515 brain: &old.key.multikey,
15516 public_key: &old.key.public_key_spki,
15517 new_brain: &new.key.multikey,
15518 new_public_key: &new.key.public_key_spki,
15519 prior_head_seq: 1,
15520 prior_feed_hash: Some(&"a".repeat(64)),
15521 ts: "2026-07-30T12:00:00.000Z".to_string(),
15522 })
15523 .unwrap();
15524 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
15525 let rotation = format!(
15526 "{},\"sig\":\"{}\"}}",
15527 &unsigned[..unsigned.len() - 1],
15528 signature
15529 );
15530 let identity = FeedIdentity {
15531 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
15532 public_key_spki: new.key.public_key_spki,
15533 previous: vec![PreviousIdentity {
15534 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
15535 public_key_spki: old.key.public_key_spki,
15536 }],
15537 rotations: vec![rotation],
15538 };
15539 let card = json!({
15540 "id": TEST_BRAIN_ID,
15541 "headSeq": 0,
15542 "feedHash": null,
15543 "identity": identity,
15544 })
15545 .to_string();
15546 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15547 let state = tempfile::tempdir().unwrap();
15548 let cfg = test_hub_config(hub, state.path().to_path_buf());
15549 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15550 assert!(
15551 error.contains("rotation claims a feed boundary beyond the advertised head"),
15552 "{error}"
15553 );
15554 assert!(
15555 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
15556 "an inconsistent empty-head identity must not become the TOFU checkpoint"
15557 );
15558 server.join().unwrap();
15559 }
15560
15561 #[test]
15562 fn trust_checkpoint_rejects_a_later_fork() {
15563 let fixture = signed_remote_fixture();
15564 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
15565 fork["feedHash"] = Value::String("b".repeat(64));
15566 let (hub, server) = scripted_json_hub(vec![
15567 (404, "{}".to_string()),
15568 (200, fixture.card),
15569 (200, fixture.feed),
15570 (404, "{}".to_string()),
15571 (200, fork.to_string()),
15572 ]);
15573 let state = tempfile::tempdir().unwrap();
15574 let cfg = test_hub_config(hub, state.path().to_path_buf());
15575 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15576 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
15577 server.join().unwrap();
15578 }
15579
15580 #[test]
15581 fn alias_and_canonical_id_share_one_identity_checkpoint() {
15582 let trusted = signed_remote_fixture();
15583 let attacker = signed_remote_fixture();
15584 let (hub, server) = scripted_json_hub(vec![
15585 (404, "{}".to_string()),
15586 (200, trusted.card),
15587 (200, trusted.feed),
15588 (404, "{}".to_string()),
15589 (200, attacker.card),
15590 ]);
15591 let state = tempfile::tempdir().unwrap();
15592 let cfg = test_hub_config(hub, state.path().to_path_buf());
15593 assert!(head(&cfg, "trusted-slug").unwrap().verified);
15594 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15595 assert!(
15596 error.contains("equivocation")
15597 || error.contains("pinned")
15598 || error.contains("identity"),
15599 "{error}"
15600 );
15601 server.join().unwrap();
15602 }
15603
15604 #[test]
15605 fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
15606 let state = tempfile::tempdir().unwrap();
15607 let cfg = test_hub_config(
15608 "https://hub.example".to_string(),
15609 state.path().to_path_buf(),
15610 );
15611 let directory = open_trust_dir(&cfg).unwrap();
15612 let old = TEST_BRAIN_ID;
15613 let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15614 save_alias_in(
15615 &cfg,
15616 &directory,
15617 &AliasBinding {
15618 v: 1,
15619 origin: normalized_origin(&cfg.hub).unwrap(),
15620 requested: "company-brain".to_string(),
15621 brain: old.to_string(),
15622 home: Some("company-brain".to_string()),
15623 },
15624 )
15625 .unwrap();
15626
15627 let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
15628 assert!(matches!(
15629 error,
15630 LinkError::AliasRebindRequired {
15631 alias,
15632 from,
15633 to
15634 } if alias == "company-brain" && from == old && to == new
15635 ));
15636 let unchanged = load_alias_in(&cfg, &directory, "company-brain")
15637 .unwrap()
15638 .unwrap();
15639 assert_eq!(unchanged.brain, old);
15640 assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
15641 }
15642
15643 #[test]
15644 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
15645 let alpha = signed_remote_fixture();
15646 let beta = signed_remote_fixture();
15647 let alpha_card = alpha.card.clone();
15648 let alpha_feed = alpha.feed.clone();
15649 let beta_card = beta.card.clone();
15650 let beta_feed = beta.feed.clone();
15651 let (hub, server) = routed_json_hub(5, move |path| {
15652 if path.ends_with("/v2/head") {
15653 (404, "{}".to_string())
15654 } else if path.contains("/alpha/feed?") {
15655 (200, alpha_feed.clone())
15656 } else if path.contains("/beta/feed?") {
15657 (200, beta_feed.clone())
15658 } else if path.ends_with("/alpha") {
15659 (200, alpha_card.clone())
15660 } else if path.ends_with("/beta") {
15661 (200, beta_card.clone())
15662 } else {
15663 (500, r#"{"error":"unexpected path"}"#.to_string())
15664 }
15665 });
15666 let state = tempfile::tempdir().unwrap();
15667 let cfg = test_hub_config(hub, state.path().to_path_buf());
15668 let alpha_cfg = cfg.clone();
15669 let beta_cfg = cfg;
15670 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
15671 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
15672 let results = [first.join().unwrap(), second.join().unwrap()];
15673 assert_eq!(
15674 results.iter().filter(|result| result.is_ok()).count(),
15675 1,
15676 "only one alias identity may establish canonical TOFU: {results:?}"
15677 );
15678 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
15679 server.join().unwrap();
15680 }
15681
15682 #[cfg(unix)]
15683 #[test]
15684 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
15685 use std::os::unix::fs::symlink;
15686
15687 let fixture = signed_remote_fixture();
15688 let card = json!({
15689 "id": TEST_BRAIN_ID,
15690 "headSeq": 0,
15691 "feedHash": Value::Null,
15692 "identity": fixture.identity,
15693 })
15694 .to_string();
15695 let work = tempfile::tempdir().unwrap();
15696 let outside = tempfile::tempdir().unwrap();
15697 let state = work.path().join("state");
15698 let moved = work.path().join("state-held");
15699 let swap_state = state.clone();
15700 let swap_moved = moved.clone();
15701 let outside_path = outside.path().to_path_buf();
15702 let (hub, server) = routed_json_hub(1, move |_| {
15703 std::fs::rename(&swap_state, &swap_moved).unwrap();
15705 symlink(&outside_path, &swap_state).unwrap();
15706 (200, card.clone())
15707 });
15708 let cfg = test_hub_config(hub, state);
15709
15710 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
15711 assert_eq!(verified.head.seq, 0);
15712 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
15713 assert!(std::fs::read_dir(moved.join("trust"))
15714 .unwrap()
15715 .flatten()
15716 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
15717 server.join().unwrap();
15718 }
15719
15720 #[test]
15721 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
15722 let remote = signed_remote_fixture();
15723 let unrelated = signed_remote_fixture().key;
15724 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
15725 let state = tempfile::tempdir().unwrap();
15726 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
15727 cfg.brain_key = Some(unrelated);
15728 let error = sync_push(
15729 &cfg,
15730 TEST_BRAIN_ID,
15731 &[("DB.md".to_string(), "signed local content".to_string())],
15732 )
15733 .unwrap_err()
15734 .to_string();
15735 assert!(
15736 error.contains("not the verified current brain identity"),
15737 "{error}"
15738 );
15739 server.join().unwrap();
15740 }
15741
15742 #[test]
15743 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
15744 let remote = signed_remote_fixture();
15745 let new = signed_remote_fixture().key;
15746 let state = tempfile::tempdir().unwrap();
15747 let new_file = state.path().join("new.key");
15748 std::fs::write(
15749 &new_file,
15750 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
15751 )
15752 .unwrap();
15753 #[cfg(unix)]
15754 {
15755 use std::os::unix::fs::PermissionsExt as _;
15756 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
15757 }
15758 let forged = json!({
15759 "brain": TEST_BRAIN_ID,
15760 "identity": {
15761 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
15762 "publicKeySpki": new.public_key_spki,
15763 }
15764 })
15765 .to_string();
15766 let (hub, server) = scripted_json_hub(vec![
15767 (404, "{}".to_string()),
15768 (200, remote.card.clone()),
15769 (200, remote.feed.clone()),
15770 (200, forged),
15771 (200, remote.card),
15772 (200, remote.feed),
15773 ]);
15774 let cfg = test_hub_config(hub, state.path().to_path_buf());
15775 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
15776 .unwrap_err()
15777 .to_string();
15778 assert!(
15779 error.contains("without committing the verified new identity"),
15780 "{error}"
15781 );
15782 server.join().unwrap();
15783 }
15784
15785 #[test]
15786 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
15787 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15788 let raw = format!(
15789 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
15790 );
15791 let pack = build_store_pack(&[
15792 (
15793 "DB.md".to_string(),
15794 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
15795 ),
15796 ("records/clients/truth.md".to_string(), raw.clone()),
15797 ])
15798 .unwrap();
15799 let by_id = resolve_from_verified_pack(
15800 "01j5qc3v9k4ym8rwbn2tqe6f7d",
15801 &AddressTarget::Id(record_id.to_string()),
15802 pack.clone(),
15803 )
15804 .unwrap();
15805 assert_eq!(by_id["document"]["summary"], "Signed truth");
15806 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
15807 assert_eq!(
15808 by_id["document"]["contentSha"],
15809 content_sha256(raw.as_bytes())
15810 );
15811
15812 let by_path = resolve_from_verified_pack(
15813 "01j5qc3v9k4ym8rwbn2tqe6f7d",
15814 &AddressTarget::Path("records/clients/truth.md".to_string()),
15815 pack,
15816 )
15817 .unwrap();
15818 assert_eq!(by_path["document"]["id"], record_id);
15819 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
15820
15821 let wrong_id = resolve_from_verified_record_bytes(
15822 TEST_BRAIN_ID,
15823 &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
15824 "records/clients/truth.md".to_string(),
15825 raw.as_bytes().to_vec(),
15826 )
15827 .unwrap_err()
15828 .to_string();
15829 assert!(wrong_id.contains("id differs"), "{wrong_id}");
15830
15831 let wrong_path = resolve_from_verified_record_bytes(
15832 TEST_BRAIN_ID,
15833 &AddressTarget::Path("records/clients/other.md".to_string()),
15834 "records/clients/truth.md".to_string(),
15835 raw.into_bytes(),
15836 )
15837 .unwrap_err()
15838 .to_string();
15839 assert!(wrong_path.contains("path differs"), "{wrong_path}");
15840 }
15841
15842 #[test]
15843 fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
15844 let path = "records/clients/truth.md";
15845 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15846 let raw = format!(
15847 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
15848 );
15849 let sha256 = content_sha256(raw.as_bytes());
15850 let mut nonce = 0_u128;
15851 let tree = crate::linkmd_v2::build_content_tree(
15852 &[crate::linkmd_v2::ContentFile {
15853 path: path.to_string(),
15854 blob_hash: sha256.clone(),
15855 bytes: raw.len() as u64,
15856 }],
15857 None,
15858 &mut || {
15859 nonce += 1;
15860 format!("{nonce:032x}")
15861 },
15862 )
15863 .unwrap();
15864 let root = tree.root.clone().unwrap();
15865 let mut directory_root = root.clone();
15866 let mut proof = Vec::new();
15867 for component in path.split('/') {
15868 let inclusion =
15869 crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
15870 let child = match &inclusion {
15871 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
15872 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
15873 panic!("fixture path must have an inclusion proof")
15874 }
15875 };
15876 proof.push(json!({
15877 "directory_root": directory_root,
15878 "component": component,
15879 "proof": inclusion,
15880 }));
15881 directory_root = child;
15882 }
15883 let commit_hash = "c".repeat(64);
15884 let pointer = V2PointerBody {
15885 v: 2,
15886 brain: TEST_BRAIN_ID.to_string(),
15887 seq: 1,
15888 commit_hash: commit_hash.clone(),
15889 feed_hash: "f".repeat(64),
15890 content_root: Some(root.clone()),
15891 asset_root: None,
15892 materializer: "dbmd-projection-v1".to_string(),
15893 signer_epoch: 1,
15894 control_revision: "d".repeat(64),
15895 backup_preparation: "e".repeat(64),
15896 prior_pointer_hash: None,
15897 signed_at: "2026-08-21T12:00:00.000Z".to_string(),
15898 };
15899 let manifest = json!({
15900 "v": 2,
15901 "commit": commit_hash,
15902 "content_root": root,
15903 "files": [{
15904 "path": path,
15905 "sha256": sha256,
15906 "bytes": raw.len(),
15907 "proof": proof,
15908 }],
15909 "next_cursor": Value::Null,
15910 })
15911 .to_string();
15912
15913 let path_manifest = manifest.clone();
15914 let (hub, server) = routed_json_hub(1, move |request| {
15915 assert_eq!(
15916 request,
15917 format!(
15918 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
15919 "c".repeat(64)
15920 )
15921 );
15922 (200, path_manifest.clone())
15923 });
15924 let state = tempfile::tempdir().unwrap();
15925 let cfg = test_hub_config(hub, state.path().to_path_buf());
15926 let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
15927 .unwrap()
15928 .unwrap();
15929 assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
15930 assert!(by_path.proof.is_some());
15931 server.join().unwrap();
15932
15933 let id_manifest = manifest;
15934 let (hub, server) = routed_json_hub(1, move |request| {
15935 assert_eq!(
15936 request,
15937 format!(
15938 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
15939 "c".repeat(64)
15940 )
15941 );
15942 (200, id_manifest.clone())
15943 });
15944 let state = tempfile::tempdir().unwrap();
15945 let cfg = test_hub_config(hub, state.path().to_path_buf());
15946 let (located_path, by_id) =
15947 v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
15948 assert_eq!(located_path, path);
15949 assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
15950 server.join().unwrap();
15951 }
15952
15953 #[test]
15954 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
15955 let unsorted = vec![
15956 ("records/a.md".to_string(), "alpha\n".to_string()),
15957 ("DB.md".to_string(), "# db\n".to_string()),
15958 ];
15959 let sorted = vec![
15960 ("DB.md".to_string(), "# db\n".to_string()),
15961 ("records/a.md".to_string(), "alpha\n".to_string()),
15962 ];
15963 let pack = build_store_pack(&unsorted).unwrap();
15964
15965 assert_eq!(pack.len(), 219);
15970 assert_eq!(
15971 content_sha256(&pack),
15972 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
15973 );
15974 assert_eq!(pack, build_store_pack(&sorted).unwrap());
15975 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
15976 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
15977 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
15978
15979 assert_eq!(
15980 parse_store_pack(pack).unwrap(),
15981 vec![
15982 ("DB.md".to_string(), b"# db\n".to_vec()),
15983 ("records/a.md".to_string(), b"alpha\n".to_vec()),
15984 ]
15985 );
15986 }
15987
15988 #[test]
15989 fn canonical_store_pack_validates_every_path_before_writing() {
15990 let duplicate = vec![
15991 ("DB.md".to_string(), "first".to_string()),
15992 ("DB.md".to_string(), "second".to_string()),
15993 ];
15994 assert!(build_store_pack(&duplicate)
15995 .unwrap_err()
15996 .to_string()
15997 .contains("duplicate path"));
15998 assert!(matches!(
15999 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
16000 Err(LinkError::UnsafePath { .. })
16001 ));
16002 }
16003
16004 #[test]
16005 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
16006 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16007 let mut bytes = vec![0_u8];
16010 let zip64_offset = bytes.len() as u64;
16011 bytes.extend_from_slice(b"PK\x06\x06");
16012 bytes.extend_from_slice(&44_u64.to_le_bytes());
16013 bytes.extend_from_slice(&[0_u8; 12]);
16014 bytes.extend_from_slice(&COUNT.to_le_bytes());
16015 bytes.extend_from_slice(&COUNT.to_le_bytes());
16016 bytes.extend_from_slice(&1_u64.to_le_bytes());
16017 bytes.extend_from_slice(&0_u64.to_le_bytes());
16018 bytes.extend_from_slice(b"PK\x06\x07");
16019 bytes.extend_from_slice(&0_u32.to_le_bytes());
16020 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16021 bytes.extend_from_slice(&1_u32.to_le_bytes());
16022 bytes.extend_from_slice(b"PK\x05\x06");
16023 bytes.extend_from_slice(&0_u16.to_le_bytes());
16024 bytes.extend_from_slice(&0_u16.to_le_bytes());
16025 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16026 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16027 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16028 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16029 bytes.extend_from_slice(&0_u16.to_le_bytes());
16030
16031 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16032 .unwrap_err()
16033 .to_string();
16034 assert!(error.contains("invalid file count"), "{error}");
16035 }
16036
16037 #[test]
16038 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
16039 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16040 let mut bytes = vec![0_u8];
16041 let zip64_offset = bytes.len() as u64;
16042 bytes.extend_from_slice(b"PK\x06\x06");
16043 bytes.extend_from_slice(&44_u64.to_le_bytes());
16044 bytes.extend_from_slice(&[0_u8; 12]);
16045 bytes.extend_from_slice(&COUNT.to_le_bytes());
16046 bytes.extend_from_slice(&COUNT.to_le_bytes());
16047 bytes.extend_from_slice(&1_u64.to_le_bytes());
16048 bytes.extend_from_slice(&0_u64.to_le_bytes());
16049 bytes.extend_from_slice(b"PK\x06\x07");
16050 bytes.extend_from_slice(&0_u32.to_le_bytes());
16051 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16052 bytes.extend_from_slice(&1_u32.to_le_bytes());
16053 bytes.extend_from_slice(b"PK\x05\x06");
16054 bytes.extend_from_slice(&0_u16.to_le_bytes());
16055 bytes.extend_from_slice(&0_u16.to_le_bytes());
16056 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16057 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16058 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16059 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16060 bytes.extend_from_slice(&0_u16.to_le_bytes());
16061 let fake_eocd = bytes.len() as u32;
16065 bytes.extend_from_slice(b"PK\x05\x06");
16066 bytes.extend_from_slice(&0_u16.to_le_bytes());
16067 bytes.extend_from_slice(&0_u16.to_le_bytes());
16068 bytes.extend_from_slice(&1_u16.to_le_bytes());
16069 bytes.extend_from_slice(&1_u16.to_le_bytes());
16070 bytes.extend_from_slice(&0_u32.to_le_bytes());
16071 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
16072 bytes.extend_from_slice(&0_u16.to_le_bytes());
16073
16074 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16075 .unwrap_err()
16076 .to_string();
16077 assert!(error.contains("central directory"), "{error}");
16078 }
16079
16080 #[test]
16081 fn strict_http_status_handling_rejects_redirects_without_panicking() {
16082 let error = ensure_ok(
16083 HubResponse {
16084 status: 302,
16085 body: Some(json!({"redirect": "/elsewhere"})),
16086 },
16087 "mutation",
16088 )
16089 .unwrap_err();
16090 assert!(matches!(error, LinkError::Http { status: 302, .. }));
16091
16092 let error = ensure_raw_ok(
16093 RawHubResponse {
16094 status: 302,
16095 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
16096 },
16097 "feed",
16098 )
16099 .unwrap_err();
16100 assert!(matches!(error, LinkError::Http { status: 302, .. }));
16101 }
16102
16103 #[cfg(unix)]
16104 #[test]
16105 fn collect_push_files_refuses_external_symlink_and_nested_store() {
16106 use std::os::unix::fs::symlink;
16107
16108 let root = tempfile::tempdir().unwrap();
16109 std::fs::write(
16110 root.path().join("DB.md"),
16111 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
16112 )
16113 .unwrap();
16114 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
16115
16116 let external = tempfile::tempdir().unwrap();
16117 let secret = external.path().join("secret.md");
16118 std::fs::write(&secret, "TOP SECRET").unwrap();
16119 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
16120
16121 let store = Store::open_strict(root.path()).unwrap();
16122 let err = collect_push_files(&store).unwrap_err().to_string();
16123 assert!(err.contains("cannot push"), "{err}");
16124 assert!(
16125 !err.contains("TOP SECRET"),
16126 "external bytes must never leak"
16127 );
16128
16129 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
16130 let nested = root.path().join("records/nested");
16131 std::fs::create_dir_all(&nested).unwrap();
16132 std::fs::write(
16133 nested.join("DB.md"),
16134 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
16135 )
16136 .unwrap();
16137 let err = collect_push_files(&store).unwrap_err().to_string();
16138 assert!(err.contains("nested db.md store"), "{err}");
16139 }
16140
16141 #[cfg(unix)]
16142 #[test]
16143 fn remote_push_uses_opened_root_after_path_replacement() {
16144 use std::os::unix::fs::symlink;
16145
16146 let sandbox = tempfile::tempdir().unwrap();
16147 let root = sandbox.path().join("store");
16148 std::fs::create_dir_all(root.join("records/notes")).unwrap();
16149 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16150 std::fs::write(
16151 root.join("records/notes/owned.md"),
16152 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
16153 )
16154 .unwrap();
16155 let store = Store::open_strict(&root).unwrap();
16156 let detached = sandbox.path().join("detached");
16157 std::fs::rename(&root, &detached).unwrap();
16158
16159 let replacement = sandbox.path().join("replacement");
16160 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
16161 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16162 std::fs::write(
16163 replacement.join("records/notes/secret.md"),
16164 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
16165 )
16166 .unwrap();
16167 symlink(&replacement, &root).unwrap();
16168
16169 let files = collect_push_files(&store).unwrap();
16170 let wire_text = files
16171 .iter()
16172 .map(|(path, content)| format!("{path}\n{content}"))
16173 .collect::<Vec<_>>()
16174 .join("\n");
16175 assert!(wire_text.contains("owned upload"));
16176 assert!(!wire_text.contains("replacement sentinel"));
16177 assert!(!wire_text.contains("records/notes/secret.md"));
16178
16179 let remote = signed_remote_fixture();
16180 let (hub, server) = scripted_json_hub(vec![
16181 (200, remote.card),
16182 (200, remote.feed),
16183 (200, json!({"ok": true}).to_string()),
16184 ]);
16185 let state = tempfile::tempdir().unwrap();
16186 let cfg = test_hub_config(hub, state.path().to_path_buf());
16187 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
16188 assert_eq!(pushed, json!({"ok": true}));
16189 server.join().unwrap();
16190 }
16191
16192 #[test]
16193 fn signed_feed_item_verifies_identity_hash_and_signature() {
16194 use ring::rand::SystemRandom;
16195 use ring::signature::{Ed25519KeyPair, KeyPair};
16196
16197 const PREFIX: &[u8] = &[
16198 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
16199 ];
16200 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
16201 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16202 let mut spki = PREFIX.to_vec();
16203 spki.extend_from_slice(pair.public_key().as_ref());
16204 let public_key = URL_SAFE_NO_PAD.encode(&spki);
16205 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
16206 let mut entry = FeedEntry {
16207 v: 1,
16208 seq: 1,
16209 ts: "2026-07-14T00:00:00.000Z".to_string(),
16210 brain: format!("ed25519:{fingerprint}"),
16211 public_key: public_key.clone(),
16212 kind: "push".to_string(),
16213 op: "snapshot".to_string(),
16214 pack_sha256: "a".repeat(64),
16215 files: vec![FeedFile {
16216 path: "DB.md".to_string(),
16217 sha256: "b".repeat(64),
16218 bytes: 3,
16219 }],
16220 removed: vec![],
16221 prev_entry_hash: None,
16222 sig: String::new(),
16223 };
16224 let unsigned = UnsignedFeedEntry {
16225 v: entry.v,
16226 seq: entry.seq,
16227 ts: &entry.ts,
16228 brain: &entry.brain,
16229 public_key: &entry.public_key,
16230 kind: &entry.kind,
16231 op: &entry.op,
16232 pack_sha256: &entry.pack_sha256,
16233 files: &entry.files,
16234 removed: &entry.removed,
16235 prev_entry_hash: &entry.prev_entry_hash,
16236 };
16237 entry.sig =
16238 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16239 let mut exact = serde_json::to_vec(&entry).unwrap();
16240 exact.push(b'\n');
16241 let item = FeedItem {
16242 hash: format!("{:x}", Sha256::digest(&exact)),
16243 entry,
16244 };
16245 let identity = FeedIdentity {
16246 fingerprint,
16247 public_key_spki: public_key,
16248 previous: Vec::new(),
16249 rotations: Vec::new(),
16250 };
16251 assert!(verify_feed_item(&item, &identity).is_ok());
16252 let mut tampered = item;
16253 tampered.entry.pack_sha256 = "c".repeat(64);
16254 assert!(verify_feed_item(&tampered, &identity).is_err());
16255 }
16256
16257 #[test]
16258 fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
16259 let rng = ring::rand::SystemRandom::new();
16260 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16261 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16262 let (spki, multikey) = public_identity_for(&pair);
16263 let identity = V2HeadIdentity {
16264 custody: "self".to_string(),
16265 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16266 public_key_spki: spki.clone(),
16267 previous: Vec::new(),
16268 rotations: Vec::new(),
16269 };
16270 let unsigned = json!({
16271 "actor_ref": "a".repeat(64),
16272 "asset_root": Value::Null,
16273 "brain": multikey,
16274 "changes_sha256": "b".repeat(64),
16275 "control_revision": "c".repeat(64),
16276 "materializer": "dbmd-projection-v1",
16277 "op": "changeset",
16278 "parent_asset_root": Value::Null,
16279 "parent_commit": Value::Null,
16280 "parent_root": Value::Null,
16281 "prev_entry_hash": Value::Null,
16282 "public_key": spki,
16283 "seq": 1,
16284 "signer_epoch": 1,
16285 "state_root": "d".repeat(64),
16286 "ts": "2026-08-19T12:00:00.000Z",
16287 "v": 2,
16288 "v1_bridge": {
16289 "feed_hash": "e".repeat(64),
16290 "head_seq": 7,
16291 "pack_sha256": "f".repeat(64),
16292 },
16293 });
16294 let sign_value = |value: Value| {
16295 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16296 let mut object = value.as_object().unwrap().clone();
16297 object.insert(
16298 "sig".to_string(),
16299 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16300 );
16301 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16302 };
16303 assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
16304
16305 let mut extra = unsigned.clone();
16306 extra
16307 .as_object_mut()
16308 .unwrap()
16309 .insert("future".to_string(), Value::Bool(true));
16310 assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
16311
16312 let mut missing = unsigned.clone();
16313 missing.as_object_mut().unwrap().remove("v1_bridge");
16314 assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
16315
16316 let mut invalid_bridge = unsigned;
16317 invalid_bridge.as_object_mut().unwrap().insert(
16318 "v1_bridge".to_string(),
16319 json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
16320 );
16321 assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
16322 }
16323
16324 #[test]
16325 fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
16326 let vector: Value = serde_json::from_str(include_str!(
16327 "../tests/vectors/linkmd-v2-commit-bridge.json"
16328 ))
16329 .unwrap();
16330 let identity_value = vector.get("identity").unwrap();
16331 let identity = V2HeadIdentity {
16332 custody: "self".to_string(),
16333 fingerprint: identity_value
16334 .get("fingerprint")
16335 .and_then(Value::as_str)
16336 .unwrap()
16337 .to_string(),
16338 public_key_spki: identity_value
16339 .get("public_key_spki")
16340 .and_then(Value::as_str)
16341 .unwrap()
16342 .to_string(),
16343 previous: Vec::new(),
16344 rotations: Vec::new(),
16345 };
16346 let private = URL_SAFE_NO_PAD
16347 .decode(
16348 identity_value
16349 .get("private_key_pkcs8")
16350 .and_then(Value::as_str)
16351 .unwrap(),
16352 )
16353 .unwrap();
16354 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
16355 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
16356 .unwrap();
16357 let base = vector.get("body").unwrap().as_object().unwrap();
16358
16359 for item in vector.get("valid").unwrap().as_array().unwrap() {
16360 let mut body = base.clone();
16361 body.insert(
16362 "v1_bridge".to_string(),
16363 item.get("v1_bridge").unwrap().clone(),
16364 );
16365 body.insert(
16366 "sig".to_string(),
16367 item.get("signature_base64url").unwrap().clone(),
16368 );
16369 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16370 assert!(verified_v2_commit_object(&signed, &identity).is_ok());
16371 assert_eq!(
16372 crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
16373 item.get("commit_hash").and_then(Value::as_str).unwrap()
16374 );
16375 assert_eq!(
16376 format!("{:x}", Sha256::digest(&signed)),
16377 item.get("feed_hash").and_then(Value::as_str).unwrap()
16378 );
16379 }
16380
16381 for item in vector.get("invalid").unwrap().as_array().unwrap() {
16382 let mut body = base.clone();
16383 if let Some(remove) = item.get("remove").and_then(Value::as_array) {
16384 for field in remove {
16385 body.remove(field.as_str().unwrap());
16386 }
16387 }
16388 if let Some(set) = item.get("set").and_then(Value::as_object) {
16389 for (field, value) in set {
16390 body.insert(field.clone(), value.clone());
16391 }
16392 }
16393 let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
16394 body.insert(
16395 "sig".to_string(),
16396 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16397 );
16398 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16399 assert!(
16400 verified_v2_commit_object(&signed, &identity).is_err(),
16401 "accepted invalid shared vector {}",
16402 item.get("reason").and_then(Value::as_str).unwrap()
16403 );
16404 }
16405 }
16406
16407 #[test]
16408 fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
16409 let vector: Value = serde_json::from_str(include_str!(
16410 "../tests/vectors/linkmd-v2-changeset-withheld.json"
16411 ))
16412 .unwrap();
16413 assert_eq!(
16414 vector.get("profile").and_then(Value::as_str),
16415 Some("link.md-v2-changeset-withheld")
16416 );
16417 let canonical =
16418 crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
16419 let expected = STANDARD
16420 .decode(
16421 vector
16422 .get("canonical_base64")
16423 .and_then(Value::as_str)
16424 .unwrap(),
16425 )
16426 .unwrap();
16427 assert_eq!(canonical, expected);
16428 assert_eq!(
16429 crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
16430 vector.get("domain_hash").and_then(Value::as_str).unwrap()
16431 );
16432 }
16433
16434 #[test]
16435 fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
16436 let remote = signed_remote_fixture();
16437 let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
16438 let legacy_item = legacy.entries.first().unwrap();
16439 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
16440 let body = json!({
16441 "actor_ref": "a".repeat(64),
16442 "asset_root": Value::Null,
16443 "brain": remote.key.multikey,
16444 "changes_sha256": "b".repeat(64),
16445 "control_revision": "c".repeat(64),
16446 "materializer": "dbmd-projection-v1",
16447 "op": "changeset",
16448 "parent_asset_root": Value::Null,
16449 "parent_commit": Value::Null,
16450 "parent_root": Value::Null,
16451 "prev_entry_hash": Value::Null,
16452 "public_key": remote.key.public_key_spki,
16453 "seq": 1,
16454 "signer_epoch": 1,
16455 "state_root": "d".repeat(64),
16456 "ts": "2026-08-19T12:00:00.000Z",
16457 "v": 2,
16458 "v1_bridge": {
16459 "feed_hash": legacy_item.hash,
16460 "head_seq": legacy_item.entry.seq,
16461 "pack_sha256": legacy_item.entry.pack_sha256,
16462 },
16463 });
16464 let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
16465 let mut signed = body.as_object().unwrap().clone();
16466 signed.insert(
16467 "sig".to_string(),
16468 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16469 );
16470 let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
16471 let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
16472 let feed_hash = content_sha256(&raw);
16473 let pointer = V2PointerBody {
16474 v: 2,
16475 brain: TEST_BRAIN_ID.to_string(),
16476 seq: 1,
16477 commit_hash: commit_hash.clone(),
16478 feed_hash: feed_hash.clone(),
16479 content_root: Some("d".repeat(64)),
16480 asset_root: None,
16481 materializer: "dbmd-projection-v1".to_string(),
16482 signer_epoch: 1,
16483 control_revision: "c".repeat(64),
16484 backup_preparation: "e".repeat(64),
16485 prior_pointer_hash: None,
16486 signed_at: "2026-08-19T12:00:00.000Z".to_string(),
16487 };
16488 let v2_page = json!({
16489 "v": 2,
16490 "head_seq": 1,
16491 "head_commit_hash": commit_hash,
16492 "head_feed_hash": feed_hash,
16493 "entries": [{
16494 "seq": 1,
16495 "commit_hash": pointer.commit_hash,
16496 "feed_hash": pointer.feed_hash,
16497 "bytes_base64": STANDARD.encode(&raw),
16498 }],
16499 "next_after": 1,
16500 "complete": true,
16501 })
16502 .to_string();
16503 let identity = V2HeadIdentity {
16504 custody: "self".to_string(),
16505 fingerprint: remote.identity.fingerprint.clone(),
16506 public_key_spki: remote.identity.public_key_spki.clone(),
16507 previous: Vec::new(),
16508 rotations: Vec::new(),
16509 };
16510 let checkpoint = TrustState {
16511 v: 2,
16512 origin: "unused".to_string(),
16513 requested: TEST_BRAIN_ID.to_string(),
16514 brain: TEST_BRAIN_ID.to_string(),
16515 home: None,
16516 anchor: remote.key.multikey.clone(),
16517 current: remote.key.multikey,
16518 head_seq: legacy_item.entry.seq,
16519 feed_hash: Some(legacy_item.hash.clone()),
16520 rotations: Vec::new(),
16521 hub_signer: None,
16522 protocol_profile: None,
16523 };
16524 let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
16525 let state = tempfile::tempdir().unwrap();
16526 let cfg = test_hub_config(hub, state.path().to_path_buf());
16527 verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
16528 server.join().unwrap();
16529
16530 let mut wrong = checkpoint;
16531 wrong.feed_hash = Some("0".repeat(64));
16532 let (hub, server) = scripted_json_hub(vec![(
16533 200,
16534 json!({
16535 "v": 2,
16536 "head_seq": 1,
16537 "head_commit_hash": pointer.commit_hash,
16538 "head_feed_hash": pointer.feed_hash,
16539 "entries": [{
16540 "seq": 1,
16541 "commit_hash": pointer.commit_hash,
16542 "feed_hash": pointer.feed_hash,
16543 "bytes_base64": STANDARD.encode(&raw),
16544 }],
16545 "next_after": 1,
16546 "complete": true,
16547 })
16548 .to_string(),
16549 )]);
16550 let state = tempfile::tempdir().unwrap();
16551 let cfg = test_hub_config(hub, state.path().to_path_buf());
16552 assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
16553 server.join().unwrap();
16554 }
16555
16556 #[test]
16557 fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
16558 let rng = ring::rand::SystemRandom::new();
16559 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16560 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
16561 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16562 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
16563 let (old_spki, old_multikey) = public_identity_for(&old);
16564 let (new_spki, new_multikey) = public_identity_for(&new);
16565 let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
16566 v: 1,
16567 op: "rotate",
16568 brain: &old_multikey,
16569 public_key: &old_spki,
16570 new_brain: &new_multikey,
16571 new_public_key: &new_spki,
16572 prior_head_seq: 1,
16573 prior_feed_hash: Some(&"9".repeat(64)),
16574 ts: "2026-08-19T12:01:00.000Z".to_string(),
16575 })
16576 .unwrap();
16577 let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
16578 let rotation = format!(
16579 "{},\"sig\":\"{}\"}}",
16580 &rotation_unsigned[..rotation_unsigned.len() - 1],
16581 rotation_sig
16582 );
16583 let identity = V2HeadIdentity {
16584 custody: "self".to_string(),
16585 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
16586 public_key_spki: new_spki.clone(),
16587 previous: vec![V2PreviousIdentity {
16588 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
16589 public_key_spki: old_spki.clone(),
16590 }],
16591 rotations: vec![rotation],
16592 };
16593 let commit = |seq: u64,
16594 epoch: u64,
16595 multikey: &str,
16596 spki: &str,
16597 pair: &ring::signature::Ed25519KeyPair| {
16598 let value = json!({
16599 "actor_ref": "a".repeat(64),
16600 "asset_root": Value::Null,
16601 "brain": multikey,
16602 "changes_sha256": "b".repeat(64),
16603 "control_revision": "c".repeat(64),
16604 "materializer": "dbmd-projection-v1",
16605 "op": "changeset",
16606 "parent_asset_root": Value::Null,
16607 "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
16608 "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
16609 "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
16610 "public_key": spki,
16611 "seq": seq,
16612 "signer_epoch": epoch,
16613 "state_root": "1".repeat(64),
16614 "ts": "2026-08-19T12:00:00.000Z",
16615 "v": 2,
16616 "v1_bridge": Value::Null,
16617 });
16618 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16619 let mut object = value.as_object().unwrap().clone();
16620 object.insert(
16621 "sig".to_string(),
16622 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16623 );
16624 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16625 };
16626
16627 assert!(verified_v2_commit_object(
16628 &commit(1, 1, &old_multikey, &old_spki, &old),
16629 &identity,
16630 )
16631 .is_ok());
16632 assert!(verified_v2_commit_object(
16633 &commit(2, 2, &new_multikey, &new_spki, &new),
16634 &identity,
16635 )
16636 .is_ok());
16637 assert!(verified_v2_commit_object(
16638 &commit(2, 1, &old_multikey, &old_spki, &old),
16639 &identity,
16640 )
16641 .is_err());
16642 assert!(verified_v2_commit_object(
16643 &commit(1, 2, &new_multikey, &new_spki, &new),
16644 &identity,
16645 )
16646 .is_err());
16647 }
16648
16649 #[test]
16650 fn a_self_custody_entry_verifies_like_any_hub_entry() {
16651 let rng = ring::rand::SystemRandom::new();
16652 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16653 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16654 let (spki, multikey) = public_identity_for(&pair);
16655 let key = AgentSigningKey {
16656 pkcs8: pkcs8.as_ref().to_vec(),
16657 multikey: multikey.clone(),
16658 public_key_spki: spki.clone(),
16659 };
16660 let files = vec![WireFeedFile {
16661 path: "DB.md".to_string(),
16662 sha256: "a".repeat(64),
16663 bytes: 3,
16664 }];
16665 let raw = self_custody_entry(
16666 &key,
16667 1,
16668 "2026-07-23T12:00:00.000Z".to_string(),
16669 &"c".repeat(64),
16670 &files,
16671 None,
16672 )
16673 .unwrap();
16674 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
16678 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
16679 let item = FeedItem { hash, entry };
16680 let identity = FeedIdentity {
16681 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16682 public_key_spki: spki,
16683 previous: Vec::new(),
16684 rotations: Vec::new(),
16685 };
16686 assert!(verify_feed_item(&item, &identity).is_ok());
16687 }
16688
16689 #[test]
16690 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
16691 let rng = ring::rand::SystemRandom::new();
16692 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16693 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
16694 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16695 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
16696 let (old_spki, old_multikey) = public_identity_for(&old);
16697 let (new_spki, new_multikey) = public_identity_for(&new);
16698 let unsigned = serde_json::to_string(&UnsignedRotation {
16699 v: 1,
16700 op: "rotate",
16701 brain: &old_multikey,
16702 public_key: &old_spki,
16703 new_brain: &new_multikey,
16704 new_public_key: &new_spki,
16705 prior_head_seq: 1,
16706 prior_feed_hash: Some(&"a".repeat(64)),
16707 ts: "2026-07-30T12:00:00.000Z".to_string(),
16708 })
16709 .unwrap();
16710 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
16711 let rotation = format!(
16712 "{},\"sig\":\"{}\"}}",
16713 &unsigned[..unsigned.len() - 1],
16714 signature
16715 );
16716 let identity = FeedIdentity {
16717 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
16718 public_key_spki: new_spki,
16719 previous: vec![PreviousIdentity {
16720 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
16721 public_key_spki: old_spki,
16722 }],
16723 rotations: vec![rotation],
16724 };
16725 let pin = TrustState {
16726 v: 2,
16727 origin: "https://hub.example".to_string(),
16728 requested: "brain".to_string(),
16729 brain: "brain".to_string(),
16730 home: None,
16731 anchor: old_multikey.clone(),
16732 current: old_multikey.clone(),
16733 head_seq: 1,
16734 feed_hash: Some("a".repeat(64)),
16735 rotations: Vec::new(),
16736 hub_signer: None,
16737 protocol_profile: None,
16738 };
16739 assert_eq!(
16740 verify_identity_chain(&identity, Some(&pin)).unwrap(),
16741 old_multikey
16742 );
16743 let mut accepted = pin.clone();
16744 accepted.current = new_multikey.clone();
16745 accepted.rotations = identity.rotations.clone();
16746 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
16747 v: 1,
16748 op: "rotate",
16749 brain: &old_multikey,
16750 public_key: &identity.previous[0].public_key_spki,
16751 new_brain: &new_multikey,
16752 new_public_key: &identity.public_key_spki,
16753 prior_head_seq: 1,
16754 prior_feed_hash: Some(&"a".repeat(64)),
16755 ts: "2026-07-30T12:00:01.000Z".to_string(),
16756 })
16757 .unwrap();
16758 let alternate_signature =
16759 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
16760 let mut rewritten = identity.clone();
16761 rewritten.rotations[0] = format!(
16762 "{},\"sig\":\"{}\"}}",
16763 &alternate_unsigned[..alternate_unsigned.len() - 1],
16764 alternate_signature
16765 );
16766 assert!(
16767 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
16768 "an alternate valid statement must not rewrite accepted history"
16769 );
16770
16771 let mut stale_entry = FeedEntry {
16772 v: 1,
16773 seq: 2,
16774 ts: "2026-07-30T12:01:00.000Z".to_string(),
16775 brain: pin.current.clone(),
16776 public_key: identity.previous[0].public_key_spki.clone(),
16777 kind: "push".to_string(),
16778 op: "snapshot".to_string(),
16779 pack_sha256: "b".repeat(64),
16780 files: Vec::new(),
16781 removed: Vec::new(),
16782 prev_entry_hash: pin.feed_hash.clone(),
16783 sig: String::new(),
16784 };
16785 let stale_unsigned = UnsignedFeedEntry {
16786 v: stale_entry.v,
16787 seq: stale_entry.seq,
16788 ts: &stale_entry.ts,
16789 brain: &stale_entry.brain,
16790 public_key: &stale_entry.public_key,
16791 kind: &stale_entry.kind,
16792 op: &stale_entry.op,
16793 pack_sha256: &stale_entry.pack_sha256,
16794 files: &stale_entry.files,
16795 removed: &stale_entry.removed,
16796 prev_entry_hash: &stale_entry.prev_entry_hash,
16797 };
16798 stale_entry.sig = URL_SAFE_NO_PAD.encode(
16799 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
16800 .as_ref(),
16801 );
16802 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
16803 stale_exact.push(b'\n');
16804 let stale_item = FeedItem {
16805 hash: content_sha256(&stale_exact),
16806 entry: stale_entry,
16807 };
16808 assert!(
16809 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
16810 .is_err(),
16811 "a key retired before the checkpoint must never append after it"
16812 );
16813 assert!(
16814 verify_feed_item(&stale_item, &identity).is_err(),
16815 "an old key must never append after its signed rotation boundary"
16816 );
16817
16818 let mut missing = identity.clone();
16819 missing.rotations.clear();
16820 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
16821
16822 let mut tampered = identity;
16823 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
16824 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
16825 }
16826
16827 #[cfg(unix)]
16828 #[test]
16829 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
16830 use std::os::unix::fs::symlink;
16831
16832 let dir = tempfile::tempdir().unwrap();
16833 let target = dir.path().join("valuable.txt");
16834 let planted = dir.path().join("agent.key");
16835 std::fs::write(&target, "do not overwrite").unwrap();
16836 symlink(&target, &planted).unwrap();
16837
16838 assert!(matches!(
16839 generate_agent_key(&planted),
16840 Err(LinkError::BadAgentKey { .. })
16841 ));
16842 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
16843 }
16844
16845 #[cfg(unix)]
16846 #[test]
16847 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
16848 use std::os::unix::fs::symlink;
16849
16850 let root = tempfile::tempdir().unwrap();
16851 let outside = tempfile::tempdir().unwrap();
16852 symlink(outside.path(), root.path().join("redirect")).unwrap();
16853
16854 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
16855 assert!(!outside.path().join("agent.key").exists());
16856 }
16857
16858 #[test]
16861 fn address_bare_brain_with_and_without_sigil() {
16862 for raw in ["@acme-ops", "acme-ops"] {
16863 let a = Address::parse(raw).expect(raw);
16864 assert_eq!(a.brain, "acme-ops");
16865 assert_eq!(a.target, None);
16866 }
16867 }
16868
16869 #[test]
16870 fn address_ulid_target_parses_as_id() {
16871 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
16872 assert_eq!(a.brain, "acme");
16873 assert_eq!(
16874 a.target,
16875 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
16876 );
16877 }
16878
16879 #[test]
16880 fn address_md_path_target_parses_as_path() {
16881 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
16882 assert_eq!(
16883 a.target,
16884 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
16885 );
16886 }
16887
16888 #[test]
16889 fn address_rejects_malformed_forms() {
16890 for raw in [
16891 "",
16892 "@",
16893 "@/x",
16894 "@acme/",
16895 "@acme/../etc/passwd",
16896 "@acme/records/.hidden.md",
16897 "@ACME", "@acme/notes/x.txt", "@a b", ] {
16901 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
16902 }
16903 }
16904
16905 #[test]
16908 fn safe_paths_accept_store_shapes_and_reject_escapes() {
16909 for ok in [
16910 "DB.md",
16911 "assets.jsonl",
16912 "records/clients/lumio.md",
16913 "sources/emails/2026/07/x.md",
16914 ] {
16915 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
16916 }
16917 for bad in [
16918 "",
16919 "/etc/passwd",
16920 "../up.md",
16921 "records/../../up.md",
16922 "records//x.md",
16923 ".dbmd/config",
16924 "records/.hidden/x.md",
16925 "records/a b.md",
16926 "records\\win.md",
16927 ] {
16928 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
16929 }
16930 }
16931
16932 #[cfg(unix)]
16933 #[test]
16934 fn opened_destination_capability_survives_an_ancestor_path_swap() {
16935 use std::os::unix::fs::symlink;
16936
16937 let work = tempfile::tempdir().unwrap();
16938 let outside = tempfile::tempdir().unwrap();
16939 let original = work.path().join("destination");
16940 let moved = work.path().join("destination-moved");
16941 let directory = open_or_create_dir_nofollow(&original).unwrap();
16942
16943 std::fs::rename(&original, &moved).unwrap();
16944 symlink(outside.path(), &original).unwrap();
16945 write_pull_entries_beneath_dir(
16946 &directory,
16947 &[("records/note.md".to_string(), b"held inode".to_vec())],
16948 )
16949 .unwrap();
16950
16951 assert_eq!(
16952 std::fs::read(moved.join("records/note.md")).unwrap(),
16953 b"held inode"
16954 );
16955 assert!(!outside.path().join("records/note.md").exists());
16956 }
16957
16958 #[test]
16962 fn hub_config_flag_beats_file_and_requires_some_source() {
16963 let dir = tempfile::tempdir().unwrap();
16964 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
16965 std::fs::write(
16966 dir.path().join(CONFIG_REL_PATH),
16967 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
16968 )
16969 .unwrap();
16970
16971 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
16972 assert_eq!(from_flag.hub, "https://flag.example.com");
16973
16974 let from_file = hub_config(None, dir.path()).unwrap();
16975 assert_eq!(from_file.hub, "https://file.example.com");
16976
16977 let none = hub_config(None, tempfile::tempdir().unwrap().path());
16978 assert!(matches!(none, Err(LinkError::NoHub)));
16979 }
16980
16981 #[test]
16982 fn https_guard_allows_loopback_only_for_plain_http() {
16983 assert!(assert_safe_hub("https://hub.example.com").is_ok());
16984 assert!(assert_safe_hub("http://localhost:3000").is_ok());
16985 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
16986 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
16987 assert!(matches!(
16988 assert_safe_hub("http://hub.example.com"),
16989 Err(LinkError::UnsafeHub { .. })
16990 ));
16991 assert!(matches!(
16992 assert_safe_hub("hub.example.com"),
16993 Err(LinkError::UnsafeHub { .. })
16994 ));
16995 assert!(matches!(
16996 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
16997 Err(LinkError::UnsafeHub { .. })
16998 ));
16999 assert!(matches!(
17000 assert_safe_hub("https://hub.example.com@attacker.example"),
17001 Err(LinkError::UnsafeHub { .. })
17002 ));
17003 assert!(matches!(
17004 assert_safe_hub("https://hub.example.com/base"),
17005 Err(LinkError::UnsafeHub { .. })
17006 ));
17007 }
17008
17009 #[test]
17010 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
17011 for blocked in [
17012 "127.0.0.1",
17013 "10.0.0.1",
17014 "100.64.0.1",
17015 "169.254.169.254",
17016 "172.16.0.1",
17017 "192.168.0.1",
17018 "192.88.99.1",
17019 "198.18.0.1",
17020 "203.0.113.1",
17021 "::1",
17022 "fe80::1",
17023 "fd00::1",
17024 "2001:db8::1",
17025 "2001:1::1",
17026 "2002:7f00:1::",
17027 "3fff::1",
17028 ] {
17029 assert!(
17030 !is_public_registry_ip(blocked.parse().unwrap()),
17031 "must block {blocked}"
17032 );
17033 }
17034 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
17035 assert!(is_public_registry_ip(
17036 "2606:4700:4700::1111".parse().unwrap()
17037 ));
17038 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
17039 }
17040
17041 #[test]
17042 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
17043 use ureq::Resolver as _;
17044
17045 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
17046 let resolver = PinnedRegistryResolver {
17047 netloc: "home.example:443".to_string(),
17048 addresses: vec![pinned],
17049 };
17050 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
17051 assert!(resolver.resolve("127.0.0.1:443").is_err());
17052 assert_eq!(
17053 resolver.resolve("home.example:443").unwrap(),
17054 vec![pinned],
17055 "subsequent connects reuse the validated answer instead of DNS"
17056 );
17057 }
17058
17059 #[test]
17060 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
17061 let cfg = HubConfig {
17062 hub: "https://hub.example".to_string(),
17063 key: None,
17064 agent_key: None,
17065 brain_key: None,
17066 state_dir: tempfile::tempdir().unwrap().keep(),
17067 store_selected: false,
17068 };
17069 assert!(
17070 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
17071 "a production hub must not turn its presigned URL into an SSRF primitive"
17072 );
17073
17074 let store_selected = HubConfig {
17075 hub: "https://127.0.0.1".to_string(),
17076 store_selected: true,
17077 ..cfg
17078 };
17079 assert!(
17080 hub_agent(&store_selected).is_err(),
17081 "bytes in a cloned store must not select a private-network hub"
17082 );
17083 }
17084
17085 #[test]
17086 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
17087 assert_eq!(
17088 one_past_bounded_limit(MAX_PACK_BYTES),
17089 Some(MAX_PACK_BYTES + 1),
17090 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
17091 );
17092 assert_eq!(
17093 presigned_download_read_limit(),
17094 MAX_PACK_BYTES + 1,
17095 "the presigned reader is capped by the client constant, not a hub response"
17096 );
17097 assert_eq!(
17098 one_past_bounded_limit(u64::MAX),
17099 None,
17100 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
17101 );
17102 }
17103
17104 #[test]
17105 fn https_guard_matches_the_scheme_case_insensitively() {
17106 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
17109 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
17110 assert!(matches!(
17112 assert_safe_hub("HTTP://hub.example.com"),
17113 Err(LinkError::UnsafeHub { .. })
17114 ));
17115 }
17116
17117 #[test]
17118 fn clean_key_refuses_paste_artifacts_without_echoing() {
17119 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
17120 for bad in ["vc account", "vc\naccount", "ключ", ""] {
17121 let err = clean_key(bad).unwrap_err();
17122 assert!(matches!(err, LinkError::BadKey));
17123 assert!(
17124 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
17125 "error must not echo the key"
17126 );
17127 }
17128 }
17129
17130 fn dead_hub() -> HubConfig {
17136 HubConfig {
17137 hub: "http://127.0.0.1:9".to_string(),
17138 key: Some("k".to_string()),
17139 agent_key: None,
17140 brain_key: None,
17141 state_dir: PathBuf::from("."),
17142 store_selected: false,
17143 }
17144 }
17145
17146 #[test]
17147 fn request_retries_a_connection_failure_before_sending() {
17148 use std::io::{Read as _, Write as _};
17149 use std::net::TcpListener;
17150 use std::thread;
17151 use std::time::Duration;
17152
17153 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
17154 let address = probe.local_addr().unwrap();
17155 drop(probe);
17156 let server = thread::spawn(move || {
17157 thread::sleep(Duration::from_millis(40));
17158 let listener = TcpListener::bind(address).unwrap();
17159 let (mut stream, _) = listener.accept().unwrap();
17160 let mut request_bytes = [0_u8; 1024];
17161 let _ = stream.read(&mut request_bytes).unwrap();
17162 stream
17163 .write_all(
17164 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17165 )
17166 .unwrap();
17167 });
17168 let cfg = HubConfig {
17169 hub: format!("http://{address}"),
17170 key: None,
17171 agent_key: None,
17172 brain_key: None,
17173 state_dir: tempfile::tempdir().unwrap().keep(),
17174 store_selected: false,
17175 };
17176
17177 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
17178 assert_eq!(response.status, 200);
17179 assert_eq!(response.body, Some(json!({ "ok": true })));
17180 server.join().unwrap();
17181 }
17182
17183 #[test]
17184 fn a_commit_goes_back_for_a_receipt_it_lost() {
17185 use std::io::{Read as _, Write as _};
17186 use std::net::TcpListener;
17187 use std::thread;
17188
17189 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17195 let address = listener.local_addr().unwrap();
17196 let server = thread::spawn(move || {
17197 let (mut first, _) = listener.accept().unwrap();
17199 let mut bytes = [0_u8; 4096];
17200 let _ = first.read(&mut bytes).unwrap();
17201 first
17202 .write_all(
17203 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
17204 )
17205 .unwrap();
17206 drop(first);
17207 let (mut second, _) = listener.accept().unwrap();
17209 let _ = second.read(&mut bytes).unwrap();
17210 second
17211 .write_all(
17212 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 29\r\nConnection: close\r\n\r\n{\"v\":2,\"outcome\":\"converged\"}",
17213 )
17214 .unwrap();
17215 });
17216 let cfg = HubConfig {
17217 hub: format!("http://{address}"),
17218 key: Some("k".to_string()),
17219 agent_key: None,
17220 brain_key: None,
17221 state_dir: tempfile::tempdir().unwrap().keep(),
17222 store_selected: false,
17223 };
17224
17225 let response = request_patient(
17226 &cfg,
17227 "POST",
17228 "/api/hub/brains/b/v2/commits",
17229 Some(&json!({ "mutation_id": "dbmd-1" })),
17230 Auth::Required,
17231 )
17232 .expect("the receipt is collected on the second ask");
17233 assert_eq!(response.status, 200);
17234 assert_eq!(
17235 response
17236 .body
17237 .as_ref()
17238 .and_then(|value| value.get("outcome"))
17239 .and_then(Value::as_str),
17240 Some("converged"),
17241 "an already-applied mutation answers with its receipt"
17242 );
17243 server.join().unwrap();
17244 }
17245
17246 #[test]
17247 fn a_body_that_dies_mid_stream_is_a_transport_failure() {
17248 use std::io::{Read as _, Write as _};
17249 use std::net::TcpListener;
17250 use std::thread;
17251
17252 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17258 let address = listener.local_addr().unwrap();
17259 let server = thread::spawn(move || {
17260 let (mut stream, _) = listener.accept().unwrap();
17261 let mut request_bytes = [0_u8; 1024];
17262 let _ = stream.read(&mut request_bytes).unwrap();
17263 stream
17265 .write_all(
17266 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17267 )
17268 .unwrap();
17269 });
17270 let cfg = HubConfig {
17271 hub: format!("http://{address}"),
17272 key: None,
17273 agent_key: None,
17274 brain_key: None,
17275 state_dir: tempfile::tempdir().unwrap().keep(),
17276 store_selected: false,
17277 };
17278
17279 let error = request(&cfg, "GET", "/truncated", None, Auth::None)
17280 .expect_err("a truncated body must not read as success");
17281 match error {
17282 LinkError::Transport { hub, .. } => {
17283 assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
17284 }
17285 other => panic!("expected a transport failure, got {other:?}"),
17286 }
17287 server.join().unwrap();
17288 }
17289
17290 #[test]
17291 fn object_store_transport_errors_never_render_presigned_urls() {
17292 use std::net::TcpListener;
17293
17294 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17295 let address = listener.local_addr().unwrap();
17296 drop(listener);
17297 let signature = "do-not-render-this-presigned-signature";
17298 let raw =
17299 format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
17300 let error = ureq::get(&raw)
17301 .timeout(std::time::Duration::from_millis(250))
17302 .call()
17303 .expect_err("the closed local port must fail");
17304 let ureq::Error::Transport(transport) = error else {
17305 panic!("expected a transport failure");
17306 };
17307
17308 let rendered = object_store_transport_error(transport).to_string();
17309 assert!(rendered.contains("the object store"));
17310 assert!(rendered.contains("network error"));
17311 assert!(!rendered.contains(&raw));
17312 assert!(!rendered.contains(signature));
17313 assert!(!rendered.contains("X-Amz-"));
17314 }
17315
17316 #[test]
17317 fn endpoint_cap_refuses_a_body_before_json_parsing() {
17318 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
17319 let cfg = HubConfig {
17320 hub,
17321 key: None,
17322 agent_key: None,
17323 brain_key: None,
17324 state_dir: tempfile::tempdir().unwrap().keep(),
17325 store_selected: false,
17326 };
17327
17328 assert!(matches!(
17329 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
17330 Err(LinkError::ResponseTooLarge { .. })
17331 ));
17332 server.join().unwrap();
17333 }
17334
17335 #[test]
17336 fn overall_deadline_stops_a_dribbled_response_body() {
17337 use std::io::{Read as _, Write as _};
17338 use std::net::TcpListener;
17339 use std::time::{Duration, Instant};
17340
17341 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17342 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
17343 let server = std::thread::spawn(move || {
17344 let (mut stream, _) = listener.accept().unwrap();
17345 let mut request = [0_u8; 1024];
17346 let _ = stream.read(&mut request);
17347 stream
17348 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
17349 .unwrap();
17350 for byte in [b'x'; 32] {
17351 if stream.write_all(&[byte]).is_err() {
17352 break;
17353 }
17354 std::thread::sleep(Duration::from_millis(40));
17355 }
17356 });
17357 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17358 let started = Instant::now();
17359 let response = http.get(&url).call().unwrap();
17360 let mut body = Vec::new();
17361 let error = response
17362 .into_reader()
17363 .read_to_end(&mut body)
17364 .expect_err("per-read progress must not reset the overall deadline");
17365 assert!(
17366 started.elapsed() < Duration::from_millis(700),
17367 "dribbled body exceeded the wall-clock budget: {error}"
17368 );
17369 server.join().unwrap();
17370 }
17371
17372 #[test]
17373 fn overall_deadline_stops_a_stalled_upload() {
17374 use std::net::TcpListener;
17375 use std::time::{Duration, Instant};
17376
17377 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17378 let url = format!("http://{}/upload", listener.local_addr().unwrap());
17379 let server = std::thread::spawn(move || {
17380 let (_stream, _) = listener.accept().unwrap();
17381 std::thread::sleep(Duration::from_millis(600));
17384 });
17385 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17386 let body = vec![0x5a; 32 * 1024 * 1024];
17387 let started = Instant::now();
17388 let error = http
17389 .put(&url)
17390 .send_bytes(&body)
17391 .expect_err("stalled request-body writes must time out");
17392 assert!(
17393 started.elapsed() < Duration::from_millis(700),
17394 "stalled upload exceeded the wall-clock budget: {error}"
17395 );
17396 server.join().unwrap();
17397 }
17398
17399 #[test]
17400 fn presigned_source_retries_share_one_upload_deadline() {
17401 use std::net::TcpListener;
17402 use std::time::{Duration, Instant};
17403
17404 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17405 let address = listener.local_addr().unwrap();
17406 let signature = "do-not-render-this-stalled-upload-signature";
17407 let url = format!(
17408 "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
17409 );
17410 let server = std::thread::spawn(move || {
17411 let (_stream, _) = listener.accept().unwrap();
17412 std::thread::sleep(Duration::from_millis(600));
17416 });
17417
17418 let directory = tempfile::tempdir().unwrap();
17419 std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17420 std::fs::create_dir(directory.path().join("records")).unwrap();
17421 let relative = "records/stalled.bin";
17422 let bytes = vec![0x5a; 32 * 1024 * 1024];
17423 std::fs::write(directory.path().join(relative), &bytes).unwrap();
17424 let store = Store::open_strict(directory.path()).unwrap();
17425 let cfg = HubConfig {
17426 hub: format!("http://{address}"),
17427 key: None,
17428 agent_key: None,
17429 brain_key: None,
17430 state_dir: tempfile::tempdir().unwrap().keep(),
17431 store_selected: false,
17432 };
17433 let source = V2UploadSource {
17434 path: relative.to_string(),
17435 bytes: bytes.len() as u64,
17436 };
17437
17438 let started = Instant::now();
17439 let error = put_presigned_source_with_budget(
17440 &cfg,
17441 &url,
17442 &json!({ "content-length": source.bytes.to_string() }),
17443 &store,
17444 &source,
17445 None,
17446 Duration::from_millis(150),
17447 )
17448 .expect_err("a black-holed upload must leave at its shared deadline");
17449 assert!(
17450 started.elapsed() < Duration::from_millis(700),
17451 "presigned retries exceeded their shared budget: {error}"
17452 );
17453 let rendered = error.to_string();
17454 assert!(rendered.contains("the object store"));
17455 assert!(!rendered.contains(&url));
17456 assert!(!rendered.contains(signature));
17457 server.join().unwrap();
17458 }
17459
17460 #[test]
17461 fn verb_entry_gates_accept_the_hub_ref_shapes() {
17462 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
17463 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
17464 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
17465 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
17466 }
17467 }
17468
17469 #[test]
17470 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
17471 let cfg = dead_hub();
17472 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
17473 assert!(
17474 matches!(
17475 sync_pull(&cfg, bad, None),
17476 Err(LinkError::BadAddress { .. })
17477 ),
17478 "sync_pull must refuse {bad:?}"
17479 );
17480 assert!(
17481 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
17482 "sync_push must refuse {bad:?}"
17483 );
17484 assert!(
17485 matches!(
17486 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
17487 Err(LinkError::BadAddress { .. })
17488 ),
17489 "grant_issue must refuse {bad:?}"
17490 );
17491 assert!(
17492 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
17493 "grant_list must refuse {bad:?}"
17494 );
17495 assert!(
17496 matches!(
17497 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
17498 Err(LinkError::BadAddress { .. })
17499 ),
17500 "grant_revoke must refuse brain {bad:?}"
17501 );
17502 assert!(
17503 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
17504 "head must refuse {bad:?}"
17505 );
17506 }
17507 }
17508
17509 #[test]
17510 fn grant_revoke_refuses_url_reshaping_grant_ids() {
17511 let cfg = dead_hub();
17512 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
17513 assert!(
17514 matches!(
17515 grant_revoke(&cfg, "acme", bad),
17516 Err(LinkError::BadGrantId { .. })
17517 ),
17518 "grant_revoke must refuse grant id {bad:?}"
17519 );
17520 }
17521 }
17522
17523 #[test]
17524 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
17525 let cfg = dead_hub();
17526 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
17527 assert!(
17528 matches!(
17529 propose(&cfg, bad, "intake", "hi"),
17530 Err(LinkError::BadAddress { .. })
17531 ),
17532 "propose must refuse handle {bad:?}"
17533 );
17534 }
17535 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
17536 assert!(matches!(
17537 propose(&cfg, "acme-site", "intake", &oversize),
17538 Err(LinkError::ProposeTooLarge { .. })
17539 ));
17540 assert!(matches!(
17543 propose(&cfg, "acme-site", "intake", "hi"),
17544 Err(LinkError::Transport { .. })
17545 ));
17546 }
17547
17548 #[test]
17549 fn resolve_refuses_a_hand_built_unsafe_address() {
17550 let cfg = dead_hub();
17551 for brain in ["../up", "a/b", "a?x", "a#f"] {
17552 let addr = Address {
17553 brain: brain.to_string(),
17554 target: None,
17555 };
17556 assert!(
17557 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
17558 "resolve must refuse brain {brain:?}"
17559 );
17560 }
17561 for target in [
17562 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
17563 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
17565 AddressTarget::Path("records/x.md#frag".to_string()),
17566 ] {
17567 let addr = Address {
17568 brain: "acme".to_string(),
17569 target: Some(target.clone()),
17570 };
17571 assert!(
17572 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
17573 "resolve must refuse target {target:?}"
17574 );
17575 }
17576 }
17577
17578 #[test]
17579 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
17580 let mut local = std::collections::BTreeMap::new();
17581 local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
17582 local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
17583 let mut remote = std::collections::BTreeMap::new();
17584 remote.insert(
17585 "records/a.md".to_string(),
17586 V2BaselineFile {
17587 sha256: "c".repeat(64),
17588 bytes: 1,
17589 proof: None,
17590 },
17591 );
17592 remote.insert(
17593 "records/b.md".to_string(),
17594 V2BaselineFile {
17595 sha256: "b".repeat(64),
17596 bytes: 1,
17597 proof: None,
17598 },
17599 );
17600 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
17601 }
17602
17603 #[test]
17604 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
17605 let local = std::collections::BTreeMap::new();
17606 let mut remote = std::collections::BTreeMap::new();
17607 remote.insert(
17608 "private/local.md".to_string(),
17609 V2BaselineFile {
17610 sha256: "d".repeat(64),
17611 bytes: 1,
17612 proof: None,
17613 },
17614 );
17615 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
17616 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
17617 }
17618
17619 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
17620 V2VerifiedHead {
17621 requested: TEST_BRAIN_ID.to_string(),
17622 brain_id: TEST_BRAIN_ID.to_string(),
17623 view_kind: "scoped".to_string(),
17624 view_revision: revision.to_string(),
17625 control_revision: revision.to_string(),
17626 identity: V2HeadIdentity {
17627 custody: "hub".to_string(),
17628 fingerprint: "test".to_string(),
17629 public_key_spki: "test".to_string(),
17630 previous: Vec::new(),
17631 rotations: Vec::new(),
17632 },
17633 pointer: None,
17634 trust: TrustState {
17635 v: 2,
17636 origin: "https://hub.example".to_string(),
17637 requested: TEST_BRAIN_ID.to_string(),
17638 brain: TEST_BRAIN_ID.to_string(),
17639 home: None,
17640 anchor: "ed25519:test".to_string(),
17641 current: "ed25519:test".to_string(),
17642 head_seq: 0,
17643 feed_hash: None,
17644 rotations: Vec::new(),
17645 hub_signer: None,
17646 protocol_profile: Some("link-v2".to_string()),
17647 },
17648 alias: None,
17649 }
17650 }
17651
17652 #[test]
17653 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
17654 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
17655 assert!(accepted_as_v2(&trust));
17656
17657 trust.protocol_profile = None;
17658 trust.hub_signer = Some("ed25519:hub".to_string());
17659 assert!(accepted_as_v2(&trust));
17660
17661 trust.hub_signer = None;
17662 assert!(!accepted_as_v2(&trust));
17663 }
17664
17665 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
17666 V2SyncBaseline {
17667 v: 2,
17668 origin: "https://hub.example".to_string(),
17669 brain: TEST_BRAIN_ID.to_string(),
17670 checkout_id: Some("c".repeat(64)),
17671 head_seq: Some(0),
17672 commit_hash: None,
17673 content_root: None,
17674 asset_root: None,
17675 assets: std::collections::BTreeMap::new(),
17676 view_kind: Some("scoped".to_string()),
17677 view_revision: Some(revision.to_string()),
17678 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
17679 files: std::collections::BTreeMap::new(),
17680 local_policy_digest: None,
17681 local_eligibility: std::collections::BTreeMap::new(),
17682 remote_copy_remains: std::collections::BTreeMap::new(),
17683 }
17684 }
17685
17686 #[test]
17687 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
17688 let directory = tempfile::tempdir().unwrap();
17689 std::fs::write(
17690 directory.path().join("DB.md"),
17691 scoped_projection_bytes(TEST_BRAIN_ID),
17692 )
17693 .unwrap();
17694 let store = Store::open_strict(directory.path()).unwrap();
17695 let head = scoped_test_head(&"a".repeat(64));
17696 let baseline = scoped_test_baseline(&"a".repeat(64));
17697 let mut view = v2_local_files(&store).unwrap();
17698 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
17699 assert!(!view.riding.contains_key("DB.md"));
17700 assert!(!view.eligibility.contains_key("DB.md"));
17701 }
17702
17703 #[test]
17704 fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
17705 let directory = tempfile::tempdir().unwrap();
17706 std::fs::write(
17707 directory.path().join("DB.md"),
17708 scoped_projection_bytes(TEST_BRAIN_ID),
17709 )
17710 .unwrap();
17711 let store = Store::open_strict(directory.path()).unwrap();
17712 let head = scoped_test_head(&"a".repeat(64));
17713 let baseline = scoped_test_baseline(&"a".repeat(64));
17714
17715 let mut carried = v2_local_files(&store).unwrap();
17716 remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
17717 let handed_off =
17718 local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
17719 assert!(!handed_off.riding.contains_key("DB.md"));
17720
17721 let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
17722 assert!(!freshly_scanned.riding.contains_key("DB.md"));
17723
17724 std::fs::write(
17725 directory.path().join("DB.md"),
17726 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
17727 )
17728 .unwrap();
17729 let tampered = Store::open_strict(directory.path()).unwrap();
17730 assert!(matches!(
17731 local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
17732 Err(LinkError::ScopedProjectionModified)
17733 ));
17734 }
17735
17736 #[test]
17737 fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
17738 let directory = tempfile::tempdir().unwrap();
17739 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
17740 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
17741 std::fs::write(
17742 directory.path().join("DB.md"),
17743 b"---\nname: Kept home test\n---\n",
17744 )
17745 .unwrap();
17746 std::fs::write(
17747 directory.path().join("records/notes/a.md"),
17748 b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
17749 )
17750 .unwrap();
17751 std::fs::write(
17752 directory.path().join("sources/private/secret.md"),
17753 b"---\ntype: note\n---\nlocal only\n",
17754 )
17755 .unwrap();
17756 std::fs::write(
17757 directory.path().join("sources/private/unlinked.md"),
17758 b"---\ntype: note\n---\nnot disclosed\n",
17759 )
17760 .unwrap();
17761 std::fs::write(
17762 directory.path().join(".sevralocal"),
17763 b"sources/private/**\n",
17764 )
17765 .unwrap();
17766
17767 let store = Store::open_strict(directory.path()).unwrap();
17768 let view = v2_local_files(&store).unwrap();
17769 assert!(!view.riding.contains_key("sources/private/secret.md"));
17770 assert_eq!(
17771 view.withheld_links,
17772 vec![V2WithheldLink {
17773 source: "records/notes/a.md".to_string(),
17774 target: "sources/private/secret.md".to_string(),
17775 }]
17776 );
17777 }
17778
17779 #[test]
17780 fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
17781 let directory = tempfile::tempdir().unwrap();
17786 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
17787 std::fs::write(
17788 directory.path().join("DB.md"),
17789 b"---\nname: Restored export\n---\n",
17790 )
17791 .unwrap();
17792 std::fs::write(
17793 directory.path().join("records/notes/a.md"),
17794 b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
17795 )
17796 .unwrap();
17797 std::fs::write(
17798 directory.path().join(".sevralocal"),
17799 b"sources/private/**\n",
17800 )
17801 .unwrap();
17802
17803 let store = Store::open_strict(directory.path()).unwrap();
17804 let view = v2_local_files(&store).unwrap();
17805 assert_eq!(
17806 view.withheld_links,
17807 vec![V2WithheldLink {
17808 source: "records/notes/a.md".to_string(),
17809 target: "sources/private/absent.md".to_string(),
17810 }]
17811 );
17812 std::fs::write(
17814 directory.path().join("records/notes/b.md"),
17815 b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
17816 )
17817 .unwrap();
17818 let store = Store::open_strict(directory.path()).unwrap();
17819 let view = v2_local_files(&store).unwrap();
17820 assert!(
17821 !view
17822 .withheld_links
17823 .iter()
17824 .any(|link| link.target == "records/notes/nowhere.md"),
17825 "an unclaimed dangling target must not be declared withheld"
17826 );
17827 }
17828
17829 #[test]
17830 fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
17831 let directory = tempfile::tempdir().unwrap();
17832 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
17833 std::fs::write(
17834 directory.path().join("DB.md"),
17835 b"---\nname: Withdrawal test\n---\n",
17836 )
17837 .unwrap();
17838 let source = b"---\ntype: note\n---\nlocal evidence\n";
17839 std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
17840 std::fs::write(
17841 directory.path().join(".sevralocal"),
17842 b"sources/private/**\n",
17843 )
17844 .unwrap();
17845 let store = Store::open_strict(directory.path()).unwrap();
17846 let view = v2_local_files(&store).unwrap();
17847 let mut remote = std::collections::BTreeMap::new();
17848 remote.insert(
17849 "sources/private/evidence.md".to_string(),
17850 V2BaselineFile {
17851 sha256: content_sha256(source),
17852 bytes: source.len() as u64,
17853 proof: None,
17854 },
17855 );
17856 assert_eq!(
17857 v2_content_withdrawal_operation(
17858 &store,
17859 &view,
17860 &remote,
17861 "sources/private/evidence.md",
17862 "approved retention change",
17863 )
17864 .unwrap(),
17865 json!({
17866 "op": "withdraw_from_hosting",
17867 "path": "sources/private/evidence.md",
17868 "expected": { "kind": "blob", "hash": content_sha256(source) },
17869 "reason": "approved retention change",
17870 })
17871 );
17872
17873 std::fs::write(
17874 directory.path().join("sources/private/evidence.md"),
17875 b"changed after review",
17876 )
17877 .unwrap();
17878 assert!(matches!(
17879 v2_content_withdrawal_operation(
17880 &store,
17881 &view,
17882 &remote,
17883 "sources/private/evidence.md",
17884 "approved retention change",
17885 ),
17886 Err(LinkError::InvalidPack { .. })
17887 ));
17888 }
17889
17890 #[test]
17891 fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
17892 let directory = tempfile::tempdir().unwrap();
17893 std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
17894 std::fs::write(
17895 directory.path().join("DB.md"),
17896 b"---\nname: Asset withdrawal test\n---\n",
17897 )
17898 .unwrap();
17899 let bytes = b"private binary";
17900 std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
17901 std::fs::write(
17902 directory.path().join(".sevralocal"),
17903 b"sources/files/private.pdf\n",
17904 )
17905 .unwrap();
17906 let store = Store::open_strict(directory.path()).unwrap();
17907 let view = v2_local_files(&store).unwrap();
17908 let local = crate::AssetRecord {
17909 path: "sources/files/private.pdf".to_string(),
17910 sha256: content_sha256(bytes),
17911 bytes: bytes.len() as u64,
17912 media_type: "application/pdf".to_string(),
17913 wrappers: vec!["sources/files/private.md".to_string()],
17914 required: true,
17915 };
17916 let current = V2BaselineAsset {
17917 blob_sha256: local.sha256.clone(),
17918 bytes: local.bytes,
17919 media_type: local.media_type.clone(),
17920 wrappers: local.wrappers.clone(),
17921 required: local.required,
17922 disposition: "hosted".to_string(),
17923 leaf_hash: "d".repeat(64),
17924 };
17925 assert_eq!(
17926 v2_asset_withdrawal_operation(
17927 &store,
17928 &view,
17929 &local.path,
17930 &local,
17931 ¤t,
17932 "approved retention change",
17933 )
17934 .unwrap(),
17935 json!({
17936 "op": "asset_withdraw",
17937 "path": local.path,
17938 "expected": { "kind": "asset", "hash": "d".repeat(64) },
17939 "reason": "approved retention change",
17940 })
17941 );
17942
17943 let mut mismatched = current.clone();
17944 mismatched.required = false;
17945 assert!(matches!(
17946 v2_asset_withdrawal_operation(
17947 &store,
17948 &view,
17949 &local.path,
17950 &local,
17951 &mismatched,
17952 "approved retention change",
17953 ),
17954 Err(LinkError::InvalidPack { .. })
17955 ));
17956 }
17957
17958 #[test]
17959 fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
17960 let first = v2_checkout_id(None).unwrap();
17961 assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
17962 assert_ne!(first, v2_checkout_id(None).unwrap());
17963 assert!(is_sha256(&first));
17964 }
17965
17966 #[test]
17967 fn scoped_projection_edit_and_scope_transition_fail_closed() {
17968 let directory = tempfile::tempdir().unwrap();
17969 std::fs::write(
17970 directory.path().join("DB.md"),
17971 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
17972 )
17973 .unwrap();
17974 let store = Store::open_strict(directory.path()).unwrap();
17975 let head = scoped_test_head(&"a".repeat(64));
17976 let baseline = scoped_test_baseline(&"a".repeat(64));
17977 let mut view = v2_local_files(&store).unwrap();
17978 assert!(matches!(
17979 remove_scoped_projection(&head, Some(&baseline), &mut view),
17980 Err(LinkError::ScopedProjectionModified)
17981 ));
17982
17983 let changed = scoped_test_head(&"b".repeat(64));
17984 assert!(matches!(
17985 ensure_v2_view_compatible(&changed, Some(&baseline)),
17986 Err(LinkError::ScopedViewChanged)
17987 ));
17988
17989 let mut same_view_new_control = head.clone();
17990 same_view_new_control.control_revision = "c".repeat(64);
17991 assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
17992 assert!(!same_v2_head(&head, &same_view_new_control));
17993 }
17994
17995 #[test]
17996 fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
17997 let scoped = scoped_test_head(&"a".repeat(64));
17998 let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
17999 assert!(matches!(
18000 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
18001 Err(LinkError::ScopedProjectionModified)
18002 ));
18003
18004 let mut full = scoped.clone();
18005 full.view_kind = "full".to_string();
18006 let mut full_baseline = scoped_baseline.clone();
18007 full_baseline.view_kind = Some("full".to_string());
18008 full_baseline.projection_sha256 = None;
18009 assert!(matches!(
18010 ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
18011 Err(LinkError::InvalidPack { .. })
18012 ));
18013
18014 assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
18015 assert!(
18016 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
18017 );
18018 }
18019
18020 #[test]
18021 fn scoped_view_metadata_is_explicitly_non_authoritative() {
18022 let head = scoped_test_head(&"a".repeat(64));
18023 let value: Value =
18024 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
18025 assert_eq!(value["kind"], "link.md-scoped-view");
18026 assert_eq!(value["authoritative"], false);
18027 assert_eq!(value["visible_files"], 7);
18028 assert_eq!(value["brain"], TEST_BRAIN_ID);
18029 }
18030
18031 #[test]
18032 fn local_scoped_marker_requires_the_exact_generated_projection() {
18033 let directory = tempfile::tempdir().unwrap();
18034 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
18035 std::fs::write(
18036 directory.path().join("DB.md"),
18037 scoped_projection_bytes(TEST_BRAIN_ID),
18038 )
18039 .unwrap();
18040 let head = scoped_test_head(&"a".repeat(64));
18041 std::fs::write(
18042 directory.path().join(".dbmd/view.json"),
18043 scoped_view_metadata(&head, 0).unwrap(),
18044 )
18045 .unwrap();
18046 let store = Store::open_strict(directory.path()).unwrap();
18047 assert!(has_verified_local_scoped_view(&store));
18048
18049 std::fs::write(
18050 directory.path().join("DB.md"),
18051 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18052 )
18053 .unwrap();
18054 let altered = Store::open_strict(directory.path()).unwrap();
18055 assert!(!has_verified_local_scoped_view(&altered));
18056 }
18057
18058 fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
18059 use ring::signature::KeyPair as _;
18060
18061 let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
18062 let rng = ring::rand::SystemRandom::new();
18063 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18064 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18065 let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
18066 let public_key = URL_SAFE_NO_PAD.encode(&public_der);
18067 let fingerprint = format!("{:x}", Sha256::digest(&public_der));
18068 let blob = b"new";
18069 let blob_hash = content_sha256(blob);
18070 let changes = json!({
18071 "mutation_id": "sync:proposal-fixture",
18072 "operations": [{
18073 "blob": blob_hash,
18074 "bytes": blob.len(),
18075 "expected": null,
18076 "op": "put",
18077 "path": "records/new.md",
18078 }],
18079 "reason": "fixture",
18080 "v": 2,
18081 });
18082 let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
18083 let changes_base64 = STANDARD.encode(&changes_bytes);
18084 let descriptor = json!({
18085 "base": null,
18086 "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
18087 "changes_base64": changes_base64,
18088 "rebase": "strict",
18089 "v": 2,
18090 });
18091 let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
18092 let payload_hash = "b".repeat(64);
18093 let submitted_at = "2026-08-19T12:00:00.000Z";
18094 let claim = json!({
18095 "actor_root": {
18096 "actor_class": "foreign_key",
18097 "credential": "ed25519:fixture",
18098 "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
18099 "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
18100 "principal": "key:fixture",
18101 "role": null,
18102 },
18103 "brain": TEST_BRAIN_ID,
18104 "clear_sha256": clear_hash,
18105 "control_revision": "c".repeat(64),
18106 "mutation_id": "sync:proposal-fixture",
18107 "payload_sha256": payload_hash,
18108 "proposal_id": proposal_id,
18109 "submitted_at": submitted_at,
18110 "v": 2,
18111 });
18112 let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
18113 let envelope = json!({
18114 "claim": claim,
18115 "fingerprint": fingerprint,
18116 "public_key": public_key,
18117 "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
18118 });
18119 let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18120 let submission_hash =
18121 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
18122 let mut head = scoped_test_head(&"c".repeat(64));
18123 head.view_kind = "full".to_string();
18124 head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
18125 let value = json!({
18126 "proposal": {
18127 "base": null,
18128 "blobs": [{
18129 "bytes": blob.len(),
18130 "endpoint": format!(
18131 "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
18132 ),
18133 "sha256": blob_hash,
18134 }],
18135 "changes_base64": changes_base64,
18136 "clear_sha256": clear_hash,
18137 "expires_at": "2026-08-26T12:00:00.000Z",
18138 "id": proposal_id,
18139 "payload_sha256": payload_hash,
18140 "proposer": { "class": "foreign_key" },
18141 "rebase": "strict",
18142 "state": "pending",
18143 "submission_claim_base64": STANDARD.encode(envelope_bytes),
18144 "submission_claim_sha256": submission_hash,
18145 "submitted_at": submitted_at,
18146 },
18147 "v": 2,
18148 });
18149 (head, proposal_id, value)
18150 }
18151
18152 #[test]
18153 fn v2_proposal_verifier_accepts_exact_signed_payload() {
18154 let (head, proposal_id, value) = signed_proposal_fixture();
18155 let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
18156 assert_eq!(verified.blobs.len(), 1);
18157 assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
18158 }
18159
18160 #[test]
18161 fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
18162 let (head, proposal_id, value) = signed_proposal_fixture();
18163
18164 let mut changed = value.clone();
18165 changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
18166 assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
18167
18168 let mut redirected = value.clone();
18169 redirected["proposal"]["blobs"][0]["endpoint"] =
18170 Value::String("https://attacker.example/blob".to_string());
18171 assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
18172
18173 let mut forged = value;
18174 let encoded = forged["proposal"]["submission_claim_base64"]
18175 .as_str()
18176 .unwrap();
18177 let mut envelope: Value =
18178 serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
18179 envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
18180 let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18181 forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
18182 forged["proposal"]["submission_claim_sha256"] = Value::String(
18183 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
18184 );
18185 assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
18186 }
18187
18188 #[cfg(unix)]
18189 #[test]
18190 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
18191 let sandbox = tempfile::tempdir().unwrap();
18192 let destination = sandbox.path().join("brain");
18193 let entries = vec![
18194 (
18195 "DB.md".to_string(),
18196 scoped_projection_bytes(TEST_BRAIN_ID),
18197 ),
18198 (
18199 "records/contacts/a.md".to_string(),
18200 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
18201 .to_vec(),
18202 ),
18203 ];
18204 install_pulled_delta(&destination, &entries, &[], true).unwrap();
18205 assert!(destination.join("index.md").is_file());
18206 assert!(destination.join("records/index.md").is_file());
18207 assert!(destination.join("records/contacts/index.md").is_file());
18208 assert!(destination.join("records/contacts/index.jsonl").is_file());
18209 }
18210
18211 #[cfg(unix)]
18212 #[test]
18213 fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
18214 let sandbox = tempfile::tempdir().unwrap();
18215 let destination = sandbox.path().join("brain");
18216 let cache = sandbox.path().join("cache");
18217 std::fs::create_dir(&cache).unwrap();
18218 let db = scoped_projection_bytes(TEST_BRAIN_ID);
18219 let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
18220 let db_source = cache.join("db");
18221 let shared_source = cache.join("shared");
18222 crate::fsx::write_atomic(&db_source, &db).unwrap();
18223 crate::fsx::write_atomic(&shared_source, shared).unwrap();
18224 let mut entries = vec![V2StagedFile {
18225 path: "DB.md".to_string(),
18226 source: db_source,
18227 sha256: content_sha256(&db),
18228 bytes: db.len() as u64,
18229 }];
18230 for index in 0..512 {
18231 entries.push(V2StagedFile {
18232 path: format!("records/items/{index:05}.md"),
18233 source: shared_source.clone(),
18234 sha256: content_sha256(shared),
18235 bytes: shared.len() as u64,
18236 });
18237 }
18238 install_pulled_delta_sources(
18239 &destination,
18240 &entries,
18241 &[],
18242 false,
18243 None,
18244 &scoped_test_head(&"c".repeat(64)),
18245 )
18246 .unwrap();
18247 assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
18248 for index in 0..512 {
18249 assert_eq!(
18250 std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
18251 shared
18252 );
18253 }
18254 assert!(
18255 std::fs::read_dir(sandbox.path())
18256 .unwrap()
18257 .all(|entry| !entry
18258 .unwrap()
18259 .file_name()
18260 .to_string_lossy()
18261 .contains("pull-stage")),
18262 "the private stage must be atomically installed or removed"
18263 );
18264 }
18265
18266 #[cfg(unix)]
18267 #[test]
18268 fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
18269 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
18270
18271 let sandbox = tempfile::tempdir().unwrap();
18272 let root = sandbox.path().join("brain");
18273 std::fs::create_dir_all(root.join("records/items")).unwrap();
18274 let db = scoped_projection_bytes(TEST_BRAIN_ID);
18275 let old = b"---\ntype: note\n---\n\nold\n";
18276 let new = b"---\ntype: note\n---\n\nnew\n";
18277 let removed = b"---\ntype: note\n---\n\nremove me\n";
18278 std::fs::write(root.join("DB.md"), &db).unwrap();
18279 std::fs::write(root.join("records/items/change.md"), old).unwrap();
18280 std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
18281 for index in 0..512 {
18282 std::fs::write(
18283 root.join(format!("records/items/untouched-{index:04}.md")),
18284 old,
18285 )
18286 .unwrap();
18287 }
18288 let untouched = root.join("records/items/untouched-0256.md");
18289 let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
18290 let source = sandbox.path().join("changed-source");
18291 crate::fsx::write_atomic(&source, new).unwrap();
18292 let same_source = sandbox.path().join("unchanged-source");
18293 crate::fsx::write_atomic(&same_source, old).unwrap();
18294 let same_entry = V2StagedFile {
18295 path: "records/items/change.md".to_string(),
18296 source: same_source,
18297 sha256: content_sha256(old),
18298 bytes: old.len() as u64,
18299 };
18300 let entry = V2StagedFile {
18301 path: "records/items/change.md".to_string(),
18302 source,
18303 sha256: content_sha256(new),
18304 bytes: new.len() as u64,
18305 };
18306 let head = scoped_test_head(&"c".repeat(64));
18307
18308 install_established_v2_delta(
18312 Store::open_strict(&root).unwrap(),
18313 &[same_entry],
18314 &["records/items/already-absent.md".to_string()],
18315 true,
18316 None,
18317 &head,
18318 )
18319 .unwrap();
18320 assert_eq!(
18321 std::fs::metadata(&untouched).unwrap().ino(),
18322 untouched_inode
18323 );
18324 assert!(!root.join(V2_PULL_JOURNAL).exists());
18325
18326 install_established_v2_delta(
18327 Store::open_strict(&root).unwrap(),
18328 &[entry],
18329 &["records/items/delete.md".to_string()],
18330 false,
18331 None,
18332 &head,
18333 )
18334 .unwrap();
18335 assert_eq!(
18336 std::fs::read(root.join("records/items/change.md")).unwrap(),
18337 new
18338 );
18339 assert!(!root.join("records/items/delete.md").exists());
18340 assert_eq!(
18341 std::fs::metadata(&untouched).unwrap().ino(),
18342 untouched_inode
18343 );
18344 assert!(root.join(V2_PULL_JOURNAL).is_file());
18345 assert_eq!(
18346 std::fs::metadata(root.join(V2_PULL_JOURNAL))
18347 .unwrap()
18348 .permissions()
18349 .mode()
18350 & 0o777,
18351 0o600
18352 );
18353 let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
18354 .unwrap()
18355 .unwrap();
18356 assert_eq!(
18357 std::fs::metadata(root.join(&journal.backup_dir))
18358 .unwrap()
18359 .permissions()
18360 .mode()
18361 & 0o777,
18362 0o700
18363 );
18364 for entry in &journal.entries {
18365 if let Some(backup) = &entry.backup {
18366 assert_eq!(
18367 std::fs::metadata(root.join(&journal.backup_dir).join(backup))
18368 .unwrap()
18369 .permissions()
18370 .mode()
18371 & 0o777,
18372 0o600
18373 );
18374 }
18375 }
18376
18377 let cfg = test_hub_config(
18378 "https://example.test".to_string(),
18379 sandbox.path().join("state"),
18380 );
18381 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18382 assert_eq!(
18383 std::fs::read(root.join("records/items/change.md")).unwrap(),
18384 old
18385 );
18386 assert_eq!(
18387 std::fs::read(root.join("records/items/delete.md")).unwrap(),
18388 removed
18389 );
18390 assert_eq!(
18391 std::fs::metadata(&untouched).unwrap().ino(),
18392 untouched_inode
18393 );
18394 assert!(!root.join(V2_PULL_JOURNAL).exists());
18395 }
18396
18397 #[test]
18398 fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
18399 let body = b"bounded bytes";
18400 let path = "records/example.md".to_string();
18401 let file = V2BaselineFile {
18402 sha256: content_sha256(body),
18403 bytes: body.len() as u64,
18404 proof: None,
18405 };
18406 let header = serde_json::to_vec(&json!({
18407 "bytes": body.len(),
18408 "path": path,
18409 "sha256": file.sha256,
18410 "v": 2,
18411 }))
18412 .unwrap();
18413 let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
18414 stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
18415 stream.extend_from_slice(&header);
18416 stream.extend_from_slice(body);
18417 stream.extend_from_slice(&0_u32.to_be_bytes());
18418 let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
18419 assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
18420
18421 let mut tampered = stream.clone();
18422 let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
18423 tampered[body_offset] ^= 1;
18424 assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
18425
18426 let mut trailing = stream;
18427 trailing.push(0);
18428 assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
18429 }
18430
18431 #[test]
18432 fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
18433 let sandbox = tempfile::TempDir::new().unwrap();
18434 let root = sandbox.path().join("brain");
18435 std::fs::create_dir_all(&root).unwrap();
18436 std::fs::write(
18437 root.join("DB.md"),
18438 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18439 )
18440 .unwrap();
18441 let store = Store::open_strict(&root).unwrap();
18442 let incomplete = crate::ulid::mint();
18443 store
18444 .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
18445 .unwrap();
18446 let expired = crate::ulid::mint();
18447 store
18448 .create_dir_all(&v2_conflict_relative(&expired, "files"))
18449 .unwrap();
18450 let plan = V2ConflictPlan {
18451 v: 2,
18452 class: "content_resolution_required".to_string(),
18453 bundle: expired.clone(),
18454 brain: TEST_BRAIN_ID.to_string(),
18455 origin: "https://example.test".to_string(),
18456 created_unix: 0,
18457 expires_unix: 0,
18458 base_seq: None,
18459 base_commit: None,
18460 remote_seq: 0,
18461 remote_commit: None,
18462 remote_content_root: None,
18463 view_kind: "full".to_string(),
18464 view_revision: "a".repeat(64),
18465 files: vec![V2ConflictFile {
18466 path: "records/value.md".to_string(),
18467 base: V2ConflictCoordinate {
18468 sha256: None,
18469 bytes: None,
18470 file: None,
18471 },
18472 local: V2ConflictCoordinate {
18473 sha256: None,
18474 bytes: None,
18475 file: None,
18476 },
18477 remote: V2ConflictCoordinate {
18478 sha256: None,
18479 bytes: None,
18480 file: None,
18481 },
18482 }],
18483 };
18484 let mut bytes = serde_json::to_vec(&plan).unwrap();
18485 bytes.push(b'\n');
18486 store
18487 .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
18488 .unwrap();
18489
18490 let listed = sync_conflicts(&root, false, false).unwrap();
18491 assert_eq!(listed["bundles"], 2);
18492 assert_eq!(listed["pruned"], 0);
18493 let pruned = sync_conflicts(&root, true, false).unwrap();
18494 assert_eq!(pruned["bundles"], 0);
18495 assert_eq!(pruned["pruned"], 2);
18496 assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
18497 assert!(!root.join(".dbmd/conflicts").join(expired).exists());
18498 }
18499
18500 #[test]
18501 fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
18502 let sandbox = tempfile::TempDir::new().unwrap();
18503 let root = sandbox.path().join("brain");
18504 std::fs::create_dir_all(&root).unwrap();
18505 std::fs::write(
18506 root.join("DB.md"),
18507 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18508 )
18509 .unwrap();
18510 let store = Store::open_strict(&root).unwrap();
18511 let bundle = crate::ulid::mint();
18512 store
18513 .create_dir_all(&v2_conflict_relative(&bundle, "files"))
18514 .unwrap();
18515 store
18516 .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
18517 .unwrap();
18518
18519 assert!(sync_conflicts(&root, true, false).is_err());
18520 assert!(sync_conflicts(&root, false, true).is_err());
18521 let pruned = sync_conflicts(&root, true, true).unwrap();
18522 assert_eq!(pruned["pruned"], 1);
18523 assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
18524 }
18525
18526 #[test]
18527 fn ready_pull_journal_rolls_back_exact_preimages() {
18528 let sandbox = tempfile::TempDir::new().unwrap();
18529 let root = sandbox.path().join("brain");
18530 std::fs::create_dir_all(root.join("records")).unwrap();
18531 std::fs::write(
18532 root.join("DB.md"),
18533 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18534 )
18535 .unwrap();
18536 let path = "records/value.md";
18537 let old = b"---\ntype: note\n---\n\nold\n";
18538 let new = b"---\ntype: note\n---\n\nnew\n";
18539 std::fs::write(root.join(path), old).unwrap();
18540 let store = Store::open_strict(&root).unwrap();
18541 let bundle = crate::ulid::mint();
18542 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18543 store
18544 .create_private_dir_all(Path::new(&backup_dir))
18545 .unwrap();
18546 store
18547 .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
18548 .unwrap();
18549 let journal = V2PullJournal {
18550 v: 1,
18551 phase: V2PullPhase::Ready,
18552 brain: TEST_BRAIN_ID.to_string(),
18553 previous: V2PullCoordinate {
18554 head_seq: None,
18555 commit_hash: None,
18556 view_kind: None,
18557 view_revision: None,
18558 },
18559 next: V2PullCoordinate {
18560 head_seq: Some(2),
18561 commit_hash: Some("c".repeat(64)),
18562 view_kind: Some("full".to_string()),
18563 view_revision: Some("d".repeat(64)),
18564 },
18565 backup_dir: backup_dir.clone(),
18566 entries: vec![V2PullJournalEntry {
18567 path: path.to_string(),
18568 old: Some(V2PullFileCoordinate {
18569 sha256: content_sha256(old),
18570 bytes: old.len() as u64,
18571 }),
18572 new: Some(V2PullFileCoordinate {
18573 sha256: content_sha256(new),
18574 bytes: new.len() as u64,
18575 }),
18576 backup: Some("00000000".to_string()),
18577 }],
18578 };
18579 validate_v2_pull_journal(&journal).unwrap();
18580 store
18581 .write_private_atomic_new(
18582 Path::new(V2_PULL_JOURNAL),
18583 &v2_pull_journal_bytes(&journal).unwrap(),
18584 )
18585 .unwrap();
18586 store.write_atomic(Path::new(path), new).unwrap();
18587
18588 let cfg = test_hub_config(
18589 "https://example.test".to_string(),
18590 sandbox.path().join("state"),
18591 );
18592 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18593 assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
18594 assert!(!root.join(V2_PULL_JOURNAL).exists());
18595 assert!(!root.join(backup_dir).exists());
18596 }
18597
18598 #[test]
18599 fn preparing_pull_journal_discards_only_private_staging() {
18600 let sandbox = tempfile::TempDir::new().unwrap();
18601 let root = sandbox.path().join("brain");
18602 std::fs::create_dir_all(root.join(".dbmd")).unwrap();
18603 std::fs::write(
18604 root.join("DB.md"),
18605 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18606 )
18607 .unwrap();
18608 let store = Store::open_strict(&root).unwrap();
18609 let bundle = crate::ulid::mint();
18610 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18611 store
18612 .create_private_dir_all(Path::new(&backup_dir))
18613 .unwrap();
18614 let journal = V2PullJournal {
18615 v: 1,
18616 phase: V2PullPhase::Preparing,
18617 brain: TEST_BRAIN_ID.to_string(),
18618 previous: V2PullCoordinate {
18619 head_seq: None,
18620 commit_hash: None,
18621 view_kind: None,
18622 view_revision: None,
18623 },
18624 next: V2PullCoordinate {
18625 head_seq: Some(1),
18626 commit_hash: Some("a".repeat(64)),
18627 view_kind: Some("full".to_string()),
18628 view_revision: Some("b".repeat(64)),
18629 },
18630 backup_dir: backup_dir.clone(),
18631 entries: vec![V2PullJournalEntry {
18632 path: "records/new.md".to_string(),
18633 old: None,
18634 new: Some(V2PullFileCoordinate {
18635 sha256: "c".repeat(64),
18636 bytes: 1,
18637 }),
18638 backup: None,
18639 }],
18640 };
18641 store
18642 .write_private_atomic_new(
18643 Path::new(V2_PULL_JOURNAL),
18644 &v2_pull_journal_bytes(&journal).unwrap(),
18645 )
18646 .unwrap();
18647 let cfg = test_hub_config(
18648 "https://example.test".to_string(),
18649 sandbox.path().join("state"),
18650 );
18651
18652 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18653
18654 assert!(root.join("DB.md").is_file());
18655 assert!(!root.join(V2_PULL_JOURNAL).exists());
18656 assert!(!root.join(backup_dir).exists());
18657 }
18658
18659 #[test]
18660 fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
18661 let sandbox = tempfile::TempDir::new().unwrap();
18662 let root = sandbox.path().join("brain");
18663 std::fs::create_dir_all(root.join("records")).unwrap();
18664 std::fs::write(
18665 root.join("DB.md"),
18666 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18667 )
18668 .unwrap();
18669 let new = b"---\ntype: note\n---\n\nnew\n";
18670 std::fs::write(root.join("records/value.md"), new).unwrap();
18671 let store = Store::open_strict(&root).unwrap();
18672 let bundle = crate::ulid::mint();
18673 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18674 store
18675 .create_private_dir_all(Path::new(&backup_dir))
18676 .unwrap();
18677 let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
18678 store.create_private_dir_all(Path::new(&orphan)).unwrap();
18679 let next = V2PullCoordinate {
18680 head_seq: Some(2),
18681 commit_hash: Some("c".repeat(64)),
18682 view_kind: Some("full".to_string()),
18683 view_revision: Some("d".repeat(64)),
18684 };
18685 let journal = V2PullJournal {
18686 v: 1,
18687 phase: V2PullPhase::Ready,
18688 brain: TEST_BRAIN_ID.to_string(),
18689 previous: V2PullCoordinate {
18690 head_seq: Some(1),
18691 commit_hash: Some("a".repeat(64)),
18692 view_kind: Some("full".to_string()),
18693 view_revision: Some("b".repeat(64)),
18694 },
18695 next: next.clone(),
18696 backup_dir: backup_dir.clone(),
18697 entries: vec![V2PullJournalEntry {
18698 path: "records/value.md".to_string(),
18699 old: Some(V2PullFileCoordinate {
18700 sha256: "e".repeat(64),
18701 bytes: new.len() as u64,
18702 }),
18703 new: Some(V2PullFileCoordinate {
18704 sha256: content_sha256(new),
18705 bytes: new.len() as u64,
18706 }),
18707 backup: Some("00000000".to_string()),
18708 }],
18709 };
18710 store
18711 .write_private_atomic_new(
18712 Path::new(V2_PULL_JOURNAL),
18713 &v2_pull_journal_bytes(&journal).unwrap(),
18714 )
18715 .unwrap();
18716 let cfg = test_hub_config(
18717 "https://example.test".to_string(),
18718 sandbox.path().join("state"),
18719 );
18720 save_v2_baseline(
18721 &cfg,
18722 TEST_BRAIN_ID,
18723 &root,
18724 &V2SyncBaseline {
18725 v: 2,
18726 origin: "https://example.test".to_string(),
18727 brain: TEST_BRAIN_ID.to_string(),
18728 checkout_id: Some("c".repeat(64)),
18729 head_seq: next.head_seq,
18730 commit_hash: next.commit_hash.clone(),
18731 content_root: Some("f".repeat(64)),
18732 asset_root: None,
18733 assets: Default::default(),
18734 view_kind: next.view_kind.clone(),
18735 view_revision: next.view_revision.clone(),
18736 projection_sha256: None,
18737 files: Default::default(),
18738 local_policy_digest: None,
18739 local_eligibility: Default::default(),
18740 remote_copy_remains: Default::default(),
18741 },
18742 )
18743 .unwrap();
18744
18745 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18746
18747 assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
18748 assert!(!root.join(V2_PULL_JOURNAL).exists());
18749 assert!(!root.join(backup_dir).exists());
18750 assert!(!root.join(orphan).exists());
18751 }
18752}