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_ASSET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
148const MAX_PACK_BYTES: u64 =
151 MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
152const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
161const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
162
163const MAX_IDENTITY_ROTATIONS: usize = 1_024;
166
167fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
170 let mut batches: Vec<Vec<Value>> = Vec::new();
171 let mut current: Vec<Value> = Vec::new();
172 let mut current_bytes = 0usize;
173 for declaration in declarations {
174 let declared_bytes = serde_json::to_string(&declaration)
175 .map(|text| text.len())
176 .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
177 + 1;
178 if !current.is_empty()
179 && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
180 || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
181 {
182 batches.push(std::mem::take(&mut current));
183 current_bytes = 0;
184 }
185 current_bytes += declared_bytes;
186 current.push(declaration);
187 }
188 if !current.is_empty() {
189 batches.push(current);
190 }
191 batches
192}
193const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
197const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
198const FEED_PAGE_LIMIT: usize = 100;
199
200pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
205
206const CONNECT_TIMEOUT_SECS: u64 = 10;
209const READ_TIMEOUT_SECS: u64 = 120;
210const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
214const COMMIT_REQUEST_TIMEOUT_SECS: u64 = 900;
221const COMMIT_ATTEMPTS: usize = 4;
225const COMMIT_RETRY_BACKOFF_MS: [u64; COMMIT_ATTEMPTS - 1] = [5_000, 20_000, 45_000];
226const CONNECT_ATTEMPTS: usize = 3;
227const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
228const SAFE_READ_ATTEMPTS: usize = 4;
233const SAFE_READ_RETRY_BACKOFF_MS: [u64; SAFE_READ_ATTEMPTS - 1] = [200, 1_000, 3_000];
234
235const UPLOAD_ATTEMPTS: usize = 6;
239const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
240const UPLOAD_TOTAL_TIMEOUT_SECS: u64 = 300;
244
245fn upload_retry_backoff_ms(attempt: usize) -> u64 {
246 UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
247}
248
249fn upload_deadline_error() -> LinkError {
250 LinkError::Transport {
251 hub: "the object store".to_string(),
252 message: "network error (upload deadline exceeded)".to_string(),
253 }
254}
255
256fn upload_attempt_timeout(deadline: std::time::Instant) -> LinkResult<std::time::Duration> {
257 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
258 if remaining.is_zero() {
259 return Err(upload_deadline_error());
260 }
261 Ok(remaining.min(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS)))
262}
263
264fn wait_for_upload_retry(deadline: std::time::Instant, attempt: usize) -> bool {
265 if attempt + 1 >= UPLOAD_ATTEMPTS {
266 return false;
267 }
268 let pause = std::time::Duration::from_millis(upload_retry_backoff_ms(attempt));
269 if deadline.saturating_duration_since(std::time::Instant::now()) <= pause {
270 return false;
271 }
272 std::thread::sleep(pause);
273 true
274}
275
276const RESERVATION_ATTEMPTS: usize = 7;
281const RESERVATION_BACKOFF_MS: [u64; RESERVATION_ATTEMPTS - 1] =
282 [500, 2_000, 5_000, 15_000, 30_000, 60_000];
283
284fn is_retryable_hub_status(status: u16) -> bool {
288 matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
289}
290
291fn is_retryable_upload_status(status: u16) -> bool {
295 matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
296}
297const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
301#[cfg(unix)]
305const V2_PULL_INSTALL_WORKERS: usize = 16;
306const V2_BULK_STREAM_FILES: usize = 256;
310const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
311const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
312const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
313const V2_DOWNLOAD_CAPABILITY_FILES: usize = V2_BLOB_DOWNLOAD_WORKERS;
318const V2_DOWNLOAD_CAPABILITY_BYTES: u64 = 512 * 1024 * 1024;
319const V2_DOWNLOAD_CAPABILITY_ATTEMPTS: usize = 4;
320const V2_DOWNLOAD_CAPABILITY_BACKOFF_MS: [u64; V2_DOWNLOAD_CAPABILITY_ATTEMPTS - 1] =
321 [200, 1_000, 3_000];
322
323#[derive(Debug, thiserror::Error)]
327pub enum LinkError {
328 #[error(
330 "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
331 )]
332 NoHub,
333
334 #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
336 NoCredential,
337
338 #[error(
341 "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
342 )]
343 BadKey,
344
345 #[error(
351 "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}"
352 )]
353 UnboundCredential,
354
355 #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
359 BadAgentKey {
360 message: String,
362 },
363
364 #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
366 UnsafeHub {
367 hub: String,
369 },
370
371 #[error("hub unreachable at {hub}: {message}")]
373 Transport {
374 hub: String,
376 message: String,
378 },
379
380 #[error("{what} failed (HTTP {status}): {message}")]
382 Http {
383 what: &'static str,
385 status: u16,
387 message: String,
389 code: Option<String>,
391 details: Option<Value>,
393 },
394
395 #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
398 NotJson {
399 what: &'static str,
401 status: u16,
403 },
404
405 #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
407 ResponseTooLarge {
408 limit_bytes: u64,
410 },
411
412 #[error("invalid address `{given}`: {reason}")]
414 BadAddress {
415 given: String,
417 reason: String,
419 },
420
421 #[error(
423 "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
424 )]
425 BadGrantId {
426 given: String,
428 },
429
430 #[error("refusing unsafe path from the hub: `{path}`")]
434 UnsafePath {
435 path: String,
437 },
438
439 #[error(
441 "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
442 MAX_STORE_BYTES / (1024 * 1024),
443 MAX_PACK_BYTES / (1024 * 1024)
444 )]
445 PushTooLarge {
446 detail: String,
448 },
449
450 #[error(
452 "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
453 MAX_PROPOSE_BYTES / 1024
454 )]
455 ProposeTooLarge {
456 bytes: u64,
458 },
459
460 #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
462 NotUtf8 {
463 path: String,
465 },
466
467 #[error("invalid store pack: {message}")]
469 InvalidPack {
470 message: String,
472 },
473
474 #[error("invalid signed feed: {message}")]
476 InvalidFeed {
477 message: String,
479 },
480
481 #[error(
485 "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}`"
486 )]
487 AliasRebindRequired {
488 alias: String,
489 from: String,
490 to: String,
491 },
492
493 #[error("sync conflict on {paths:?} — resolve the named files and retry")]
496 Conflict {
497 paths: Vec<String>,
499 },
500
501 #[error(
505 "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
506 )]
507 ConflictBundle {
508 bundle: String,
510 paths: Vec<String>,
512 },
513
514 #[error(
518 "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
519 )]
520 LocalPolicyTransition {
521 paths: Vec<String>,
523 },
524
525 #[error(
530 "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
531 )]
532 BulkPreviewRequired {
533 preview: Value,
535 },
536
537 #[error(
540 "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
541 )]
542 ScopedProjectionModified,
543
544 #[error(
548 "the checkout's permission scope changed — clone into a new directory to accept the new view"
549 )]
550 ScopedViewChanged,
551
552 #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
555 BrainUnavailable,
556
557 #[error(
560 "the remote brain advanced during sync — retry to converge from the new verified head"
561 )]
562 RemoteAdvancedDuringSync,
563
564 #[error(
567 "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
568 )]
569 UnsupportedPlatform {
570 operation: &'static str,
572 },
573
574 #[error(transparent)]
576 Io(#[from] std::io::Error),
577
578 #[error(transparent)]
580 Store(#[from] crate::StoreError),
581}
582
583pub type LinkResult<T> = std::result::Result<T, LinkError>;
585
586#[derive(Debug, Clone, PartialEq, Eq)]
588pub struct V2BulkConfirmation {
589 pub id: String,
591 pub digest: String,
594}
595
596impl V2BulkConfirmation {
597 pub fn parse(value: &str) -> LinkResult<Self> {
600 let (id, digest) = value
601 .split_once(':')
602 .ok_or_else(|| LinkError::InvalidPack {
603 message: "bulk confirmation must be <id>:<digest>".to_string(),
604 })?;
605 if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
606 return Err(LinkError::InvalidPack {
607 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
608 .to_string(),
609 });
610 }
611 Ok(Self {
612 id: id.to_string(),
613 digest: digest.to_string(),
614 })
615 }
616}
617
618fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
623 #[cfg(any(target_os = "linux", target_os = "macos", windows))]
624 {
625 let _ = operation;
626 Ok(())
627 }
628 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
629 {
630 Err(LinkError::UnsupportedPlatform { operation })
631 }
632}
633
634#[derive(Debug, Clone, PartialEq, Eq)]
640pub enum AddressTarget {
641 Id(String),
643 Path(String),
647}
648
649const BAD_BRAIN_REASON: &str =
652 "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
653
654const BAD_TARGET_REASON: &str =
657 "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
658
659#[derive(Debug, Clone, PartialEq, Eq)]
664pub struct Address {
665 pub brain: String,
667 pub target: Option<AddressTarget>,
669}
670
671impl Address {
672 pub fn parse(raw: &str) -> LinkResult<Address> {
676 let bad = |reason: &str| LinkError::BadAddress {
677 given: raw.to_string(),
678 reason: reason.to_string(),
679 };
680
681 let trimmed = raw.trim();
682 let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
683 if body.is_empty() {
684 return Err(bad("empty address"));
685 }
686
687 let (brain, rest) = match body.split_once('/') {
688 Some((b, r)) => (b, Some(r)),
689 None => (body, None),
690 };
691
692 if brain.is_empty() {
693 return Err(bad("missing brain reference before `/`"));
694 }
695 if !is_safe_ref(brain) {
696 return Err(bad(BAD_BRAIN_REASON));
697 }
698
699 let target = match rest {
700 None => None,
701 Some("") => return Err(bad("trailing `/` with no record id or path")),
702 Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
703 Some(r) => {
704 if !safe_store_rel_path(r) || !r.ends_with(".md") {
705 return Err(bad(BAD_TARGET_REASON));
706 }
707 Some(AddressTarget::Path(r.to_string()))
708 }
709 };
710
711 Ok(Address {
712 brain: brain.to_string(),
713 target,
714 })
715 }
716}
717
718fn is_safe_ref(s: &str) -> bool {
721 !s.is_empty()
722 && s.len() <= 64
723 && s.bytes()
724 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
725}
726
727pub fn is_valid_handle(s: &str) -> bool {
730 is_safe_ref(s)
731}
732
733pub fn safe_store_rel_path(p: &str) -> bool {
739 if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
740 return false;
741 }
742 if !p
743 .bytes()
744 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
745 {
746 return false;
747 }
748 p.split('/')
749 .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
750}
751
752fn require_safe_ref(brain: &str) -> LinkResult<()> {
760 if is_safe_ref(brain) {
761 Ok(())
762 } else {
763 Err(LinkError::BadAddress {
764 given: brain.to_string(),
765 reason: BAD_BRAIN_REASON.to_string(),
766 })
767 }
768}
769
770fn require_valid_handle(handle: &str) -> LinkResult<()> {
772 if is_valid_handle(handle) {
773 Ok(())
774 } else {
775 Err(LinkError::BadAddress {
776 given: handle.to_string(),
777 reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
778 })
779 }
780}
781
782fn require_safe_grant_id(id: &str) -> LinkResult<()> {
786 if is_safe_ref(id) {
787 Ok(())
788 } else {
789 Err(LinkError::BadGrantId {
790 given: id.to_string(),
791 })
792 }
793}
794
795#[derive(Debug, Clone)]
801pub struct HubConfig {
802 pub hub: String,
804 pub key: Option<String>,
806 pub agent_key: Option<AgentSigningKey>,
809 pub brain_key: Option<AgentSigningKey>,
812 pub state_dir: PathBuf,
815 store_selected: bool,
818}
819
820#[derive(Clone)]
823pub struct AgentSigningKey {
824 pkcs8: Vec<u8>,
825 pub multikey: String,
827 pub public_key_spki: String,
829}
830
831impl std::fmt::Debug for AgentSigningKey {
832 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
833 f.debug_struct("AgentSigningKey")
834 .field("multikey", &self.multikey)
835 .field("pkcs8", &"<redacted>")
836 .finish()
837 }
838}
839
840impl HubConfig {
841 pub fn require_key(&self) -> LinkResult<&str> {
844 self.key.as_deref().ok_or(LinkError::NoCredential)
845 }
846}
847
848pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
853 let explicit_hub = flag_hub
854 .map(str::to_string)
855 .or_else(|| env_nonempty(HUB_URL_ENV));
856 let selected_by_store = explicit_hub.is_none();
857 let hub = explicit_hub
858 .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
859 .ok_or(LinkError::NoHub)?;
860 let hub = hub.trim().trim_end_matches('/').to_string();
861 assert_safe_hub(&hub)?;
862 if selected_by_store {
863 let parsed =
864 url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
865 if !parsed.scheme().eq_ignore_ascii_case("https")
869 || (parsed.path() != "/" && !parsed.path().is_empty())
870 {
871 return Err(LinkError::UnsafeHub { hub });
872 }
873 }
874
875 let key = match env_nonempty(HUB_KEY_ENV) {
876 Some(raw) => Some(clean_key(&raw)?),
877 None => None,
878 };
879
880 let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
881 Some(path) => Some(load_agent_key(Path::new(&path))?),
882 None => None,
883 };
884
885 let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
886 Some(path) => Some(load_agent_key(Path::new(&path))?),
887 None => None,
888 };
889
890 if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
897 let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
898 .and_then(|value| normalized_origin(&value).ok());
899 let selected_origin = normalized_origin(&hub)?;
900 if bound.as_deref() != Some(selected_origin.as_str()) {
901 return Err(LinkError::UnboundCredential);
902 }
903 }
904
905 Ok(HubConfig {
906 hub,
907 key,
908 agent_key,
909 brain_key,
910 state_dir: toolkit_state_dir()?,
911 store_selected: selected_by_store,
912 })
913}
914
915fn toolkit_state_dir() -> LinkResult<PathBuf> {
916 if let Some(path) = env_nonempty(STATE_DIR_ENV) {
917 let path = PathBuf::from(path);
918 if !path.is_absolute() {
919 return Err(LinkError::UnsafePath {
920 path: path.display().to_string(),
921 });
922 }
923 return Ok(path);
924 }
925 #[cfg(windows)]
926 if let Some(base) = env_nonempty("LOCALAPPDATA") {
927 let base = PathBuf::from(base);
928 if base.is_absolute() {
929 return Ok(base.join("dbmd").join("state"));
930 }
931 }
932 #[cfg(windows)]
933 {
934 Err(LinkError::Io(std::io::Error::new(
935 std::io::ErrorKind::NotFound,
936 format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
937 )))
938 }
939 #[cfg(not(windows))]
940 if let Some(base) = env_nonempty("XDG_STATE_HOME") {
941 let base = PathBuf::from(base);
942 if base.is_absolute() {
943 return Ok(base.join("dbmd"));
944 }
945 }
946 #[cfg(not(windows))]
947 let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
948 LinkError::Io(std::io::Error::new(
949 std::io::ErrorKind::NotFound,
950 format!("cannot locate user state; set {STATE_DIR_ENV}"),
951 ))
952 })?);
953 #[cfg(not(windows))]
954 if !home.is_absolute() {
955 return Err(LinkError::UnsafePath {
956 path: home.display().to_string(),
957 });
958 }
959 #[cfg(target_os = "macos")]
960 {
961 Ok(home
962 .join("Library")
963 .join("Application Support")
964 .join("dbmd")
965 .join("state"))
966 }
967 #[cfg(all(not(target_os = "macos"), not(windows)))]
968 {
969 Ok(home.join(".local").join("state").join("dbmd"))
970 }
971}
972
973fn normalized_origin(value: &str) -> LinkResult<String> {
974 let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
975 hub: value.to_string(),
976 })?;
977 if !(parsed.scheme().eq_ignore_ascii_case("https")
978 || parsed.scheme().eq_ignore_ascii_case("http"))
979 || !parsed.username().is_empty()
980 || parsed.password().is_some()
981 || (parsed.path() != "/" && !parsed.path().is_empty())
982 || parsed.query().is_some()
983 || parsed.fragment().is_some()
984 {
985 return Err(LinkError::UnsafeHub {
986 hub: value.to_string(),
987 });
988 }
989 let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
990 hub: value.to_string(),
991 })?;
992 let host = if host.contains(':') {
993 format!("[{host}]")
994 } else {
995 host.to_ascii_lowercase()
996 };
997 let port = parsed
998 .port_or_known_default()
999 .ok_or_else(|| LinkError::UnsafeHub {
1000 hub: value.to_string(),
1001 })?;
1002 let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
1003 || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
1004 Ok(format!(
1005 "{}://{}{}",
1006 parsed.scheme().to_ascii_lowercase(),
1007 host,
1008 if default {
1009 String::new()
1010 } else {
1011 format!(":{port}")
1012 }
1013 ))
1014}
1015
1016const ED25519_SPKI_PREFIX: [u8; 12] = [
1023 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1024];
1025
1026fn bad_agent_key(message: &str) -> LinkError {
1027 LinkError::BadAgentKey {
1028 message: message.to_string(),
1029 }
1030}
1031
1032fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
1033 ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
1037 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
1038 .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
1039}
1040
1041fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
1043 use ring::signature::KeyPair as _;
1044 let mut spki = Vec::with_capacity(44);
1045 spki.extend_from_slice(&ED25519_SPKI_PREFIX);
1046 spki.extend_from_slice(pair.public_key().as_ref());
1047 (
1048 URL_SAFE_NO_PAD.encode(&spki),
1049 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
1050 )
1051}
1052
1053pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1057 load_agent_key(path)
1058}
1059
1060fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1062 #[cfg(unix)]
1063 let file = {
1064 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1065 use std::os::unix::ffi::OsStrExt as _;
1066 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1067 .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1068 let leaf = path
1069 .file_name()
1070 .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1071 let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1072 let fd = unsafe {
1073 libc::openat(
1074 parent.as_raw_fd(),
1075 leaf.as_ptr(),
1076 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1077 )
1078 };
1079 if fd < 0 {
1080 return Err(bad_agent_key(
1081 "the key path must be an existing regular file without symlink ancestors",
1082 ));
1083 }
1084 unsafe { std::fs::File::from_raw_fd(fd) }
1085 };
1086 #[cfg(not(unix))]
1087 let file = std::fs::File::open(path)
1088 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1089 let metadata = file
1090 .metadata()
1091 .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1092 if !metadata.is_file() {
1093 return Err(bad_agent_key("the key path must be a regular file"));
1094 }
1095 #[cfg(unix)]
1096 {
1097 use std::os::unix::fs::PermissionsExt as _;
1098 if metadata.permissions().mode() & 0o077 != 0 {
1099 return Err(bad_agent_key(
1100 "the key file is accessible to group/other; set mode 0600",
1101 ));
1102 }
1103 }
1104 let mut text = String::new();
1105 file.take(1024 * 1024 + 1)
1106 .read_to_string(&mut text)
1107 .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1108 if text.len() > 1024 * 1024 {
1109 return Err(bad_agent_key("the key file exceeds the size limit"));
1110 }
1111 let pkcs8 = URL_SAFE_NO_PAD
1112 .decode(text.trim())
1113 .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1114 let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1115 Ok(AgentSigningKey {
1116 pkcs8,
1117 multikey,
1118 public_key_spki,
1119 })
1120}
1121
1122fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1128 #[cfg(unix)]
1129 let (mut file, parent, leaf) = {
1130 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1131 use std::os::unix::ffi::OsStrExt as _;
1132 let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1133 let leaf_name = path
1134 .file_name()
1135 .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1136 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1137 let fd = unsafe {
1138 libc::openat(
1139 parent.as_raw_fd(),
1140 leaf.as_ptr(),
1141 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1142 0o600,
1143 )
1144 };
1145 if fd < 0 {
1146 let error = std::io::Error::last_os_error();
1147 if error.kind() == std::io::ErrorKind::AlreadyExists {
1148 return Err(bad_agent_key(
1149 "the output file already exists — refusing to overwrite a key",
1150 ));
1151 }
1152 return Err(error.into());
1153 }
1154 (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1155 };
1156 #[cfg(not(unix))]
1157 let mut file = std::fs::OpenOptions::new()
1158 .write(true)
1159 .create_new(true)
1160 .open(path)
1161 .map_err(|error| {
1162 if error.kind() == std::io::ErrorKind::AlreadyExists {
1163 bad_agent_key("the output file already exists — refusing to overwrite a key")
1164 } else {
1165 LinkError::Io(error)
1166 }
1167 })?;
1168 if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1169 drop(file);
1170 #[cfg(unix)]
1171 let _ =
1172 unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1173 #[cfg(not(unix))]
1174 let _ = std::fs::remove_file(path);
1175 return Err(LinkError::Io(error));
1176 }
1177 drop(file);
1178 #[cfg(unix)]
1179 parent.sync_all()?;
1180 Ok(())
1181}
1182
1183#[derive(Debug, Serialize)]
1186pub struct GeneratedAgentKey {
1187 pub multikey: String,
1189 #[serde(rename = "publicKeySpki")]
1191 pub public_key_spki: String,
1192 #[serde(rename = "keyFile")]
1194 pub key_file: String,
1195}
1196
1197pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1202 require_hardened_filesystem("key generation")?;
1203 let rng = ring::rand::SystemRandom::new();
1204 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1205 .map_err(|_| bad_agent_key("key generation failed"))?;
1206 let pair = agent_keypair(pkcs8.as_ref())?;
1207 let (spki_b64u, multikey) = public_identity_for(&pair);
1208
1209 write_secret_new(
1210 out,
1211 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1212 )?;
1213
1214 Ok(GeneratedAgentKey {
1215 multikey,
1216 public_key_spki: spki_b64u,
1217 key_file: out.display().to_string(),
1218 })
1219}
1220
1221fn linkmd_sig_header(
1230 key: &AgentSigningKey,
1231 origin: &str,
1232 method: &str,
1233 path: &str,
1234 body: Option<&str>,
1235) -> LinkResult<String> {
1236 let ts = std::time::SystemTime::now()
1237 .duration_since(std::time::UNIX_EPOCH)
1238 .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1239 .as_secs();
1240 let body_hash = match body {
1241 Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1242 None => "-".to_string(),
1243 };
1244 let canonical = format!(
1245 "v2\n{}\n{}\n{}\n{}\n{}",
1246 origin,
1247 method.to_uppercase(),
1248 path,
1249 ts,
1250 body_hash
1251 );
1252 let pair = agent_keypair(&key.pkcs8)?;
1253 let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1254 let fingerprint = key.multikey.trim_start_matches("ed25519:");
1255 Ok(format!(
1256 "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1257 ))
1258}
1259
1260#[derive(Serialize)]
1267struct WireFeedFile {
1268 path: String,
1269 sha256: String,
1270 bytes: u64,
1271}
1272
1273#[derive(Serialize)]
1276struct UnsignedWireEntry<'a> {
1277 v: u8,
1278 seq: u64,
1279 ts: String,
1280 brain: &'a str,
1281 public_key: &'a str,
1282 kind: &'a str,
1283 op: &'a str,
1284 pack_sha256: &'a str,
1285 files: &'a [WireFeedFile],
1286 removed: &'a [String],
1287 prev_entry_hash: Option<&'a str>,
1288}
1289
1290fn self_custody_entry(
1296 key: &AgentSigningKey,
1297 seq: u64,
1298 ts: String,
1299 pack_sha256: &str,
1300 files: &[WireFeedFile],
1301 prev_entry_hash: Option<&str>,
1302) -> LinkResult<String> {
1303 let removed: [String; 0] = [];
1304 let unsigned = serde_json::to_string(&UnsignedWireEntry {
1305 v: 1,
1306 seq,
1307 ts,
1308 brain: &key.multikey,
1309 public_key: &key.public_key_spki,
1310 kind: "push",
1311 op: "snapshot",
1312 pack_sha256,
1313 files,
1314 removed: &removed,
1315 prev_entry_hash,
1316 })
1317 .expect("serialize feed entry");
1318 let pair = agent_keypair(&key.pkcs8)?;
1319 let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1320 Ok(format!(
1321 "{},\"sig\":\"{}\"}}",
1322 &unsigned[..unsigned.len() - 1],
1323 sig
1324 ))
1325}
1326
1327fn env_nonempty(name: &str) -> Option<String> {
1330 std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1331}
1332
1333fn config_file_hub(path: &Path) -> Option<String> {
1338 const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1339 #[cfg(unix)]
1340 let file = {
1341 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1342 use std::os::unix::ffi::OsStrExt as _;
1343 let parent =
1344 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1345 let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1346 let fd = unsafe {
1347 libc::openat(
1348 parent.as_raw_fd(),
1349 leaf.as_ptr(),
1350 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1351 )
1352 };
1353 if fd < 0 {
1354 return None;
1355 }
1356 unsafe { std::fs::File::from_raw_fd(fd) }
1357 };
1358 #[cfg(not(unix))]
1359 let file = std::fs::File::open(path).ok()?;
1360 let metadata = file.metadata().ok()?;
1361 if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1362 return None;
1363 }
1364 let mut bytes = Vec::with_capacity(metadata.len() as usize);
1365 file.take(MAX_CONFIG_BYTES + 1)
1366 .read_to_end(&mut bytes)
1367 .ok()?;
1368 if bytes.len() as u64 > MAX_CONFIG_BYTES {
1369 return None;
1370 }
1371 let text = String::from_utf8(bytes).ok()?;
1372 for line in text.lines() {
1373 let line = line.trim();
1374 if line.is_empty() || line.starts_with('#') {
1375 continue;
1376 }
1377 if let Some((k, v)) = line.split_once('=') {
1378 if k.trim() == "hub" {
1379 let v = v.trim();
1380 if !v.is_empty() {
1381 return Some(v.to_string());
1382 }
1383 }
1384 }
1385 }
1386 None
1387}
1388
1389fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1392 let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1393 hub: hub.to_string(),
1394 })?;
1395 if !(parsed.scheme().eq_ignore_ascii_case("https")
1396 || parsed.scheme().eq_ignore_ascii_case("http"))
1397 || !parsed.username().is_empty()
1398 || parsed.password().is_some()
1399 || (parsed.path() != "/" && !parsed.path().is_empty())
1400 || parsed.query().is_some()
1401 || parsed.fragment().is_some()
1402 {
1403 return Err(LinkError::UnsafeHub {
1404 hub: hub.to_string(),
1405 });
1406 }
1407 let loopback = match parsed.host() {
1408 Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1409 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1410 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1411 None => false,
1412 };
1413 if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1414 Ok(())
1415 } else {
1416 Err(LinkError::UnsafeHub {
1417 hub: hub.to_string(),
1418 })
1419 }
1420}
1421
1422fn clean_key(raw: &str) -> LinkResult<String> {
1427 let k = raw.trim();
1428 if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1429 return Err(LinkError::BadKey);
1430 }
1431 Ok(k.to_string())
1432}
1433
1434#[derive(Debug)]
1440pub struct HubResponse {
1441 pub status: u16,
1443 pub body: Option<Value>,
1445}
1446
1447struct RawHubResponse {
1448 status: u16,
1449 body: Vec<u8>,
1450}
1451
1452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1454enum Auth {
1455 Required,
1457 None,
1459 Optional,
1463}
1464
1465fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1466 ureq::AgentBuilder::new()
1467 .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1468 .redirects(0)
1472 .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1473 .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1474 .timeout_write(overall)
1475 .timeout(overall)
1476}
1477
1478fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1479 hub_agent_with_timeout(
1480 cfg,
1481 std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1482 )
1483}
1484
1485fn hub_agent_with_timeout(
1486 cfg: &HubConfig,
1487 overall: std::time::Duration,
1488) -> LinkResult<ureq::Agent> {
1489 if !cfg.store_selected {
1490 return Ok(agent_builder_with_timeout(overall).build());
1491 }
1492 let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1493 hub: cfg.hub.clone(),
1494 })?;
1495 pinned_public_agent_pooled(
1496 &parsed,
1497 false,
1498 "store-selected hub",
1499 AgentShape {
1500 overall,
1501 ..AgentShape::default()
1502 },
1503 )
1504}
1505
1506fn request_raw(
1511 cfg: &HubConfig,
1512 method: &str,
1513 path: &str,
1514 body: Option<&Value>,
1515 auth: Auth,
1516 max_response_bytes: u64,
1517) -> LinkResult<RawHubResponse> {
1518 let http = hub_agent(cfg)?;
1519 request_raw_with_agent(
1520 cfg,
1521 &http,
1522 method,
1523 path,
1524 body,
1525 RawRequestOptions {
1526 auth,
1527 max_response_bytes,
1528 request_id: None,
1529 retry_transport: false,
1530 },
1531 )
1532}
1533
1534fn request_raw_retryable_read(
1538 cfg: &HubConfig,
1539 method: &str,
1540 path: &str,
1541 body: Option<&Value>,
1542 auth: Auth,
1543 max_response_bytes: u64,
1544) -> LinkResult<RawHubResponse> {
1545 let http = hub_agent(cfg)?;
1546 request_raw_with_agent(
1547 cfg,
1548 &http,
1549 method,
1550 path,
1551 body,
1552 RawRequestOptions {
1553 auth,
1554 max_response_bytes,
1555 request_id: None,
1556 retry_transport: true,
1557 },
1558 )
1559}
1560
1561struct RawRequestOptions<'a> {
1562 auth: Auth,
1563 max_response_bytes: u64,
1564 request_id: Option<&'a str>,
1565 retry_transport: bool,
1566}
1567
1568fn request_raw_with_agent(
1569 cfg: &HubConfig,
1570 http: &ureq::Agent,
1571 method: &str,
1572 path: &str,
1573 body: Option<&Value>,
1574 options: RawRequestOptions<'_>,
1575) -> LinkResult<RawHubResponse> {
1576 let url = format!("{}{}", cfg.hub, path);
1577 let encoded_body = body.map(Value::to_string);
1578 let origin = normalized_origin(&cfg.hub)?;
1579 let safe_read = (method == "GET" && encoded_body.is_none()) || options.retry_transport;
1580 let mut read_attempt = 0;
1581 loop {
1582 let credential = match options.auth {
1589 Auth::Required => Some(match &cfg.agent_key {
1590 Some(key) => {
1591 linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?
1592 }
1593 None => format!("Bearer {}", cfg.require_key()?),
1594 }),
1595 Auth::Optional => match &cfg.agent_key {
1596 Some(key) => Some(linkmd_sig_header(
1597 key,
1598 &origin,
1599 method,
1600 path,
1601 encoded_body.as_deref(),
1602 )?),
1603 None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1604 },
1605 Auth::None => None,
1606 };
1607 let result = with_connect_retries(|| {
1608 let mut req = http.request(method, &url);
1609 if let Some(value) = &credential {
1610 req = req.set("authorization", value);
1611 }
1612 if let Some(value) = options.request_id {
1613 req = req.set("x-request-id", value);
1614 }
1615 match &encoded_body {
1616 Some(value) => req
1617 .set("content-type", "application/json")
1618 .send_string(value)
1619 .map_err(Box::new),
1620 None => req.call().map_err(Box::new),
1621 }
1622 });
1623 let resp = match result {
1624 Ok(resp) => resp,
1625 Err(error) => match *error {
1626 ureq::Error::Status(_, resp) => resp,
1627 ureq::Error::Transport(error) => {
1628 if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS {
1629 std::thread::sleep(std::time::Duration::from_millis(
1630 SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1631 ));
1632 read_attempt += 1;
1633 continue;
1634 }
1635 return Err(LinkError::Transport {
1636 hub: cfg.hub.clone(),
1637 message: error.to_string(),
1638 });
1639 }
1640 },
1641 };
1642
1643 let status = resp.status();
1644 let buf = match read_response_body(resp, options.max_response_bytes + 1, &cfg.hub) {
1645 Ok(buf) => buf,
1646 Err(LinkError::Transport { .. })
1647 if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS =>
1648 {
1649 std::thread::sleep(std::time::Duration::from_millis(
1650 SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1651 ));
1652 read_attempt += 1;
1653 continue;
1654 }
1655 Err(error) => return Err(error),
1656 };
1657 if buf.len() as u64 > options.max_response_bytes {
1658 return Err(LinkError::ResponseTooLarge {
1659 limit_bytes: options.max_response_bytes,
1660 });
1661 }
1662 return Ok(RawHubResponse { status, body: buf });
1663 }
1664}
1665
1666fn request_capped(
1667 cfg: &HubConfig,
1668 method: &str,
1669 path: &str,
1670 body: Option<&Value>,
1671 auth: Auth,
1672 max_response_bytes: u64,
1673) -> LinkResult<HubResponse> {
1674 let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1675 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1676 Ok(HubResponse {
1677 status: raw.status,
1678 body: parsed,
1679 })
1680}
1681
1682fn request_patient(
1694 cfg: &HubConfig,
1695 method: &str,
1696 path: &str,
1697 body: Option<&Value>,
1698 auth: Auth,
1699) -> LinkResult<HubResponse> {
1700 let http = hub_agent_with_timeout(
1701 cfg,
1702 std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1703 )?;
1704 let mut attempt = 0;
1705 loop {
1706 let sent = request_raw_with_agent(
1707 cfg,
1708 &http,
1709 method,
1710 path,
1711 body,
1712 RawRequestOptions {
1713 auth,
1714 max_response_bytes: MAX_RESPONSE_BYTES,
1715 request_id: None,
1716 retry_transport: false,
1717 },
1718 );
1719 match sent {
1720 Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1721 std::thread::sleep(std::time::Duration::from_millis(
1722 COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1723 ));
1724 attempt += 1;
1725 }
1726 Err(error) => return Err(error),
1727 Ok(raw) => {
1728 return Ok(HubResponse {
1729 status: raw.status,
1730 body: serde_json::from_slice(&raw.body).ok(),
1731 })
1732 }
1733 }
1734 }
1735}
1736
1737fn request(
1738 cfg: &HubConfig,
1739 method: &str,
1740 path: &str,
1741 body: Option<&Value>,
1742 auth: Auth,
1743) -> LinkResult<HubResponse> {
1744 request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1745}
1746
1747fn request_with_request_id(
1752 cfg: &HubConfig,
1753 method: &str,
1754 path: &str,
1755 body: Option<&Value>,
1756 auth: Auth,
1757 request_id: &str,
1758) -> LinkResult<HubResponse> {
1759 if request_id.is_empty()
1760 || request_id.len() > 128
1761 || !request_id
1762 .bytes()
1763 .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1764 {
1765 return Err(invalid_feed("hub returned an unsafe request id"));
1766 }
1767 let http = hub_agent_with_timeout(
1770 cfg,
1771 std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1772 )?;
1773 let raw = request_raw_with_agent(
1774 cfg,
1775 &http,
1776 method,
1777 path,
1778 body,
1779 RawRequestOptions {
1780 auth,
1781 max_response_bytes: MAX_RESPONSE_BYTES,
1782 request_id: Some(request_id),
1783 retry_transport: false,
1784 },
1785 )?;
1786 Ok(HubResponse {
1787 status: raw.status,
1788 body: serde_json::from_slice(&raw.body).ok(),
1789 })
1790}
1791
1792fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1793 if (200..300).contains(&r.status) {
1794 return Ok(r.body);
1795 }
1796 ensure_ok(
1797 HubResponse {
1798 status: r.status,
1799 body: serde_json::from_slice(&r.body).ok(),
1800 },
1801 what,
1802 )
1803 .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1804}
1805
1806fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1811 matches!(
1812 kind,
1813 ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1814 )
1815}
1816
1817fn with_connect_retries(
1818 mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1819) -> Result<ureq::Response, Box<ureq::Error>> {
1820 let mut attempt = 0;
1821 loop {
1822 match send() {
1823 Err(error)
1824 if matches!(
1825 error.as_ref(),
1826 ureq::Error::Transport(transport)
1827 if is_pre_request_transport(transport.kind())
1828 ) && attempt + 1 < CONNECT_ATTEMPTS =>
1829 {
1830 std::thread::sleep(std::time::Duration::from_millis(
1831 CONNECT_RETRY_BACKOFF_MS[attempt],
1832 ));
1833 attempt += 1;
1834 }
1835 result => return result,
1836 }
1837 }
1838}
1839
1840fn hub_is_loopback(hub: &str) -> bool {
1841 url::Url::parse(hub).ok().is_some_and(|parsed| {
1842 parsed.host().is_some_and(|host| match host {
1843 url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1844 url::Host::Ipv4(ip) => ip.is_loopback(),
1845 url::Host::Ipv6(ip) => ip.is_loopback(),
1846 })
1847 })
1848}
1849
1850fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1854 let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1855 message: "the hub returned an invalid object-store URL".to_string(),
1856 })?;
1857 let allow_private = hub_is_loopback(&cfg.hub)
1858 || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1859 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1860 || !parsed.username().is_empty()
1861 || parsed.password().is_some()
1862 || parsed.fragment().is_some()
1863 {
1864 return Err(LinkError::InvalidPack {
1865 message: "the hub returned an unsafe object-store URL".to_string(),
1866 });
1867 }
1868 Ok((parsed, allow_private))
1869}
1870
1871fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1872 let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1873 pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1874 LinkError::InvalidPack {
1875 message: "the hub returned an object-store URL with an unsafe network target"
1876 .to_string(),
1877 }
1878 })
1879}
1880
1881fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1890 let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1891 let authority = (
1892 first.host_str()?.to_string(),
1893 first.port_or_known_default()?,
1894 );
1895 for raw in &urls[1..] {
1896 let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1897 if (parsed.host_str()?, parsed.port_or_known_default()?)
1898 != (authority.0.as_str(), authority.1)
1899 {
1900 return None;
1901 }
1902 }
1903 pinned_public_agent_pooled(
1904 &first,
1905 allow_private,
1906 "object-store URL",
1907 AgentShape {
1908 idle_per_host: V2_UPLOAD_CONCURRENCY,
1909 ..AgentShape::default()
1910 },
1911 )
1912 .ok()
1913}
1914
1915fn object_store_transport_error(error: ureq::Transport) -> LinkError {
1921 LinkError::Transport {
1922 hub: "the object store".to_string(),
1923 message: format!("network error ({:?})", error.kind()),
1924 }
1925}
1926
1927fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1928 let http = presigned_agent(cfg, raw)?;
1929 let deadline = std::time::Instant::now()
1930 .checked_add(std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS))
1931 .ok_or_else(upload_deadline_error)?;
1932 let mut attempt = 0;
1933 let result = loop {
1934 let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
1938 if let Some(map) = headers.as_object() {
1939 for (name, value) in map {
1940 if let Some(value) = value.as_str() {
1941 req = req.set(name, value);
1942 }
1943 }
1944 }
1945 match req.send_bytes(bytes) {
1946 Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
1952 attempt += 1;
1953 }
1954 Err(ureq::Error::Status(status, _))
1955 if status != 412
1956 && is_retryable_upload_status(status)
1957 && wait_for_upload_retry(deadline, attempt) =>
1958 {
1959 attempt += 1;
1960 }
1961 result => break result,
1962 }
1963 };
1964 match result {
1965 Ok(resp) if (200..300).contains(&resp.status()) => {
1966 drain_presigned_response(resp);
1967 Ok(())
1968 }
1969 Ok(resp) => Err(presigned_upload_refusal(resp)),
1970 Err(error) => match error {
1971 ureq::Error::Status(412, _) => Ok(()),
1976 ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
1977 ureq::Error::Transport(err) => Err(object_store_transport_error(err)),
1978 },
1979 }
1980}
1981
1982fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
1991 let mut buf = Vec::new();
1992 response
1993 .into_reader()
1994 .take(limit)
1995 .read_to_end(&mut buf)
1996 .map_err(|error| LinkError::Transport {
1997 hub: peer.to_string(),
1998 message: error.to_string(),
1999 })?;
2000 Ok(buf)
2001}
2002
2003fn drain_presigned_response(response: ureq::Response) {
2008 let mut reader = response.into_reader().take(64 * 1024);
2009 let _ = std::io::copy(&mut reader, &mut std::io::sink());
2010}
2011
2012fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
2015 let status = response.status();
2016 let detail = response
2017 .into_string()
2018 .ok()
2019 .map(|body| body.chars().take(400).collect::<String>())
2020 .filter(|body| !body.trim().is_empty());
2021 LinkError::Http {
2022 what: "pack upload",
2023 status,
2024 message: match detail {
2025 Some(body) => format!(
2026 "object store rejected the upload: {}",
2027 body.replace('\n', " ")
2028 ),
2029 None => "object store rejected the upload".to_string(),
2030 },
2031 code: None,
2032 details: None,
2033 }
2034}
2035
2036fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
2037 max_bytes.checked_add(1)
2038}
2039
2040fn presigned_download_read_limit() -> u64 {
2041 one_past_bounded_limit(MAX_PACK_BYTES)
2042 .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
2043}
2044
2045fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
2046 let http = presigned_agent(cfg, raw)?;
2047 let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
2048 Ok(resp) => resp,
2049 Err(error) => match *error {
2050 ureq::Error::Status(_, resp) => {
2051 return Err(LinkError::Http {
2052 what: "pack download",
2053 status: resp.status(),
2054 message: "object store rejected the download".to_string(),
2055 code: None,
2056 details: None,
2057 });
2058 }
2059 ureq::Error::Transport(err) => {
2060 return Err(LinkError::Transport {
2061 hub: "the object store".to_string(),
2062 message: err.to_string(),
2063 });
2064 }
2065 },
2066 };
2067 if !(200..300).contains(&resp.status()) {
2068 return Err(LinkError::Http {
2069 what: "pack download",
2070 status: resp.status(),
2071 message: "object store rejected the download".to_string(),
2072 code: None,
2073 details: None,
2074 });
2075 }
2076 let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
2077 if bytes.len() as u64 > MAX_PACK_BYTES {
2078 return Err(LinkError::InvalidPack {
2079 message: "download exceeds the compressed-size limit".to_string(),
2080 });
2081 }
2082 Ok(bytes)
2083}
2084
2085fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
2089 if !(200..300).contains(&r.status) {
2090 let message = r
2091 .body
2092 .as_ref()
2093 .and_then(|b| b.get("error"))
2094 .and_then(Value::as_str)
2095 .unwrap_or("unknown error")
2096 .to_string();
2097 let code = r
2098 .body
2099 .as_ref()
2100 .and_then(|b| b.get("code"))
2101 .and_then(Value::as_str)
2102 .map(str::to_string);
2103 let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
2104 return Err(LinkError::Http {
2105 what,
2106 status: r.status,
2107 message,
2108 code,
2109 details,
2110 });
2111 }
2112 r.body.ok_or(LinkError::NotJson {
2113 what,
2114 status: r.status,
2115 })
2116}
2117
2118fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2127 match ip {
2128 std::net::IpAddr::V4(ip) => {
2129 let [a, b, c, _] = ip.octets();
2130 !(a == 0
2131 || a == 10
2132 || a == 127
2133 || (a == 100 && (64..=127).contains(&b))
2134 || (a == 169 && b == 254)
2135 || (a == 172 && (16..=31).contains(&b))
2136 || (a == 192 && b == 0 && c == 0)
2137 || (a == 192 && b == 0 && c == 2)
2138 || (a == 192 && b == 88 && c == 99)
2139 || (a == 192 && b == 168)
2140 || (a == 198 && (b == 18 || b == 19))
2141 || (a == 198 && b == 51 && c == 100)
2142 || (a == 203 && b == 0 && c == 113)
2143 || a >= 224)
2144 }
2145 std::net::IpAddr::V6(ip) => {
2146 let segments = ip.segments();
2147 (segments[0] & 0xe000) == 0x2000
2152 && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2153 && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2154 && segments[0] != 0x2002
2155 && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2156 }
2157 }
2158}
2159
2160#[derive(Clone)]
2161struct PinnedRegistryResolver {
2162 netloc: String,
2163 addresses: Vec<std::net::SocketAddr>,
2164}
2165
2166impl ureq::Resolver for PinnedRegistryResolver {
2167 fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2168 if requested == self.netloc {
2169 Ok(self.addresses.clone())
2170 } else {
2171 Err(std::io::Error::new(
2172 std::io::ErrorKind::PermissionDenied,
2173 "registry request attempted to resolve an unvalidated authority",
2174 ))
2175 }
2176 }
2177}
2178
2179fn pinned_public_agent(
2180 url: &url::Url,
2181 allow_private: bool,
2182 label: &str,
2183) -> LinkResult<ureq::Agent> {
2184 pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2185}
2186
2187struct AgentShape {
2192 idle_per_host: usize,
2193 overall: std::time::Duration,
2194}
2195
2196impl Default for AgentShape {
2197 fn default() -> Self {
2198 Self {
2199 idle_per_host: 1,
2200 overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2201 }
2202 }
2203}
2204
2205fn pinned_public_agent_pooled(
2206 url: &url::Url,
2207 allow_private: bool,
2208 label: &str,
2209 shape: AgentShape,
2210) -> LinkResult<ureq::Agent> {
2211 let host = url
2212 .host_str()
2213 .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2214 let port = url
2215 .port_or_known_default()
2216 .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2217 let addresses = resolve_addresses_with_deadline(
2218 host,
2219 port,
2220 std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2221 )
2222 .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2223 if addresses.is_empty() {
2224 return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2225 }
2226 if !allow_private
2227 && addresses
2228 .iter()
2229 .any(|address| !is_public_registry_ip(address.ip()))
2230 {
2231 return Err(invalid_feed(format!(
2232 "{label} resolves to a non-public address"
2233 )));
2234 }
2235 let netloc = if host.contains(':') {
2236 format!("[{host}]:{port}")
2237 } else {
2238 format!("{host}:{port}")
2239 };
2240 Ok(agent_builder_with_timeout(shape.overall)
2241 .max_idle_connections_per_host(shape.idle_per_host.max(1))
2242 .resolver(PinnedRegistryResolver { netloc, addresses })
2243 .build())
2244}
2245
2246fn resolve_addresses_with_deadline(
2251 host: &str,
2252 port: u16,
2253 timeout: std::time::Duration,
2254) -> std::io::Result<Vec<std::net::SocketAddr>> {
2255 use std::net::ToSocketAddrs as _;
2256
2257 let host = host.to_string();
2258 let (send, receive) = std::sync::mpsc::sync_channel(1);
2259 std::thread::Builder::new()
2260 .name("dbmd-dns".to_string())
2261 .spawn(move || {
2262 let result = (host.as_str(), port)
2263 .to_socket_addrs()
2264 .map(|addresses| addresses.collect());
2265 let _ = send.send(result);
2266 })
2267 .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2268 match receive.recv_timeout(timeout) {
2269 Ok(result) => result,
2270 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2271 std::io::ErrorKind::TimedOut,
2272 "resolution exceeded its deadline",
2273 )),
2274 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2275 "resolver stopped without returning a result",
2276 )),
2277 }
2278}
2279
2280fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2281 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2282 pinned_public_agent(url, allow_private, "registry home")
2283}
2284
2285fn get_json_absolute(url: &str) -> LinkResult<Value> {
2290 let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2291 let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2292 if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2293 || !parsed.username().is_empty()
2294 || parsed.password().is_some()
2295 || parsed.query().is_some()
2296 || parsed.fragment().is_some()
2297 {
2298 return Err(invalid_feed("unsafe registry home URL"));
2299 }
2300 let http = registry_agent(&parsed)?;
2301 let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2302 Ok(resp) => resp,
2303 Err(error) => match *error {
2304 ureq::Error::Status(status, resp) => {
2305 let _ = resp;
2306 return Err(LinkError::Http {
2307 what: "registry home fetch",
2308 status,
2309 message: "the home node rejected the card request".to_string(),
2310 code: None,
2311 details: None,
2312 });
2313 }
2314 ureq::Error::Transport(err) => {
2315 return Err(LinkError::Transport {
2316 hub: url.to_string(),
2317 message: err.to_string(),
2318 });
2319 }
2320 },
2321 };
2322 if !(200..300).contains(&resp.status()) {
2323 return Err(LinkError::Http {
2324 what: "registry home fetch",
2325 status: resp.status(),
2326 message: "the home node returned a redirect or error".to_string(),
2327 code: None,
2328 details: None,
2329 });
2330 }
2331 let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2332 if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2333 return Err(LinkError::ResponseTooLarge {
2334 limit_bytes: MAX_REGISTRY_CARD_BYTES,
2335 });
2336 }
2337 serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2338 message: "the home node returned invalid JSON".to_string(),
2339 })
2340}
2341
2342pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2349 require_safe_ref(handle)?;
2350 let trust_directory = open_trust_dir(cfg)?;
2354 let reg = request_capped(
2355 cfg,
2356 "GET",
2357 &format!("/api/hub/registry/{handle}"),
2358 None,
2359 Auth::None,
2360 MAX_REGISTRY_CARD_BYTES,
2361 )?;
2362 if reg.status == 404 {
2363 return Ok(None);
2364 }
2365 let body = ensure_ok(reg, "registry resolve")?;
2366 let home = body
2367 .get("home")
2368 .and_then(Value::as_str)
2369 .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2370 let brain = body
2371 .get("brain")
2372 .and_then(Value::as_str)
2373 .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2374 if !crate::ulid::is_ulid(brain) {
2375 return Err(invalid_feed(
2376 "registry entry brain is not a canonical lowercase ULID",
2377 ));
2378 }
2379 let want_fp = body
2380 .get("identity")
2381 .and_then(|i| i.get("fingerprint"))
2382 .and_then(Value::as_str)
2383 .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2384
2385 let home = home.trim_end_matches('/');
2386 let origin = normalized_origin(home)?;
2387 if origin != home {
2388 return Err(invalid_feed(
2389 "registry home must be an origin without a path, query, or fragment",
2390 ));
2391 }
2392 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2393 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2394 if let Some(binding) = &alias_binding {
2395 if binding
2396 .home
2397 .as_deref()
2398 .is_some_and(|pinned_home| pinned_home != home)
2399 {
2400 return Err(invalid_feed(
2401 "registry relocated a pinned handle to a different home",
2402 ));
2403 }
2404 }
2405 let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2406 if card.get("id").and_then(Value::as_str) != Some(brain) {
2407 return Err(invalid_feed(
2408 "the home node served a card for a different brain",
2409 ));
2410 }
2411 let identity: FeedIdentity = serde_json::from_value(
2412 card.get("identity")
2413 .cloned()
2414 .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2415 )
2416 .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2417 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2418 let got_fp = card
2419 .get("identity")
2420 .and_then(|i| i.get("fingerprint"))
2421 .and_then(Value::as_str)
2422 .unwrap_or_default();
2423 if got_fp != want_fp {
2424 return Err(invalid_feed(
2425 "the home node served an identity that does not match the registry — refusing",
2426 ));
2427 }
2428 let current = format!("ed25519:{}", identity.fingerprint);
2429 let advertised_seq = card
2430 .get("headSeq")
2431 .and_then(Value::as_u64)
2432 .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2433 let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2434 if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2435 || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2436 {
2437 return Err(invalid_feed(
2438 "the home node served an invalid feed head boundary",
2439 ));
2440 }
2441 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2445 let registry_alias = AliasBinding {
2446 v: 1,
2447 origin: normalized_origin(&cfg.hub)?,
2448 requested: handle.to_string(),
2449 brain: brain.to_string(),
2450 home: Some(home.to_string()),
2451 };
2452 save_canonical_pin_and_alias(
2453 cfg,
2454 &trust_directory,
2455 handle,
2456 brain,
2457 TrustState {
2458 v: 2,
2459 origin: normalized_origin(&cfg.hub)?,
2460 requested: brain.to_string(),
2461 brain: brain.to_string(),
2462 home: None,
2463 anchor,
2464 current,
2465 head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2466 feed_hash: pinned
2467 .as_ref()
2468 .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2469 rotations: identity.rotations.clone(),
2470 hub_signer: None,
2471 protocol_profile: None,
2472 },
2473 Some(®istry_alias),
2474 )?;
2475 let mut out = card;
2476 if let Value::Object(map) = &mut out {
2477 map.insert("home".to_string(), Value::String(home.to_string()));
2478 map.insert(
2479 "resolvedVia".to_string(),
2480 Value::String("registry".to_string()),
2481 );
2482 }
2483 Ok(Some(out))
2484}
2485
2486pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2487 require_safe_ref(&addr.brain)?;
2491 if let Some(target) = &addr.target {
2492 let (given, ok) = match target {
2493 AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2494 AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2495 };
2496 if !ok {
2497 return Err(LinkError::BadAddress {
2498 given: given.clone(),
2499 reason: BAD_TARGET_REASON.to_string(),
2500 });
2501 }
2502 }
2503
2504 if let Some(target) = &addr.target {
2510 if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2511 let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2512 what: "resolve",
2513 status: 404,
2514 message: "record not found".to_string(),
2515 code: Some("NOT_FOUND".to_string()),
2516 details: None,
2517 })?;
2518 let (path, file) = match target {
2519 AddressTarget::Path(path) => {
2520 let file =
2521 v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2522 LinkError::Http {
2523 what: "resolve",
2524 status: 404,
2525 message: "record not found".to_string(),
2526 code: Some("NOT_FOUND".to_string()),
2527 details: None,
2528 }
2529 })?;
2530 (path.clone(), file)
2531 }
2532 AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2533 };
2534 let mut downloaded =
2535 download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2536 let (_, bytes) = downloaded
2537 .pop()
2538 .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2539 let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2540 accept_v2_head(cfg, &head)?;
2541 return Ok(resolved);
2542 }
2543 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2544 if !remote.head.verified {
2545 return Err(invalid_feed(
2546 "a path-scoped feed cannot prove a record against the full signed snapshot",
2547 ));
2548 }
2549 if remote.head.seq == 0 {
2550 return Err(LinkError::Http {
2551 what: "resolve",
2552 status: 404,
2553 message: "record not found".to_string(),
2554 code: Some("NOT_FOUND".to_string()),
2555 details: None,
2556 });
2557 }
2558 let brain = remote.head.brain.clone();
2559 let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2560 return resolve_from_verified_pack(&brain, target, pack);
2561 }
2562
2563 let path = format!("/api/hub/brains/{}", addr.brain);
2564 let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2569 if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2570 if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2571 return Ok(card);
2572 }
2573 }
2574 let mut resolved = ensure_ok(direct, "resolve")?;
2575 if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2576 let v2 = v2_verified_head(cfg, &addr.brain)?
2577 .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2578 if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2579 return Err(invalid_feed(
2580 "resolve card is not bound to the verified v2 brain",
2581 ));
2582 }
2583 let card_identity: FeedIdentity = serde_json::from_value(
2584 resolved
2585 .get("identity")
2586 .cloned()
2587 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2588 )
2589 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2590 if card_identity != v2_identity(&v2.identity) {
2591 return Err(invalid_feed(
2592 "resolve card identity differs from the verified v2 identity",
2593 ));
2594 }
2595 accept_v2_head(cfg, &v2)?;
2596 if let Value::Object(card) = &mut resolved {
2597 card.insert(
2598 "headSeq".to_string(),
2599 json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2600 );
2601 card.insert(
2602 "feedHash".to_string(),
2603 v2.pointer
2604 .as_ref()
2605 .map(|pointer| Value::String(pointer.feed_hash.clone()))
2606 .unwrap_or(Value::Null),
2607 );
2608 card.insert(
2609 "storageProfile".to_string(),
2610 Value::String("v2".to_string()),
2611 );
2612 if let Some(pointer) = &v2.pointer {
2613 card.insert(
2614 "updatedAt".to_string(),
2615 Value::String(pointer.signed_at.clone()),
2616 );
2617 }
2618 }
2619 return Ok(resolved);
2620 }
2621 let remote = verified_remote_head(cfg, &addr.brain, false)?;
2625 if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2626 || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2627 || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2628 {
2629 return Err(invalid_feed(
2630 "resolve card is not bound to the exact verified feed checkpoint",
2631 ));
2632 }
2633 let card_identity: FeedIdentity = serde_json::from_value(
2634 resolved
2635 .get("identity")
2636 .cloned()
2637 .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2638 )
2639 .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2640 if remote.identity.as_ref() != Some(&card_identity) {
2641 return Err(invalid_feed(
2642 "resolve card identity differs from the verified feed identity",
2643 ));
2644 }
2645 Ok(resolved)
2646}
2647
2648fn resolve_from_verified_pack(
2653 brain: &str,
2654 target: &AddressTarget,
2655 pack: Vec<u8>,
2656) -> LinkResult<Value> {
2657 let entries = parse_store_pack(pack)?;
2658 let mut matched: Option<(String, Vec<u8>)> = None;
2659
2660 for (path, bytes) in entries {
2661 let is_candidate = match target {
2662 AddressTarget::Path(want) => &path == want,
2663 AddressTarget::Id(_) => {
2664 path.ends_with(".md")
2665 && (path.starts_with("records/") || path.starts_with("sources/"))
2666 }
2667 };
2668 if !is_candidate {
2669 continue;
2670 }
2671 let text = std::str::from_utf8(&bytes)
2672 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2673 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2674 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2675 if let AddressTarget::Id(want) = target {
2676 let frontmatter =
2677 crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2678 .map_err(|_| {
2679 invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2680 })?;
2681 if frontmatter.id.as_deref() != Some(want) {
2682 continue;
2683 }
2684 }
2685 if matched.is_some() {
2686 return Err(invalid_feed(
2687 "signed snapshot contains more than one record for the requested target",
2688 ));
2689 }
2690 matched = Some((path, bytes));
2691 }
2692
2693 let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2694 what: "resolve",
2695 status: 404,
2696 message: "record not found".to_string(),
2697 code: Some("NOT_FOUND".to_string()),
2698 details: None,
2699 })?;
2700 resolve_from_verified_record_bytes(brain, target, path, bytes)
2701}
2702
2703fn resolve_from_verified_record_bytes(
2704 brain: &str,
2705 target: &AddressTarget,
2706 path: String,
2707 bytes: Vec<u8>,
2708) -> LinkResult<Value> {
2709 match target {
2710 AddressTarget::Path(expected) if expected != &path => {
2711 return Err(invalid_feed(
2712 "verified record path differs from the requested path",
2713 ));
2714 }
2715 AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2716 return Err(invalid_feed(
2717 "verified id resolved outside records or sources",
2718 ));
2719 }
2720 _ => {}
2721 }
2722 let text = std::str::from_utf8(&bytes)
2723 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2724 let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2725 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2726 let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2727 .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2728 let Value::Object(fields) = frontmatter else {
2729 return Err(invalid_feed(format!(
2730 "signed snapshot record `{path}` frontmatter is not a mapping"
2731 )));
2732 };
2733 if let AddressTarget::Id(expected) = target {
2734 if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2735 return Err(invalid_feed(
2736 "verified record id differs from the requested id",
2737 ));
2738 }
2739 }
2740 let mut document = serde_json::Map::new();
2741 document.insert("path".to_string(), Value::String(path));
2742 for (key, value) in fields {
2743 document.insert(key, value);
2744 }
2745 document.insert("body".to_string(), Value::String(parsed.body));
2746 document.insert(
2747 "contentSha".to_string(),
2748 Value::String(content_sha256(&bytes)),
2749 );
2750 Ok(json!({
2751 "brain": brain,
2752 "document": Value::Object(document),
2753 }))
2754}
2755
2756#[derive(Debug, Clone, serde::Serialize)]
2762pub struct PullReport {
2763 pub brain: String,
2765 pub slug: String,
2767 #[serde(rename = "headSeq")]
2769 pub head_seq: u64,
2770 pub files: usize,
2772 pub dest: String,
2774 #[serde(rename = "extraLocal")]
2777 pub extra_local: Vec<String>,
2778 #[serde(rename = "syncStatus")]
2780 pub sync_status: String,
2781}
2782
2783struct V2PulledSnapshot {
2784 report: PullReport,
2785 head: V2VerifiedHead,
2786 files: std::collections::BTreeMap<String, V2BaselineFile>,
2787 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2788 local: V2LocalView,
2789 local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2790}
2791
2792fn download_verified_snapshot_pack(
2793 cfg: &HubConfig,
2794 brain: &str,
2795 remote: &VerifiedRemote,
2796) -> LinkResult<Vec<u8>> {
2797 let feed_hash = remote
2798 .head
2799 .feed_hash
2800 .as_deref()
2801 .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2802 let signed_head = remote
2803 .head_entry
2804 .as_ref()
2805 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2806 let expected = &signed_head.entry.pack_sha256;
2807 if !is_sha256(expected) {
2808 return Err(invalid_feed(
2809 "signed head carries an invalid snapshot pack digest",
2810 ));
2811 }
2812 let path = format!(
2813 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2814 remote.head.seq
2815 );
2816 let body = ensure_ok(
2817 request(cfg, "GET", &path, None, Auth::Required)?,
2818 "sync pull",
2819 )?;
2820 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2821 || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2822 || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2823 || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2824 {
2825 return Err(invalid_feed(
2826 "export response is not bound to the exact verified snapshot",
2827 ));
2828 }
2829 let url = body
2830 .get("url")
2831 .and_then(Value::as_str)
2832 .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2833 let bytes = get_presigned(cfg, url)?;
2834 if content_sha256(&bytes) != *expected {
2835 return Err(LinkError::InvalidPack {
2836 message: "downloaded pack does not match the signed snapshot digest".to_string(),
2837 });
2838 }
2839 let entries = parse_store_pack(bytes.clone())?;
2840 if signed_head.entry.kind == "push" {
2841 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2842 }
2843 Ok(bytes)
2844}
2845
2846#[derive(Debug, Clone, Deserialize, Serialize)]
2847struct V2PointerBody {
2848 v: u8,
2849 brain: String,
2850 seq: u64,
2851 commit_hash: String,
2852 feed_hash: String,
2853 content_root: Option<String>,
2854 asset_root: Option<String>,
2855 materializer: String,
2856 signer_epoch: u64,
2857 control_revision: String,
2858 backup_preparation: String,
2859 prior_pointer_hash: Option<String>,
2860 signed_at: String,
2861}
2862
2863#[derive(Debug, Clone, Deserialize)]
2864struct V2SignedPointer {
2865 pointer: V2PointerBody,
2866 hub_public_key: String,
2867 hub_fingerprint: String,
2868 sig: String,
2869}
2870
2871#[derive(Debug, Clone, Deserialize)]
2872struct V2HeadIdentity {
2873 #[serde(default)]
2874 custody: String,
2875 fingerprint: String,
2876 public_key_spki: String,
2877 #[serde(default)]
2878 previous: Vec<V2PreviousIdentity>,
2879 #[serde(default)]
2880 rotations: Vec<String>,
2881}
2882
2883#[derive(Debug, Clone, Deserialize)]
2884struct V2PreviousIdentity {
2885 fingerprint: String,
2886 public_key_spki: String,
2887}
2888
2889#[derive(Debug, Deserialize)]
2890struct V2HeadResponse {
2891 v: u8,
2892 brain_id: String,
2893 profile: String,
2894 view: Option<V2HeadView>,
2895 pointer: Option<V2SignedPointer>,
2896 identity: Option<V2HeadIdentity>,
2897}
2898
2899#[derive(Debug, Clone, Deserialize)]
2900struct V2HeadView {
2901 kind: String,
2902 #[serde(default)]
2903 id: Option<String>,
2904 control_revision: String,
2905}
2906
2907#[derive(Debug, Clone)]
2908struct V2VerifiedHead {
2909 requested: String,
2910 brain_id: String,
2911 view_kind: String,
2912 view_revision: String,
2914 control_revision: String,
2916 identity: V2HeadIdentity,
2917 pointer: Option<V2PointerBody>,
2918 trust: TrustState,
2919 alias: Option<AliasBinding>,
2920}
2921
2922fn verify_v2_spki_signature(
2923 public_key: &str,
2924 message: &[u8],
2925 signature: &str,
2926) -> LinkResult<Vec<u8>> {
2927 let der = URL_SAFE_NO_PAD
2928 .decode(public_key)
2929 .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2930 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2931 return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2932 }
2933 let sig = URL_SAFE_NO_PAD
2934 .decode(signature)
2935 .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2936 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2937 .verify(message, &sig)
2938 .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2939 Ok(der)
2940}
2941
2942fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2943 if pointer.pointer.v != 2
2944 || pointer.pointer.brain != expected_brain
2945 || pointer.pointer.seq == 0
2946 || !is_sha256(&pointer.pointer.commit_hash)
2947 || !is_sha256(&pointer.pointer.feed_hash)
2948 || pointer
2949 .pointer
2950 .content_root
2951 .as_deref()
2952 .is_some_and(|hash| !is_sha256(hash))
2953 || !is_sha256(&pointer.pointer.backup_preparation)
2954 {
2955 return Err(invalid_feed("v2 pointer fields are invalid"));
2956 }
2957 let value = serde_json::to_value(&pointer.pointer)
2958 .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2959 let message = crate::linkmd_v2::canonical_bytes(&value)
2960 .map_err(|error| invalid_feed(error.to_string()))?;
2961 let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2962 let fingerprint = format!("{:x}", Sha256::digest(&der));
2963 if fingerprint != pointer.hub_fingerprint {
2964 return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2965 }
2966 Ok(format!(
2967 "{}:{}",
2968 pointer.hub_fingerprint, pointer.hub_public_key
2969 ))
2970}
2971
2972fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2973 FeedIdentity {
2974 fingerprint: identity.fingerprint.clone(),
2975 public_key_spki: identity.public_key_spki.clone(),
2976 previous: identity
2977 .previous
2978 .iter()
2979 .map(|previous| PreviousIdentity {
2980 fingerprint: previous.fingerprint.clone(),
2981 public_key_spki: previous.public_key_spki.clone(),
2982 })
2983 .collect(),
2984 rotations: identity.rotations.clone(),
2985 }
2986}
2987
2988fn verified_v2_commit_object(
2989 raw: &[u8],
2990 identity: &V2HeadIdentity,
2991) -> LinkResult<serde_json::Map<String, Value>> {
2992 let mut value: Value =
2993 serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2994 let canonical = crate::linkmd_v2::canonical_bytes(&value)
2995 .map_err(|error| invalid_feed(error.to_string()))?;
2996 if canonical != raw {
2997 return Err(invalid_feed("v2 commit is not canonical JSON"));
2998 }
2999 let object = value
3000 .as_object_mut()
3001 .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
3002 let sig = object
3003 .remove("sig")
3004 .and_then(|value| value.as_str().map(str::to_string))
3005 .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
3006 const FIELDS: [&str; 18] = [
3007 "actor_ref",
3008 "asset_root",
3009 "brain",
3010 "changes_sha256",
3011 "control_revision",
3012 "materializer",
3013 "op",
3014 "parent_asset_root",
3015 "parent_commit",
3016 "parent_root",
3017 "prev_entry_hash",
3018 "public_key",
3019 "seq",
3020 "signer_epoch",
3021 "state_root",
3022 "ts",
3023 "v",
3024 "v1_bridge",
3025 ];
3026 if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
3027 return Err(invalid_feed("v2 commit has a non-normative field set"));
3028 }
3029 let seq = object
3030 .get("seq")
3031 .and_then(Value::as_u64)
3032 .filter(|seq| *seq > 0)
3033 .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
3034 let signer_epoch = object
3035 .get("signer_epoch")
3036 .and_then(Value::as_u64)
3037 .filter(|epoch| *epoch > 0)
3038 .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
3039 let hash_or_null = |field: &str| {
3040 object
3041 .get(field)
3042 .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
3043 };
3044 if object.get("v").and_then(Value::as_u64) != Some(2)
3045 || object.get("op").and_then(Value::as_str) != Some("changeset")
3046 || !object
3047 .get("changes_sha256")
3048 .and_then(Value::as_str)
3049 .is_some_and(is_sha256)
3050 || !object
3051 .get("actor_ref")
3052 .and_then(Value::as_str)
3053 .is_some_and(is_sha256)
3054 || !object
3055 .get("control_revision")
3056 .and_then(Value::as_str)
3057 .is_some_and(is_sha256)
3058 || !object
3059 .get("state_root")
3060 .and_then(Value::as_str)
3061 .is_some_and(is_sha256)
3062 || !hash_or_null("parent_commit")
3063 || !hash_or_null("parent_root")
3064 || !hash_or_null("parent_asset_root")
3065 || !hash_or_null("asset_root")
3066 || !hash_or_null("prev_entry_hash")
3067 || !object
3068 .get("materializer")
3069 .and_then(Value::as_str)
3070 .is_some_and(|value| !value.is_empty() && value.len() <= 128)
3071 || !object
3072 .get("ts")
3073 .and_then(Value::as_str)
3074 .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
3075 {
3076 return Err(invalid_feed("v2 commit fields are invalid"));
3077 }
3078 if (seq == 1
3079 && [
3080 "parent_commit",
3081 "parent_root",
3082 "parent_asset_root",
3083 "prev_entry_hash",
3084 ]
3085 .iter()
3086 .any(|field| !object.get(*field).is_some_and(Value::is_null)))
3087 || (seq > 1
3088 && ["parent_commit", "parent_root", "prev_entry_hash"]
3089 .iter()
3090 .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
3091 {
3092 return Err(invalid_feed("v2 commit parent shape is invalid"));
3093 }
3094 match object.get("v1_bridge") {
3095 Some(Value::Null) => {}
3096 Some(Value::Object(bridge))
3097 if seq == 1
3098 && bridge.len() == 3
3099 && bridge
3100 .get("head_seq")
3101 .and_then(Value::as_u64)
3102 .is_some_and(|v| v > 0)
3103 && bridge
3104 .get("feed_hash")
3105 .and_then(Value::as_str)
3106 .is_some_and(is_sha256)
3107 && bridge
3108 .get("pack_sha256")
3109 .and_then(Value::as_str)
3110 .is_some_and(is_sha256) => {}
3111 _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
3112 }
3113 let public_key = object
3114 .get("public_key")
3115 .and_then(Value::as_str)
3116 .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3117 let der = URL_SAFE_NO_PAD
3118 .decode(public_key)
3119 .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3120 let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3121 if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3122 return Err(invalid_feed("v2 commit brain identity mismatch"));
3123 }
3124 verify_identity_chain(&v2_identity(identity), None)?;
3126 let mut chain: Vec<(&str, &str)> = identity
3129 .previous
3130 .iter()
3131 .rev()
3132 .map(|previous| {
3133 (
3134 previous.fingerprint.as_str(),
3135 previous.public_key_spki.as_str(),
3136 )
3137 })
3138 .collect();
3139 chain.push((&identity.fingerprint, &identity.public_key_spki));
3140 let signer_index = chain.iter().position(|(fingerprint, spki)| {
3141 *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3142 });
3143 let Some(signer_index) = signer_index else {
3144 return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3145 };
3146 if signer_epoch != signer_index as u64 + 1 {
3147 return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3148 }
3149 let lower_boundary = if signer_index == 0 {
3150 None
3151 } else {
3152 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3153 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3154 Some(prior.prior_head_seq)
3155 };
3156 let upper_boundary = if signer_index == identity.rotations.len() {
3157 None
3158 } else {
3159 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3160 .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3161 Some(next.prior_head_seq)
3162 };
3163 if lower_boundary.is_some_and(|boundary| seq <= boundary)
3164 || upper_boundary.is_some_and(|boundary| seq > boundary)
3165 {
3166 return Err(invalid_feed(
3167 "v2 commit signer is outside its authenticated rotation epoch",
3168 ));
3169 }
3170 let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3171 .map_err(|error| invalid_feed(error.to_string()))?;
3172 verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3173 Ok(object.clone())
3174}
3175
3176#[derive(Debug, Deserialize)]
3177struct V2FeedWireEntry {
3178 seq: u64,
3179 commit_hash: String,
3180 feed_hash: String,
3181 bytes_base64: String,
3182}
3183
3184#[derive(Debug, Deserialize)]
3185struct V2FeedPage {
3186 v: u8,
3187 head_seq: u64,
3188 head_commit_hash: String,
3189 head_feed_hash: String,
3190 entries: Vec<V2FeedWireEntry>,
3191 next_after: u64,
3192 complete: bool,
3193}
3194
3195fn replay_v2_feed(
3196 cfg: &HubConfig,
3197 brain: &str,
3198 pointer: &V2PointerBody,
3199 identity: &V2HeadIdentity,
3200 start_after: u64,
3201 start_feed: Option<String>,
3202) -> LinkResult<()> {
3203 let mut after = start_after;
3204 let mut prior_feed = start_feed;
3205 let mut final_object = None;
3206 let mut replayed_entries = 0_u64;
3207 let mut replayed_bytes = 0_u64;
3208 while after < pointer.seq {
3209 let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3210 let value = ensure_ok(
3211 request_capped(
3212 cfg,
3213 "GET",
3214 &path,
3215 None,
3216 Auth::Required,
3217 MAX_FEED_REPLAY_BYTES,
3218 )?,
3219 "v2 feed replay",
3220 )?;
3221 let page: V2FeedPage = serde_json::from_value(value)
3222 .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3223 if page.v != 2
3224 || page.head_seq != pointer.seq
3225 || page.head_commit_hash != pointer.commit_hash
3226 || page.head_feed_hash != pointer.feed_hash
3227 || page.entries.is_empty()
3228 || page.entries.len() > FEED_PAGE_LIMIT
3229 {
3230 return Err(invalid_feed("v2 feed page differs from the signed head"));
3231 }
3232 for entry in page.entries {
3233 if entry.seq != after + 1
3234 || !is_sha256(&entry.commit_hash)
3235 || !is_sha256(&entry.feed_hash)
3236 {
3237 return Err(invalid_feed("v2 feed sequence is not contiguous"));
3238 }
3239 let raw = base64::engine::general_purpose::STANDARD
3240 .decode(&entry.bytes_base64)
3241 .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3242 replayed_entries = replayed_entries
3243 .checked_add(1)
3244 .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3245 replayed_bytes = replayed_bytes
3246 .checked_add(raw.len() as u64)
3247 .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3248 if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3249 {
3250 return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3251 }
3252 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3253 .map_err(|error| invalid_feed(error.to_string()))?
3254 != entry.commit_hash
3255 || content_sha256(&raw) != entry.feed_hash
3256 {
3257 return Err(invalid_feed("v2 feed entry address mismatch"));
3258 }
3259 let object = verified_v2_commit_object(&raw, identity)?;
3260 if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3261 || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3262 {
3263 return Err(invalid_feed(
3264 "v2 feed entry does not extend its predecessor",
3265 ));
3266 }
3267 after = entry.seq;
3268 prior_feed = Some(entry.feed_hash);
3269 final_object = Some((entry.commit_hash, object));
3270 }
3271 if page.next_after != after || (page.complete != (after == pointer.seq)) {
3272 return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3273 }
3274 }
3275 let (final_hash, object) =
3276 final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3277 if final_hash != pointer.commit_hash
3278 || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3279 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3280 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3281 || object.get("control_revision").and_then(Value::as_str)
3282 != Some(pointer.control_revision.as_str())
3283 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3284 {
3285 return Err(invalid_feed(
3286 "v2 replay did not converge on the signed pointer",
3287 ));
3288 }
3289 Ok(())
3290}
3291
3292fn verify_v1_to_v2_bridge(
3293 cfg: &HubConfig,
3294 brain: &str,
3295 pointer: &V2PointerBody,
3296 identity: &V2HeadIdentity,
3297 checkpoint: &TrustState,
3298) -> LinkResult<()> {
3299 let value = ensure_ok(
3300 request_capped(
3301 cfg,
3302 "GET",
3303 &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3304 None,
3305 Auth::Required,
3306 MAX_FEED_RESPONSE_BYTES,
3307 )?,
3308 "v2 genesis bridge",
3309 )?;
3310 let page: V2FeedPage = serde_json::from_value(value)
3311 .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3312 if page.v != 2
3313 || page.head_seq != pointer.seq
3314 || page.head_commit_hash != pointer.commit_hash
3315 || page.head_feed_hash != pointer.feed_hash
3316 || page.entries.len() != 1
3317 || page.entries[0].seq != 1
3318 || !is_sha256(&page.entries[0].commit_hash)
3319 || !is_sha256(&page.entries[0].feed_hash)
3320 {
3321 return Err(invalid_feed(
3322 "v2 genesis bridge page differs from the signed head",
3323 ));
3324 }
3325 let first = &page.entries[0];
3326 let raw = STANDARD
3327 .decode(&first.bytes_base64)
3328 .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3329 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3330 .map_err(|error| invalid_feed(error.to_string()))?
3331 != first.commit_hash
3332 || content_sha256(&raw) != first.feed_hash
3333 {
3334 return Err(invalid_feed("v2 genesis bridge address mismatch"));
3335 }
3336 let object = verified_v2_commit_object(&raw, identity)?;
3337 if checkpoint.head_seq == 0 {
3338 if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3339 return Err(invalid_feed(
3340 "empty v1 checkpoint did not transition through an empty v2 genesis",
3341 ));
3342 }
3343 return Ok(());
3344 }
3345 let bridge = object
3346 .get("v1_bridge")
3347 .and_then(Value::as_object)
3348 .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3349 let checkpoint_feed = checkpoint
3350 .feed_hash
3351 .as_deref()
3352 .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3353 if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3354 || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3355 {
3356 return Err(invalid_feed(
3357 "v2 genesis bridge differs from the pinned v1 checkpoint",
3358 ));
3359 }
3360 let legacy_raw = ensure_raw_ok(
3361 request_raw(
3362 cfg,
3363 "GET",
3364 &format!(
3365 "/api/hub/brains/{brain}/feed?after={}&limit=1",
3366 checkpoint.head_seq - 1
3367 ),
3368 None,
3369 Auth::Required,
3370 MAX_FEED_RESPONSE_BYTES,
3371 )?,
3372 "v1 bridge boundary",
3373 )?;
3374 let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3375 .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3376 let legacy_identity = legacy
3377 .identity
3378 .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3379 let item = legacy
3380 .entries
3381 .first()
3382 .filter(|_| legacy.entries.len() == 1)
3383 .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3384 if legacy.scope_limited
3385 || legacy.head_seq != checkpoint.head_seq
3386 || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3387 || item.entry.seq != checkpoint.head_seq
3388 || item.hash != checkpoint_feed
3389 || legacy_identity != v2_identity(identity)
3390 || bridge.get("pack_sha256").and_then(Value::as_str)
3391 != Some(item.entry.pack_sha256.as_str())
3392 {
3393 return Err(invalid_feed(
3394 "v1 bridge boundary differs from its signed legacy head",
3395 ));
3396 }
3397 let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3398 if anchor != checkpoint.anchor {
3399 return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3400 }
3401 verify_feed_item(item, &legacy_identity)?;
3402 verify_rotation_feed_boundaries(
3403 &legacy_identity,
3404 Some(checkpoint),
3405 std::slice::from_ref(item),
3406 checkpoint.head_seq,
3407 )?;
3408 Ok(())
3409}
3410
3411fn verify_v2_commit(
3412 cfg: &HubConfig,
3413 brain: &str,
3414 pointer: &V2PointerBody,
3415 identity: &V2HeadIdentity,
3416 pinned: Option<&TrustState>,
3417) -> LinkResult<()> {
3418 let path = format!(
3419 "/api/hub/brains/{brain}/v2/commit?commit={}",
3420 pointer.commit_hash
3421 );
3422 let raw = ensure_raw_ok(
3423 request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3424 "v2 commit",
3425 )?;
3426 if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3427 .map_err(|error| invalid_feed(error.to_string()))?
3428 != pointer.commit_hash
3429 || content_sha256(&raw) != pointer.feed_hash
3430 {
3431 return Err(invalid_feed("v2 commit address differs from the pointer"));
3432 }
3433 let object = verified_v2_commit_object(&raw, identity)?;
3434 if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3435 || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3436 || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3437 || object.get("control_revision").and_then(Value::as_str)
3438 != Some(pointer.control_revision.as_str())
3439 || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3440 {
3441 return Err(invalid_feed("v2 commit fields differ from the pointer"));
3442 }
3443 if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3444 if pointer.seq == checkpoint.head_seq + 1
3445 && object.get("prev_entry_hash").and_then(Value::as_str)
3446 != checkpoint.feed_hash.as_deref()
3447 {
3448 return Err(invalid_feed(
3449 "v2 commit does not extend the pinned feed hash",
3450 ));
3451 }
3452 if pointer.seq > checkpoint.head_seq + 1 {
3453 return replay_v2_feed(
3454 cfg,
3455 brain,
3456 pointer,
3457 identity,
3458 checkpoint.head_seq,
3459 checkpoint.feed_hash.clone(),
3460 );
3461 }
3462 } else {
3463 if let Some(checkpoint) = pinned {
3464 verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3465 }
3466 if pointer.seq > 1 {
3467 return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3468 }
3469 }
3470 Ok(())
3471}
3472
3473fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3474 require_hardened_filesystem("verified link.md v2 state")?;
3475 require_safe_ref(brain)?;
3476 let trust_directory = open_trust_dir(cfg)?;
3480 let path = format!("/api/hub/brains/{brain}/v2/head");
3481 let response = request(cfg, "GET", &path, None, Auth::Required)?;
3482 if response.status == 404 {
3483 if has_accepted_v2_ref(cfg, brain)? {
3484 return Err(LinkError::BrainUnavailable);
3485 }
3486 return Ok(None);
3487 }
3488 let body = ensure_ok(response, "v2 head")?;
3489 let head: V2HeadResponse = serde_json::from_value(body)
3490 .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3491 if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3492 return Err(invalid_feed("v2 head has no canonical brain id"));
3493 }
3494 if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3495 return Err(invalid_feed("v2 head resolved a different brain id"));
3496 }
3497 if head.profile == "v1" {
3498 return Ok(None);
3499 }
3500 if head.profile != "v2" && head.profile != "v2-empty" {
3501 return Err(invalid_feed("v2 head advertised an unknown profile"));
3502 }
3503 let view = head
3504 .view
3505 .as_ref()
3506 .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3507 if !matches!(view.kind.as_str(), "full" | "scoped")
3508 || !is_sha256(&view.control_revision)
3509 || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3510 {
3511 return Err(invalid_feed("v2 head has an invalid permission view"));
3512 }
3513 let view_kind = view.kind.clone();
3514 let view_revision = view
3517 .id
3518 .clone()
3519 .unwrap_or_else(|| view.control_revision.clone());
3520 let control_revision = view.control_revision.clone();
3521 let identity = head
3522 .identity
3523 .as_ref()
3524 .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3525 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3526 let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3527 let feed_identity = v2_identity(identity);
3528 let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3529 let (seq, feed_hash, hub_signer) = match &head.pointer {
3530 None => {
3531 if head.profile != "v2-empty" {
3532 return Err(invalid_feed("initialized v2 head has no pointer"));
3533 }
3534 (
3535 0,
3536 None,
3537 pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3538 )
3539 }
3540 Some(signed) => {
3541 let signer = verify_v2_pointer(signed, &head.brain_id)?;
3542 if pinned
3543 .as_ref()
3544 .and_then(|state| state.hub_signer.as_ref())
3545 .is_some_and(|known| known != &signer)
3546 {
3547 return Err(invalid_feed(
3548 "v2 hub pointer signer changed without a trust transition",
3549 ));
3550 }
3551 if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3552 if signed.pointer.seq < checkpoint.head_seq
3553 || (signed.pointer.seq == checkpoint.head_seq
3554 && checkpoint.feed_hash.as_deref()
3555 != Some(signed.pointer.feed_hash.as_str()))
3556 {
3557 return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3558 }
3559 }
3560 verify_v2_commit(
3561 cfg,
3562 &head.brain_id,
3563 &signed.pointer,
3564 identity,
3565 pinned.as_ref(),
3566 )?;
3567 (
3568 signed.pointer.seq,
3569 Some(signed.pointer.feed_hash.clone()),
3570 Some(signer),
3571 )
3572 }
3573 };
3574 let trust = TrustState {
3575 v: 2,
3576 origin: normalized_origin(&cfg.hub)?,
3577 requested: head.brain_id.clone(),
3578 brain: head.brain_id.clone(),
3579 home: None,
3580 anchor,
3581 current: format!("ed25519:{}", identity.fingerprint),
3582 head_seq: seq,
3583 feed_hash,
3584 rotations: identity.rotations.clone(),
3585 hub_signer,
3586 protocol_profile: Some("link-v2".to_string()),
3587 };
3588 Ok(Some(V2VerifiedHead {
3589 requested: brain.to_string(),
3590 brain_id: head.brain_id,
3591 view_kind,
3592 view_revision,
3593 control_revision,
3594 identity: identity.clone(),
3595 pointer: head.pointer.map(|signed| signed.pointer),
3596 trust,
3597 alias: alias_binding,
3598 }))
3599}
3600
3601fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3602 let directory = open_trust_dir(cfg)?;
3603 let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3604 let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3605 if let Some(current) = current {
3606 let common_invalid = head.trust.anchor != current.anchor
3607 || !head.trust.rotations.starts_with(¤t.rotations);
3608 let profile_invalid = if accepted_as_v2(¤t) {
3609 head.trust.head_seq < current.head_seq
3610 || (head.trust.head_seq == current.head_seq
3611 && head.trust.feed_hash != current.feed_hash)
3612 || current
3613 .hub_signer
3614 .as_ref()
3615 .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3616 } else {
3617 head.trust.protocol_profile.as_deref() != Some("link-v2")
3618 || head.trust.hub_signer.is_none()
3619 };
3620 if common_invalid || profile_invalid {
3621 return Err(invalid_feed(
3622 "v2 head cannot advance the currently accepted trust checkpoint",
3623 ));
3624 }
3625 }
3626 save_canonical_pin_and_alias(
3627 cfg,
3628 &directory,
3629 &head.requested,
3630 &head.brain_id,
3631 head.trust.clone(),
3632 alias.as_ref().or(head.alias.as_ref()),
3633 )
3634}
3635
3636#[derive(Debug, Clone, Deserialize, Serialize)]
3637struct V2BaselineFile {
3638 sha256: String,
3639 bytes: u64,
3640 #[serde(skip)]
3641 proof: Option<Vec<V2ProofStep>>,
3642}
3643
3644#[derive(Debug, Clone, Deserialize, Serialize)]
3645struct V2SyncBaseline {
3646 v: u8,
3647 origin: String,
3648 brain: String,
3649 #[serde(default)]
3650 checkout_id: Option<String>,
3651 #[serde(default)]
3652 head_seq: Option<u64>,
3653 commit_hash: Option<String>,
3654 content_root: Option<String>,
3655 #[serde(default)]
3656 asset_root: Option<String>,
3657 #[serde(default)]
3658 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3659 #[serde(default)]
3660 view_kind: Option<String>,
3661 #[serde(default)]
3662 view_revision: Option<String>,
3663 #[serde(default)]
3664 projection_sha256: Option<String>,
3665 files: std::collections::BTreeMap<String, V2BaselineFile>,
3666 #[serde(default)]
3667 local_policy_digest: Option<String>,
3668 #[serde(default)]
3669 local_eligibility: std::collections::BTreeMap<String, bool>,
3670 #[serde(default)]
3671 remote_copy_remains: std::collections::BTreeMap<String, String>,
3672}
3673
3674struct V2LocalView {
3675 riding: std::collections::BTreeMap<String, (String, u64)>,
3676 eligibility: std::collections::BTreeMap<String, bool>,
3677 policy: crate::linkmd_sync_policy::SyncPolicy,
3678 withheld_links: Vec<V2WithheldLink>,
3679}
3680
3681#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3682struct V2WithheldLink {
3683 source: String,
3684 target: String,
3685}
3686
3687#[derive(Debug, Clone, Deserialize, Serialize)]
3688struct V2ProofStep {
3689 directory_root: String,
3690 component: String,
3691 proof: crate::linkmd_v2::HamtProof,
3692}
3693
3694#[derive(Debug, Deserialize)]
3695struct V2ManifestFile {
3696 path: String,
3697 sha256: String,
3698 bytes: u64,
3699 proof: Vec<V2ProofStep>,
3700}
3701
3702#[derive(Debug, Deserialize)]
3703struct V2ManifestPage {
3704 v: u8,
3705 commit: String,
3706 content_root: Option<String>,
3707 files: Vec<V2ManifestFile>,
3708 next_cursor: Option<String>,
3709}
3710
3711#[derive(Debug, Clone, Deserialize, Serialize)]
3712struct V2BaselineAsset {
3713 blob_sha256: String,
3714 bytes: u64,
3715 media_type: String,
3716 wrappers: Vec<String>,
3717 required: bool,
3718 disposition: String,
3719 leaf_hash: String,
3720}
3721
3722#[derive(Debug, Deserialize)]
3723struct V2AssetManifestItem {
3724 path: String,
3725 blob_sha256: String,
3726 bytes: u64,
3727 media_type: String,
3728 wrappers: Vec<String>,
3729 required: bool,
3730 disposition: String,
3731 leaf_hash: String,
3732 proof: crate::linkmd_v2::HamtProof,
3733}
3734
3735#[derive(Debug, Deserialize)]
3736struct V2AssetManifestPage {
3737 v: u8,
3738 commit: String,
3739 asset_root: Option<String>,
3740 assets: Vec<V2AssetManifestItem>,
3741 next_cursor: Option<String>,
3742}
3743
3744#[derive(Debug, Deserialize)]
3745struct V2SigningCandidate {
3746 seq: u64,
3747 content_root: Option<String>,
3748 asset_root: Option<String>,
3749 signing_bytes_base64: String,
3750 changes_base64: String,
3751 actor_claim_base64: String,
3752}
3753
3754#[derive(Debug, Deserialize)]
3755struct V2SigningCandidatePage {
3756 v: u8,
3757 challenge_id: String,
3758 mutation_id: String,
3759 request_hash: String,
3760 parent: V2SigningParent,
3761 candidate: V2SigningCandidate,
3762 files: Vec<V2ManifestFile>,
3763 #[serde(default)]
3764 assets: Vec<V2AssetManifestItem>,
3765 next_cursor: Option<String>,
3766 expires_at: String,
3767}
3768
3769#[derive(Debug, Deserialize)]
3770struct V2SigningParent {
3771 seq: u64,
3772 commit_hash: Option<String>,
3773}
3774
3775fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3776 let normalized = crate::linkmd_v2::normalize_path(&file.path)
3777 .map_err(|error| invalid_feed(error.to_string()))?;
3778 let components = normalized.split('/').collect::<Vec<_>>();
3779 if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3780 return Err(invalid_feed("v2 file proof has the wrong shape"));
3781 }
3782 let mut directory_root = root.to_string();
3783 for (index, step) in file.proof.iter().enumerate() {
3784 if step.directory_root != directory_root || step.component != components[index] {
3785 return Err(invalid_feed(
3786 "v2 file proof path chain differs from its manifest",
3787 ));
3788 }
3789 if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3790 .map_err(|error| invalid_feed(error.to_string()))?
3791 {
3792 return Err(invalid_feed("v2 file proof failed verification"));
3793 }
3794 let entry = match &step.proof {
3795 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3796 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3797 return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3798 }
3799 };
3800 if index + 1 == components.len() {
3801 if entry.kind != crate::linkmd_v2::EntryKind::Blob
3802 || entry.child_hash != file.sha256
3803 || entry.bytes != Some(file.bytes)
3804 {
3805 return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3806 }
3807 } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3808 return Err(invalid_feed("v2 file proof traversed a non-directory"));
3809 } else {
3810 directory_root = entry.child_hash.clone();
3811 }
3812 }
3813 Ok(())
3814}
3815
3816fn v2_manifest(
3817 cfg: &HubConfig,
3818 brain: &str,
3819 pointer: Option<&V2PointerBody>,
3820) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3821 let Some(pointer) = pointer else {
3822 return Ok(std::collections::BTreeMap::new());
3823 };
3824 let Some(root) = pointer.content_root.as_deref() else {
3825 return Ok(std::collections::BTreeMap::new());
3826 };
3827 let mut files = std::collections::BTreeMap::new();
3828 let mut after = String::new();
3829 loop {
3830 let encoded_after: String =
3831 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3832 let path = format!(
3833 "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3834 pointer.commit_hash
3835 );
3836 let value = ensure_ok(
3837 request_capped(
3838 cfg,
3839 "GET",
3840 &path,
3841 None,
3842 Auth::Required,
3843 MAX_FEED_RESPONSE_BYTES,
3844 )?,
3845 "v2 file manifest",
3846 )?;
3847 let page: V2ManifestPage = serde_json::from_value(value)
3848 .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3849 if page.v != 2
3850 || page.commit != pointer.commit_hash
3851 || page.content_root.as_deref() != Some(root)
3852 || page.files.len() > 500
3853 {
3854 return Err(invalid_feed(
3855 "v2 file manifest is not bound to the verified head",
3856 ));
3857 }
3858 for file in page.files {
3859 verify_v2_file_proof(root, &file)?;
3860 if files
3861 .insert(
3862 file.path.clone(),
3863 V2BaselineFile {
3864 sha256: file.sha256,
3865 bytes: file.bytes,
3866 proof: Some(file.proof),
3867 },
3868 )
3869 .is_some()
3870 {
3871 return Err(invalid_feed("v2 file manifest repeats a path"));
3872 }
3873 if files.len() > MAX_PUSH_FILES {
3874 return Err(invalid_feed(
3875 "v2 file manifest exceeds the file-count bound",
3876 ));
3877 }
3878 }
3879 match page.next_cursor {
3880 None => break,
3881 Some(next) if next > after => after = next,
3882 Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3883 }
3884 }
3885 Ok(files)
3886}
3887
3888fn v2_manifest_file(
3893 cfg: &HubConfig,
3894 brain: &str,
3895 pointer: &V2PointerBody,
3896 path: &str,
3897) -> LinkResult<Option<V2BaselineFile>> {
3898 let Some(root) = pointer.content_root.as_deref() else {
3899 return Ok(None);
3900 };
3901 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
3902 path: error.to_string(),
3903 })?;
3904 let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
3905 let response = request_capped(
3906 cfg,
3907 "GET",
3908 &format!(
3909 "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
3910 pointer.commit_hash
3911 ),
3912 None,
3913 Auth::Required,
3914 MAX_FEED_RESPONSE_BYTES,
3915 )?;
3916 if response.status == 404 {
3920 return Ok(None);
3921 }
3922 let value = ensure_ok(response, "v2 exact file proof")?;
3923 let mut page: V2ManifestPage = serde_json::from_value(value)
3924 .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
3925 if page.v != 2
3926 || page.commit != pointer.commit_hash
3927 || page.content_root.as_deref() != Some(root)
3928 || page.next_cursor.is_some()
3929 || page.files.len() != 1
3930 || page.files[0].path != path
3931 {
3932 return Err(invalid_feed(
3933 "v2 exact file proof is not bound to the requested signed path",
3934 ));
3935 }
3936 let file = page.files.pop().expect("exactly one file was checked");
3937 verify_v2_file_proof(root, &file)?;
3938 Ok(Some(V2BaselineFile {
3939 sha256: file.sha256,
3940 bytes: file.bytes,
3941 proof: Some(file.proof),
3942 }))
3943}
3944
3945fn v2_manifest_file_by_id(
3950 cfg: &HubConfig,
3951 brain: &str,
3952 pointer: &V2PointerBody,
3953 id: &str,
3954) -> LinkResult<(String, V2BaselineFile)> {
3955 let root = pointer
3956 .content_root
3957 .as_deref()
3958 .ok_or_else(|| LinkError::Http {
3959 what: "resolve",
3960 status: 404,
3961 message: "record not found".to_string(),
3962 code: Some("NOT_FOUND".to_string()),
3963 details: None,
3964 })?;
3965 if !crate::ulid::is_ulid(id) {
3966 return Err(LinkError::BadAddress {
3967 given: id.to_string(),
3968 reason: BAD_TARGET_REASON.to_string(),
3969 });
3970 }
3971 let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
3972 let value = ensure_ok(
3973 request_capped(
3974 cfg,
3975 "GET",
3976 &format!(
3977 "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
3978 pointer.commit_hash
3979 ),
3980 None,
3981 Auth::Required,
3982 MAX_FEED_RESPONSE_BYTES,
3983 )?,
3984 "v2 exact id proof",
3985 )?;
3986 let mut page: V2ManifestPage = serde_json::from_value(value)
3987 .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
3988 if page.v != 2
3989 || page.commit != pointer.commit_hash
3990 || page.content_root.as_deref() != Some(root)
3991 || page.next_cursor.is_some()
3992 || page.files.len() != 1
3993 {
3994 return Err(invalid_feed(
3995 "v2 exact id proof is not bound to one signed path",
3996 ));
3997 }
3998 let file = page.files.pop().expect("exactly one file was checked");
3999 if !safe_store_rel_path(&file.path)
4000 || !file.path.ends_with(".md")
4001 || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
4002 {
4003 return Err(invalid_feed("v2 exact id proof has an invalid record path"));
4004 }
4005 verify_v2_file_proof(root, &file)?;
4006 Ok((
4007 file.path,
4008 V2BaselineFile {
4009 sha256: file.sha256,
4010 bytes: file.bytes,
4011 proof: Some(file.proof),
4012 },
4013 ))
4014}
4015
4016fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
4017 crate::linkmd_v2::normalize_path(&item.path)
4018 .map_err(|error| invalid_feed(error.to_string()))?;
4019 if !is_sha256(&item.blob_sha256)
4020 || !is_sha256(&item.leaf_hash)
4021 || item.bytes > MAX_ASSET_BYTES
4022 || item.wrappers.is_empty()
4023 || !matches!(item.disposition.as_str(), "hosted" | "withheld")
4024 || item
4025 .wrappers
4026 .iter()
4027 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4028 {
4029 return Err(invalid_feed("v2 asset manifest item is invalid"));
4030 }
4031 let leaf = json!({
4032 "blob_sha256": item.blob_sha256,
4033 "bytes": item.bytes,
4034 "disposition": item.disposition,
4035 "media_type": item.media_type,
4036 "path": item.path,
4037 "required": item.required,
4038 "v": 2,
4039 "wrappers": item.wrappers,
4040 });
4041 if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
4042 .map_err(|error| invalid_feed(error.to_string()))?
4043 != item.leaf_hash
4044 || !crate::linkmd_v2::verify_proof_with_domain(
4045 root,
4046 &item.path,
4047 &item.proof,
4048 crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
4049 )
4050 .map_err(|error| invalid_feed(error.to_string()))?
4051 {
4052 return Err(invalid_feed("v2 asset inclusion proof failed"));
4053 }
4054 match &item.proof {
4055 crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
4056 if entry.name == item.path
4057 && entry.kind == crate::linkmd_v2::EntryKind::Blob
4058 && entry.child_hash == item.leaf_hash
4059 && entry.bytes == Some(item.bytes) =>
4060 {
4061 Ok(())
4062 }
4063 _ => Err(invalid_feed(
4064 "v2 asset proof leaf differs from its manifest",
4065 )),
4066 }
4067}
4068
4069fn v2_asset_manifest(
4070 cfg: &HubConfig,
4071 brain: &str,
4072 pointer: Option<&V2PointerBody>,
4073) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
4074 let Some(pointer) = pointer else {
4075 return Ok(std::collections::BTreeMap::new());
4076 };
4077 let Some(root) = pointer.asset_root.as_deref() else {
4078 return Ok(std::collections::BTreeMap::new());
4079 };
4080 let mut assets = std::collections::BTreeMap::new();
4081 let mut after = String::new();
4082 loop {
4083 let encoded_after: String =
4084 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4085 let path = format!(
4086 "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4087 pointer.commit_hash
4088 );
4089 let value = ensure_ok(
4090 request_capped(
4091 cfg,
4092 "GET",
4093 &path,
4094 None,
4095 Auth::Required,
4096 MAX_FEED_RESPONSE_BYTES,
4097 )?,
4098 "v2 asset manifest",
4099 )?;
4100 let page: V2AssetManifestPage = serde_json::from_value(value)
4101 .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4102 if page.v != 2
4103 || page.commit != pointer.commit_hash
4104 || page.asset_root.as_deref() != Some(root)
4105 || page.assets.len() > 500
4106 {
4107 return Err(invalid_feed(
4108 "v2 asset manifest is not bound to the verified head",
4109 ));
4110 }
4111 for item in page.assets {
4112 verify_v2_asset_proof(root, &item)?;
4113 let path = item.path.clone();
4114 if assets
4115 .insert(
4116 path,
4117 V2BaselineAsset {
4118 blob_sha256: item.blob_sha256,
4119 bytes: item.bytes,
4120 media_type: item.media_type,
4121 wrappers: item.wrappers,
4122 required: item.required,
4123 disposition: item.disposition,
4124 leaf_hash: item.leaf_hash,
4125 },
4126 )
4127 .is_some()
4128 {
4129 return Err(invalid_feed("v2 asset manifest repeats a path"));
4130 }
4131 if assets.len() > MAX_PUSH_FILES {
4132 return Err(invalid_feed(
4133 "v2 asset manifest exceeds the item-count bound",
4134 ));
4135 }
4136 }
4137 match page.next_cursor {
4138 None => break,
4139 Some(next) if next > after => after = next,
4140 Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4141 }
4142 }
4143 Ok(assets)
4144}
4145
4146fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4147 crate::AssetRecord {
4148 path: path.to_string(),
4149 sha256: asset.blob_sha256.clone(),
4150 bytes: asset.bytes,
4151 media_type: asset.media_type.clone(),
4152 wrappers: asset.wrappers.clone(),
4153 required: asset.required,
4154 }
4155}
4156
4157fn v2_asset_resumes_hosting(
4158 remote: Option<&V2BaselineAsset>,
4159 path: &str,
4160 record: &crate::AssetRecord,
4161 disposition: &str,
4162) -> bool {
4163 remote.is_some_and(|asset| {
4164 asset.disposition == "withheld"
4165 && disposition == "hosted"
4166 && v2_asset_record(asset, path) == *record
4167 })
4168}
4169
4170fn v2_asset_record_manifest_bytes(
4171 assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4172) -> LinkResult<Vec<u8>> {
4173 let mut bytes = Vec::new();
4174 for (path, asset) in assets {
4175 if asset.path != *path {
4176 return Err(invalid_feed(
4177 "local asset manifest key differs from its record path",
4178 ));
4179 }
4180 serde_json::to_writer(&mut bytes, asset)
4181 .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4182 bytes.push(b'\n');
4183 }
4184 Ok(bytes)
4185}
4186
4187fn v2_local_asset_records(
4188 store: &Store,
4189) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4190 let assets = crate::assets::read_manifest(store)
4191 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?;
4192 if assets.iter().any(|asset| asset.bytes > MAX_ASSET_BYTES) {
4193 return Err(LinkError::InvalidPack {
4194 message: "local asset exceeds the link.md v2 per-blob limit".to_string(),
4195 });
4196 }
4197 Ok(assets
4198 .into_iter()
4199 .map(|asset| (asset.path.clone(), asset))
4200 .collect())
4201}
4202
4203fn v2_asset_records_match_remote(
4204 local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4205 remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4206) -> bool {
4207 local.len() == remote.len()
4208 && remote
4209 .iter()
4210 .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4211}
4212
4213#[derive(Debug, Clone, PartialEq, Eq)]
4214struct V2PulledMerge<T> {
4215 records: std::collections::BTreeMap<String, T>,
4216 accept_remote: std::collections::BTreeSet<String>,
4217 conflicts: Vec<String>,
4218}
4219
4220fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4226 base: &std::collections::BTreeMap<String, Base>,
4227 remote: &std::collections::BTreeMap<String, Remote>,
4228 local: &std::collections::BTreeMap<String, Record>,
4229 base_record: BaseRecord,
4230 remote_record: RemoteRecord,
4231 keep_local: KeepLocal,
4232) -> V2PulledMerge<Record>
4233where
4234 Record: Clone + Eq,
4235 BaseRecord: Fn(&Base, &str) -> Record,
4236 RemoteRecord: Fn(&Remote, &str) -> Record,
4237 KeepLocal: Fn(&str) -> bool,
4238{
4239 let paths = base
4240 .keys()
4241 .chain(remote.keys())
4242 .chain(local.keys())
4243 .cloned()
4244 .collect::<std::collections::BTreeSet<_>>();
4245 let mut records = local.clone();
4246 let mut accept_remote = std::collections::BTreeSet::new();
4247 let mut conflicts = Vec::new();
4248 for path in paths {
4249 if keep_local(&path) {
4250 continue;
4251 }
4252 let base_value = base.get(&path).map(|value| base_record(value, &path));
4253 let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4254 let local_value = local.get(&path).cloned();
4255 if local_value != base_value && remote_value != base_value && local_value != remote_value {
4256 conflicts.push(path);
4257 continue;
4258 }
4259 if local_value == base_value || local_value == remote_value {
4260 accept_remote.insert(path.clone());
4261 match remote_value {
4262 Some(value) => {
4263 records.insert(path, value);
4264 }
4265 None => {
4266 records.remove(&path);
4267 }
4268 }
4269 }
4270 }
4271 V2PulledMerge {
4272 records,
4273 accept_remote,
4274 conflicts,
4275 }
4276}
4277
4278fn sign_verified_v2_candidate(
4279 cfg: &HubConfig,
4280 head: &V2VerifiedHead,
4281 expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4282 expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4283 mutation_id: &str,
4284 request_body: &Value,
4285 challenge_value: &Value,
4286) -> LinkResult<(String, String, String)> {
4287 if head.view_kind != "full" {
4288 return Err(invalid_feed(
4289 "a scoped self-custody writer must use the proposal workflow",
4290 ));
4291 }
4292 if head.identity.custody != "self" {
4293 return Err(invalid_feed(
4294 "a hub-custodied brain unexpectedly requested an external signature",
4295 ));
4296 }
4297 let key = cfg
4298 .brain_key
4299 .as_ref()
4300 .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4301 if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4302 || key.public_key_spki != head.identity.public_key_spki
4303 {
4304 return Err(bad_agent_key(
4305 "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4306 ));
4307 }
4308 let challenge_id = challenge_value
4309 .get("id")
4310 .and_then(Value::as_str)
4311 .filter(|id| crate::ulid::is_ulid(id))
4312 .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4313 let expected_endpoint = format!(
4314 "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4315 head.brain_id
4316 );
4317 if challenge_value
4318 .get("candidate_endpoint")
4319 .and_then(Value::as_str)
4320 != Some(expected_endpoint.as_str())
4321 {
4322 return Err(invalid_feed(
4323 "self-custody challenge candidate endpoint is not origin-bound",
4324 ));
4325 }
4326
4327 let mut files = std::collections::BTreeMap::new();
4328 let mut after = String::new();
4329 type CandidateCoordinate = (
4330 String,
4331 String,
4332 String,
4333 String,
4334 Option<String>,
4335 Option<String>,
4336 u64,
4337 Option<String>,
4338 );
4339 let mut pinned: Option<CandidateCoordinate> = None;
4340 loop {
4341 let encoded_after: String =
4342 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4343 let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4344 let value = ensure_ok(
4345 request_capped(
4346 cfg,
4347 "GET",
4348 &path,
4349 None,
4350 Auth::Required,
4351 MAX_FEED_RESPONSE_BYTES,
4352 )?,
4353 "v2 self-custody candidate",
4354 )?;
4355 let page: V2SigningCandidatePage = serde_json::from_value(value)
4356 .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4357 if page.v != 2
4358 || page.challenge_id != challenge_id
4359 || page.mutation_id != mutation_id
4360 || page.candidate.seq != page.parent.seq + 1
4361 || page.files.len() > 500
4362 || page.expires_at.is_empty()
4363 {
4364 return Err(invalid_feed(
4365 "self-custody candidate is not bound to this mutation",
4366 ));
4367 }
4368 let coordinate = (
4369 page.request_hash.clone(),
4370 page.candidate.signing_bytes_base64.clone(),
4371 page.candidate.changes_base64.clone(),
4372 page.candidate.actor_claim_base64.clone(),
4373 page.candidate.content_root.clone(),
4374 page.candidate.asset_root.clone(),
4375 page.parent.seq,
4376 page.parent.commit_hash.clone(),
4377 );
4378 if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4379 return Err(invalid_feed(
4380 "self-custody candidate changed between manifest pages",
4381 ));
4382 }
4383 pinned = Some(coordinate);
4384 let root = page
4385 .candidate
4386 .content_root
4387 .as_deref()
4388 .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4389 for file in page.files {
4390 verify_v2_file_proof(root, &file)?;
4391 if files
4392 .insert(
4393 file.path.clone(),
4394 V2BaselineFile {
4395 sha256: file.sha256,
4396 bytes: file.bytes,
4397 proof: Some(file.proof),
4398 },
4399 )
4400 .is_some()
4401 {
4402 return Err(invalid_feed(
4403 "self-custody candidate repeats a manifest path",
4404 ));
4405 }
4406 if files.len() > MAX_PUSH_FILES {
4407 return Err(invalid_feed(
4408 "self-custody candidate exceeds the file-count bound",
4409 ));
4410 }
4411 }
4412 match page.next_cursor {
4413 None => break,
4414 Some(next) if next > after => after = next,
4415 Some(_) => {
4416 return Err(invalid_feed(
4417 "self-custody candidate cursor did not advance",
4418 ))
4419 }
4420 }
4421 }
4422 if files.len() != expected.len()
4423 || files.iter().any(|(path, file)| {
4424 expected.get(path).is_none_or(|expected| {
4425 expected.sha256 != file.sha256 || expected.bytes != file.bytes
4426 })
4427 })
4428 {
4429 return Err(invalid_feed(
4430 "self-custody candidate contains an unexpected file mutation",
4431 ));
4432 }
4433 let mut assets = std::collections::BTreeMap::new();
4434 after.clear();
4435 loop {
4436 let encoded_after: String =
4437 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4438 let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4439 let value = ensure_ok(
4440 request_capped(
4441 cfg,
4442 "GET",
4443 &path,
4444 None,
4445 Auth::Required,
4446 MAX_FEED_RESPONSE_BYTES,
4447 )?,
4448 "v2 self-custody asset candidate",
4449 )?;
4450 let page: V2SigningCandidatePage = serde_json::from_value(value)
4451 .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4452 let coordinate = (
4453 page.request_hash.clone(),
4454 page.candidate.signing_bytes_base64.clone(),
4455 page.candidate.changes_base64.clone(),
4456 page.candidate.actor_claim_base64.clone(),
4457 page.candidate.content_root.clone(),
4458 page.candidate.asset_root.clone(),
4459 page.parent.seq,
4460 page.parent.commit_hash.clone(),
4461 );
4462 if page.v != 2
4463 || page.challenge_id != challenge_id
4464 || page.mutation_id != mutation_id
4465 || page.assets.len() > 500
4466 || pinned.as_ref() != Some(&coordinate)
4467 {
4468 return Err(invalid_feed(
4469 "self-custody asset candidate changed or is not bound",
4470 ));
4471 }
4472 let root = page.candidate.asset_root.as_deref();
4473 if !page.assets.is_empty() && root.is_none() {
4474 return Err(invalid_feed("asset candidate has no asset root"));
4475 }
4476 for item in page.assets {
4477 verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4478 if assets
4479 .insert(
4480 item.path.clone(),
4481 V2BaselineAsset {
4482 blob_sha256: item.blob_sha256,
4483 bytes: item.bytes,
4484 media_type: item.media_type,
4485 wrappers: item.wrappers,
4486 required: item.required,
4487 disposition: item.disposition,
4488 leaf_hash: item.leaf_hash,
4489 },
4490 )
4491 .is_some()
4492 {
4493 return Err(invalid_feed("self-custody candidate repeats an asset"));
4494 }
4495 }
4496 match page.next_cursor {
4497 None => break,
4498 Some(next) if next > after => after = next,
4499 Some(_) => {
4500 return Err(invalid_feed(
4501 "self-custody asset candidate cursor did not advance",
4502 ))
4503 }
4504 }
4505 }
4506 if assets.len() != expected_assets.len()
4507 || assets.iter().any(|(path, asset)| {
4508 expected_assets.get(path).is_none_or(|expected| {
4509 asset.blob_sha256 != expected.blob_sha256
4510 || asset.bytes != expected.bytes
4511 || asset.media_type != expected.media_type
4512 || asset.wrappers != expected.wrappers
4513 || asset.required != expected.required
4514 || asset.disposition != expected.disposition
4515 })
4516 })
4517 {
4518 return Err(invalid_feed(
4519 "self-custody candidate contains an unexpected asset mutation",
4520 ));
4521 }
4522 let Some((
4523 request_hash,
4524 signing_b64,
4525 changes_b64,
4526 actor_b64,
4527 root,
4528 asset_root,
4529 parent_seq,
4530 parent,
4531 )) = pinned
4532 else {
4533 return Err(invalid_feed("self-custody candidate has no manifest"));
4534 };
4535 let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4536 let current_commit = head
4537 .pointer
4538 .as_ref()
4539 .map(|pointer| pointer.commit_hash.clone());
4540 if parent_seq != current_seq || parent != current_commit {
4541 return Err(LinkError::RemoteAdvancedDuringSync);
4542 }
4543 let changes = STANDARD
4544 .decode(changes_b64)
4545 .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4546 let mut expected_changes = json!({
4547 "mutation_id": mutation_id,
4548 "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4549 "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4550 "v": 2,
4551 });
4552 if let Some(withheld_links) = request_body.get("withheld_links") {
4553 expected_changes["withheld_links"] = withheld_links.clone();
4554 }
4555 if let Some(checkout_id) = request_body.get("checkout_id") {
4556 expected_changes["checkout_id"] = checkout_id.clone();
4557 }
4558 let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4559 .map_err(|error| invalid_feed(error.to_string()))?;
4560 if changes != expected_changes_bytes {
4561 return Err(invalid_feed(
4562 "self-custody changeset differs from the requested mutation",
4563 ));
4564 }
4565 let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4566 .map_err(|error| invalid_feed(error.to_string()))?;
4567 let request_value = json!({
4568 "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4569 "brain": head.brain_id,
4570 "changes_sha256": changes_hash,
4571 "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4572 "v": 2,
4573 "v1_bridge": Value::Null,
4574 });
4575 let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4576 .map_err(|error| invalid_feed(error.to_string()))?;
4577 if request_hash != expected_request_hash {
4578 return Err(invalid_feed(
4579 "self-custody request hash differs from the requested mutation",
4580 ));
4581 }
4582 let actor = STANDARD
4583 .decode(actor_b64)
4584 .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4585 let actor_value: Value = serde_json::from_slice(&actor)
4586 .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4587 if crate::linkmd_v2::canonical_bytes(&actor_value)
4588 .map_err(|error| invalid_feed(error.to_string()))?
4589 != actor
4590 {
4591 return Err(invalid_feed("self-custody actor claim is not canonical"));
4592 }
4593 let actor_object = actor_value
4594 .as_object()
4595 .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4596 let actor_claim = actor_object
4597 .get("claim")
4598 .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4599 let actor_public_key = actor_object
4600 .get("public_key")
4601 .and_then(Value::as_str)
4602 .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4603 let actor_fingerprint = actor_object
4604 .get("fingerprint")
4605 .and_then(Value::as_str)
4606 .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4607 let actor_signature = actor_object
4608 .get("sig")
4609 .and_then(Value::as_str)
4610 .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4611 let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4612 .map_err(|error| invalid_feed(error.to_string()))?;
4613 let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4614 let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4615 let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4616 let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4617 let impact = actor_claim
4618 .get("result")
4619 .and_then(|result| result.get("impact"))
4620 .and_then(Value::as_object);
4621 let impact_fields = [
4622 "creates",
4623 "updates",
4624 "deletes",
4625 "withdrawals",
4626 "renames",
4627 "restores",
4628 "asset_changes",
4629 "public_expansions",
4630 "executable_activations",
4631 ];
4632 let impact_is_valid = impact.is_some_and(|impact| {
4633 impact.len() == impact_fields.len() + 1
4634 && impact.get("v").and_then(Value::as_u64) == Some(1)
4635 && impact_fields
4636 .iter()
4637 .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4638 });
4639 if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4640 || head
4641 .trust
4642 .hub_signer
4643 .as_ref()
4644 .is_some_and(|known| known != &expected_actor_signer)
4645 || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4646 || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4647 || actor_claim
4648 .get("candidate")
4649 .and_then(|candidate| candidate.get("changes_sha256"))
4650 .and_then(Value::as_str)
4651 != Some(changes_hash.as_str())
4652 || actor_claim
4653 .get("candidate")
4654 .and_then(|candidate| candidate.get("state_root"))
4655 != Some(&expected_actor_root)
4656 || actor_claim
4657 .get("candidate")
4658 .and_then(|candidate| candidate.get("asset_root"))
4659 != Some(&expected_actor_asset_root)
4660 || actor_claim
4661 .get("candidate")
4662 .and_then(|candidate| candidate.get("control_revision"))
4663 .and_then(Value::as_str)
4664 != Some(head.control_revision.as_str())
4665 || !impact_is_valid
4666 {
4667 return Err(invalid_feed(
4668 "self-custody actor claim does not bind the verified authority",
4669 ));
4670 }
4671 let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4672 .map_err(|error| invalid_feed(error.to_string()))?;
4673 let signing = STANDARD
4674 .decode(signing_b64)
4675 .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4676 let signing_value: Value = serde_json::from_slice(&signing)
4677 .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4678 if crate::linkmd_v2::canonical_bytes(&signing_value)
4679 .map_err(|error| invalid_feed(error.to_string()))?
4680 != signing
4681 {
4682 return Err(invalid_feed("self-custody signing bytes are not canonical"));
4683 }
4684 let pointer = head.pointer.as_ref();
4685 let expected_materializer = pointer
4686 .map(|value| value.materializer.as_str())
4687 .unwrap_or("dbmd-projection-v1");
4688 let expected_parent_commit = request_body
4689 .get("base")
4690 .and_then(|base| base.get("commit_hash"))
4691 .cloned()
4692 .unwrap_or(Value::Null);
4693 let expected_parent_root = request_body
4694 .get("base")
4695 .and_then(|base| base.get("content_root"))
4696 .cloned()
4697 .unwrap_or(Value::Null);
4698 let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4699 let expected_parent_asset_root = request_body
4700 .get("base")
4701 .and_then(|base| base.get("asset_root"))
4702 .cloned()
4703 .unwrap_or(Value::Null);
4704 let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4705 let expected_prev_entry = pointer
4706 .map(|value| Value::String(value.feed_hash.clone()))
4707 .unwrap_or(Value::Null);
4708 let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4709 .map_err(|_| invalid_feed("brain identity history is too large"))?
4710 + 1;
4711 if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4712 || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4713 || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4714 || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4715 || signing_value.get("public_key").and_then(Value::as_str)
4716 != Some(key.public_key_spki.as_str())
4717 || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4718 || signing_value.get("parent_root") != Some(&expected_parent_root)
4719 || signing_value.get("state_root") != Some(&expected_state_root)
4720 || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4721 || signing_value.get("asset_root") != Some(&expected_asset_root)
4722 || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4723 || signing_value.get("changes_sha256").and_then(Value::as_str)
4724 != Some(changes_hash.as_str())
4725 || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4726 || signing_value
4727 .get("control_revision")
4728 .and_then(Value::as_str)
4729 != Some(head.control_revision.as_str())
4730 || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4731 || signing_value.get("v1_bridge") != Some(&Value::Null)
4732 || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4733 {
4734 return Err(invalid_feed(
4735 "self-custody signing bytes do not bind the verified candidate",
4736 ));
4737 }
4738 let pair = agent_keypair(&key.pkcs8)?;
4739 let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4740 Ok((challenge_id.to_string(), signature, expected_actor_signer))
4741}
4742
4743fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4744 let origin = normalized_origin(&cfg.hub)?;
4745 let absolute = if checkout.is_absolute() {
4746 checkout.to_path_buf()
4747 } else {
4748 std::env::current_dir()?.join(checkout)
4749 };
4750 Ok(format!(
4751 "sync-{}.json",
4752 content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4753 ))
4754}
4755
4756fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4757 if let Some(value) = existing {
4758 if !is_sha256(value) {
4759 return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4760 }
4761 return Ok(value.to_string());
4762 }
4763 use ring::rand::SecureRandom as _;
4764 let mut random = [0_u8; 32];
4765 ring::rand::SystemRandom::new()
4766 .fill(&mut random)
4767 .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4768 Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4769}
4770
4771#[cfg(any(unix, windows))]
4772fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4773 let directory = open_trust_dir(cfg)?;
4774 let origin = normalized_origin(&cfg.hub)?;
4775 let name = format!(
4776 "operation-{}.lock",
4777 content_sha256(format!("{origin}\0{brain}").as_bytes())
4778 );
4779 lock_trust_name(&directory, &name)
4780}
4781
4782#[cfg(not(any(unix, windows)))]
4783fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4784 Err(LinkError::UnsupportedPlatform {
4785 operation: "serialized link.md v2 sync",
4786 })
4787}
4788
4789fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4790 left.brain_id == right.brain_id
4791 && left.view_kind == right.view_kind
4792 && left.view_revision == right.view_revision
4793 && left.control_revision == right.control_revision
4794 && match (&left.pointer, &right.pointer) {
4795 (None, None) => true,
4796 (Some(left), Some(right)) => {
4797 left.seq == right.seq
4798 && left.commit_hash == right.commit_hash
4799 && left.content_root == right.content_root
4800 && left.asset_root == right.asset_root
4801 && left.feed_hash == right.feed_hash
4802 }
4803 _ => false,
4804 }
4805}
4806
4807fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4808 format!(
4809 "---\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"
4810 )
4811 .into_bytes()
4812}
4813
4814fn scoped_projection_sha256(brain: &str) -> String {
4815 content_sha256(&scoped_projection_bytes(brain))
4816}
4817
4818#[derive(Deserialize)]
4819struct LocalScopedViewMarker {
4820 v: u8,
4821 kind: String,
4822 authoritative: bool,
4823 brain: String,
4824 projection_sha256: String,
4825}
4826
4827pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4831 let marker = store
4832 .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4833 .ok()
4834 .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4835 let Some(marker) = marker else {
4836 return false;
4837 };
4838 if marker.v != 1
4839 || marker.kind != "link.md-scoped-view"
4840 || marker.authoritative
4841 || !crate::ulid::is_ulid(&marker.brain)
4842 || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4843 {
4844 return false;
4845 }
4846 store
4847 .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4848 .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4849}
4850
4851fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4852 let mut bytes = serde_json::to_vec_pretty(&json!({
4853 "v": 1,
4854 "kind": "link.md-scoped-view",
4855 "authoritative": false,
4856 "brain": head.brain_id,
4857 "view_revision": head.view_revision,
4858 "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4859 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4860 "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4861 "visible_files": files,
4862 "projection_sha256": scoped_projection_sha256(&head.brain_id),
4863 }))
4864 .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4865 bytes.push(b'\n');
4866 Ok(bytes)
4867}
4868
4869fn refresh_scoped_view_marker(
4870 store: &Store,
4871 head: &V2VerifiedHead,
4872 files: usize,
4873) -> LinkResult<()> {
4874 if head.view_kind == "scoped" {
4875 store.write_atomic(
4876 Path::new(".dbmd/view.json"),
4877 &scoped_view_metadata(head, files)?,
4878 )?;
4879 }
4880 Ok(())
4881}
4882
4883fn ensure_v2_view_compatible(
4884 head: &V2VerifiedHead,
4885 baseline: Option<&V2SyncBaseline>,
4886) -> LinkResult<()> {
4887 let Some(baseline) = baseline else {
4888 return Ok(());
4889 };
4890 match (
4891 baseline.view_kind.as_deref(),
4892 baseline.view_revision.as_deref(),
4893 ) {
4894 (None, None) if head.view_kind == "full" => Ok(()),
4895 (Some(kind), Some(revision))
4896 if kind == head.view_kind && revision == head.view_revision =>
4897 {
4898 Ok(())
4899 }
4900 _ => Err(LinkError::ScopedViewChanged),
4901 }
4902}
4903
4904fn ensure_established_v2_checkout_opened(
4905 head: &V2VerifiedHead,
4906 baseline: Option<&V2SyncBaseline>,
4907 opened: bool,
4908) -> LinkResult<()> {
4909 if baseline.is_none() || opened {
4910 return Ok(());
4911 }
4912 if head.view_kind == "scoped" {
4913 return Err(LinkError::ScopedProjectionModified);
4914 }
4915 Err(LinkError::InvalidPack {
4916 message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4917 })
4918}
4919
4920fn remove_scoped_projection(
4921 head: &V2VerifiedHead,
4922 baseline: Option<&V2SyncBaseline>,
4923 view: &mut V2LocalView,
4924) -> LinkResult<()> {
4925 if head.view_kind != "scoped" {
4926 return Ok(());
4927 }
4928 let expected = scoped_projection_sha256(&head.brain_id);
4929 if baseline
4930 .and_then(|state| state.projection_sha256.as_deref())
4931 .is_some_and(|pinned| pinned != expected)
4932 {
4933 return Err(LinkError::ScopedViewChanged);
4934 }
4935 if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4936 return Err(LinkError::ScopedProjectionModified);
4937 }
4938 view.riding.remove("DB.md");
4939 view.eligibility.remove("DB.md");
4940 Ok(())
4941}
4942
4943fn local_view_for_v2_push(
4944 store: &Store,
4945 head: &V2VerifiedHead,
4946 baseline: Option<&V2SyncBaseline>,
4947 carried: Option<V2LocalView>,
4948) -> LinkResult<V2LocalView> {
4949 match carried {
4950 Some(view) => Ok(view),
4955 None => {
4956 let mut view = v2_local_files(store)?;
4957 remove_scoped_projection(head, baseline, &mut view)?;
4958 Ok(view)
4959 }
4960 }
4961}
4962
4963fn files_for_v2_view(
4964 head: &V2VerifiedHead,
4965 mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4966) -> std::collections::BTreeMap<String, V2BaselineFile> {
4967 if head.view_kind == "scoped" {
4968 files.remove("DB.md");
4972 }
4973 files
4974}
4975
4976fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4977 let baseline: V2SyncBaseline =
4978 serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
4979 if baseline.v != 2
4980 || baseline.origin != normalized_origin(&cfg.hub)?
4981 || baseline.brain != brain
4982 || baseline
4983 .commit_hash
4984 .as_deref()
4985 .is_some_and(|hash| !is_sha256(hash))
4986 || baseline
4987 .content_root
4988 .as_deref()
4989 .is_some_and(|hash| !is_sha256(hash))
4990 || baseline
4991 .asset_root
4992 .as_deref()
4993 .is_some_and(|hash| !is_sha256(hash))
4994 || baseline
4995 .local_policy_digest
4996 .as_deref()
4997 .is_some_and(|hash| !is_sha256(hash))
4998 || baseline
4999 .view_kind
5000 .as_deref()
5001 .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
5002 || baseline
5003 .view_revision
5004 .as_deref()
5005 .is_some_and(|hash| !is_sha256(hash))
5006 || baseline
5007 .projection_sha256
5008 .as_deref()
5009 .is_some_and(|hash| !is_sha256(hash))
5010 || (baseline.view_kind.as_deref() == Some("scoped")
5011 && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
5012 || baseline.files.len() > MAX_PUSH_FILES
5013 || baseline.assets.len() > MAX_PUSH_FILES
5014 || baseline.local_eligibility.len() > MAX_PUSH_FILES
5015 || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
5016 || baseline.files.iter().any(|(path, file)| {
5017 crate::linkmd_v2::normalize_path(path).is_err()
5018 || !is_sha256(&file.sha256)
5019 || file.bytes > MAX_STORE_BYTES
5020 })
5021 || baseline.assets.iter().any(|(path, asset)| {
5022 crate::linkmd_v2::normalize_path(path).is_err()
5023 || !is_sha256(&asset.blob_sha256)
5024 || !is_sha256(&asset.leaf_hash)
5025 || asset.bytes > MAX_ASSET_BYTES
5026 || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
5027 || asset.wrappers.is_empty()
5028 || asset
5029 .wrappers
5030 .iter()
5031 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
5032 })
5033 || baseline
5034 .local_eligibility
5035 .keys()
5036 .chain(baseline.remote_copy_remains.keys())
5037 .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
5038 || baseline
5039 .remote_copy_remains
5040 .values()
5041 .any(|hash| !is_sha256(hash))
5042 || baseline
5043 .checkout_id
5044 .as_deref()
5045 .is_some_and(|checkout_id| !is_sha256(checkout_id))
5046 {
5047 return Err(invalid_feed("v2 sync baseline failed validation"));
5048 }
5049 Ok(baseline)
5050}
5051
5052#[cfg(unix)]
5053fn load_v2_baseline(
5054 cfg: &HubConfig,
5055 brain: &str,
5056 checkout: &Path,
5057) -> LinkResult<Option<V2SyncBaseline>> {
5058 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5059 let directory = open_trust_dir(cfg)?;
5060 let name_string = v2_baseline_name(cfg, brain, checkout)?;
5061 let _lock = lock_trust_name(&directory, &name_string)?;
5062 let name = c_name(name_string.as_bytes(), &name_string)?;
5063 let fd = unsafe {
5064 libc::openat(
5065 directory.as_raw_fd(),
5066 name.as_ptr(),
5067 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5068 )
5069 };
5070 if fd < 0 {
5071 let error = std::io::Error::last_os_error();
5072 return if error.kind() == std::io::ErrorKind::NotFound {
5073 Ok(None)
5074 } else {
5075 Err(LinkError::UnsafePath { path: name_string })
5076 };
5077 }
5078 let file = unsafe { std::fs::File::from_raw_fd(fd) };
5079 let mut bytes = Vec::new();
5080 file.take(MAX_FEED_RESPONSE_BYTES + 1)
5081 .read_to_end(&mut bytes)?;
5082 if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
5083 return Err(invalid_feed("v2 sync baseline is oversized"));
5084 }
5085 Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
5086}
5087
5088#[cfg(windows)]
5089fn load_v2_baseline(
5090 cfg: &HubConfig,
5091 brain: &str,
5092 checkout: &Path,
5093) -> LinkResult<Option<V2SyncBaseline>> {
5094 let directory = open_trust_dir(cfg)?;
5095 let name = v2_baseline_name(cfg, brain, checkout)?;
5096 let _lock = lock_trust_name(&directory, &name)?;
5097 let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
5098 match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
5099 Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5100 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5101 Err(_) => Err(LinkError::UnsafePath { path: name }),
5102 }
5103}
5104
5105#[cfg(not(any(unix, windows)))]
5106fn load_v2_baseline(
5107 _cfg: &HubConfig,
5108 _brain: &str,
5109 _checkout: &Path,
5110) -> LinkResult<Option<V2SyncBaseline>> {
5111 Err(LinkError::UnsupportedPlatform {
5112 operation: "verified link.md v2 baseline",
5113 })
5114}
5115
5116#[cfg(unix)]
5117fn save_v2_baseline(
5118 cfg: &HubConfig,
5119 brain: &str,
5120 checkout: &Path,
5121 baseline: &V2SyncBaseline,
5122) -> LinkResult<()> {
5123 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5124 let directory = open_trust_dir(cfg)?;
5125 let name_string = v2_baseline_name(cfg, brain, checkout)?;
5126 let _lock = lock_trust_name(&directory, &name_string)?;
5127 let name = c_name(name_string.as_bytes(), &name_string)?;
5128 let mut bytes = serde_json::to_vec(baseline)
5129 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5130 bytes.push(b'\n');
5131 let temp_string = format!(
5132 ".{name_string}.tmp.{}-{}",
5133 std::process::id(),
5134 std::time::SystemTime::now()
5135 .duration_since(std::time::UNIX_EPOCH)
5136 .unwrap_or_default()
5137 .as_nanos()
5138 );
5139 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5140 let fd = unsafe {
5141 libc::openat(
5142 directory.as_raw_fd(),
5143 temp.as_ptr(),
5144 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5145 0o600,
5146 )
5147 };
5148 if fd < 0 {
5149 return Err(std::io::Error::last_os_error().into());
5150 }
5151 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5152 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5153 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5154 return Err(error.into());
5155 }
5156 drop(file);
5157 if unsafe {
5158 libc::renameat(
5159 directory.as_raw_fd(),
5160 temp.as_ptr(),
5161 directory.as_raw_fd(),
5162 name.as_ptr(),
5163 )
5164 } != 0
5165 {
5166 let error = std::io::Error::last_os_error();
5167 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5168 return Err(error.into());
5169 }
5170 directory.sync_all()?;
5171 Ok(())
5172}
5173
5174#[cfg(windows)]
5175fn save_v2_baseline(
5176 cfg: &HubConfig,
5177 brain: &str,
5178 checkout: &Path,
5179 baseline: &V2SyncBaseline,
5180) -> LinkResult<()> {
5181 let directory = open_trust_dir(cfg)?;
5182 let name = v2_baseline_name(cfg, brain, checkout)?;
5183 let _lock = lock_trust_name(&directory, &name)?;
5184 let mut bytes = serde_json::to_vec(baseline)
5185 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5186 bytes.push(b'\n');
5187 crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5188 Ok(())
5189}
5190
5191#[cfg(not(any(unix, windows)))]
5192fn save_v2_baseline(
5193 _cfg: &HubConfig,
5194 _brain: &str,
5195 _checkout: &Path,
5196 _baseline: &V2SyncBaseline,
5197) -> LinkResult<()> {
5198 Err(LinkError::UnsupportedPlatform {
5199 operation: "verified link.md v2 baseline",
5200 })
5201}
5202
5203fn v2_baseline_from_head(
5204 cfg: &HubConfig,
5205 head: &V2VerifiedHead,
5206 files: std::collections::BTreeMap<String, V2BaselineFile>,
5207 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5208 local: Option<&V2LocalView>,
5209 checkout_id: Option<&str>,
5210) -> LinkResult<V2SyncBaseline> {
5211 let mut local_eligibility = local
5212 .map(|view| view.eligibility.clone())
5213 .unwrap_or_default();
5214 if let Some(view) = local {
5215 for path in files.keys() {
5216 local_eligibility
5217 .entry(path.clone())
5218 .or_insert_with(|| !view.policy.keeps_home(path));
5219 }
5220 }
5221 let remote_copy_remains = local_eligibility
5222 .iter()
5223 .filter(|(_, riding)| !**riding)
5224 .filter_map(|(path, _)| {
5225 files
5226 .get(path)
5227 .map(|file| (path.clone(), file.sha256.clone()))
5228 })
5229 .collect();
5230 Ok(V2SyncBaseline {
5231 v: 2,
5232 origin: normalized_origin(&cfg.hub)?,
5233 brain: head.brain_id.clone(),
5234 checkout_id: Some(v2_checkout_id(checkout_id)?),
5235 head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5236 commit_hash: head
5237 .pointer
5238 .as_ref()
5239 .map(|pointer| pointer.commit_hash.clone()),
5240 content_root: head
5241 .pointer
5242 .as_ref()
5243 .and_then(|pointer| pointer.content_root.clone()),
5244 asset_root: head
5245 .pointer
5246 .as_ref()
5247 .and_then(|pointer| pointer.asset_root.clone()),
5248 assets,
5249 view_kind: Some(head.view_kind.clone()),
5250 view_revision: Some(head.view_revision.clone()),
5251 projection_sha256: (head.view_kind == "scoped")
5252 .then(|| scoped_projection_sha256(&head.brain_id)),
5253 files,
5254 local_policy_digest: local.map(|view| view.policy.digest.clone()),
5255 local_eligibility,
5256 remote_copy_remains,
5257 })
5258}
5259
5260fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5261 let policy = crate::linkmd_sync_policy::load(store)
5262 .map_err(|message| LinkError::InvalidPack { message })?;
5263 let asset_paths = crate::assets::read_manifest(store)
5264 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5265 .into_iter()
5266 .map(|asset| asset.path)
5267 .collect::<std::collections::BTreeSet<_>>();
5268 let mut result = std::collections::BTreeMap::new();
5269 let mut eligibility = std::collections::BTreeMap::new();
5270 let mut riding_links = Vec::<(String, Vec<String>)>::new();
5271 let mut total = 0_u64;
5272 let mut paths = vec![PathBuf::from("DB.md")];
5273 paths.extend(store.walk()?);
5274 for relative in paths {
5275 let path = relative.to_string_lossy().replace('\\', "/");
5276 if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5278 continue;
5279 }
5280 if asset_paths.contains(&path) {
5281 continue;
5282 }
5283 crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5284 path: error.to_string(),
5285 })?;
5286 let riding = !policy.keeps_home(&path);
5287 eligibility.insert(path.clone(), riding);
5288 if !riding {
5289 continue;
5290 }
5291 let remaining = MAX_STORE_BYTES.saturating_sub(total);
5292 let bytes = store.read_bounded(&relative, remaining)?;
5293 total = total
5294 .checked_add(bytes.len() as u64)
5295 .ok_or_else(|| LinkError::PushTooLarge {
5296 detail: "v2 local byte count overflow".to_string(),
5297 })?;
5298 if total > MAX_STORE_BYTES {
5299 return Err(LinkError::PushTooLarge {
5300 detail: format!("{total} uncompressed bytes"),
5301 });
5302 }
5303 if std::str::from_utf8(&bytes).is_err() {
5304 return Err(LinkError::NotUtf8 { path });
5305 }
5306 let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5307 riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
5308 result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
5309 }
5310 let kept_home = eligibility
5311 .iter()
5312 .filter(|(_, riding)| !**riding)
5313 .map(|(path, _)| path.clone())
5314 .collect::<std::collections::BTreeSet<_>>();
5315 let mut withheld_links = riding_links
5316 .into_iter()
5317 .flat_map(|(source, targets)| {
5318 let kept_home = &kept_home;
5319 let policy = &policy;
5320 targets.into_iter().filter_map(move |target| {
5321 let target = format!("{target}.md");
5322 (kept_home.contains(&target) || policy.keeps_home(&target)).then_some(
5332 V2WithheldLink {
5333 source: source.clone(),
5334 target,
5335 },
5336 )
5337 })
5338 })
5339 .collect::<Vec<_>>();
5340 withheld_links.sort();
5341 withheld_links.dedup();
5342 Ok(V2LocalView {
5343 riding: result,
5344 eligibility,
5345 policy,
5346 withheld_links,
5347 })
5348}
5349
5350#[derive(Debug, Clone, Deserialize)]
5351struct V2DownloadItem {
5352 path: String,
5353 sha256: String,
5354 bytes: u64,
5355 url: String,
5356 method: String,
5357}
5358
5359#[derive(Debug, Deserialize)]
5360struct V2DownloadWindow {
5361 v: u8,
5362 commit: String,
5363 downloads: Vec<V2DownloadItem>,
5364}
5365
5366#[derive(Debug, Deserialize)]
5367struct V2BulkStreamHeader {
5368 v: u8,
5369 path: String,
5370 sha256: String,
5371 bytes: u64,
5372}
5373
5374fn parse_v2_bulk_stream(
5375 bytes: &[u8],
5376 expected: &[(&String, &V2BaselineFile)],
5377) -> LinkResult<Vec<(String, Vec<u8>)>> {
5378 if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5379 return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5380 }
5381 let mut cursor = V2_BULK_STREAM_MAGIC.len();
5382 let mut result = Vec::with_capacity(expected.len());
5383 for (expected_path, expected_file) in expected {
5384 let length_bytes = bytes
5385 .get(cursor..cursor + 4)
5386 .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5387 cursor += 4;
5388 let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5389 if header_len == 0 || header_len > 4 * 1024 {
5390 return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5391 }
5392 let header_bytes = bytes
5393 .get(cursor..cursor + header_len)
5394 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5395 cursor += header_len;
5396 let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5397 .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5398 if header.v != 2
5399 || &header.path != *expected_path
5400 || header.sha256 != expected_file.sha256
5401 || header.bytes != expected_file.bytes
5402 || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5403 {
5404 return Err(invalid_feed(
5405 "v2 bulk stream frame differs from its proven manifest entry",
5406 ));
5407 }
5408 let body_len = usize::try_from(header.bytes)
5409 .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5410 let body = bytes
5411 .get(cursor..cursor + body_len)
5412 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5413 cursor += body_len;
5414 if content_sha256(body) != header.sha256 {
5415 return Err(invalid_feed(
5416 "v2 bulk stream file differs from its proven manifest entry",
5417 ));
5418 }
5419 result.push((header.path, body.to_vec()));
5420 }
5421 if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5422 return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5423 }
5424 cursor += 4;
5425 if cursor != bytes.len() {
5426 return Err(invalid_feed("v2 bulk stream carries trailing data"));
5427 }
5428 Ok(result)
5429}
5430
5431fn download_v2_bulk_stream(
5432 cfg: &HubConfig,
5433 brain: &str,
5434 pointer: &V2PointerBody,
5435 pending: &[(&String, &V2BaselineFile)],
5436) -> LinkResult<Vec<(String, Vec<u8>)>> {
5437 let claims = pending
5438 .iter()
5439 .map(|(path, file)| {
5440 Ok(json!({
5441 "path": path,
5442 "sha256": file.sha256,
5443 "bytes": file.bytes,
5444 "proof": file.proof.as_ref().ok_or_else(|| {
5445 invalid_feed("v2 manifest omitted a bulk-stream proof")
5446 })?,
5447 }))
5448 })
5449 .collect::<LinkResult<Vec<_>>>()?;
5450 let raw = request_raw_retryable_read(
5451 cfg,
5452 "POST",
5453 &format!("/api/hub/brains/{brain}/v2/stream"),
5454 Some(&json!({
5455 "commit": pointer.commit_hash,
5456 "files": claims,
5457 })),
5458 Auth::Required,
5459 V2_BULK_STREAM_RESPONSE_BYTES,
5460 )?;
5461 let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5462 parse_v2_bulk_stream(&body, pending)
5463}
5464
5465fn request_capped_retryable_read(
5466 cfg: &HubConfig,
5467 method: &str,
5468 path: &str,
5469 body: Option<&Value>,
5470 auth: Auth,
5471 max_response_bytes: u64,
5472) -> LinkResult<HubResponse> {
5473 let raw = request_raw_retryable_read(cfg, method, path, body, auth, max_response_bytes)?;
5474 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
5475 Ok(HubResponse {
5476 status: raw.status,
5477 body: parsed,
5478 })
5479}
5480
5481fn prepare_v2_downloads(
5482 cfg: &HubConfig,
5483 brain: &str,
5484 pointer: &V2PointerBody,
5485 pending: &[(&String, &V2BaselineFile)],
5486) -> LinkResult<Vec<V2DownloadItem>> {
5487 let mut result = Vec::with_capacity(pending.len());
5488 for chunk in pending.chunks(128) {
5489 let claims = chunk
5490 .iter()
5491 .map(|(path, file)| {
5492 Ok(json!({
5493 "path": path,
5494 "sha256": file.sha256,
5495 "bytes": file.bytes,
5496 "proof": file.proof.as_ref().ok_or_else(|| {
5497 invalid_feed("v2 manifest omitted a download proof")
5498 })?,
5499 }))
5500 })
5501 .collect::<LinkResult<Vec<_>>>()?;
5502 let value = ensure_ok(
5503 request_capped_retryable_read(
5504 cfg,
5505 "POST",
5506 &format!("/api/hub/brains/{brain}/v2/downloads"),
5507 Some(&json!({
5508 "commit": pointer.commit_hash,
5509 "files": claims,
5510 })),
5511 Auth::Required,
5512 MAX_FEED_RESPONSE_BYTES,
5513 )?,
5514 "prepare v2 blob downloads",
5515 )?;
5516 let window: V2DownloadWindow = serde_json::from_value(value)
5517 .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5518 if window.v != 2
5519 || window.commit != pointer.commit_hash
5520 || window.downloads.len() != chunk.len()
5521 {
5522 return Err(invalid_feed(
5523 "v2 download window is not bound to the requested files",
5524 ));
5525 }
5526 let mut by_path = window
5527 .downloads
5528 .into_iter()
5529 .map(|item| (item.path.clone(), item))
5530 .collect::<std::collections::BTreeMap<_, _>>();
5531 if by_path.len() != chunk.len() {
5532 return Err(invalid_feed("v2 download window repeats a path"));
5533 }
5534 for (path, file) in chunk {
5535 let item = by_path
5536 .remove(*path)
5537 .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5538 if item.method != "GET"
5539 || item.sha256 != file.sha256
5540 || item.bytes != file.bytes
5541 || item.url.is_empty()
5542 {
5543 return Err(invalid_feed(
5544 "v2 download capability differs from its proven file",
5545 ));
5546 }
5547 result.push(item);
5548 }
5549 }
5550 Ok(result)
5551}
5552
5553fn prepare_v2_asset_downloads(
5554 cfg: &HubConfig,
5555 brain: &str,
5556 pointer: &V2PointerBody,
5557 pending: &[(&String, &V2BaselineAsset)],
5558) -> LinkResult<Vec<V2DownloadItem>> {
5559 let mut result = Vec::with_capacity(pending.len());
5560 for chunk in pending.chunks(128) {
5561 let claims = chunk
5562 .iter()
5563 .map(|(path, asset)| {
5564 json!({
5565 "path": path,
5566 "sha256": asset.blob_sha256,
5567 "bytes": asset.bytes,
5568 "leaf_hash": asset.leaf_hash,
5569 })
5570 })
5571 .collect::<Vec<_>>();
5572 let value = ensure_ok(
5573 request_capped_retryable_read(
5574 cfg,
5575 "POST",
5576 &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5577 Some(&json!({
5578 "commit": pointer.commit_hash,
5579 "assets": claims,
5580 })),
5581 Auth::Required,
5582 MAX_FEED_RESPONSE_BYTES,
5583 )?,
5584 "prepare v2 asset downloads",
5585 )?;
5586 let window: V2DownloadWindow = serde_json::from_value(value)
5587 .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5588 if window.v != 2
5589 || window.commit != pointer.commit_hash
5590 || window.downloads.len() != chunk.len()
5591 {
5592 return Err(invalid_feed(
5593 "v2 asset download window is not bound to the requested assets",
5594 ));
5595 }
5596 let mut by_path = window
5597 .downloads
5598 .into_iter()
5599 .map(|item| (item.path.clone(), item))
5600 .collect::<std::collections::BTreeMap<_, _>>();
5601 if by_path.len() != chunk.len() {
5602 return Err(invalid_feed("v2 asset download window repeats a path"));
5603 }
5604 for (path, asset) in chunk {
5605 let item = by_path
5606 .remove(*path)
5607 .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5608 if item.method != "GET"
5609 || item.sha256 != asset.blob_sha256
5610 || item.bytes != asset.bytes
5611 || item.url.is_empty()
5612 {
5613 return Err(invalid_feed(
5614 "v2 asset download capability differs from its signed leaf",
5615 ));
5616 }
5617 result.push(item);
5618 }
5619 }
5620 Ok(result)
5621}
5622
5623#[cfg(any(unix, windows))]
5624fn stage_v2_asset_download_window(
5625 cfg: &HubConfig,
5626 brain: &str,
5627 pointer: &V2PointerBody,
5628 cache_dir: &Path,
5629 pending: &[(&String, &V2BaselineAsset)],
5630) -> LinkResult<Vec<V2StagedFile>> {
5631 if pending.is_empty() {
5632 return Ok(Vec::new());
5633 }
5634 if pending.len() > V2_DOWNLOAD_CAPABILITY_FILES {
5635 return Err(invalid_feed("v2 asset capability window is oversized"));
5636 }
5637
5638 let mut last_error = None;
5639 for retry_delay in V2_DOWNLOAD_CAPABILITY_BACKOFF_MS
5640 .iter()
5641 .copied()
5642 .map(Some)
5643 .chain(std::iter::once(None))
5644 {
5645 let downloads = prepare_v2_asset_downloads(cfg, brain, pointer, pending)?;
5650 let mut unique = std::collections::BTreeMap::<String, V2DownloadItem>::new();
5651 for item in downloads {
5652 match unique.get(&item.sha256) {
5653 Some(prior) if prior.bytes != item.bytes => {
5654 return Err(invalid_feed(
5655 "one v2 asset hash has conflicting byte lengths",
5656 ));
5657 }
5658 Some(_) => {}
5659 None => {
5660 unique.insert(item.sha256.clone(), item);
5661 }
5662 }
5663 }
5664 let downloads = unique.into_values().collect::<Vec<_>>();
5665 let next = std::sync::atomic::AtomicUsize::new(0);
5666 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5667 let mut results = std::iter::repeat_with(|| None)
5668 .take(downloads.len())
5669 .collect::<Vec<Option<LinkResult<PathBuf>>>>();
5670 std::thread::scope(|scope| {
5671 let (sender, receiver) = std::sync::mpsc::channel();
5672 for _ in 0..worker_count {
5673 let sender = sender.clone();
5674 let downloads = &downloads;
5675 let next = &next;
5676 scope.spawn(move || loop {
5677 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5678 let Some(item) = downloads.get(index) else {
5679 break;
5680 };
5681 let result = download_presigned_to_cache(
5682 cfg,
5683 &item.url,
5684 cache_dir,
5685 &item.sha256,
5686 item.bytes,
5687 );
5688 if sender.send((index, result)).is_err() {
5689 break;
5690 }
5691 });
5692 }
5693 drop(sender);
5694 for (index, result) in receiver {
5695 results[index] = Some(result);
5696 }
5697 });
5698
5699 let mut failed = None;
5700 for result in results {
5701 match result {
5702 Some(Ok(_)) => {}
5703 Some(Err(error)) if failed.is_none() => failed = Some(error),
5704 Some(Err(_)) => {}
5705 None if failed.is_none() => {
5706 failed = Some(LinkError::Transport {
5707 hub: cfg.hub.clone(),
5708 message: "a bounded v2 asset worker stopped before reporting its result"
5709 .to_string(),
5710 });
5711 }
5712 None => {}
5713 }
5714 }
5715 if let Some(error) = failed {
5716 last_error = Some(error);
5717 if let Some(milliseconds) = retry_delay {
5718 std::thread::sleep(std::time::Duration::from_millis(milliseconds));
5719 continue;
5720 }
5721 break;
5722 }
5723
5724 return pending
5725 .iter()
5726 .map(|(path, asset)| {
5727 let source = cache_dir.join(&asset.blob_sha256);
5728 if !cached_blob_is_exact(&source, &asset.blob_sha256, asset.bytes)? {
5729 return Err(invalid_feed(
5730 "v2 asset download cache omitted a proven blob",
5731 ));
5732 }
5733 Ok(V2StagedFile {
5734 path: (*path).clone(),
5735 source,
5736 sha256: asset.blob_sha256.clone(),
5737 bytes: asset.bytes,
5738 })
5739 })
5740 .collect();
5741 }
5742 Err(last_error
5743 .unwrap_or_else(|| invalid_feed("v2 asset capability window made no download attempt")))
5744}
5745
5746fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5747 let bytes = get_presigned(cfg, &item.url)?;
5748 if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5749 return Err(invalid_feed("v2 blob differs from its proven path entry"));
5750 }
5751 Ok(bytes)
5752}
5753
5754#[derive(Debug, Clone)]
5755struct V2StagedFile {
5756 path: String,
5757 source: PathBuf,
5758 sha256: String,
5759 bytes: u64,
5760}
5761
5762#[cfg(unix)]
5763fn v2_download_cache_dir(
5764 cfg: &HubConfig,
5765 brain: &str,
5766 pointer: &V2PointerBody,
5767) -> LinkResult<PathBuf> {
5768 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5769}
5770
5771#[cfg(unix)]
5772fn v2_download_cache_dir_for(
5773 cfg: &HubConfig,
5774 brain: &str,
5775 transaction: &str,
5776) -> LinkResult<PathBuf> {
5777 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5778 return Err(invalid_feed("v2 download cache address is invalid"));
5779 }
5780 let path = cfg
5781 .state_dir
5782 .join("downloads")
5783 .join(brain)
5784 .join(transaction);
5785 let directory = open_or_create_dir_nofollow(&path)?;
5786 use std::os::fd::AsRawFd as _;
5787 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5788 return Err(std::io::Error::last_os_error().into());
5789 }
5790 directory.sync_all()?;
5791 Ok(path)
5792}
5793
5794#[cfg(windows)]
5795fn v2_download_cache_dir(
5796 cfg: &HubConfig,
5797 brain: &str,
5798 pointer: &V2PointerBody,
5799) -> LinkResult<PathBuf> {
5800 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5801}
5802
5803#[cfg(windows)]
5804fn v2_download_cache_dir_for(
5805 cfg: &HubConfig,
5806 brain: &str,
5807 transaction: &str,
5808) -> LinkResult<PathBuf> {
5809 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5810 return Err(invalid_feed("v2 download cache address is invalid"));
5811 }
5812 let path = cfg
5813 .state_dir
5814 .join("downloads")
5815 .join(brain)
5816 .join(transaction);
5817 crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5818 crate::fsx::open_directory_nofollow(&path)?;
5819 Ok(path)
5820}
5821
5822#[cfg(unix)]
5823fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5824 use std::os::fd::AsRawFd as _;
5825 let parent = cfg.state_dir.join("downloads").join(brain);
5826 let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5827 return;
5828 };
5829 let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5830 return;
5831 };
5832 let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5833 let _ = directory.sync_all();
5834}
5835
5836#[cfg(windows)]
5837fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5838 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5839 return;
5840 }
5841 let parent = cfg.state_dir.join("downloads").join(brain);
5842 let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5843 return;
5844 };
5845 let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5846}
5847
5848#[cfg(not(any(unix, windows)))]
5849fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5850
5851#[cfg(not(any(unix, windows)))]
5852fn v2_download_cache_dir_for(
5853 _cfg: &HubConfig,
5854 _brain: &str,
5855 _transaction: &str,
5856) -> LinkResult<PathBuf> {
5857 Err(LinkError::UnsupportedPlatform {
5858 operation: "resumable v2 download staging",
5859 })
5860}
5861
5862#[cfg(any(unix, windows))]
5863fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5864 let file = match crate::fsx::open_regular_nofollow(path) {
5865 Ok(file) => file,
5866 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5867 Err(error) => return Err(error.into()),
5868 };
5869 if file.metadata()?.len() != bytes {
5870 return Ok(false);
5871 }
5872 Ok(content_sha256_reader(file)? == sha256)
5873}
5874
5875#[cfg(any(unix, windows))]
5876fn cache_v2_blob_bytes(
5877 cache_dir: &Path,
5878 sha256: &str,
5879 expected_bytes: u64,
5880 bytes: &[u8],
5881) -> LinkResult<PathBuf> {
5882 if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5883 return Err(invalid_feed("v2 cached blob differs from its declaration"));
5884 }
5885 let path = cache_dir.join(sha256);
5886 if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5887 crate::fsx::write_atomic(&path, bytes)?;
5888 }
5889 Ok(path)
5890}
5891
5892#[cfg(not(any(unix, windows)))]
5893fn cache_v2_blob_bytes(
5894 _cache_dir: &Path,
5895 _sha256: &str,
5896 _expected_bytes: u64,
5897 _bytes: &[u8],
5898) -> LinkResult<PathBuf> {
5899 Err(LinkError::UnsupportedPlatform {
5900 operation: "resumable v2 download staging",
5901 })
5902}
5903
5904#[cfg(unix)]
5905fn download_presigned_to_cache(
5906 cfg: &HubConfig,
5907 url: &str,
5908 cache_dir: &Path,
5909 sha256: &str,
5910 expected_bytes: u64,
5911) -> LinkResult<PathBuf> {
5912 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5913
5914 let target = cache_dir.join(sha256);
5915 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5916 return Ok(target);
5917 }
5918 let directory = open_existing_dir_nofollow(cache_dir)?;
5919 let mut nonce = [0_u8; 16];
5920 ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5921 .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5922 let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5923 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5924 let fd = unsafe {
5925 libc::openat(
5926 directory.as_raw_fd(),
5927 temp.as_ptr(),
5928 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5929 0o600,
5930 )
5931 };
5932 if fd < 0 {
5933 return Err(std::io::Error::last_os_error().into());
5934 }
5935 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5936 let response = match presigned_agent(cfg, url)?.get(url).call() {
5937 Ok(response) => response,
5938 Err(ureq::Error::Status(_, response)) => {
5939 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5940 return Err(LinkError::Http {
5941 what: "v2 direct download",
5942 status: response.status(),
5943 message: "object store rejected the download".to_string(),
5944 code: None,
5945 details: None,
5946 });
5947 }
5948 Err(ureq::Error::Transport(error)) => {
5949 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5950 return Err(LinkError::Transport {
5951 hub: cfg.hub.clone(),
5952 message: error.to_string(),
5953 });
5954 }
5955 };
5956 let mut reader = response
5957 .into_reader()
5958 .take(expected_bytes.saturating_add(1));
5959 let mut digest = Sha256::new();
5960 let mut total = 0_u64;
5961 let mut buffer = [0_u8; 64 * 1024];
5962 let write_result = (|| -> LinkResult<()> {
5967 loop {
5968 let read = reader
5969 .read(&mut buffer)
5970 .map_err(|error| LinkError::Transport {
5971 hub: cfg.hub.clone(),
5972 message: error.to_string(),
5973 })?;
5974 if read == 0 {
5975 break;
5976 }
5977 total = total.saturating_add(read as u64);
5978 digest.update(&buffer[..read]);
5979 output.write_all(&buffer[..read])?;
5980 }
5981 output.sync_all().map_err(LinkError::from)
5982 })();
5983 if let Err(error) = write_result {
5984 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5985 return Err(error);
5986 }
5987 drop(output);
5988 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5989 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5990 return Err(invalid_feed(
5991 "v2 direct download failed integrity verification",
5992 ));
5993 }
5994 let target_name = c_name(sha256.as_bytes(), sha256)?;
5995 if unsafe {
5998 libc::renameat(
5999 directory.as_raw_fd(),
6000 temp.as_ptr(),
6001 directory.as_raw_fd(),
6002 target_name.as_ptr(),
6003 )
6004 } != 0
6005 {
6006 let error = std::io::Error::last_os_error();
6007 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6008 return Err(error.into());
6009 }
6010 directory.sync_all()?;
6011 Ok(target)
6012}
6013
6014#[cfg(windows)]
6015fn download_presigned_to_cache(
6016 cfg: &HubConfig,
6017 url: &str,
6018 cache_dir: &Path,
6019 sha256: &str,
6020 expected_bytes: u64,
6021) -> LinkResult<PathBuf> {
6022 use std::fs::OpenOptions;
6023
6024 let target = cache_dir.join(sha256);
6025 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6026 return Ok(target);
6027 }
6028 let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
6032 let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
6033 let mut output = OpenOptions::new()
6034 .write(true)
6035 .create_new(true)
6036 .open(&temp)?;
6037 let response = match presigned_agent(cfg, url)?.get(url).call() {
6038 Ok(response) => response,
6039 Err(ureq::Error::Status(_, response)) => {
6040 let _ = std::fs::remove_file(&temp);
6041 return Err(LinkError::Http {
6042 what: "v2 direct download",
6043 status: response.status(),
6044 message: "object store rejected the download".to_string(),
6045 code: None,
6046 details: None,
6047 });
6048 }
6049 Err(ureq::Error::Transport(error)) => {
6050 let _ = std::fs::remove_file(&temp);
6051 return Err(LinkError::Transport {
6052 hub: cfg.hub.clone(),
6053 message: error.to_string(),
6054 });
6055 }
6056 };
6057 let mut reader = response
6058 .into_reader()
6059 .take(expected_bytes.saturating_add(1));
6060 let mut digest = Sha256::new();
6061 let mut total = 0_u64;
6062 let mut buffer = [0_u8; 64 * 1024];
6063 let copied = (|| -> LinkResult<()> {
6065 loop {
6066 let read = reader
6067 .read(&mut buffer)
6068 .map_err(|error| LinkError::Transport {
6069 hub: cfg.hub.clone(),
6070 message: error.to_string(),
6071 })?;
6072 if read == 0 {
6073 break;
6074 }
6075 total = total.saturating_add(read as u64);
6076 digest.update(&buffer[..read]);
6077 output.write_all(&buffer[..read])?;
6078 }
6079 output.sync_all()?;
6080 Ok(())
6081 })();
6082 if let Err(error) = copied {
6083 let _ = std::fs::remove_file(&temp);
6084 return Err(error);
6085 }
6086 drop(output);
6087 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6088 let _ = std::fs::remove_file(&temp);
6089 return Err(invalid_feed(
6090 "v2 direct download failed integrity verification",
6091 ));
6092 }
6093 if target.exists() {
6094 std::fs::remove_file(&target)?;
6095 }
6096 if let Err(error) = std::fs::rename(&temp, &target) {
6097 let _ = std::fs::remove_file(&temp);
6098 return Err(error.into());
6099 }
6100 Ok(target)
6101}
6102
6103#[cfg(not(any(unix, windows)))]
6104fn download_presigned_to_cache(
6105 _cfg: &HubConfig,
6106 _url: &str,
6107 _cache_dir: &Path,
6108 _sha256: &str,
6109 _expected_bytes: u64,
6110) -> LinkResult<PathBuf> {
6111 Err(LinkError::UnsupportedPlatform {
6112 operation: "resumable v2 download staging",
6113 })
6114}
6115
6116fn download_v2_blobs(
6117 cfg: &HubConfig,
6118 brain: &str,
6119 pointer: &V2PointerBody,
6120 pending: Vec<(&String, &V2BaselineFile)>,
6121) -> LinkResult<Vec<(String, Vec<u8>)>> {
6122 if pending.is_empty() {
6123 return Ok(Vec::new());
6124 }
6125 let expected_order = pending
6126 .iter()
6127 .map(|(path, _)| (*path).clone())
6128 .collect::<Vec<_>>();
6129 let mut streamed = std::collections::BTreeMap::new();
6130 let mut direct = Vec::new();
6131 let mut window = Vec::new();
6132 let mut window_bytes = 0_u64;
6133 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6134 window_bytes: &mut u64,
6135 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
6136 -> LinkResult<()> {
6137 if window.is_empty() {
6138 return Ok(());
6139 }
6140 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
6141 if streamed.insert(path, bytes).is_some() {
6142 return Err(invalid_feed("v2 bulk streams repeated a path"));
6143 }
6144 }
6145 window.clear();
6146 *window_bytes = 0;
6147 Ok(())
6148 };
6149 for &(path, file) in &pending {
6150 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6151 flush(&mut window, &mut window_bytes, &mut streamed)?;
6152 direct.push((path, file));
6153 continue;
6154 }
6155 if window.len() == V2_BULK_STREAM_FILES
6156 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6157 {
6158 flush(&mut window, &mut window_bytes, &mut streamed)?;
6159 }
6160 window.push((path, file));
6161 window_bytes += file.bytes;
6162 }
6163 flush(&mut window, &mut window_bytes, &mut streamed)?;
6164
6165 let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
6166 let next = std::sync::atomic::AtomicUsize::new(0);
6167 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6168 let mut results = std::iter::repeat_with(|| None)
6169 .take(downloads.len())
6170 .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
6171 std::thread::scope(|scope| {
6172 let (sender, receiver) = std::sync::mpsc::channel();
6173 for _ in 0..worker_count {
6174 let sender = sender.clone();
6175 let downloads = &downloads;
6176 let next = &next;
6177 scope.spawn(move || loop {
6178 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6179 let Some(item) = downloads.get(index) else {
6180 break;
6181 };
6182 let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
6183 if sender.send((index, result)).is_err() {
6184 break;
6185 }
6186 });
6187 }
6188 drop(sender);
6189 for (index, result) in receiver {
6190 results[index] = Some(result);
6191 }
6192 });
6193 for result in results.into_iter().map(|result| {
6194 result.ok_or_else(|| LinkError::Transport {
6195 hub: cfg.hub.clone(),
6196 message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
6197 })?
6198 }) {
6199 let (path, bytes) = result?;
6200 if streamed.insert(path, bytes).is_some() {
6201 return Err(invalid_feed("v2 download lanes repeated a path"));
6202 }
6203 }
6204 expected_order
6205 .into_iter()
6206 .map(|path| {
6207 streamed
6208 .remove(&path)
6209 .map(|bytes| (path, bytes))
6210 .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
6211 })
6212 .collect()
6213}
6214
6215#[cfg(any(unix, windows))]
6219fn stage_v2_blobs(
6220 cfg: &HubConfig,
6221 brain: &str,
6222 pointer: &V2PointerBody,
6223 pending: Vec<(&String, &V2BaselineFile)>,
6224) -> LinkResult<Vec<V2StagedFile>> {
6225 let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6226 let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6227 let mut direct = Vec::new();
6228 let mut window = Vec::new();
6229 let mut window_bytes = 0_u64;
6230 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6231 window_bytes: &mut u64,
6232 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
6233 -> LinkResult<()> {
6234 if window.is_empty() {
6235 return Ok(());
6236 }
6237 let missing = window
6238 .iter()
6239 .filter_map(|(path, file)| {
6240 let target = cache_dir.join(&file.sha256);
6241 match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6242 Ok(true) => {
6243 staged.insert(
6244 (*path).clone(),
6245 V2StagedFile {
6246 path: (*path).clone(),
6247 source: target,
6248 sha256: file.sha256.clone(),
6249 bytes: file.bytes,
6250 },
6251 );
6252 None
6253 }
6254 Ok(false) => Some(Ok((*path, *file))),
6255 Err(error) => Some(Err(error)),
6256 }
6257 })
6258 .collect::<LinkResult<Vec<_>>>()?;
6259 if !missing.is_empty() {
6260 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
6261 let file = missing
6262 .iter()
6263 .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
6264 .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
6265 let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
6266 staged.insert(
6267 path.clone(),
6268 V2StagedFile {
6269 path,
6270 source,
6271 sha256: file.sha256.clone(),
6272 bytes: file.bytes,
6273 },
6274 );
6275 }
6276 }
6277 window.clear();
6278 *window_bytes = 0;
6279 Ok(())
6280 };
6281 for &(path, file) in &pending {
6282 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6283 flush(&mut window, &mut window_bytes, &mut staged)?;
6284 direct.push((path, file));
6285 continue;
6286 }
6287 if window.len() == V2_BULK_STREAM_FILES
6288 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6289 {
6290 flush(&mut window, &mut window_bytes, &mut staged)?;
6291 }
6292 window.push((path, file));
6293 window_bytes += file.bytes;
6294 }
6295 flush(&mut window, &mut window_bytes, &mut staged)?;
6296 for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6297 let source =
6298 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6299 staged.insert(
6300 item.path.clone(),
6301 V2StagedFile {
6302 path: item.path,
6303 source,
6304 sha256: item.sha256,
6305 bytes: item.bytes,
6306 },
6307 );
6308 }
6309 pending
6310 .into_iter()
6311 .map(|(path, _)| {
6312 staged
6313 .remove(path)
6314 .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6315 })
6316 .collect()
6317}
6318
6319#[cfg(not(any(unix, windows)))]
6320fn stage_v2_blobs(
6321 _cfg: &HubConfig,
6322 _brain: &str,
6323 _pointer: &V2PointerBody,
6324 _pending: Vec<(&String, &V2BaselineFile)>,
6325) -> LinkResult<Vec<V2StagedFile>> {
6326 Err(LinkError::UnsupportedPlatform {
6327 operation: "resumable v2 download staging",
6328 })
6329}
6330
6331const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6332const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6333const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6334
6335#[derive(Debug, Clone, Deserialize, Serialize)]
6336struct V2ConflictCoordinate {
6337 sha256: Option<String>,
6338 bytes: Option<u64>,
6339 file: Option<String>,
6340}
6341
6342#[derive(Debug, Clone, Deserialize, Serialize)]
6343struct V2ConflictFile {
6344 path: String,
6345 base: V2ConflictCoordinate,
6346 local: V2ConflictCoordinate,
6347 remote: V2ConflictCoordinate,
6348}
6349
6350#[derive(Debug, Clone, Deserialize, Serialize)]
6351struct V2ConflictPlan {
6352 v: u8,
6353 class: String,
6354 bundle: String,
6355 brain: String,
6356 origin: String,
6357 created_unix: u64,
6358 expires_unix: u64,
6359 base_seq: Option<u64>,
6360 base_commit: Option<String>,
6361 remote_seq: u64,
6362 remote_commit: Option<String>,
6363 remote_content_root: Option<String>,
6364 view_kind: String,
6365 view_revision: String,
6366 files: Vec<V2ConflictFile>,
6367}
6368
6369fn v2_take_remote_selection(
6370 files: &[V2ConflictFile],
6371 current: &std::collections::BTreeMap<String, V2BaselineFile>,
6372) -> LinkResult<(
6373 std::collections::BTreeMap<String, V2BaselineFile>,
6374 Vec<String>,
6375)> {
6376 let mut selected = std::collections::BTreeMap::new();
6377 let mut deleted = Vec::new();
6378 for file in files {
6379 match (&file.remote.sha256, file.remote.bytes) {
6380 (Some(sha256), Some(bytes)) => {
6381 let proven = current.get(&file.path).ok_or_else(|| {
6382 invalid_feed("conflict remote coordinate disappeared from the exact head")
6383 })?;
6384 if proven.sha256 != *sha256 || proven.bytes != bytes {
6385 return Err(invalid_feed(
6386 "conflict remote coordinate differs from the exact head",
6387 ));
6388 }
6389 if selected.insert(file.path.clone(), proven.clone()).is_some() {
6390 return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6391 }
6392 }
6393 (None, None) => {
6394 if current.contains_key(&file.path) {
6395 return Err(invalid_feed(
6396 "conflict remote deletion differs from the exact head",
6397 ));
6398 }
6399 deleted.push(file.path.clone());
6400 }
6401 _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6402 }
6403 }
6404 Ok((selected, deleted))
6405}
6406
6407fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6408 PathBuf::from(".dbmd")
6409 .join("conflicts")
6410 .join(bundle)
6411 .join(suffix)
6412}
6413
6414fn read_historical_conflict_blob(
6415 cfg: &HubConfig,
6416 brain: &str,
6417 baseline: &V2SyncBaseline,
6418 path: &str,
6419 file: &V2BaselineFile,
6420) -> LinkResult<Option<Vec<u8>>> {
6421 let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6422 return Ok(None);
6423 };
6424 if seq == 0 {
6425 return Ok(None);
6426 }
6427 let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6428 let endpoint = format!(
6429 "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6430 file.sha256
6431 );
6432 let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
6433 if raw.status == 404 || raw.status == 403 {
6434 return Ok(None);
6435 }
6436 let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6437 if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6438 return Err(invalid_feed(
6439 "v2 conflict base failed integrity verification",
6440 ));
6441 }
6442 Ok(Some(bytes))
6443}
6444
6445fn create_v2_conflict_bundle(
6448 cfg: &HubConfig,
6449 store: &Store,
6450 head: &V2VerifiedHead,
6451 baseline: Option<&V2SyncBaseline>,
6452 local: &std::collections::BTreeMap<String, (String, u64)>,
6453 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6454 paths: &[String],
6455) -> LinkResult<(String, Vec<String>)> {
6456 let conflicts_root = Path::new(".dbmd/conflicts");
6457 store.create_dir_all(conflicts_root)?;
6458 let completed = store
6459 .directory_names(conflicts_root)?
6460 .into_iter()
6461 .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6462 .count();
6463 if completed >= V2_CONFLICT_BUNDLE_MAX {
6464 return Err(LinkError::InvalidPack {
6465 message: format!(
6466 "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6467 ),
6468 });
6469 }
6470
6471 let mut selected_paths = Vec::new();
6475 let mut selected_remote_bytes = 0_u64;
6476 for path in paths {
6477 let bytes = remote.get(path).map_or(0, |file| file.bytes);
6478 if !selected_paths.is_empty()
6479 && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6480 {
6481 break;
6482 }
6483 selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6484 selected_paths.push(path.clone());
6485 if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6486 break;
6487 }
6488 }
6489 if selected_paths.is_empty() {
6490 return Err(invalid_feed("content conflict set is empty"));
6491 }
6492 let bundle = crate::ulid::mint();
6493 let bundle_root = v2_conflict_relative(&bundle, "");
6494 store.create_dir_all(&bundle_root.join("files"))?;
6495 let pointer = head.pointer.as_ref();
6496 let remote_bytes = match pointer {
6497 Some(pointer) => download_v2_blobs(
6498 cfg,
6499 &head.brain_id,
6500 pointer,
6501 selected_paths
6502 .iter()
6503 .filter_map(|path| {
6504 remote
6505 .get(path)
6506 .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6507 .map(|file| (path, file))
6508 })
6509 .collect(),
6510 )?
6511 .into_iter()
6512 .collect::<std::collections::BTreeMap<_, _>>(),
6513 None => std::collections::BTreeMap::new(),
6514 };
6515
6516 let mut files = Vec::with_capacity(selected_paths.len());
6517 for (index, path) in selected_paths.iter().enumerate() {
6518 let base_file = baseline.and_then(|state| state.files.get(path));
6519 let base_bytes = match (baseline, base_file) {
6520 (Some(state), Some(file)) => {
6521 read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
6522 }
6523 _ => None,
6524 };
6525 let local_file = local.get(path);
6526 let remote_file = remote.get(path);
6527 let remote_content = remote_bytes.get(path);
6528 let prefix = format!("files/{index:04}");
6529 let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6530 let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6531 let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6532 if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6533 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6534 }
6535 if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6536 let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6537 if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6538 return Err(LinkError::InvalidPack {
6539 message: format!("local conflict path `{path}` changed while bundling"),
6540 });
6541 }
6542 store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6543 }
6544 if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6545 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6546 }
6547 files.push(V2ConflictFile {
6548 path: path.clone(),
6549 base: V2ConflictCoordinate {
6550 sha256: base_file.map(|file| file.sha256.clone()),
6551 bytes: base_file.map(|file| file.bytes),
6552 file: base_name,
6553 },
6554 local: V2ConflictCoordinate {
6555 sha256: local_file.map(|(sha256, _)| sha256.clone()),
6556 bytes: local_file.map(|(_, bytes)| *bytes),
6557 file: local_name,
6558 },
6559 remote: V2ConflictCoordinate {
6560 sha256: remote_file.map(|file| file.sha256.clone()),
6561 bytes: remote_file.map(|file| file.bytes),
6562 file: remote_name,
6563 },
6564 });
6565 }
6566 let now = SystemTime::now()
6567 .duration_since(UNIX_EPOCH)
6568 .unwrap_or_default()
6569 .as_secs();
6570 let plan = V2ConflictPlan {
6571 v: 2,
6572 class: "content_resolution_required".to_string(),
6573 bundle: bundle.clone(),
6574 brain: head.brain_id.clone(),
6575 origin: normalized_origin(&cfg.hub)?,
6576 created_unix: now,
6577 expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6578 base_seq: baseline.and_then(|state| state.head_seq),
6579 base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6580 remote_seq: pointer.map_or(0, |value| value.seq),
6581 remote_commit: pointer.map(|value| value.commit_hash.clone()),
6582 remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6583 view_kind: head.view_kind.clone(),
6584 view_revision: head.view_revision.clone(),
6585 files,
6586 };
6587 let mut bytes = serde_json::to_vec_pretty(&plan)
6588 .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6589 bytes.push(b'\n');
6590 store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6591 Ok((bundle, selected_paths))
6592}
6593
6594fn v2_sync_pull_with_resolution(
6595 cfg: &HubConfig,
6596 requested_brain: &str,
6597 expected_head: V2VerifiedHead,
6598 out: Option<&Path>,
6599 take_remote: Option<&std::collections::BTreeSet<String>>,
6600) -> LinkResult<V2PulledSnapshot> {
6601 let dest = out
6602 .map(Path::to_path_buf)
6603 .unwrap_or_else(|| PathBuf::from(requested_brain));
6604 let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6605 recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6606 let head = v2_verified_head(cfg, requested_brain)?
6607 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6608 if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6609 return Err(LinkError::RemoteAdvancedDuringSync);
6610 }
6611 let remote = files_for_v2_view(
6612 &head,
6613 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6614 );
6615 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
6616 let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6617 ensure_v2_view_compatible(&head, baseline.as_ref())?;
6618 let local_store = Store::open_strict(&dest).ok();
6619 ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6624 let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6625 if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6626 return Err(LinkError::ScopedViewChanged);
6627 }
6628 if let Some(view) = local_view.as_mut() {
6629 remove_scoped_projection(&head, baseline.as_ref(), view)?;
6630 }
6631 let empty_local = std::collections::BTreeMap::new();
6632 let local = local_view
6633 .as_ref()
6634 .map_or(&empty_local, |view| &view.riding);
6635 let kept_home = |path: &str| {
6636 local_view
6637 .as_ref()
6638 .is_some_and(|view| view.policy.keeps_home(path))
6639 };
6640 let empty_base = std::collections::BTreeMap::new();
6641 let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6642 let empty_base_assets = std::collections::BTreeMap::new();
6643 let base_assets = baseline
6644 .as_ref()
6645 .map_or(&empty_base_assets, |state| &state.assets);
6646 let mut local_assets = local_store
6647 .as_ref()
6648 .map(v2_local_asset_records)
6649 .transpose()?
6650 .unwrap_or_default();
6651 let mut content_merge = merge_v2_pulled_records(
6652 base,
6653 &remote,
6654 local,
6655 |file, _| (file.sha256.clone(), file.bytes),
6656 |file, _| (file.sha256.clone(), file.bytes),
6657 kept_home,
6658 );
6659 if let Some(selected) = take_remote {
6660 for path in selected {
6661 if let Some(position) = content_merge
6662 .conflicts
6663 .iter()
6664 .position(|conflict| conflict == path)
6665 {
6666 content_merge.conflicts.remove(position);
6667 content_merge.accept_remote.insert(path.clone());
6668 match remote.get(path) {
6669 Some(file) => {
6670 content_merge
6671 .records
6672 .insert(path.clone(), (file.sha256.clone(), file.bytes));
6673 }
6674 None => {
6675 content_merge.records.remove(path);
6676 }
6677 }
6678 } else if !content_merge.accept_remote.contains(path) {
6679 return Err(LinkError::InvalidPack {
6680 message: format!(
6681 "take-remote path `{path}` is no longer at its conflict coordinate"
6682 ),
6683 });
6684 }
6685 }
6686 }
6687 if !content_merge.conflicts.is_empty() {
6688 let mut conflicts = content_merge.conflicts.clone();
6689 conflicts.truncate(100);
6690 if let Some(store) = local_store.as_ref() {
6691 let (bundle, paths) = create_v2_conflict_bundle(
6692 cfg,
6693 store,
6694 &head,
6695 baseline.as_ref(),
6696 local,
6697 &remote,
6698 &conflicts,
6699 )?;
6700 return Err(LinkError::ConflictBundle { bundle, paths });
6701 }
6702 return Err(LinkError::Conflict { paths: conflicts });
6703 }
6704 let asset_merge = merge_v2_pulled_records(
6705 base_assets,
6706 &remote_assets,
6707 &local_assets,
6708 v2_asset_record,
6709 v2_asset_record,
6710 |_| false,
6711 );
6712 if !asset_merge.conflicts.is_empty() {
6713 let mut conflicts = asset_merge.conflicts.clone();
6714 conflicts.truncate(100);
6715 return Err(LinkError::Conflict { paths: conflicts });
6716 }
6717 let pointer = head.pointer.as_ref();
6718 let cache_transaction = pointer.map_or_else(
6719 || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6720 |value| value.commit_hash.clone(),
6721 );
6722 let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6723 let mut changed = match pointer {
6724 Some(pointer) => stage_v2_blobs(
6725 cfg,
6726 &head.brain_id,
6727 pointer,
6728 remote
6729 .iter()
6730 .filter(|(path, file)| {
6731 content_merge.accept_remote.contains(*path)
6732 && local.get(*path).map(|value| value.0.as_str())
6733 != Some(file.sha256.as_str())
6734 })
6735 .collect(),
6736 )?,
6737 None => Vec::new(),
6738 };
6739 let mut deleted = content_merge
6740 .accept_remote
6741 .iter()
6742 .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6743 .cloned()
6744 .collect::<Vec<_>>();
6745 if local_assets != asset_merge.records {
6746 if asset_merge.records.is_empty() {
6747 deleted.push("assets.jsonl".to_string());
6748 } else {
6749 let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6750 let sha256 = content_sha256(&bytes);
6751 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6752 changed.push(V2StagedFile {
6753 path: "assets.jsonl".to_string(),
6754 source,
6755 sha256,
6756 bytes: bytes.len() as u64,
6757 });
6758 }
6759 }
6760 if let Some(pointer) = pointer {
6761 let mut pending_assets = Vec::new();
6762 for (path, asset) in &remote_assets {
6763 if asset.disposition != "hosted"
6764 || kept_home(path)
6765 || !asset_merge.accept_remote.contains(path)
6766 {
6767 continue;
6768 }
6769 let already_current = local_store.as_ref().is_some_and(|store| {
6770 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6771 && store
6772 .read_bounded(Path::new(path), asset.bytes)
6773 .ok()
6774 .is_some_and(|bytes| {
6775 bytes.len() as u64 == asset.bytes
6776 && content_sha256(&bytes) == asset.blob_sha256
6777 })
6778 });
6779 if !already_current {
6780 pending_assets.push((path, asset));
6781 }
6782 }
6783 let mut window = Vec::new();
6784 let mut window_bytes = 0_u64;
6785 let flush = |window: &mut Vec<(&String, &V2BaselineAsset)>,
6786 window_bytes: &mut u64,
6787 changed: &mut Vec<V2StagedFile>|
6788 -> LinkResult<()> {
6789 changed.extend(stage_v2_asset_download_window(
6790 cfg,
6791 &head.brain_id,
6792 pointer,
6793 &cache_dir,
6794 window,
6795 )?);
6796 window.clear();
6797 *window_bytes = 0;
6798 Ok(())
6799 };
6800 for item @ (_, asset) in pending_assets {
6801 if !window.is_empty()
6802 && (window.len() == V2_DOWNLOAD_CAPABILITY_FILES
6803 || window_bytes.saturating_add(asset.bytes) > V2_DOWNLOAD_CAPABILITY_BYTES)
6804 {
6805 flush(&mut window, &mut window_bytes, &mut changed)?;
6806 }
6807 window.push(item);
6808 window_bytes = window_bytes.saturating_add(asset.bytes);
6809 if window_bytes >= V2_DOWNLOAD_CAPABILITY_BYTES {
6810 flush(&mut window, &mut window_bytes, &mut changed)?;
6811 }
6812 }
6813 flush(&mut window, &mut window_bytes, &mut changed)?;
6814 }
6815 for (path, prior) in base_assets {
6816 if remote_assets.contains_key(path)
6817 || kept_home(path)
6818 || !asset_merge.accept_remote.contains(path)
6819 {
6820 continue;
6821 }
6822 let unchanged = local_store.as_ref().is_some_and(|store| {
6823 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6824 && store
6825 .read_bounded(Path::new(path), prior.bytes)
6826 .ok()
6827 .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6828 });
6829 if unchanged {
6830 deleted.push(path.clone());
6831 }
6832 }
6833 let extra_local = content_merge
6834 .records
6835 .keys()
6836 .filter(|path| !remote.contains_key(*path))
6837 .cloned()
6838 .collect::<Vec<_>>();
6839 if head.view_kind == "scoped" {
6840 for (path, bytes) in [
6841 ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6842 (
6843 ".dbmd/view.json".to_string(),
6844 scoped_view_metadata(&head, remote.len())?,
6845 ),
6846 ] {
6847 let sha256 = content_sha256(&bytes);
6848 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6849 changed.push(V2StagedFile {
6850 path,
6851 source,
6852 sha256,
6853 bytes: bytes.len() as u64,
6854 });
6855 }
6856 }
6857 let install_changed = !changed.is_empty() || !deleted.is_empty();
6858 install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6859 let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6860 let installed_store =
6861 Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6862 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6863 })?;
6864 let installed_local = if install_changed {
6865 let mut scanned = v2_local_files(&installed_store)?;
6866 remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6867 scanned
6868 } else {
6869 local_view
6870 .take()
6871 .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6872 };
6873 if installed_local.riding != content_merge.records {
6874 return Err(LinkError::InvalidPack {
6875 message: "local content changed while installing the v2 pull".to_string(),
6876 });
6877 }
6878 let installed_assets = if install_changed {
6879 v2_local_asset_records(&installed_store)?
6880 } else {
6881 std::mem::take(&mut local_assets)
6882 };
6883 if installed_assets != asset_merge.records {
6884 return Err(LinkError::InvalidPack {
6885 message: "local assets changed while installing the v2 pull".to_string(),
6886 });
6887 }
6888 let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6889 installed_local.policy.keeps_home(path)
6890 })
6891 || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6892 let final_head = v2_verified_head(cfg, requested_brain)?
6893 .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6894 if !same_v2_head(&head, &final_head) {
6895 return Err(LinkError::RemoteAdvancedDuringSync);
6896 }
6897 accept_v2_head(cfg, &final_head)?;
6898 save_v2_baseline(
6899 cfg,
6900 &head.brain_id,
6901 &dest,
6902 &v2_baseline_from_head(
6903 cfg,
6904 &head,
6905 remote.clone(),
6906 remote_assets.clone(),
6907 Some(&installed_local),
6908 baseline
6909 .as_ref()
6910 .and_then(|current| current.checkout_id.as_deref()),
6911 )?,
6912 )?;
6913 complete_v2_pull(&dest)?;
6914 Ok((local_dirty, installed_local, installed_assets))
6915 })();
6916 let (local_dirty, installed_local, installed_assets) = match finalized {
6917 Ok(value) => value,
6918 Err(error) => {
6919 if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6920 return Err(LinkError::InvalidPack {
6921 message: format!("{error}; durable pull recovery also failed: {recovery}"),
6922 });
6923 }
6924 return Err(error);
6925 }
6926 };
6927 cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6928 let report = PullReport {
6929 brain: head.brain_id.clone(),
6930 slug: requested_brain.to_string(),
6931 head_seq: pointer.map_or(0, |value| value.seq),
6932 files: remote.len() + remote_assets.len(),
6933 dest: dest.to_string_lossy().into_owned(),
6934 extra_local,
6935 sync_status: if local_dirty {
6936 "local_dirty_after_install".to_string()
6937 } else {
6938 "synced".to_string()
6939 },
6940 };
6941 Ok(V2PulledSnapshot {
6942 report,
6943 head,
6944 files: remote,
6945 assets: remote_assets,
6946 local: installed_local,
6947 local_assets: installed_assets,
6948 })
6949}
6950
6951fn v2_sync_pull(
6952 cfg: &HubConfig,
6953 requested_brain: &str,
6954 head: V2VerifiedHead,
6955 out: Option<&Path>,
6956) -> LinkResult<PullReport> {
6957 Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
6958}
6959
6960fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
6961 match remote {
6962 Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
6963 None => json!({ "kind": "absent" }),
6964 }
6965}
6966
6967fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
6968 match remote {
6969 Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
6970 None => json!({ "kind": "absent" }),
6971 }
6972}
6973
6974fn v2_content_withdrawal_operation(
6975 store: &Store,
6976 local_view: &V2LocalView,
6977 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6978 path: &str,
6979 reason: &str,
6980) -> LinkResult<Value> {
6981 if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
6982 || path == "DB.md"
6983 {
6984 return Err(LinkError::InvalidPack {
6985 message: format!("content withdrawal path `{path}` is not a record or source"),
6986 });
6987 }
6988 if !local_view.policy.keeps_home(path)
6989 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6990 {
6991 return Err(LinkError::InvalidPack {
6992 message: format!(
6993 "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6994 ),
6995 });
6996 }
6997 let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
6998 message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
6999 })?;
7000 verify_v2_upload_source(store, path, ¤t.sha256, current.bytes)?;
7001 Ok(json!({
7002 "op": "withdraw_from_hosting",
7003 "path": path,
7004 "expected": { "kind": "blob", "hash": current.sha256 },
7005 "reason": reason,
7006 }))
7007}
7008
7009fn v2_asset_withdrawal_operation(
7010 store: &Store,
7011 local_view: &V2LocalView,
7012 path: &str,
7013 local: &crate::AssetRecord,
7014 current: &V2BaselineAsset,
7015 reason: &str,
7016) -> LinkResult<Value> {
7017 if !local_view.policy.keeps_home(path)
7018 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7019 {
7020 return Err(LinkError::InvalidPack {
7021 message: format!(
7022 "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7023 ),
7024 });
7025 }
7026 verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
7027 if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
7028 return Err(LinkError::InvalidPack {
7029 message: format!(
7030 "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
7031 ),
7032 });
7033 }
7034 Ok(json!({
7035 "op": "asset_withdraw",
7036 "path": path,
7037 "expected": v2_asset_expected(Some(current)),
7038 "reason": reason,
7039 }))
7040}
7041
7042fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
7049 let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
7050 let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
7051 for (index, operation) in operations.iter().enumerate() {
7052 match operation.get("op").and_then(Value::as_str) {
7053 Some("delete") => {
7054 let Some(path) = operation.get("path").and_then(Value::as_str) else {
7055 continue;
7056 };
7057 let Some(hash) = operation
7058 .get("expected")
7059 .and_then(|value| value.get("hash"))
7060 .and_then(Value::as_str)
7061 else {
7062 continue;
7063 };
7064 if path.starts_with("sources/") {
7065 deletes
7066 .entry(hash.to_string())
7067 .or_default()
7068 .push((index, path.to_string()));
7069 }
7070 }
7071 Some("put") => {
7072 let Some(path) = operation.get("path").and_then(Value::as_str) else {
7073 continue;
7074 };
7075 let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
7076 continue;
7077 };
7078 let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
7079 continue;
7080 };
7081 let destination_absent = operation
7082 .get("expected")
7083 .and_then(|value| value.get("kind"))
7084 .and_then(Value::as_str)
7085 == Some("absent");
7086 if path.starts_with("sources/") && destination_absent {
7087 puts.entry(hash.to_string()).or_default().push((
7088 index,
7089 path.to_string(),
7090 bytes,
7091 ));
7092 }
7093 }
7094 _ => {}
7095 }
7096 }
7097 let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
7098 let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
7099 for (hash, source) in deletes {
7100 let Some(destination) = puts.get(&hash) else {
7101 continue;
7102 };
7103 if source.len() != 1 || destination.len() != 1 {
7104 continue;
7105 }
7106 let (delete_index, from) = &source[0];
7107 let (put_index, to, bytes) = &destination[0];
7108 if from == to {
7109 continue;
7110 }
7111 rename_at.insert(
7112 *delete_index,
7113 json!({
7114 "op": "rename",
7115 "from": from,
7116 "to": to,
7117 "expected_from": { "kind": "blob", "hash": hash },
7118 "expected_to": { "kind": "absent" },
7119 "blob": hash,
7120 "bytes": bytes,
7121 }),
7122 );
7123 consumed_puts.insert(*put_index);
7124 }
7125 operations
7126 .into_iter()
7127 .enumerate()
7128 .filter_map(|(index, operation)| {
7129 if let Some(rename) = rename_at.remove(&index) {
7130 Some(rename)
7131 } else if consumed_puts.contains(&index) {
7132 None
7133 } else {
7134 Some(operation)
7135 }
7136 })
7137 .collect()
7138}
7139
7140fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
7141 json!({
7142 "blob_sha256": record.sha256,
7143 "bytes": record.bytes,
7144 "media_type": record.media_type,
7145 "wrappers": record.wrappers,
7146 "required": record.required,
7147 "disposition": disposition,
7148 })
7149}
7150
7151fn apply_generated_v2_operations(
7155 operations: &[Value],
7156 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
7157 candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
7158 candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
7159) -> LinkResult<bool> {
7160 let mut asset_changed = false;
7161 for operation in operations {
7162 match operation.get("op").and_then(Value::as_str) {
7163 Some("put") => {
7164 let path = operation
7165 .get("path")
7166 .and_then(Value::as_str)
7167 .ok_or_else(|| invalid_feed("v2 put has no path"))?;
7168 let sha256 = operation
7169 .get("blob")
7170 .and_then(Value::as_str)
7171 .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
7172 let bytes = operation
7173 .get("bytes")
7174 .and_then(Value::as_u64)
7175 .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
7176 candidate.insert(
7177 path.to_string(),
7178 V2BaselineFile {
7179 sha256: sha256.to_string(),
7180 bytes,
7181 proof: None,
7182 },
7183 );
7184 }
7185 Some("rename") => {
7186 let from = operation
7187 .get("from")
7188 .and_then(Value::as_str)
7189 .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
7190 let to = operation
7191 .get("to")
7192 .and_then(Value::as_str)
7193 .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
7194 let sha256 = operation
7195 .get("blob")
7196 .and_then(Value::as_str)
7197 .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
7198 let bytes = operation
7199 .get("bytes")
7200 .and_then(Value::as_u64)
7201 .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
7202 let expected_from = operation
7203 .get("expected_from")
7204 .and_then(|expected| expected.get("hash"))
7205 .and_then(Value::as_str);
7206 let expected_to_absent = operation
7207 .get("expected_to")
7208 .and_then(|expected| expected.get("kind"))
7209 .and_then(Value::as_str)
7210 == Some("absent");
7211 if from == to
7212 || !from.starts_with("sources/")
7213 || !to.starts_with("sources/")
7214 || expected_from != Some(sha256)
7215 || !expected_to_absent
7216 || candidate.contains_key(to)
7217 {
7218 return Err(invalid_feed("generated v2 source rename is malformed"));
7219 }
7220 let source = candidate
7221 .remove(from)
7222 .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
7223 if source.sha256 != sha256 || source.bytes != bytes {
7224 return Err(invalid_feed(
7225 "v2 rename source differs from its exact-byte claim",
7226 ));
7227 }
7228 candidate.insert(
7229 to.to_string(),
7230 V2BaselineFile {
7231 sha256: sha256.to_string(),
7232 bytes,
7233 proof: None,
7234 },
7235 );
7236 }
7237 Some("delete" | "withdraw_from_hosting") => {
7238 let path = operation
7239 .get("path")
7240 .and_then(Value::as_str)
7241 .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7242 candidate.remove(path);
7243 }
7244 Some("asset_delete") => {
7245 let path = operation
7246 .get("path")
7247 .and_then(Value::as_str)
7248 .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7249 candidate_assets.remove(path);
7250 asset_changed = true;
7251 }
7252 Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7253 let path = operation
7254 .get("path")
7255 .and_then(Value::as_str)
7256 .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7257 let record = local_assets
7258 .get(path)
7259 .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7260 let disposition =
7261 if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7262 "withheld"
7263 } else {
7264 operation
7265 .get("asset")
7266 .and_then(|asset| asset.get("disposition"))
7267 .and_then(Value::as_str)
7268 .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7269 };
7270 candidate_assets.insert(
7271 path.to_string(),
7272 V2BaselineAsset {
7273 blob_sha256: record.sha256.clone(),
7274 bytes: record.bytes,
7275 media_type: record.media_type.clone(),
7276 wrappers: record.wrappers.clone(),
7277 required: record.required,
7278 disposition: disposition.to_string(),
7279 leaf_hash: String::new(),
7282 },
7283 );
7284 asset_changed = true;
7285 }
7286 _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7287 }
7288 }
7289 Ok(asset_changed)
7290}
7291
7292fn v2_riding_matches_remote(
7293 local: &std::collections::BTreeMap<String, (String, u64)>,
7294 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7295 keeps_home: impl Fn(&str) -> bool,
7296) -> bool {
7297 remote.iter().all(|(path, file)| {
7298 keeps_home(path)
7299 || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7300 }) && local.iter().all(|(path, (hash, _))| {
7301 remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7302 })
7303}
7304
7305#[derive(Debug, Clone)]
7306struct V2ResolutionOverride {
7307 expected_remote: Option<String>,
7308 selected_local: Option<String>,
7309}
7310
7311#[derive(Debug, Clone)]
7312struct V2UploadSource {
7313 path: String,
7314 bytes: u64,
7315}
7316
7317struct V2SyncPushOptions<'a> {
7318 resume_local_policy: bool,
7319 bulk_confirmation: Option<&'a V2BulkConfirmation>,
7320 resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7321 pulled: Option<V2PulledSnapshot>,
7322 withdrawal_paths: &'a [String],
7323 withdrawal_reason: Option<&'a str>,
7324}
7325
7326fn verify_v2_upload_source(
7327 store: &Store,
7328 path: &str,
7329 sha256: &str,
7330 expected_bytes: u64,
7331) -> LinkResult<()> {
7332 let file = store.open_regular(Path::new(path))?;
7333 if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7334 return Err(LinkError::InvalidPack {
7335 message: format!("local path `{path}` changed during sync planning"),
7336 });
7337 }
7338 Ok(())
7339}
7340
7341struct V2PendingUpload<'a> {
7344 url: String,
7345 headers: Value,
7346 sha256: String,
7347 source: &'a V2UploadSource,
7348}
7349
7350const V2_UPLOAD_CONCURRENCY: usize = 16;
7357
7358fn upload_v2_batch_concurrently(
7362 cfg: &HubConfig,
7363 store: &Store,
7364 pending: &[V2PendingUpload<'_>],
7365) -> LinkResult<()> {
7366 if pending.is_empty() {
7367 return Ok(());
7368 }
7369 let urls = pending
7370 .iter()
7371 .map(|task| task.url.as_str())
7372 .collect::<Vec<_>>();
7373 let shared = shared_staging_agent(cfg, &urls);
7374 if pending.len() == 1 {
7375 let task = &pending[0];
7376 put_presigned_source(
7377 cfg,
7378 &task.url,
7379 &task.headers,
7380 store,
7381 task.source,
7382 shared.as_ref(),
7383 )?;
7384 return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7385 }
7386 let next = std::sync::atomic::AtomicUsize::new(0);
7387 let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7388 let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7389 std::thread::scope(|scope| {
7390 for _ in 0..workers {
7391 scope.spawn(|| loop {
7392 if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7393 return;
7394 }
7395 let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7396 let Some(task) = pending.get(index) else {
7397 return;
7398 };
7399 let outcome = put_presigned_source(
7400 cfg,
7401 &task.url,
7402 &task.headers,
7403 store,
7404 task.source,
7405 shared.as_ref(),
7406 )
7407 .and_then(|()| {
7408 verify_v2_upload_source(
7409 store,
7410 &task.source.path,
7411 &task.sha256,
7412 task.source.bytes,
7413 )
7414 });
7415 if let Err(error) = outcome {
7416 if let Ok(mut guard) = failure.lock() {
7417 guard.get_or_insert(error);
7418 }
7419 return;
7420 }
7421 });
7422 }
7423 });
7424 match failure.into_inner() {
7425 Ok(Some(error)) => Err(error),
7426 Ok(None) => Ok(()),
7427 Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7428 }
7429}
7430
7431fn put_presigned_source(
7432 cfg: &HubConfig,
7433 raw: &str,
7434 headers: &Value,
7435 store: &Store,
7436 source: &V2UploadSource,
7437 shared: Option<&ureq::Agent>,
7438) -> LinkResult<()> {
7439 put_presigned_source_with_budget(
7440 cfg,
7441 raw,
7442 headers,
7443 store,
7444 source,
7445 shared,
7446 std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
7447 )
7448}
7449
7450fn put_presigned_source_with_budget(
7451 cfg: &HubConfig,
7452 raw: &str,
7453 headers: &Value,
7454 store: &Store,
7455 source: &V2UploadSource,
7456 shared: Option<&ureq::Agent>,
7457 total_budget: std::time::Duration,
7458) -> LinkResult<()> {
7459 let owned = match shared {
7462 Some(_) => {
7463 checked_presigned_url(cfg, raw)?;
7464 None
7465 }
7466 None => Some(presigned_agent(cfg, raw)?),
7467 };
7468 let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7469 let deadline = std::time::Instant::now()
7470 .checked_add(total_budget)
7471 .ok_or_else(upload_deadline_error)?;
7472 let mut attempt = 0;
7473 let result = loop {
7474 let file = store.open_regular(Path::new(&source.path))?;
7475 if file.metadata()?.len() != source.bytes {
7476 return Err(LinkError::InvalidPack {
7477 message: format!("local path `{}` changed before upload", source.path),
7478 });
7479 }
7480 let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
7485 let mut has_content_length = false;
7486 if let Some(map) = headers.as_object() {
7487 for (name, value) in map {
7488 if let Some(value) = value.as_str() {
7489 has_content_length |= name.eq_ignore_ascii_case("content-length");
7490 req = req.set(name, value);
7491 }
7492 }
7493 }
7494 if !has_content_length {
7495 req = req.set("Content-Length", &source.bytes.to_string());
7496 }
7497 match req.send(file) {
7498 Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
7504 attempt += 1;
7505 }
7506 Err(ureq::Error::Status(status, _))
7512 if status != 412
7513 && is_retryable_upload_status(status)
7514 && wait_for_upload_retry(deadline, attempt) =>
7515 {
7516 attempt += 1;
7517 }
7518 result => break result,
7519 }
7520 };
7521 match result {
7522 Ok(response) if (200..300).contains(&response.status()) => {
7523 drain_presigned_response(response);
7524 Ok(())
7525 }
7526 Ok(response) => {
7527 let status = response.status();
7532 let detail = response
7533 .into_string()
7534 .ok()
7535 .map(|body| body.chars().take(400).collect::<String>())
7536 .filter(|body| !body.trim().is_empty());
7537 Err(LinkError::Http {
7538 what: "v2 changed-byte upload",
7539 status,
7540 message: match detail {
7541 Some(body) => format!(
7542 "object store rejected the upload of `{}`: {}",
7543 source.path,
7544 body.replace('\n', " ")
7545 ),
7546 None => format!("object store rejected the upload of `{}`", source.path),
7547 },
7548 code: None,
7549 details: None,
7550 })
7551 }
7552 Err(error) => match error {
7553 ureq::Error::Status(412, _) => Ok(()),
7554 ureq::Error::Status(_, response) => {
7555 let status = response.status();
7556 let detail = response
7557 .into_string()
7558 .ok()
7559 .map(|body| body.chars().take(400).collect::<String>())
7560 .filter(|body| !body.trim().is_empty());
7561 Err(LinkError::Http {
7562 what: "v2 changed-byte upload",
7563 status,
7564 message: match detail {
7565 Some(body) => format!(
7566 "object store rejected the upload of `{}`: {}",
7567 source.path,
7568 body.replace('\n', " ")
7569 ),
7570 None => {
7571 format!("object store rejected the upload of `{}`", source.path)
7572 }
7573 },
7574 code: None,
7575 details: None,
7576 })
7577 }
7578 ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
7579 },
7580 }
7581}
7582
7583fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
7587 if body.get("operations").is_some() {
7588 return body.clone();
7589 }
7590 let mut value = body.clone();
7591 if let Some(map) = value.as_object_mut() {
7592 map.remove("staged_change");
7593 map.insert("operations".to_string(), Value::Array(operations.to_vec()));
7594 }
7595 value
7596}
7597
7598fn reserve_upload_window(
7602 cfg: &HubConfig,
7603 path: &str,
7604 body: &Value,
7605 what: &'static str,
7606) -> LinkResult<Value> {
7607 let mut attempt = 0;
7608 loop {
7609 let pause = |attempt: usize| {
7610 std::thread::sleep(std::time::Duration::from_millis(
7611 RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
7612 ));
7613 };
7614 match request(cfg, "POST", path, Some(body), Auth::Required) {
7615 Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
7620 pause(attempt);
7621 attempt += 1;
7622 }
7623 Err(error) => return Err(error),
7624 Ok(response) => {
7625 if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
7626 pause(attempt);
7627 attempt += 1;
7628 continue;
7629 }
7630 return ensure_ok(response, what);
7631 }
7632 }
7633 }
7634}
7635
7636fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
7640 let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
7641 .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
7642 if bytes.len() > MAX_STAGED_CHANGE_BYTES {
7643 return Err(LinkError::PushTooLarge {
7644 detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
7645 });
7646 }
7647 Ok(bytes)
7648}
7649
7650fn stage_v2_change(
7660 cfg: &HubConfig,
7661 requested_brain: &str,
7662 operations: &[Value],
7663 blobs: Value,
7664) -> LinkResult<Value> {
7665 let bytes = v2_change_manifest(operations, blobs)?;
7666 let sha256 = content_sha256(&bytes);
7667 let reserved = reserve_upload_window(
7668 cfg,
7669 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7670 &json!({
7671 "blobs": [{
7672 "sha256": sha256,
7673 "bytes": bytes.len(),
7674 "kind": "staged_change",
7675 }],
7676 }),
7677 "stage the v2 change",
7678 )?;
7679 let items = reserved
7680 .get("uploads")
7681 .and_then(Value::as_array)
7682 .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
7683 let [item] = items.as_slice() else {
7684 return Err(invalid_feed(
7685 "v2 change staging response changed the requested set",
7686 ));
7687 };
7688 let reservation_id = item
7689 .get("reservation_id")
7690 .and_then(Value::as_str)
7691 .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
7692 if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
7693 || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
7694 || !crate::ulid::is_ulid(reservation_id)
7695 {
7696 return Err(invalid_feed("v2 change staging item is inconsistent"));
7697 }
7698 match item.get("status").and_then(Value::as_str) {
7699 Some("upload") => put_presigned(
7700 cfg,
7701 item.get("url")
7702 .and_then(Value::as_str)
7703 .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
7704 item.get("headers").unwrap_or(&Value::Null),
7705 &bytes,
7706 )?,
7707 Some("already_present") => {}
7708 _ => return Err(invalid_feed("v2 change staging has an unknown status")),
7709 }
7710 Ok(json!({
7711 "sha256": sha256,
7712 "bytes": bytes.len(),
7713 "reservation_id": reservation_id,
7714 }))
7715}
7716
7717fn stage_oversized_v2_change(
7721 cfg: &HubConfig,
7722 requested_brain: &str,
7723 operations: &[Value],
7724 body: &mut Value,
7725) -> LinkResult<()> {
7726 if body.to_string().len() <= MAX_PUSH_BYTES {
7727 return Ok(());
7728 }
7729 let staged = stage_v2_change(
7730 cfg,
7731 requested_brain,
7732 operations,
7733 body.get("blobs")
7734 .cloned()
7735 .unwrap_or(Value::Array(Vec::new())),
7736 )?;
7737 let map = body
7738 .as_object_mut()
7739 .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
7740 map.remove("operations");
7741 map.remove("blobs");
7742 map.insert("staged_change".to_string(), staged);
7743 Ok(())
7744}
7745
7746fn v2_sync_push(
7747 cfg: &HubConfig,
7748 requested_brain: &str,
7749 store: &Store,
7750 head: V2VerifiedHead,
7751 options: V2SyncPushOptions<'_>,
7752) -> LinkResult<Value> {
7753 let V2SyncPushOptions {
7754 resume_local_policy,
7755 bulk_confirmation,
7756 resolution,
7757 pulled,
7758 withdrawal_paths,
7759 withdrawal_reason,
7760 } = options;
7761 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
7762 let head = v2_verified_head(cfg, requested_brain)?
7763 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7764 let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
7765 match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
7766 Some(snapshot) => (
7767 snapshot.files,
7768 snapshot.assets,
7769 Some(snapshot.local),
7770 Some(snapshot.local_assets),
7771 ),
7772 None => (
7773 files_for_v2_view(
7774 &head,
7775 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7776 ),
7777 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7778 None,
7779 None,
7780 ),
7781 };
7782 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
7783 ensure_v2_view_compatible(&head, baseline.as_ref())?;
7784 if head.view_kind == "scoped" && baseline.is_none() {
7785 return Err(LinkError::ScopedViewChanged);
7786 }
7787 let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
7788 let local = &local_view.riding;
7789 let local_assets = match carried_local_assets {
7790 Some(assets) => assets,
7791 None => v2_local_asset_records(store)?,
7792 };
7793 if withdrawal_paths.len() > MAX_PUSH_FILES {
7794 return Err(LinkError::PushTooLarge {
7795 detail: "too many explicit withdrawal paths".to_string(),
7796 });
7797 }
7798 let withdrawal_reason = if withdrawal_paths.is_empty() {
7799 None
7800 } else {
7801 let reason = withdrawal_reason
7802 .map(str::trim)
7803 .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
7804 .ok_or_else(|| LinkError::InvalidPack {
7805 message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
7806 })?;
7807 Some(reason)
7808 };
7809 let mut withdrawals = withdrawal_paths
7810 .iter()
7811 .map(|path| {
7812 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
7813 path: error.to_string(),
7814 })
7815 })
7816 .collect::<LinkResult<Vec<_>>>()?;
7817 withdrawals.sort();
7818 withdrawals.dedup();
7819 if withdrawals.len() != withdrawal_paths.len() {
7820 return Err(LinkError::InvalidPack {
7821 message: "explicit withdrawal paths must be unique".to_string(),
7822 });
7823 }
7824 let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
7825 let mut consumed_withdrawals = BTreeSet::new();
7826 if let Some(previous) = baseline.as_ref() {
7827 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
7828 && !resume_local_policy
7829 {
7830 let mut newly_eligible = previous
7831 .local_eligibility
7832 .iter()
7833 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7834 .map(|(path, _)| path.clone())
7835 .collect::<Vec<_>>();
7836 if !newly_eligible.is_empty() {
7837 newly_eligible.truncate(100);
7838 return Err(LinkError::LocalPolicyTransition {
7839 paths: newly_eligible,
7840 });
7841 }
7842 }
7843 }
7844 let base = match baseline.as_ref() {
7845 Some(state) => &state.files,
7846 None if remote.is_empty() => &remote,
7847 None => {
7848 let mut conflicts = remote
7849 .iter()
7850 .filter(|(path, file)| {
7851 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7852 })
7853 .map(|(path, _)| path.clone())
7854 .collect::<Vec<_>>();
7855 if !conflicts.is_empty() {
7856 conflicts.truncate(100);
7857 let (bundle, paths) =
7858 create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7859 return Err(LinkError::ConflictBundle { bundle, paths });
7860 }
7861 &remote
7862 }
7863 };
7864 let all_paths = base
7865 .keys()
7866 .chain(remote.keys())
7867 .chain(local.keys())
7868 .cloned()
7869 .collect::<std::collections::BTreeSet<_>>();
7870 let mut conflicts = Vec::new();
7871 let mut operations = Vec::new();
7872 let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7873 for path in all_paths {
7874 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7875 let remote_file = remote.get(&path);
7876 let remote_hash = remote_file.map(|file| file.sha256.as_str());
7877 let local_file = local.get(&path);
7878 let local_hash = local_file.map(|file| file.0.as_str());
7879 if local_hash == base_hash {
7880 continue;
7881 }
7882 if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
7883 continue;
7884 }
7885 if local_view.policy.keeps_home(&path) {
7886 continue;
7889 }
7890 if remote_hash != base_hash && local_hash != remote_hash {
7891 let explicitly_resolved = resolution
7892 .and_then(|allowed| allowed.get(&path))
7893 .is_some_and(|selected| {
7894 selected.expected_remote.as_deref() == remote_hash
7895 && selected.selected_local.as_deref() == local_hash
7896 });
7897 if !explicitly_resolved {
7898 conflicts.push(path);
7899 continue;
7900 }
7901 }
7902 match local_file {
7903 Some((sha256, byte_count)) => {
7904 verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7905 operations.push(json!({
7906 "op": "put",
7907 "path": path,
7908 "expected": v2_expected(remote_file),
7909 "blob": sha256,
7910 "bytes": byte_count,
7911 }));
7912 upload_sources
7913 .entry(sha256.clone())
7914 .or_insert_with(|| V2UploadSource {
7915 path: path.clone(),
7916 bytes: *byte_count,
7917 });
7918 }
7919 None => {
7920 let Some(current) = remote_file else {
7921 continue;
7922 };
7923 operations.push(json!({
7924 "op": "delete",
7925 "path": path,
7926 "expected": { "kind": "blob", "hash": current.sha256 },
7927 }));
7928 }
7929 }
7930 }
7931 operations = infer_exact_source_promotions(operations);
7932 for path in &withdrawals {
7933 if local_assets.contains_key(path) {
7934 continue;
7935 }
7936 operations.push(v2_content_withdrawal_operation(
7937 store,
7938 &local_view,
7939 &remote,
7940 path,
7941 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7942 )?);
7943 consumed_withdrawals.insert(path.clone());
7944 }
7945 if !conflicts.is_empty() {
7946 conflicts.truncate(100);
7947 let (bundle, paths) = create_v2_conflict_bundle(
7948 cfg,
7949 store,
7950 &head,
7951 baseline.as_ref(),
7952 local,
7953 &remote,
7954 &conflicts,
7955 )?;
7956 return Err(LinkError::ConflictBundle { bundle, paths });
7957 }
7958 let base_assets = match baseline.as_ref() {
7959 Some(state) => &state.assets,
7960 None if remote_assets.is_empty() => &remote_assets,
7961 None => {
7962 let mismatched = remote_assets.iter().any(|(path, remote)| {
7963 local_assets.get(path) != Some(&v2_asset_record(remote, path))
7964 }) || local_assets.len() != remote_assets.len();
7965 if mismatched {
7966 return Err(LinkError::Conflict {
7967 paths: vec!["assets.jsonl".to_string()],
7968 });
7969 }
7970 &remote_assets
7971 }
7972 };
7973 let asset_paths = base_assets
7974 .keys()
7975 .chain(remote_assets.keys())
7976 .chain(local_assets.keys())
7977 .cloned()
7978 .collect::<std::collections::BTreeSet<_>>();
7979 let mut asset_policy_transitions = Vec::new();
7980 for path in asset_paths {
7981 let base_record = base_assets
7982 .get(&path)
7983 .map(|asset| v2_asset_record(asset, &path));
7984 let remote = remote_assets.get(&path);
7985 let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
7986 let local_record = local_assets.get(&path);
7987 if withdrawal_set.contains(&path) {
7988 let record = local_record.ok_or_else(|| LinkError::InvalidPack {
7989 message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
7990 })?;
7991 let current = remote.ok_or_else(|| LinkError::InvalidPack {
7992 message: format!(
7993 "asset withdrawal path `{path}` has no readable hosted coordinate"
7994 ),
7995 })?;
7996 operations.push(v2_asset_withdrawal_operation(
7997 store,
7998 &local_view,
7999 &path,
8000 record,
8001 current,
8002 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8003 )?);
8004 consumed_withdrawals.insert(path.clone());
8005 continue;
8006 }
8007 let mut raw_present = false;
8008 let mut disposition = "withheld";
8009 let mut resumes_hosting = false;
8010 if let Some(record) = local_record {
8011 crate::linkmd_v2::normalize_path(&record.path)
8012 .map_err(|error| invalid_feed(error.to_string()))?;
8013 let kept_home = local_view.policy.keeps_home(&path);
8014 raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
8015 disposition = if kept_home || !raw_present {
8016 "withheld"
8017 } else {
8018 "hosted"
8019 };
8020 if !raw_present && record.required && !kept_home {
8021 return Err(LinkError::InvalidPack {
8022 message: format!("required asset {path} is missing"),
8023 });
8024 }
8025 resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
8026 }
8027 if local_record == base_record.as_ref() && !resumes_hosting {
8028 continue;
8029 }
8030 if remote_record != base_record && local_record != remote_record.as_ref() {
8031 conflicts.push(path);
8032 continue;
8033 }
8034 let Some(record) = local_record else {
8035 if let Some(remote) = remote {
8036 operations.push(json!({
8037 "op": "asset_delete",
8038 "path": path,
8039 "expected": v2_asset_expected(Some(remote)),
8040 }));
8041 }
8042 continue;
8043 };
8044 let raw = if raw_present {
8045 verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
8046 Some(())
8047 } else {
8048 None
8049 };
8050 let op = if resumes_hosting {
8051 if !resume_local_policy {
8052 asset_policy_transitions.push(path);
8053 continue;
8054 }
8055 "asset_resume"
8056 } else {
8057 "asset_put"
8058 };
8059 operations.push(json!({
8060 "op": op,
8061 "path": path,
8062 "expected": v2_asset_expected(remote),
8063 "asset": v2_asset_value(record, disposition),
8064 }));
8065 if disposition == "hosted" {
8066 raw.expect("hosted asset was checked present");
8067 upload_sources
8068 .entry(record.sha256.clone())
8069 .or_insert_with(|| V2UploadSource {
8070 path: path.clone(),
8071 bytes: record.bytes,
8072 });
8073 }
8074 }
8075 if consumed_withdrawals != withdrawal_set {
8076 let missing = withdrawal_set
8077 .difference(&consumed_withdrawals)
8078 .next()
8079 .expect("different withdrawal sets have one member");
8080 return Err(LinkError::InvalidPack {
8081 message: format!(
8082 "withdrawal path `{missing}` is not a readable content or asset coordinate"
8083 ),
8084 });
8085 }
8086 if !conflicts.is_empty() {
8087 conflicts.truncate(100);
8088 return Err(LinkError::Conflict { paths: conflicts });
8089 }
8090 if !asset_policy_transitions.is_empty() {
8091 asset_policy_transitions.truncate(100);
8092 return Err(LinkError::LocalPolicyTransition {
8093 paths: asset_policy_transitions,
8094 });
8095 }
8096 let touched_sources = operations
8097 .iter()
8098 .filter_map(
8099 |operation| match operation.get("op").and_then(Value::as_str) {
8100 Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
8101 Some("rename") => operation.get("to").and_then(Value::as_str),
8102 _ => None,
8103 },
8104 )
8105 .collect::<std::collections::BTreeSet<_>>();
8106 let withheld_links = local_view
8107 .withheld_links
8108 .iter()
8109 .filter(|link| touched_sources.contains(link.source.as_str()))
8110 .collect::<Vec<_>>();
8111 let checkout_pseudonym = v2_checkout_id(
8112 baseline
8113 .as_ref()
8114 .and_then(|current| current.checkout_id.as_deref()),
8115 )?;
8116 let checkout_id = if withheld_links.is_empty() {
8117 None
8118 } else {
8119 Some(checkout_pseudonym.clone())
8120 };
8121 if operations.is_empty() {
8122 let final_head = v2_verified_head(cfg, requested_brain)?
8123 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
8124 if !same_v2_head(&head, &final_head) {
8125 return Err(LinkError::RemoteAdvancedDuringSync);
8126 }
8127 let mut final_local = v2_local_files(store)?;
8128 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8129 let final_assets = v2_local_asset_records(store)?;
8130 let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
8131 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
8132 final_local.policy.keeps_home(path)
8133 }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
8134 let next = v2_baseline_from_head(
8135 cfg,
8136 &head,
8137 remote,
8138 remote_assets,
8139 Some(&final_local),
8140 Some(&checkout_pseudonym),
8141 )?;
8142 let split_count = next.remote_copy_remains.len();
8143 accept_v2_head(cfg, &final_head)?;
8144 if !local_changed && !remote_ahead {
8145 refresh_scoped_view_marker(store, &head, next.files.len())?;
8146 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8147 }
8148 return Ok(json!({
8149 "v": 2,
8150 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
8151 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
8152 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8153 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8154 "local_policy": {
8155 "remote_copy_remains": split_count,
8156 },
8157 }));
8158 }
8159 let includes_contract = operations
8160 .iter()
8161 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
8162 let rebase = if head.pointer.is_none() || includes_contract {
8163 "strict"
8164 } else {
8165 "disjoint"
8166 };
8167 let base_value = head.pointer.as_ref().map(|pointer| {
8168 json!({
8169 "seq": pointer.seq,
8170 "commit_hash": pointer.commit_hash,
8171 "content_root": pointer.content_root,
8172 "asset_root": pointer.asset_root,
8173 })
8174 });
8175 let entropy = format!(
8179 "{}\0{}\0{}\0{}\0{}\0{}",
8180 normalized_origin(&cfg.hub)?,
8181 head.brain_id,
8182 serde_json::to_string(&base_value).unwrap_or_default(),
8183 serde_json::to_string(&operations).unwrap_or_default(),
8184 serde_json::to_string(&withheld_links).unwrap_or_default(),
8185 checkout_id.as_deref().unwrap_or("")
8186 );
8187 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
8188 let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
8189 total
8190 .checked_add(source.bytes)
8191 .ok_or_else(|| LinkError::PushTooLarge {
8192 detail: "v2 changed-byte total overflow".to_string(),
8193 })
8194 })?;
8195 let inline = changed_bytes <= 3 * 1024 * 1024;
8196 let inline_blobs = if inline {
8197 upload_sources
8198 .iter()
8199 .map(|(sha256, source)| {
8200 let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
8201 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
8202 return Err(LinkError::InvalidPack {
8203 message: format!("local path `{}` changed before upload", source.path),
8204 });
8205 }
8206 Ok(json!({
8207 "sha256": sha256,
8208 "bytes": source.bytes,
8209 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
8210 }))
8211 })
8212 .collect::<LinkResult<Vec<_>>>()?
8213 } else {
8214 Vec::new()
8215 };
8216 let mut body = json!({
8217 "mutation_id": mutation_id,
8218 "base": base_value,
8219 "rebase": rebase,
8220 "reason": "dbmd sync",
8221 "operations": operations,
8222 "blobs": inline_blobs,
8223 });
8224 if !withheld_links.is_empty() {
8225 body["withheld_links"] = serde_json::to_value(&withheld_links)
8226 .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
8227 body["checkout_id"] =
8228 Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
8229 }
8230 if let Some(confirmation) = bulk_confirmation {
8231 if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
8232 return Err(LinkError::InvalidPack {
8233 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
8234 .to_string(),
8235 });
8236 }
8237 body["rebase"] = Value::String("strict".to_string());
8241 body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8242 body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8243 }
8244 if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8245 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8246 for operation in &operations {
8247 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8248 return Err(invalid_feed("v2 upload operation has no kind"));
8249 };
8250 let hash = match kind {
8251 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
8252 "asset_put" | "asset_resume" => operation
8253 .get("asset")
8254 .and_then(|asset| asset.get("blob_sha256"))
8255 .and_then(Value::as_str),
8256 _ => None,
8257 };
8258 let Some(hash) = hash else { continue };
8259 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8260 if kind == "rename" {
8261 for field in ["from", "to"] {
8262 coordinates.insert(
8263 operation
8264 .get(field)
8265 .and_then(Value::as_str)
8266 .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8267 .to_string(),
8268 );
8269 }
8270 } else {
8271 let path = operation
8272 .get("path")
8273 .and_then(Value::as_str)
8274 .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8275 coordinates.insert(if kind.starts_with("asset_") {
8276 format!("assets/{path}")
8277 } else {
8278 path.to_string()
8279 });
8280 }
8281 }
8282 let declarations = upload_sources
8283 .iter()
8284 .map(|(sha256, source)| {
8285 json!({
8286 "sha256": sha256,
8287 "bytes": source.bytes,
8288 "coordinates": coordinates_by_hash
8289 .get(sha256)
8290 .into_iter()
8291 .flatten()
8292 .collect::<Vec<_>>(),
8293 })
8294 })
8295 .collect::<Vec<_>>();
8296 let mut references = Vec::with_capacity(upload_sources.len());
8297 let mut seen = std::collections::BTreeSet::new();
8298 let mut reserved_count = 0usize;
8299 let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8300 for batch in batch_upload_declarations(declarations) {
8304 let batch_len = batch.len();
8305 let reserved = reserve_upload_window(
8306 cfg,
8307 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8308 &json!({ "blobs": batch }),
8309 "prepare v2 changed-byte uploads",
8310 )?;
8311 let items = reserved
8312 .get("uploads")
8313 .and_then(Value::as_array)
8314 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8315 if items.len() != batch_len {
8316 return Err(invalid_feed(
8317 "v2 upload reservation response changed the requested set",
8318 ));
8319 }
8320 reserved_count += items.len();
8321 for item in items {
8322 let sha256 = item
8323 .get("sha256")
8324 .and_then(Value::as_str)
8325 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8326 let source = upload_sources
8327 .get(sha256)
8328 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8329 let declared_bytes = item
8330 .get("bytes")
8331 .and_then(Value::as_u64)
8332 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8333 let reservation_id = item
8334 .get("reservation_id")
8335 .and_then(Value::as_str)
8336 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8337 let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8338 invalid_feed("v2 upload reservation has no coordinate binding")
8339 })?;
8340 let returned_coordinates = item
8341 .get("coordinates")
8342 .and_then(Value::as_array)
8343 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8344 if declared_bytes != source.bytes
8345 || !crate::ulid::is_ulid(reservation_id)
8346 || !seen.insert(sha256.to_string())
8347 || returned_coordinates.len() != expected_coordinates.len()
8348 || returned_coordinates
8349 .iter()
8350 .zip(expected_coordinates)
8351 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8352 {
8353 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8354 }
8355 match item.get("status").and_then(Value::as_str) {
8356 Some("upload") => {
8357 let url = item
8358 .get("url")
8359 .and_then(Value::as_str)
8360 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8361 pending_uploads.push(V2PendingUpload {
8362 url: url.to_string(),
8363 headers: item.get("headers").cloned().unwrap_or(Value::Null),
8364 sha256: sha256.to_string(),
8365 source,
8366 });
8367 }
8368 Some("already_present") => {}
8369 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
8370 }
8371 references.push(json!({
8372 "sha256": sha256,
8373 "bytes": source.bytes,
8374 "reservation_id": reservation_id,
8375 }));
8376 }
8377 upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
8383 pending_uploads.clear();
8384 }
8385 if reserved_count != upload_sources.len() {
8386 return Err(invalid_feed(
8387 "v2 upload reservation response changed the requested set",
8388 ));
8389 }
8390 body["blobs"] = Value::Array(references);
8391 }
8392 stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
8393 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
8394 let mut candidate_hub_signer: Option<String> = None;
8395 let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8396 let bulk_preview_required = !(200..300).contains(&response.status)
8397 && response.body.as_ref().is_some_and(|value| {
8398 value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
8399 || value
8400 .get("details")
8401 .and_then(|details| details.get("code"))
8402 .and_then(Value::as_str)
8403 == Some("bulk_preview_required")
8404 });
8405 if bulk_preview_required && bulk_confirmation.is_none() {
8406 body["rebase"] = Value::String("strict".to_string());
8407 body["preview_only"] = Value::Bool(true);
8408 let preview = ensure_ok(
8409 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
8410 "v2 bulk preview",
8411 )?;
8412 let preview_code = preview.get("code").and_then(Value::as_str);
8413 let required = preview.get("required").and_then(Value::as_bool);
8414 if preview.get("v").and_then(Value::as_u64) != Some(2)
8415 || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
8416 || !matches!(
8417 preview_code,
8418 Some("bulk_preview_created" | "bulk_preview_not_required")
8419 )
8420 || required.is_none()
8421 {
8422 return Err(invalid_feed(
8423 "bulk preview response is not bound to the requested mutation",
8424 ));
8425 }
8426 if required == Some(true) {
8427 let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
8428 let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
8429 if preview_code != Some("bulk_preview_created")
8430 || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
8431 || preview_digest.is_none_or(|value| !is_sha256(value))
8432 || preview.get("expires_at").and_then(Value::as_str).is_none()
8433 || !preview.get("impact").is_some_and(Value::is_object)
8434 {
8435 return Err(invalid_feed("bulk preview receipt is malformed"));
8436 }
8437 return Err(LinkError::BulkPreviewRequired { preview });
8438 }
8439 if preview_code != Some("bulk_preview_not_required") {
8440 return Err(invalid_feed("bulk preview requirement is inconsistent"));
8441 }
8442 body.as_object_mut()
8445 .expect("v2 commit request is an object")
8446 .remove("preview_only");
8447 response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8448 }
8449 let mut result = ensure_ok(response, "v2 sync push")?;
8450 if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
8451 if let Some(object) = result.as_object_mut() {
8452 object.insert(
8453 "sync_status".to_string(),
8454 Value::String("proposal_pending".to_string()),
8455 );
8456 }
8457 return Ok(result);
8458 }
8459 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
8460 let request_id = result
8461 .get("request_id")
8462 .and_then(Value::as_str)
8463 .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
8464 .to_string();
8465 let challenge = result
8466 .get("signing_challenge")
8467 .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
8468 let mut expected_candidate = remote.clone();
8469 let mut expected_candidate_assets = remote_assets.clone();
8470 apply_generated_v2_operations(
8471 &operations,
8472 &local_assets,
8473 &mut expected_candidate,
8474 &mut expected_candidate_assets,
8475 )?;
8476 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
8477 cfg,
8478 &head,
8479 &expected_candidate,
8480 &expected_candidate_assets,
8481 &mutation_id,
8482 &v2_signed_request_view(&body, &operations),
8483 challenge,
8484 )?;
8485 body["signing_challenge_id"] = Value::String(challenge_id);
8486 body["signature_base64url"] = Value::String(signature);
8487 candidate_hub_signer = Some(actor_signer);
8488 result = ensure_ok(
8489 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
8490 "v2 self-custody commit",
8491 )?;
8492 }
8493 let refreshed = v2_verified_head(cfg, requested_brain)?
8494 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
8495 if candidate_hub_signer
8496 .as_ref()
8497 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
8498 {
8499 return Err(invalid_feed(
8500 "self-custody actor signer differs from the committed hub pointer signer",
8501 ));
8502 }
8503 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
8504 if refreshed
8505 .pointer
8506 .as_ref()
8507 .map(|pointer| pointer.commit_hash.as_str())
8508 != accepted_hash
8509 {
8510 return Err(LinkError::RemoteAdvancedDuringSync);
8511 }
8512 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
8513 let rebased = result
8514 .get("rebased")
8515 .and_then(Value::as_bool)
8516 .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
8517 let (refreshed_files, refreshed_assets) = if rebased {
8518 (
8519 files_for_v2_view(
8520 &refreshed,
8521 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8522 ),
8523 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8524 )
8525 } else {
8526 let asset_changed = apply_generated_v2_operations(
8527 &operations,
8528 &local_assets,
8529 &mut remote,
8530 &mut remote_assets,
8531 )?;
8532 let assets = if asset_changed {
8533 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
8536 } else {
8537 remote_assets
8538 };
8539 (remote, assets)
8540 };
8541 let mut final_local = v2_local_files(store)?;
8542 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
8543 let final_assets = v2_local_asset_records(store)?;
8544 let local_dirty = final_local.riding != local_view.riding
8545 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
8546 final_local.policy.keeps_home(path)
8547 })
8548 || final_assets != local_assets
8549 || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
8550 let next = v2_baseline_from_head(
8551 cfg,
8552 &refreshed,
8553 refreshed_files,
8554 refreshed_assets,
8555 Some(&final_local),
8556 Some(&checkout_pseudonym),
8557 )?;
8558 let split_count = next.remote_copy_remains.len();
8559 accept_v2_head(cfg, &refreshed)?;
8560 if !local_dirty {
8561 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
8562 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
8563 }
8564 if let Some(object) = result.as_object_mut() {
8565 object.insert(
8566 "local_policy".to_string(),
8567 json!({ "remote_copy_remains": split_count }),
8568 );
8569 object.insert(
8570 "sync_status".to_string(),
8571 Value::String(if local_dirty {
8572 "remote_committed_local_dirty".to_string()
8573 } else {
8574 "synced".to_string()
8575 }),
8576 );
8577 }
8578 Ok(result)
8579}
8580
8581pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
8584 sync_push_incremental_with_policy(cfg, brain, store, false)
8585}
8586
8587pub fn sync_push_incremental_with_policy(
8590 cfg: &HubConfig,
8591 brain: &str,
8592 store: &Store,
8593 resume_local_policy: bool,
8594) -> LinkResult<Value> {
8595 sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
8596}
8597
8598pub fn sync_push_incremental_with_options(
8601 cfg: &HubConfig,
8602 brain: &str,
8603 store: &Store,
8604 resume_local_policy: bool,
8605 bulk_confirmation: Option<&V2BulkConfirmation>,
8606) -> LinkResult<Value> {
8607 sync_push_incremental_with_controls(
8608 cfg,
8609 brain,
8610 store,
8611 resume_local_policy,
8612 bulk_confirmation,
8613 &[],
8614 None,
8615 )
8616}
8617
8618pub fn sync_push_incremental_with_controls(
8620 cfg: &HubConfig,
8621 brain: &str,
8622 store: &Store,
8623 resume_local_policy: bool,
8624 bulk_confirmation: Option<&V2BulkConfirmation>,
8625 withdrawal_paths: &[String],
8626 withdrawal_reason: Option<&str>,
8627) -> LinkResult<Value> {
8628 require_safe_ref(brain)?;
8629 if let Some(head) = v2_verified_head(cfg, brain)? {
8630 return v2_sync_push(
8631 cfg,
8632 brain,
8633 store,
8634 head,
8635 V2SyncPushOptions {
8636 resume_local_policy,
8637 bulk_confirmation,
8638 resolution: None,
8639 pulled: None,
8640 withdrawal_paths,
8641 withdrawal_reason,
8642 },
8643 );
8644 }
8645 if !withdrawal_paths.is_empty() {
8646 return Err(LinkError::InvalidPack {
8647 message: "explicit withdrawal requires a link.md v2 brain".to_string(),
8648 });
8649 }
8650 legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
8651}
8652
8653pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
8657 require_safe_ref(brain)?;
8658 Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
8659}
8660
8661#[cfg(windows)]
8662fn legacy_sync_push_incremental(
8663 _cfg: &HubConfig,
8664 _brain: &str,
8665 _store: &Store,
8666 _resume_local_policy: bool,
8667 _bulk_confirmation: Option<&V2BulkConfirmation>,
8668) -> LinkResult<Value> {
8669 Err(LinkError::UnsupportedPlatform {
8670 operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
8671 })
8672}
8673
8674#[cfg(not(windows))]
8675fn legacy_sync_push_incremental(
8676 cfg: &HubConfig,
8677 brain: &str,
8678 store: &Store,
8679 resume_local_policy: bool,
8680 bulk_confirmation: Option<&V2BulkConfirmation>,
8681) -> LinkResult<Value> {
8682 if resume_local_policy || bulk_confirmation.is_some() {
8683 return Err(LinkError::InvalidPack {
8684 message: "v2 sync options require a link.md v2 brain".to_string(),
8685 });
8686 }
8687 let files = collect_push_files(store)?;
8688 sync_push(cfg, brain, &files)
8689}
8690
8691#[derive(Debug, Clone)]
8693pub enum V2ConflictChoice {
8694 KeepLocal,
8695 TakeRemote,
8696 From(PathBuf),
8697}
8698
8699fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
8700 if !crate::ulid::is_ulid(bundle) {
8701 return Err(LinkError::InvalidPack {
8702 message: "conflict bundle must be a lowercase ULID".to_string(),
8703 });
8704 }
8705 let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
8706 let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
8707 .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
8708 if plan.v != 2
8709 || plan.class != "content_resolution_required"
8710 || plan.bundle != bundle
8711 || !crate::ulid::is_ulid(&plan.brain)
8712 || plan.files.is_empty()
8713 || plan.files.len() > 100
8714 || plan.files.iter().any(|file| {
8715 crate::linkmd_v2::normalize_path(&file.path).is_err()
8716 || [&file.base, &file.local, &file.remote]
8717 .into_iter()
8718 .any(|coordinate| {
8719 coordinate
8720 .sha256
8721 .as_deref()
8722 .is_some_and(|hash| !is_sha256(hash))
8723 || coordinate.file.as_deref().is_some_and(|name| {
8724 name.starts_with('/')
8725 || name
8726 .split('/')
8727 .any(|part| part.is_empty() || part == "." || part == "..")
8728 })
8729 })
8730 })
8731 {
8732 return Err(invalid_feed("private conflict plan failed validation"));
8733 }
8734 Ok(plan)
8735}
8736
8737pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
8742 require_hardened_filesystem("private conflict maintenance")?;
8743 if all && !prune {
8744 return Err(LinkError::InvalidPack {
8745 message: "discarding all conflict bundles requires prune=true".to_string(),
8746 });
8747 }
8748 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8749 message: format!("conflict checkout is not a valid db.md store: {error}"),
8750 })?;
8751 let _transaction = store.transaction()?;
8752 let root = Path::new(".dbmd/conflicts");
8753 let names = match store.directory_names(root) {
8754 Ok(names) => names,
8755 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8756 Err(error) => return Err(error.into()),
8757 };
8758 let now = SystemTime::now()
8759 .duration_since(UNIX_EPOCH)
8760 .unwrap_or_default()
8761 .as_secs();
8762 let mut bundles = Vec::new();
8763 let mut pruned = 0_u64;
8764 for name in names {
8765 let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
8766 continue;
8767 };
8768 let plan_path = v2_conflict_relative(bundle, "plan.json");
8769 let plan_exists = store.regular_file_exists(&plan_path)?;
8770 let expired = if plan_exists {
8771 match load_v2_conflict_plan(&store, bundle) {
8772 Ok(plan) => plan.expires_unix < now,
8773 Err(error) if all => {
8774 let _ = error;
8775 true
8776 }
8777 Err(error) => return Err(error),
8778 }
8779 } else {
8780 true
8781 };
8782 if prune && (all || expired) {
8783 store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8784 pruned += 1;
8785 continue;
8786 }
8787 bundles.push(json!({
8788 "bundle": bundle,
8789 "complete": plan_exists,
8790 "expired": expired,
8791 }));
8792 }
8793 Ok(json!({
8794 "v": 2,
8795 "class": "private_conflict_state",
8796 "bundles": bundles.len(),
8797 "pruned": pruned,
8798 "items": bundles,
8799 }))
8800}
8801
8802pub fn sync_resolve_conflict(
8806 cfg: &HubConfig,
8807 checkout: &Path,
8808 bundle: &str,
8809 choice: V2ConflictChoice,
8810 bulk_confirmation: Option<&V2BulkConfirmation>,
8811) -> LinkResult<Value> {
8812 require_hardened_filesystem("conflict resolution")?;
8813 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8814 message: format!("conflict checkout is not a valid db.md store: {error}"),
8815 })?;
8816 let plan = load_v2_conflict_plan(&store, bundle)?;
8817 if plan.origin != normalized_origin(&cfg.hub)? {
8818 return Err(invalid_feed(
8819 "conflict bundle belongs to another hub origin",
8820 ));
8821 }
8822 let now = SystemTime::now()
8823 .duration_since(UNIX_EPOCH)
8824 .unwrap_or_default()
8825 .as_secs();
8826 if now > plan.expires_unix {
8827 return Err(LinkError::InvalidPack {
8828 message: "conflict bundle expired; rerun sync to obtain current coordinates"
8829 .to_string(),
8830 });
8831 }
8832 let head = v2_verified_head(cfg, &plan.brain)?
8833 .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8834 let pointer = head.pointer.as_ref();
8835 if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8836 || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8837 || pointer.and_then(|value| value.content_root.as_deref())
8838 != plan.remote_content_root.as_deref()
8839 || head.view_kind != plan.view_kind
8840 || head.view_revision != plan.view_revision
8841 {
8842 return Err(LinkError::RemoteAdvancedDuringSync);
8843 }
8844
8845 for file in &plan.files {
8847 let actual = match store.regular_file_exists(Path::new(&file.path))? {
8848 true => Some(content_sha256(&store.read_bounded(
8849 Path::new(&file.path),
8850 file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8851 )?)),
8852 false => None,
8853 };
8854 if actual.as_deref() != file.local.sha256.as_deref() {
8855 return Err(LinkError::InvalidPack {
8856 message: format!(
8857 "local conflict path `{}` changed after the bundle was created",
8858 file.path
8859 ),
8860 });
8861 }
8862 }
8863
8864 let from_source = match &choice {
8865 V2ConflictChoice::From(source) => Some(source.clone()),
8866 _ => None,
8867 };
8868 let result = match choice {
8869 V2ConflictChoice::TakeRemote => {
8870 if bulk_confirmation.is_some() {
8871 return Err(LinkError::InvalidPack {
8872 message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8873 });
8874 }
8875 let current_remote =
8879 files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8880 let _ = v2_take_remote_selection(&plan.files, ¤t_remote)?;
8881 let selected = plan
8882 .files
8883 .iter()
8884 .map(|file| file.path.clone())
8885 .collect::<std::collections::BTreeSet<_>>();
8886 serde_json::to_value(
8887 v2_sync_pull_with_resolution(
8888 cfg,
8889 &plan.brain,
8890 head,
8891 Some(checkout),
8892 Some(&selected),
8893 )?
8894 .report,
8895 )
8896 .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
8897 }
8898 V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
8899 if let Some(source) = from_source.as_ref() {
8900 if plan.files.len() != 1 {
8901 return Err(LinkError::InvalidPack {
8902 message: "--from requires a bundle with exactly one conflict".to_string(),
8903 });
8904 }
8905 let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
8906 if std::str::from_utf8(&candidate).is_err() {
8907 return Err(LinkError::NotUtf8 {
8908 path: source.display().to_string(),
8909 });
8910 }
8911 store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
8912 }
8913 let refreshed_store =
8914 Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8915 message: format!("resolved checkout is not a valid db.md store: {error}"),
8916 })?;
8917 let mut overrides = std::collections::BTreeMap::new();
8918 for file in &plan.files {
8919 let selected_local = match refreshed_store
8920 .regular_file_exists(Path::new(&file.path))?
8921 {
8922 true => Some(content_sha256(
8923 &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
8924 )),
8925 false => None,
8926 };
8927 overrides.insert(
8928 file.path.clone(),
8929 V2ResolutionOverride {
8930 expected_remote: file.remote.sha256.clone(),
8931 selected_local,
8932 },
8933 );
8934 }
8935 v2_sync_push(
8936 cfg,
8937 &plan.brain,
8938 &refreshed_store,
8939 head,
8940 V2SyncPushOptions {
8941 resume_local_policy: true,
8942 bulk_confirmation,
8943 resolution: Some(&overrides),
8944 pulled: None,
8945 withdrawal_paths: &[],
8946 withdrawal_reason: None,
8947 },
8948 )?
8949 }
8950 };
8951
8952 if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
8953 let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8954 message: format!("resolved checkout is not a valid db.md store: {error}"),
8955 })?;
8956 installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8957 }
8958 Ok(json!({
8959 "v": 2,
8960 "class": "auto_converged",
8961 "bundle": bundle,
8962 "receipt": result,
8963 }))
8964}
8965
8966pub fn sync_converge(
8977 cfg: &HubConfig,
8978 brain: &str,
8979 checkout: &Path,
8980 resume_local_policy: bool,
8981) -> LinkResult<Value> {
8982 sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
8983}
8984
8985pub fn sync_converge_with_options(
8987 cfg: &HubConfig,
8988 brain: &str,
8989 checkout: &Path,
8990 resume_local_policy: bool,
8991 bulk_confirmation: Option<&V2BulkConfirmation>,
8992) -> LinkResult<Value> {
8993 sync_converge_with_controls(
8994 cfg,
8995 brain,
8996 checkout,
8997 resume_local_policy,
8998 bulk_confirmation,
8999 &[],
9000 None,
9001 )
9002}
9003
9004pub fn sync_converge_with_controls(
9006 cfg: &HubConfig,
9007 brain: &str,
9008 checkout: &Path,
9009 resume_local_policy: bool,
9010 bulk_confirmation: Option<&V2BulkConfirmation>,
9011 withdrawal_paths: &[String],
9012 withdrawal_reason: Option<&str>,
9013) -> LinkResult<Value> {
9014 require_hardened_filesystem("bidirectional sync")?;
9015 require_safe_ref(brain)?;
9016 let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
9017 message:
9018 "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
9019 .to_string(),
9020 })?;
9021 let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
9022 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9023 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9024 })?;
9025 let _transaction = store.transaction()?;
9026 let pulled_report = pulled.report.clone();
9027 let pulled_head = pulled.head.clone();
9028 let mut result = v2_sync_push(
9029 cfg,
9030 brain,
9031 &store,
9032 pulled_head,
9033 V2SyncPushOptions {
9034 resume_local_policy,
9035 bulk_confirmation,
9036 resolution: None,
9037 pulled: Some(pulled),
9038 withdrawal_paths,
9039 withdrawal_reason,
9040 },
9041 )?;
9042 if let Some(object) = result.as_object_mut() {
9043 object.insert("pulled_files".to_string(), json!(pulled_report.files));
9044 object.insert("checkout".to_string(), Value::String(pulled_report.dest));
9045 object.insert(
9046 "mode".to_string(),
9047 Value::String("bidirectional".to_string()),
9048 );
9049 }
9050 Ok(result)
9051}
9052
9053pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9059 require_hardened_filesystem("sync pull")?;
9060 require_safe_ref(brain)?;
9061 if let Some(head) = v2_verified_head(cfg, brain)? {
9062 return v2_sync_pull(cfg, brain, head, out);
9063 }
9064 legacy_sync_pull(cfg, brain, out)
9065}
9066
9067#[cfg(windows)]
9068fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
9069 Err(LinkError::UnsupportedPlatform {
9070 operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
9071 })
9072}
9073
9074#[cfg(not(windows))]
9075fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9076 let remote = verified_remote_head(cfg, brain, false)?;
9077 if !remote.head.verified {
9078 return Err(invalid_feed(
9079 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
9080 ));
9081 }
9082 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
9083 let path = format!(
9084 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
9085 remote.head.seq
9086 );
9087 let body = ensure_ok(
9088 request(cfg, "GET", &path, None, Auth::Required)?,
9089 "sync pull",
9090 )?;
9091 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
9092 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
9093 {
9094 return Err(invalid_feed(
9095 "export response is not bound to the verified snapshot token",
9096 ));
9097 }
9098
9099 let remote_slug = body
9100 .get("slug")
9101 .and_then(Value::as_str)
9102 .filter(|slug| is_safe_slug(slug));
9103 let slug = remote_slug
9104 .or_else(|| is_safe_slug(brain).then_some(brain))
9105 .unwrap_or("brain")
9106 .to_string();
9107 let brain_id = body
9108 .get("brain")
9109 .and_then(Value::as_str)
9110 .unwrap_or(&remote.head.brain)
9111 .to_string();
9112 if brain_id != remote.head.brain {
9113 return Err(invalid_feed(
9114 "export response names a different brain than the verified head",
9115 ));
9116 }
9117 let head_seq = remote.head.seq;
9118 let dest: PathBuf = match out {
9119 Some(p) => p.to_path_buf(),
9120 None => PathBuf::from(&slug),
9121 };
9122 let entries = if head_seq == 0 {
9123 let files = body
9124 .get("files")
9125 .and_then(Value::as_array)
9126 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
9127 if !files.is_empty() || body.get("url").is_some() {
9128 return Err(invalid_feed(
9129 "empty signed feed cannot authorize non-empty exported content",
9130 ));
9131 }
9132 Vec::new()
9133 } else {
9134 let signed_head = remote
9135 .head_entry
9136 .as_ref()
9137 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
9138 let expected = &signed_head.entry.pack_sha256;
9139 if !is_sha256(expected) {
9140 return Err(invalid_feed(
9141 "signed head carries an invalid snapshot pack digest",
9142 ));
9143 }
9144 if let Some(url) = body.get("url").and_then(Value::as_str) {
9145 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
9146 return Err(invalid_feed(
9147 "export pack digest does not match the signed head entry",
9148 ));
9149 }
9150 let bytes = get_presigned(cfg, url)?;
9151 let actual = format!("{:x}", Sha256::digest(&bytes));
9152 if actual != *expected {
9153 return Err(LinkError::InvalidPack {
9154 message: "downloaded pack does not match the signed snapshot digest"
9155 .to_string(),
9156 });
9157 }
9158 let entries = parse_store_pack(bytes)?;
9159 if signed_head.entry.kind == "push" {
9160 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9161 }
9162 entries
9163 } else {
9164 if signed_head.entry.kind != "push" {
9165 return Err(invalid_feed(
9166 "delta snapshots must export the exact signed pack",
9167 ));
9168 }
9169 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
9170 invalid_feed("verified snapshot export carried neither a pack nor files")
9171 })?;
9172 let mut entries = Vec::with_capacity(files.len());
9173 for file in files {
9174 let path = file
9175 .get("path")
9176 .and_then(Value::as_str)
9177 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
9178 let content = file
9179 .get("content")
9180 .and_then(Value::as_str)
9181 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
9182 entries.push((path.to_string(), content.as_bytes().to_vec()));
9183 }
9184 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9185 entries
9186 }
9187 };
9188
9189 let mut seen = std::collections::HashSet::new();
9191 for (path, _) in &entries {
9192 if !safe_store_rel_path(path) {
9193 return Err(LinkError::UnsafePath { path: path.clone() });
9194 }
9195 if !seen.insert(path) {
9196 return Err(LinkError::InvalidPack {
9197 message: format!("duplicate path `{path}`"),
9198 });
9199 }
9200 }
9201 let pulled: std::collections::BTreeSet<&str> =
9204 entries.iter().map(|(p, _)| p.as_str()).collect();
9205 let mut extra_local = Vec::new();
9206 if let Ok(store) = Store::open(&dest) {
9207 if let Ok(walked) = store.walk() {
9208 for rel in walked {
9209 let rel_str = rel.to_string_lossy().replace('\\', "/");
9210 if !pulled.contains(rel_str.as_str()) {
9211 extra_local.push(rel_str);
9212 }
9213 }
9214 }
9215 }
9216 #[cfg(unix)]
9217 install_pulled_snapshot(&dest, &entries)?;
9218
9219 Ok(PullReport {
9220 brain: brain_id,
9221 slug,
9222 head_seq,
9223 files: entries.len(),
9224 dest: dest.to_string_lossy().into_owned(),
9225 extra_local,
9226 sync_status: "synced".to_string(),
9227 })
9228}
9229
9230#[cfg(unix)]
9231fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
9232 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
9233 path: display.to_string(),
9234 })
9235}
9236
9237#[cfg(unix)]
9238fn open_dir_at(
9239 parent: std::os::fd::RawFd,
9240 name: &std::ffi::CStr,
9241 display: &str,
9242) -> LinkResult<std::fs::File> {
9243 use std::os::fd::FromRawFd as _;
9244 let fd = unsafe {
9245 libc::openat(
9246 parent,
9247 name.as_ptr(),
9248 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9249 )
9250 };
9251 if fd < 0 {
9252 return Err(LinkError::UnsafePath {
9253 path: display.to_string(),
9254 });
9255 }
9256 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
9257}
9258
9259#[cfg(unix)]
9263fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
9264 use std::os::fd::AsRawFd as _;
9265
9266 #[cfg(target_os = "macos")]
9270 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
9271 .into_iter()
9272 .find_map(|(alias, real)| {
9273 path.strip_prefix(alias)
9274 .ok()
9275 .map(|rest| Path::new(real).join(rest))
9276 })
9277 .unwrap_or_else(|| path.to_path_buf());
9278 #[cfg(not(target_os = "macos"))]
9279 let normalized = path.to_path_buf();
9280
9281 let start = if normalized.is_absolute() {
9282 std::fs::File::open("/")?
9283 } else {
9284 std::fs::File::open(".")?
9285 };
9286 let mut directory = start;
9287 for component in normalized.components() {
9288 use std::path::Component;
9289 let name = match component {
9290 Component::RootDir | Component::CurDir => continue,
9291 Component::Normal(name) => name,
9292 Component::ParentDir | Component::Prefix(_) => {
9293 return Err(LinkError::UnsafePath {
9294 path: path.display().to_string(),
9295 });
9296 }
9297 };
9298 use std::os::unix::ffi::OsStrExt as _;
9299 let name = c_name(name.as_bytes(), &path.display().to_string())?;
9300 if create {
9301 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9302 if made != 0 {
9303 let error = std::io::Error::last_os_error();
9304 if error.raw_os_error() != Some(libc::EEXIST) {
9305 return Err(error.into());
9306 }
9307 }
9308 }
9309 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
9310 }
9311 Ok(directory)
9312}
9313
9314#[cfg(unix)]
9315fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9316 open_dir_path_nofollow(path, true)
9317}
9318
9319#[cfg(unix)]
9320fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9321 open_dir_path_nofollow(path, false)
9322}
9323
9324#[cfg(unix)]
9325fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
9326 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9327 let result =
9328 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
9329 if result == 0 {
9330 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
9331 }
9332 let error = std::io::Error::last_os_error();
9333 if error.kind() == std::io::ErrorKind::NotFound {
9334 Ok(None)
9335 } else {
9336 Err(error.into())
9337 }
9338}
9339
9340#[cfg(unix)]
9341fn create_dir_exclusive_at(
9342 parent: std::os::fd::RawFd,
9343 name: &std::ffi::CStr,
9344 display: &str,
9345) -> LinkResult<std::fs::File> {
9346 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
9347 if made != 0 {
9348 return Err(LinkError::UnsafePath {
9349 path: display.to_string(),
9350 });
9351 }
9352 open_dir_at(parent, name, display)
9353}
9354
9355#[cfg(unix)]
9356fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
9357 use std::os::fd::AsRawFd as _;
9358
9359 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
9360 if duplicate < 0 {
9361 return Err(std::io::Error::last_os_error().into());
9362 }
9363 let stream = unsafe { libc::fdopendir(duplicate) };
9364 if stream.is_null() {
9365 let error = std::io::Error::last_os_error();
9366 unsafe {
9367 libc::close(duplicate);
9368 }
9369 return Err(error.into());
9370 }
9371 let mut names = Vec::new();
9372 loop {
9373 let entry = unsafe { libc::readdir(stream) };
9374 if entry.is_null() {
9375 break;
9376 }
9377 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
9378 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
9379 names.push(raw.to_owned());
9380 }
9381 }
9382 if unsafe { libc::closedir(stream) } != 0 {
9383 return Err(std::io::Error::last_os_error().into());
9384 }
9385 Ok(names)
9386}
9387
9388#[cfg(unix)]
9391fn remove_tree_at(
9392 parent: std::os::fd::RawFd,
9393 name: &std::ffi::CStr,
9394 display: &str,
9395) -> LinkResult<()> {
9396 use std::os::fd::AsRawFd as _;
9397
9398 match entry_is_dir_at(parent, name)? {
9399 None => return Ok(()),
9400 Some(false) => {
9401 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
9402 return Err(std::io::Error::last_os_error().into());
9403 }
9404 }
9405 Some(true) => {
9406 let directory = open_dir_at(parent, name, display)?;
9407 for child in directory_entry_names(&directory)? {
9408 let child_display =
9409 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
9410 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
9411 }
9412 drop(directory);
9413 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
9414 return Err(std::io::Error::last_os_error().into());
9415 }
9416 }
9417 }
9418 Ok(())
9419}
9420
9421#[cfg(unix)]
9425fn clone_tree_contents(
9426 source: &std::fs::File,
9427 destination: &std::fs::File,
9428 display: &str,
9429) -> LinkResult<()> {
9430 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9431
9432 for name in directory_entry_names(source)? {
9433 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9434 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9435 if unsafe {
9436 libc::fstatat(
9437 source.as_raw_fd(),
9438 name.as_ptr(),
9439 &mut stat,
9440 libc::AT_SYMLINK_NOFOLLOW,
9441 )
9442 } != 0
9443 {
9444 return Err(std::io::Error::last_os_error().into());
9445 }
9446 match stat.st_mode & libc::S_IFMT {
9447 libc::S_IFDIR => {
9448 if unsafe {
9449 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
9450 } != 0
9451 {
9452 return Err(std::io::Error::last_os_error().into());
9453 }
9454 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
9455 let destination_child =
9456 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
9457 clone_tree_contents(&source_child, &destination_child, &child_display)?;
9458 destination_child.sync_all()?;
9459 }
9460 libc::S_IFREG => {
9461 let source_fd = unsafe {
9462 libc::openat(
9463 source.as_raw_fd(),
9464 name.as_ptr(),
9465 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9466 )
9467 };
9468 if source_fd < 0 {
9469 return Err(std::io::Error::last_os_error().into());
9470 }
9471 let destination_fd = unsafe {
9472 libc::openat(
9473 destination.as_raw_fd(),
9474 name.as_ptr(),
9475 libc::O_WRONLY
9476 | libc::O_CREAT
9477 | libc::O_EXCL
9478 | libc::O_CLOEXEC
9479 | libc::O_NOFOLLOW,
9480 (stat.st_mode & 0o777) as libc::c_uint,
9481 )
9482 };
9483 if destination_fd < 0 {
9484 unsafe {
9485 libc::close(source_fd);
9486 }
9487 return Err(std::io::Error::last_os_error().into());
9488 }
9489 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
9490 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
9491 std::io::copy(&mut input, &mut output)?;
9492 output.sync_all()?;
9493 }
9494 libc::S_IFLNK => {
9495 let mut target = vec![0_u8; 4097];
9496 let length = unsafe {
9497 libc::readlinkat(
9498 source.as_raw_fd(),
9499 name.as_ptr(),
9500 target.as_mut_ptr().cast(),
9501 target.len(),
9502 )
9503 };
9504 if length < 0 || length as usize >= target.len() {
9505 return Err(LinkError::UnsafePath {
9506 path: child_display,
9507 });
9508 }
9509 target.truncate(length as usize);
9510 let target = c_name(&target, &child_display)?;
9511 if unsafe {
9512 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
9513 } != 0
9514 {
9515 return Err(std::io::Error::last_os_error().into());
9516 }
9517 }
9518 _ => {
9519 return Err(LinkError::UnsafePath {
9520 path: child_display,
9521 });
9522 }
9523 }
9524 }
9525 destination.sync_all()?;
9526 Ok(())
9527}
9528
9529#[cfg(target_os = "linux")]
9530fn install_stage_at(
9531 parent: std::os::fd::RawFd,
9532 stage: &std::ffi::CStr,
9533 dest: &std::ffi::CStr,
9534 dest_exists: bool,
9535) -> LinkResult<()> {
9536 let flags = if dest_exists {
9537 libc::RENAME_EXCHANGE
9538 } else {
9539 libc::RENAME_NOREPLACE
9540 };
9541 let result = unsafe {
9545 libc::syscall(
9546 libc::SYS_renameat2,
9547 parent,
9548 stage.as_ptr(),
9549 parent,
9550 dest.as_ptr(),
9551 flags,
9552 )
9553 };
9554 if result == 0 {
9555 Ok(())
9556 } else {
9557 Err(std::io::Error::last_os_error().into())
9558 }
9559}
9560
9561#[cfg(target_os = "macos")]
9562fn install_stage_at(
9563 parent: std::os::fd::RawFd,
9564 stage: &std::ffi::CStr,
9565 dest: &std::ffi::CStr,
9566 dest_exists: bool,
9567) -> LinkResult<()> {
9568 let flags = if dest_exists {
9569 libc::RENAME_SWAP
9570 } else {
9571 libc::RENAME_EXCL
9572 };
9573 let result =
9574 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
9575 if result == 0 {
9576 Ok(())
9577 } else {
9578 Err(std::io::Error::last_os_error().into())
9579 }
9580}
9581
9582#[cfg(unix)]
9583fn write_pull_entries_beneath_dir(
9584 root: &std::fs::File,
9585 entries: &[(String, Vec<u8>)],
9586) -> LinkResult<()> {
9587 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9588
9589 for (path, content) in entries {
9590 let components: Vec<&str> = path.split('/').collect();
9591 let (leaf, parents) = components
9592 .split_last()
9593 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9594 let mut directory = root.try_clone()?;
9595 for component in parents {
9596 let name = c_name(component.as_bytes(), path)?;
9597 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9598 if made != 0 {
9599 let error = std::io::Error::last_os_error();
9600 if error.raw_os_error() != Some(libc::EEXIST) {
9601 return Err(error.into());
9602 }
9603 }
9604 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9605 }
9606
9607 let leaf_name = c_name(leaf.as_bytes(), path)?;
9608 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9609 let inspected = unsafe {
9610 libc::fstatat(
9611 directory.as_raw_fd(),
9612 leaf_name.as_ptr(),
9613 &mut existing,
9614 libc::AT_SYMLINK_NOFOLLOW,
9615 )
9616 };
9617 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
9618 return Err(LinkError::UnsafePath { path: path.clone() });
9619 }
9620
9621 let nonce = std::time::SystemTime::now()
9622 .duration_since(std::time::UNIX_EPOCH)
9623 .unwrap_or_default()
9624 .as_nanos();
9625 let temp_name = format!(
9626 ".dbmd-pull-{}-{nonce}-{}",
9627 std::process::id(),
9628 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
9629 );
9630 let temp = c_name(temp_name.as_bytes(), path)?;
9631 let fd = unsafe {
9632 libc::openat(
9633 directory.as_raw_fd(),
9634 temp.as_ptr(),
9635 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9636 0o600,
9637 )
9638 };
9639 if fd < 0 {
9640 return Err(std::io::Error::last_os_error().into());
9641 }
9642 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9643 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
9644 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9645 return Err(error.into());
9646 }
9647 drop(file);
9648 let renamed = unsafe {
9649 libc::renameat(
9650 directory.as_raw_fd(),
9651 temp.as_ptr(),
9652 directory.as_raw_fd(),
9653 leaf_name.as_ptr(),
9654 )
9655 };
9656 if renamed != 0 {
9657 let error = std::io::Error::last_os_error();
9658 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9659 return Err(error.into());
9660 }
9661 directory.sync_all()?;
9662 }
9663 root.sync_all()?;
9664 Ok(())
9665}
9666
9667#[cfg(unix)]
9668fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9669 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9670
9671 let path = &entry.path;
9672 let components: Vec<&str> = path.split('/').collect();
9673 let (leaf, parents) = components
9674 .split_last()
9675 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9676 let mut directory = root.try_clone()?;
9677 for component in parents {
9678 let name = c_name(component.as_bytes(), path)?;
9679 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9680 if made != 0 {
9681 let error = std::io::Error::last_os_error();
9682 if error.raw_os_error() != Some(libc::EEXIST) {
9683 return Err(error.into());
9684 }
9685 }
9686 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9687 }
9688 let leaf_name = c_name(leaf.as_bytes(), path)?;
9689 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9690 if unsafe {
9691 libc::fstatat(
9692 directory.as_raw_fd(),
9693 leaf_name.as_ptr(),
9694 &mut existing,
9695 libc::AT_SYMLINK_NOFOLLOW,
9696 )
9697 } == 0
9698 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
9699 {
9700 return Err(LinkError::UnsafePath { path: path.clone() });
9701 }
9702 let nonce = SystemTime::now()
9703 .duration_since(UNIX_EPOCH)
9704 .unwrap_or_default()
9705 .as_nanos();
9706 let temp_name = format!(
9707 ".dbmd-pull-{}-{nonce}-{}",
9708 std::process::id(),
9709 content_sha256(path.as_bytes())
9710 );
9711 let temp = c_name(temp_name.as_bytes(), path)?;
9712 let fd = unsafe {
9713 libc::openat(
9714 directory.as_raw_fd(),
9715 temp.as_ptr(),
9716 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9717 0o600,
9718 )
9719 };
9720 if fd < 0 {
9721 return Err(std::io::Error::last_os_error().into());
9722 }
9723 let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
9724 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
9725 let mut digest = Sha256::new();
9726 let mut total = 0_u64;
9727 let mut buffer = [0_u8; 64 * 1024];
9728 let copied = (|| -> std::io::Result<()> {
9729 loop {
9730 let read = input.read(&mut buffer)?;
9731 if read == 0 {
9732 break;
9733 }
9734 total = total.saturating_add(read as u64);
9735 if total > entry.bytes {
9736 return Err(std::io::Error::new(
9737 std::io::ErrorKind::InvalidData,
9738 "staged sync source grew beyond its verified length",
9739 ));
9740 }
9741 digest.update(&buffer[..read]);
9742 output.write_all(&buffer[..read])?;
9743 }
9744 Ok(())
9745 })();
9746 if let Err(error) = copied {
9747 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9748 return Err(error.into());
9749 }
9750 drop(output);
9751 if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
9752 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9753 return Err(invalid_feed(
9754 "private staged sync source failed final integrity verification",
9755 ));
9756 }
9757 if unsafe {
9758 libc::renameat(
9759 directory.as_raw_fd(),
9760 temp.as_ptr(),
9761 directory.as_raw_fd(),
9762 leaf_name.as_ptr(),
9763 )
9764 } != 0
9765 {
9766 let error = std::io::Error::last_os_error();
9767 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9768 return Err(error.into());
9769 }
9770 Ok(())
9771}
9772
9773#[cfg(unix)]
9774fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9775 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9776
9777 let path = &entry.path;
9778 let components: Vec<&str> = path.split('/').collect();
9779 let (leaf, parents) = components
9780 .split_last()
9781 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9782 let mut directory = root.try_clone()?;
9783 for component in parents {
9784 directory = open_dir_at(
9785 directory.as_raw_fd(),
9786 &c_name(component.as_bytes(), path)?,
9787 path,
9788 )?;
9789 }
9790 let leaf = c_name(leaf.as_bytes(), path)?;
9791 let fd = unsafe {
9792 libc::openat(
9793 directory.as_raw_fd(),
9794 leaf.as_ptr(),
9795 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9796 )
9797 };
9798 if fd < 0 {
9799 return Err(std::io::Error::last_os_error().into());
9800 }
9801 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9802 if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
9803 return Err(invalid_feed(
9804 "private pull stage changed before its durability barrier",
9805 ));
9806 }
9807 file.sync_all()?;
9808 Ok(())
9809}
9810
9811#[cfg(unix)]
9812fn run_pull_source_workers(
9813 root: &std::fs::File,
9814 entries: &[V2StagedFile],
9815 operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
9816) -> LinkResult<()> {
9817 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9818
9819 let next = AtomicUsize::new(0);
9820 let failed = AtomicBool::new(false);
9821 let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
9822 let mut first_error = None;
9823 std::thread::scope(|scope| {
9824 let (sender, receiver) = std::sync::mpsc::channel();
9825 for _ in 0..worker_count {
9826 let sender = sender.clone();
9827 let next = &next;
9828 let failed = &failed;
9829 scope.spawn(move || {
9830 while !failed.load(Ordering::Acquire) {
9831 let index = next.fetch_add(1, Ordering::Relaxed);
9832 let Some(entry) = entries.get(index) else {
9833 break;
9834 };
9835 let result = operation(root, entry);
9836 if result.is_err() {
9837 failed.store(true, Ordering::Release);
9838 }
9839 if sender.send(result).is_err() {
9840 break;
9841 }
9842 }
9843 });
9844 }
9845 drop(sender);
9846 for result in receiver {
9847 if let Err(error) = result {
9848 if first_error.is_none() {
9849 first_error = Some(error);
9850 }
9851 }
9852 }
9853 });
9854 if let Some(error) = first_error {
9855 return Err(error);
9856 }
9857 if next.load(Ordering::Relaxed) < entries.len() {
9858 return Err(invalid_feed(
9859 "a bounded pull worker stopped before reporting every file",
9860 ));
9861 }
9862 Ok(())
9863}
9864
9865#[cfg(unix)]
9866fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9867 use std::os::fd::AsRawFd as _;
9868
9869 for name in directory_entry_names(root)? {
9870 if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9871 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9872 let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9873 sync_pull_directory_tree(&child, &child_display)?;
9874 }
9875 }
9876 root.sync_all()?;
9877 Ok(())
9878}
9879
9880#[cfg(unix)]
9881fn write_pull_sources_beneath_dir(
9882 root: &std::fs::File,
9883 entries: &[V2StagedFile],
9884) -> LinkResult<()> {
9885 run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
9892 run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
9893 sync_pull_directory_tree(root, "v2 pull stage")
9894}
9895
9896#[cfg(unix)]
9897fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
9898 use std::os::fd::AsRawFd as _;
9899 for path in paths {
9900 if !safe_store_rel_path(path) {
9901 return Err(LinkError::UnsafePath { path: path.clone() });
9902 }
9903 let components = path.split('/').collect::<Vec<_>>();
9904 let Some((leaf, parents)) = components.split_last() else {
9905 return Err(LinkError::UnsafePath { path: path.clone() });
9906 };
9907 let mut directory = root.try_clone()?;
9908 let mut missing = false;
9909 for component in parents {
9910 let name = c_name(component.as_bytes(), path)?;
9911 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
9912 None => {
9913 missing = true;
9914 break;
9915 }
9916 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
9917 Some(true) => {
9918 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9919 }
9920 }
9921 }
9922 if missing {
9923 continue;
9924 }
9925 let leaf = c_name(leaf.as_bytes(), path)?;
9926 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
9927 None => {}
9928 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
9929 Some(false) => {
9930 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
9931 return Err(std::io::Error::last_os_error().into());
9932 }
9933 directory.sync_all()?;
9934 }
9935 }
9936 }
9937 Ok(())
9938}
9939
9940#[cfg(unix)]
9941fn install_pulled_delta(
9942 dest: &Path,
9943 entries: &[(String, Vec<u8>)],
9944 deleted: &[String],
9945 rebuild_indexes: bool,
9946) -> LinkResult<()> {
9947 use ring::rand::SecureRandom as _;
9948 use std::os::fd::AsRawFd as _;
9949 use std::os::unix::ffi::OsStrExt as _;
9950
9951 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9952 let name = dest
9953 .file_name()
9954 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9955 .ok_or_else(|| LinkError::UnsafePath {
9956 path: dest.display().to_string(),
9957 })?;
9958 let parent_dir = open_or_create_dir_nofollow(parent)?;
9959 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9960 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9961 None => false,
9962 Some(true) => true,
9963 Some(false) => {
9964 return Err(LinkError::UnsafePath {
9965 path: dest.display().to_string(),
9966 });
9967 }
9968 };
9969
9970 let mut nonce = [0_u8; 16];
9971 ring::rand::SystemRandom::new()
9972 .fill(&mut nonce)
9973 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9974 let stage_label = format!(
9975 ".{}.dbmd-pull-stage-{}",
9976 name.to_string_lossy(),
9977 URL_SAFE_NO_PAD.encode(nonce)
9978 );
9979 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9980 let stage_dir = create_dir_exclusive_at(
9981 parent_dir.as_raw_fd(),
9982 &stage_name,
9983 &dest.display().to_string(),
9984 )?;
9985
9986 let prepared = (|| -> LinkResult<()> {
9987 if dest_exists {
9988 let live = open_dir_at(
9989 parent_dir.as_raw_fd(),
9990 &dest_name,
9991 &dest.display().to_string(),
9992 )?;
9993 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9994 }
9995 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9996 write_pull_entries_beneath_dir(&stage_dir, entries)?;
9997 if rebuild_indexes {
9998 let stage_store =
9999 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10000 .map_err(|error| LinkError::InvalidPack {
10001 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10002 })?;
10003 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10004 LinkError::InvalidPack {
10005 message: format!("could not materialize v2 local catalogs: {error}"),
10006 }
10007 })?;
10008 }
10009 stage_dir.sync_all()?;
10010 Ok(())
10011 })();
10012 if let Err(error) = prepared {
10013 let _ = remove_tree_at(
10014 parent_dir.as_raw_fd(),
10015 &stage_name,
10016 &dest.display().to_string(),
10017 );
10018 return Err(error);
10019 }
10020
10021 if let Err(error) =
10022 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10023 {
10024 let _ = remove_tree_at(
10025 parent_dir.as_raw_fd(),
10026 &stage_name,
10027 &dest.display().to_string(),
10028 );
10029 return Err(error);
10030 }
10031 parent_dir.sync_all()?;
10032 if dest_exists {
10033 let _ = remove_tree_at(
10037 parent_dir.as_raw_fd(),
10038 &stage_name,
10039 &dest.display().to_string(),
10040 );
10041 let _ = parent_dir.sync_all();
10042 }
10043 Ok(())
10044}
10045
10046#[cfg(unix)]
10047fn install_pulled_delta_sources(
10048 dest: &Path,
10049 entries: &[V2StagedFile],
10050 deleted: &[String],
10051 rebuild_indexes: bool,
10052 _previous: Option<&V2SyncBaseline>,
10053 _next: &V2VerifiedHead,
10054) -> LinkResult<()> {
10055 use ring::rand::SecureRandom as _;
10056 use std::os::fd::AsRawFd as _;
10057 use std::os::unix::ffi::OsStrExt as _;
10058
10059 if let Ok(store) = Store::open_strict(dest) {
10063 return install_established_v2_delta(
10064 store,
10065 entries,
10066 deleted,
10067 rebuild_indexes,
10068 _previous,
10069 _next,
10070 );
10071 }
10072
10073 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10074 let name = dest
10075 .file_name()
10076 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10077 .ok_or_else(|| LinkError::UnsafePath {
10078 path: dest.display().to_string(),
10079 })?;
10080 let parent_dir = open_or_create_dir_nofollow(parent)?;
10081 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10082 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10083 None => false,
10084 Some(true) => true,
10085 Some(false) => {
10086 return Err(LinkError::UnsafePath {
10087 path: dest.display().to_string(),
10088 })
10089 }
10090 };
10091 let mut nonce = [0_u8; 16];
10092 ring::rand::SystemRandom::new()
10093 .fill(&mut nonce)
10094 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10095 let stage_label = format!(
10096 ".{}.dbmd-pull-stage-{}",
10097 name.to_string_lossy(),
10098 URL_SAFE_NO_PAD.encode(nonce)
10099 );
10100 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10101 let stage_dir = create_dir_exclusive_at(
10102 parent_dir.as_raw_fd(),
10103 &stage_name,
10104 &dest.display().to_string(),
10105 )?;
10106 let prepared = (|| -> LinkResult<()> {
10107 if dest_exists {
10108 let live = open_dir_at(
10109 parent_dir.as_raw_fd(),
10110 &dest_name,
10111 &dest.display().to_string(),
10112 )?;
10113 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10114 }
10115 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10116 write_pull_sources_beneath_dir(&stage_dir, entries)?;
10117 if rebuild_indexes {
10118 let stage_store =
10119 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10120 .map_err(|error| LinkError::InvalidPack {
10121 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10122 })?;
10123 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10124 LinkError::InvalidPack {
10125 message: format!("could not materialize v2 local catalogs: {error}"),
10126 }
10127 })?;
10128 }
10129 stage_dir.sync_all()?;
10130 Ok(())
10131 })();
10132 if let Err(error) = prepared {
10133 let _ = remove_tree_at(
10134 parent_dir.as_raw_fd(),
10135 &stage_name,
10136 &dest.display().to_string(),
10137 );
10138 return Err(error);
10139 }
10140 if let Err(error) =
10141 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10142 {
10143 let _ = remove_tree_at(
10144 parent_dir.as_raw_fd(),
10145 &stage_name,
10146 &dest.display().to_string(),
10147 );
10148 return Err(error);
10149 }
10150 parent_dir.sync_all()?;
10151 if dest_exists {
10152 let _ = remove_tree_at(
10153 parent_dir.as_raw_fd(),
10154 &stage_name,
10155 &dest.display().to_string(),
10156 );
10157 let _ = parent_dir.sync_all();
10158 }
10159 Ok(())
10160}
10161
10162#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10163struct V2PullCoordinate {
10164 head_seq: Option<u64>,
10165 commit_hash: Option<String>,
10166 view_kind: Option<String>,
10167 view_revision: Option<String>,
10168}
10169
10170#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10171struct V2PullFileCoordinate {
10172 sha256: String,
10173 bytes: u64,
10174}
10175
10176#[derive(Debug, Clone, Deserialize, Serialize)]
10177struct V2PullJournalEntry {
10178 path: String,
10179 old: Option<V2PullFileCoordinate>,
10180 new: Option<V2PullFileCoordinate>,
10181 backup: Option<String>,
10182}
10183
10184#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10185#[serde(rename_all = "snake_case")]
10186enum V2PullPhase {
10187 Preparing,
10188 Ready,
10189}
10190
10191#[derive(Debug, Clone, Deserialize, Serialize)]
10192struct V2PullJournal {
10193 v: u8,
10194 phase: V2PullPhase,
10195 brain: String,
10196 previous: V2PullCoordinate,
10197 next: V2PullCoordinate,
10198 backup_dir: String,
10199 entries: Vec<V2PullJournalEntry>,
10200}
10201
10202const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
10203
10204fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
10205 V2PullCoordinate {
10206 head_seq: baseline.and_then(|value| value.head_seq),
10207 commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
10208 view_kind: baseline.and_then(|value| value.view_kind.clone()),
10209 view_revision: baseline.and_then(|value| value.view_revision.clone()),
10210 }
10211}
10212
10213fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
10214 V2PullCoordinate {
10215 head_seq: head.pointer.as_ref().map(|value| value.seq),
10216 commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
10217 view_kind: Some(head.view_kind.clone()),
10218 view_revision: Some(head.view_revision.clone()),
10219 }
10220}
10221
10222fn v2_pull_file_coordinate(
10223 store: &Store,
10224 path: &str,
10225 limit: u64,
10226) -> LinkResult<Option<V2PullFileCoordinate>> {
10227 let file = match store.open_regular(Path::new(path)) {
10228 Ok(file) => file,
10229 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10230 Err(error) => return Err(error.into()),
10231 };
10232 let bytes = file.metadata()?.len();
10233 if bytes > limit || bytes > MAX_STORE_BYTES {
10234 return Err(invalid_feed(
10235 "pull transaction file exceeds its declared bound",
10236 ));
10237 }
10238 Ok(Some(V2PullFileCoordinate {
10239 sha256: content_sha256_reader(file)?,
10240 bytes,
10241 }))
10242}
10243
10244fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
10245 let mut bytes = serde_json::to_vec_pretty(journal)
10246 .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
10247 bytes.push(b'\n');
10248 Ok(bytes)
10249}
10250
10251fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
10252 let backup_prefix = ".dbmd/pull-backup-";
10253 let suffix = journal
10254 .backup_dir
10255 .strip_prefix(backup_prefix)
10256 .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
10257 let mut paths = std::collections::BTreeSet::new();
10258 if journal.v != 1
10259 || !crate::ulid::is_ulid(&journal.brain)
10260 || !crate::ulid::is_ulid(suffix)
10261 || journal.entries.is_empty()
10262 || journal.entries.len() > MAX_PUSH_FILES + 4
10263 || journal.previous == journal.next
10264 {
10265 return Err(invalid_feed("v2 pull journal failed validation"));
10266 }
10267 for (index, entry) in journal.entries.iter().enumerate() {
10268 if !safe_store_rel_path(&entry.path)
10269 || entry.path == V2_PULL_JOURNAL
10270 || entry.path.starts_with(backup_prefix)
10271 || !paths.insert(entry.path.clone())
10272 || (entry.old.is_none() && entry.new.is_none())
10273 || entry
10274 .old
10275 .iter()
10276 .chain(entry.new.iter())
10277 .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
10278 || entry.backup.as_deref()
10279 != entry
10280 .old
10281 .as_ref()
10282 .map(|_| format!("{index:08x}"))
10283 .as_deref()
10284 {
10285 return Err(invalid_feed("v2 pull journal entry failed validation"));
10286 }
10287 }
10288 Ok(())
10289}
10290
10291fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
10292 #[cfg(unix)]
10293 {
10294 use std::os::unix::fs::PermissionsExt as _;
10295 match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
10296 Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
10297 return Err(invalid_feed(
10298 "v2 pull journal is accessible to group/other; set mode 0600",
10299 ));
10300 }
10301 Ok(_) => {}
10302 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10303 Err(error) => return Err(error.into()),
10304 }
10305 }
10306 let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
10307 Ok(bytes) => bytes,
10308 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10309 Err(error) => return Err(error.into()),
10310 };
10311 let journal: V2PullJournal =
10312 serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
10313 validate_v2_pull_journal(&journal)?;
10314 Ok(Some(journal))
10315}
10316
10317fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10318 match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
10322 Ok(()) => {}
10323 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
10324 Err(error) => return Err(error.into()),
10325 }
10326 match store.remove_private_tree(Path::new(&journal.backup_dir)) {
10327 Ok(()) => Ok(()),
10328 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
10329 Err(error) => Err(error.into()),
10330 }
10331}
10332
10333fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
10334 let names = match store.directory_names(Path::new(".dbmd")) {
10335 Ok(names) => names,
10336 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
10337 Err(error) => return Err(error.into()),
10338 };
10339 for name in names {
10340 let Some(name) = name.to_str() else {
10341 continue;
10342 };
10343 let Some(suffix) = name.strip_prefix("pull-backup-") else {
10344 continue;
10345 };
10346 if crate::ulid::is_ulid(suffix) {
10347 store.remove_private_tree(&Path::new(".dbmd").join(name))?;
10348 }
10349 }
10350 Ok(())
10351}
10352
10353fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10354 for entry in &journal.entries {
10356 let limit = entry
10357 .old
10358 .as_ref()
10359 .into_iter()
10360 .chain(entry.new.iter())
10361 .map(|value| value.bytes)
10362 .max()
10363 .unwrap_or(0);
10364 let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
10365 if current != entry.old && current != entry.new {
10366 return Err(LinkError::InvalidPack {
10367 message: format!(
10368 "cannot recover interrupted pull because `{}` changed afterward",
10369 entry.path
10370 ),
10371 });
10372 }
10373 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10374 let path = Path::new(&journal.backup_dir).join(backup);
10375 let file = store.open_regular(&path)?;
10376 if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
10377 return Err(invalid_feed("v2 pull recovery backup failed verification"));
10378 }
10379 }
10380 }
10381 for entry in journal.entries.iter().rev() {
10382 match (&entry.old, &entry.backup) {
10383 (Some(old), Some(backup)) => {
10384 let bytes =
10385 store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
10386 store.write_atomic(Path::new(&entry.path), &bytes)?;
10387 }
10388 (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
10389 store.remove_file(Path::new(&entry.path))?;
10390 }
10391 (None, None) => {}
10392 _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
10393 }
10394 }
10395 crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
10396 message: format!("could not rebuild catalogs after pull recovery: {error}"),
10397 })?;
10398 cleanup_v2_pull_journal(store, journal)
10399}
10400
10401fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
10402 let Ok(store) = Store::open_strict(dest) else {
10403 return Ok(());
10404 };
10405 if let Some(journal) = load_v2_pull_journal(&store)? {
10406 if journal.brain != brain {
10407 return Err(invalid_feed("v2 pull journal belongs to another brain"));
10408 }
10409 if journal.phase == V2PullPhase::Preparing {
10410 cleanup_v2_pull_journal(&store, &journal)?;
10411 } else {
10412 let baseline = load_v2_baseline(cfg, brain, dest)?;
10413 let current = v2_pull_baseline_coordinate(baseline.as_ref());
10414 if current == journal.next {
10415 cleanup_v2_pull_journal(&store, &journal)?;
10416 } else {
10417 if current != journal.previous {
10418 return Err(invalid_feed(
10419 "cannot recover interrupted pull because its baseline changed afterward",
10420 ));
10421 }
10422 rollback_v2_pull(&store, &journal)?;
10423 }
10424 }
10425 }
10426 prune_orphan_v2_pull_backups(&store)
10431}
10432
10433fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
10434 let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
10435 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
10436 })?;
10437 if let Some(journal) = load_v2_pull_journal(&store)? {
10438 cleanup_v2_pull_journal(&store, &journal)?;
10439 }
10440 Ok(())
10441}
10442
10443#[cfg(windows)]
10444fn install_windows_initial_sources(
10445 dest: &Path,
10446 entries: &[V2StagedFile],
10447 rebuild_indexes: bool,
10448) -> LinkResult<()> {
10449 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10450 let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
10451 path: dest.display().to_string(),
10452 })?;
10453 let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
10454 if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
10455 return Err(LinkError::UnsafePath {
10456 path: dest.display().to_string(),
10457 });
10458 }
10459 let stage_name = format!(
10460 ".{}.dbmd-pull-stage-{}",
10461 name.to_string_lossy(),
10462 crate::ulid::mint()
10463 );
10464 let stage_path = parent.join(&stage_name);
10465 let stage_capability =
10466 crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
10467 let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
10468 let prepared = (|| -> LinkResult<()> {
10469 for entry in entries {
10470 let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
10471 if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
10472 return Err(invalid_feed(
10473 "private staged sync source failed final integrity verification",
10474 ));
10475 }
10476 stage.write_atomic(Path::new(&entry.path), &bytes)?;
10477 }
10478 let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
10479 .map_err(|error| LinkError::InvalidPack {
10480 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10481 })?;
10482 if rebuild_indexes {
10483 crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
10484 message: format!("could not materialize v2 local catalogs: {error}"),
10485 })?;
10486 }
10487 Ok(())
10488 })();
10489 if let Err(error) = prepared {
10490 let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
10491 return Err(error);
10492 }
10493 crate::fsx::rename_directory_beneath(
10494 &parent_capability,
10495 Path::new(&stage_name),
10496 Path::new(name),
10497 )?;
10498 Ok(())
10499}
10500
10501fn install_established_v2_delta(
10502 store: Store,
10503 entries: &[V2StagedFile],
10504 deleted: &[String],
10505 rebuild_indexes: bool,
10506 previous: Option<&V2SyncBaseline>,
10507 next: &V2VerifiedHead,
10508) -> LinkResult<()> {
10509 if load_v2_pull_journal(&store)?.is_some() {
10510 return Err(invalid_feed(
10511 "an interrupted pull must be recovered before installing",
10512 ));
10513 }
10514 let mut sources = std::collections::BTreeMap::new();
10515 for entry in entries {
10516 if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
10517 return Err(invalid_feed("pull mutation repeats a path"));
10518 }
10519 }
10520 let mut paths = sources.keys().cloned().collect::<Vec<_>>();
10521 paths.extend(deleted.iter().cloned());
10522 paths.sort();
10523 paths.dedup();
10524 if paths.is_empty() {
10525 return Ok(());
10526 }
10527 let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
10528 let mut journal = V2PullJournal {
10529 v: 1,
10530 phase: V2PullPhase::Preparing,
10531 brain: next.brain_id.clone(),
10532 previous: v2_pull_baseline_coordinate(previous),
10533 next: v2_pull_head_coordinate(next),
10534 backup_dir: backup_dir.clone(),
10535 entries: Vec::with_capacity(paths.len()),
10536 };
10537 for path in &paths {
10538 let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
10539 let new = sources.get(path).map(|entry| V2PullFileCoordinate {
10540 sha256: entry.sha256.clone(),
10541 bytes: entry.bytes,
10542 });
10543 if old == new {
10544 continue;
10545 }
10546 let index = journal.entries.len();
10547 journal.entries.push(V2PullJournalEntry {
10548 path: path.clone(),
10549 backup: old.as_ref().map(|_| format!("{index:08x}")),
10550 old,
10551 new,
10552 });
10553 }
10554 if journal.entries.is_empty() {
10555 return Ok(());
10556 }
10557 let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
10558 entry
10559 .old
10560 .as_ref()
10561 .map_or(Some(total), |old| total.checked_add(old.bytes))
10562 });
10563 if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
10564 return Err(LinkError::InvalidPack {
10565 message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
10566 });
10567 }
10568 validate_v2_pull_journal(&journal)?;
10569 store.write_private_atomic_new(
10570 Path::new(V2_PULL_JOURNAL),
10571 &v2_pull_journal_bytes(&journal)?,
10572 )?;
10573 let prepared = (|| -> LinkResult<()> {
10574 store.create_private_dir_all(Path::new(&backup_dir))?;
10575 for entry in &journal.entries {
10576 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10577 let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
10578 if content_sha256(&bytes) != old.sha256 {
10579 return Err(invalid_feed("live pull source changed during backup"));
10580 }
10581 store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
10582 }
10583 }
10584 journal.phase = V2PullPhase::Ready;
10585 store.write_private_atomic(
10586 Path::new(V2_PULL_JOURNAL),
10587 &v2_pull_journal_bytes(&journal)?,
10588 )?;
10589 Ok(())
10590 })();
10591 if let Err(error) = prepared {
10592 let cleanup = cleanup_v2_pull_journal(&store, &journal);
10593 return match cleanup {
10594 Ok(()) => Err(error),
10595 Err(cleanup) => Err(LinkError::InvalidPack {
10596 message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
10597 }),
10598 };
10599 }
10600 let installed = (|| -> LinkResult<()> {
10601 for entry in &journal.entries {
10602 if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
10603 return Err(LinkError::InvalidPack {
10604 message: format!("local path `{}` changed during pull", entry.path),
10605 });
10606 }
10607 if let Some(source) = sources.get(&entry.path) {
10608 let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
10609 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
10610 return Err(invalid_feed(
10611 "private staged sync source failed final integrity verification",
10612 ));
10613 }
10614 store.write_atomic(Path::new(&entry.path), &bytes)?;
10615 } else if entry.old.is_some() {
10616 store.remove_file(Path::new(&entry.path))?;
10617 }
10618 }
10619 if rebuild_indexes {
10620 crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
10621 message: format!("could not materialize v2 local catalogs: {error}"),
10622 })?;
10623 }
10624 Ok(())
10625 })();
10626 if let Err(error) = installed {
10627 return match rollback_v2_pull(&store, &journal) {
10628 Ok(()) => Err(error),
10629 Err(rollback) => Err(LinkError::InvalidPack {
10630 message: format!("{error}; durable pull rollback also failed: {rollback}"),
10631 }),
10632 };
10633 }
10634 Ok(())
10635}
10636
10637#[cfg(windows)]
10638fn install_pulled_delta_sources(
10639 dest: &Path,
10640 entries: &[V2StagedFile],
10641 deleted: &[String],
10642 rebuild_indexes: bool,
10643 previous: Option<&V2SyncBaseline>,
10644 next: &V2VerifiedHead,
10645) -> LinkResult<()> {
10646 match Store::open_strict(dest) {
10647 Ok(store) => {
10648 install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
10649 }
10650 Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
10651 }
10652}
10653
10654#[cfg(not(any(unix, windows)))]
10655fn install_pulled_delta_sources(
10656 _dest: &Path,
10657 _entries: &[V2StagedFile],
10658 _deleted: &[String],
10659 _rebuild_indexes: bool,
10660 _previous: Option<&V2SyncBaseline>,
10661 _next: &V2VerifiedHead,
10662) -> LinkResult<()> {
10663 Err(LinkError::UnsupportedPlatform {
10664 operation: "atomic v2 pull install",
10665 })
10666}
10667
10668#[cfg(unix)]
10669fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
10670 install_pulled_delta(dest, entries, &[], false)
10671}
10672
10673#[cfg(not(windows))]
10674fn is_safe_slug(slug: &str) -> bool {
10675 !slug.is_empty()
10676 && slug.len() <= 63
10677 && !slug.starts_with('-')
10678 && !slug.ends_with('-')
10679 && slug
10680 .bytes()
10681 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
10682}
10683
10684fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
10685 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
10686}
10687
10688fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
10689 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
10690}
10691
10692fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
10693 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
10694}
10695
10696fn preflight_zip_central_directory(
10697 bytes: &[u8],
10698 offset: usize,
10699 size: usize,
10700 count: u64,
10701) -> LinkResult<()> {
10702 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
10703 let end = offset
10704 .checked_add(size)
10705 .filter(|end| *end <= bytes.len())
10706 .ok_or_else(|| LinkError::InvalidPack {
10707 message: "ZIP central directory is out of bounds".to_string(),
10708 })?;
10709 let mut cursor = offset;
10710 for _ in 0..count {
10711 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
10712 return Err(LinkError::InvalidPack {
10713 message: "ZIP central directory entry count is inconsistent".to_string(),
10714 });
10715 }
10716 if le_u16(bytes, cursor + 34) != Some(0) {
10717 return Err(LinkError::InvalidPack {
10718 message: "multi-disk ZIP archives are not supported".to_string(),
10719 });
10720 }
10721 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
10722 total.checked_add(le_u16(bytes, cursor + at)? as usize)
10723 });
10724 cursor = cursor
10725 .checked_add(46)
10726 .and_then(|fixed| fixed.checked_add(variable?))
10727 .filter(|cursor| *cursor <= end)
10728 .ok_or_else(|| LinkError::InvalidPack {
10729 message: "ZIP central directory entry is truncated".to_string(),
10730 })?;
10731 }
10732 if cursor != end {
10733 return Err(LinkError::InvalidPack {
10734 message: "ZIP central directory size is inconsistent".to_string(),
10735 });
10736 }
10737 Ok(())
10738}
10739
10740fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
10744 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
10745 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
10746 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
10747 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
10748 let eocd = bytes[search_start..]
10749 .windows(4)
10750 .rposition(|window| window == EOCD_SIG)
10751 .map(|offset| search_start + offset)
10752 .ok_or_else(|| LinkError::InvalidPack {
10753 message: "ZIP has no end-of-central-directory record".to_string(),
10754 })?;
10755 let invalid_end = || LinkError::InvalidPack {
10756 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
10757 };
10758 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
10759 if eocd
10760 .checked_add(22)
10761 .and_then(|end| end.checked_add(comment_len))
10762 != Some(bytes.len())
10763 {
10764 return Err(invalid_end());
10768 }
10769 let disk = le_u16(bytes, eocd + 4);
10770 let central_disk = le_u16(bytes, eocd + 6);
10771 if disk != Some(0) || central_disk != Some(0) {
10772 return Err(LinkError::InvalidPack {
10773 message: "multi-disk ZIP archives are not supported".to_string(),
10774 });
10775 }
10776 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
10777 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
10778 if entries_on_disk != ordinary {
10779 return Err(LinkError::InvalidPack {
10780 message: "multi-disk ZIP archives are not supported".to_string(),
10781 });
10782 }
10783 let zip64_locator = eocd
10784 .checked_sub(20)
10785 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
10786 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
10787 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
10788 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
10789 if central_offset
10790 .checked_add(central_size)
10791 .filter(|end| *end == eocd)
10792 .is_none()
10793 {
10794 return Err(invalid_end());
10795 }
10796 (ordinary as u64, central_offset, central_size)
10797 } else {
10798 let Some(locator) = zip64_locator else {
10799 return Err(invalid_end());
10800 };
10801 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
10802 return Err(LinkError::InvalidPack {
10803 message: "multi-disk ZIP64 archives are not supported".to_string(),
10804 });
10805 }
10806 let record = le_u64(bytes, locator + 8)
10807 .and_then(|offset| usize::try_from(offset).ok())
10808 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
10809 .ok_or_else(|| LinkError::InvalidPack {
10810 message: "ZIP64 archive has an invalid end record".to_string(),
10811 })?;
10812 let record_size = le_u64(bytes, record + 4)
10813 .and_then(|size| usize::try_from(size).ok())
10814 .filter(|size| *size >= 44)
10815 .ok_or_else(invalid_end)?;
10816 if record
10817 .checked_add(12)
10818 .and_then(|end| end.checked_add(record_size))
10819 != Some(locator)
10820 || le_u32(bytes, record + 16) != Some(0)
10821 || le_u32(bytes, record + 20) != Some(0)
10822 {
10823 return Err(invalid_end());
10824 }
10825 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
10826 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
10827 let central_size = le_u64(bytes, record + 40)
10828 .and_then(|size| usize::try_from(size).ok())
10829 .ok_or_else(invalid_end)?;
10830 let central_offset = le_u64(bytes, record + 48)
10831 .and_then(|offset| usize::try_from(offset).ok())
10832 .ok_or_else(invalid_end)?;
10833 if zip64_on_disk != zip64_total
10834 || central_offset
10835 .checked_add(central_size)
10836 .filter(|end| *end == record)
10837 .is_none()
10838 {
10839 return Err(invalid_end());
10840 }
10841 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10842 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10843 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10844 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10845 {
10846 return Err(invalid_end());
10847 }
10848 (zip64_total, central_offset, central_size)
10849 };
10850 if count == 0 || count > max_entries as u64 {
10851 return Err(LinkError::InvalidPack {
10852 message: format!("invalid file count {count}"),
10853 });
10854 }
10855 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10856 Ok(())
10857}
10858
10859fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10860 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10861 let mut archive =
10862 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10863 message: format!("ZIP parse failed: {err}"),
10864 })?;
10865 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10866 return Err(LinkError::InvalidPack {
10867 message: format!("invalid file count {}", archive.len()),
10868 });
10869 }
10870 let mut total = 0u64;
10871 let mut seen = std::collections::HashSet::new();
10872 let mut entries = Vec::with_capacity(archive.len());
10873 for index in 0..archive.len() {
10874 let mut file = archive
10875 .by_index(index)
10876 .map_err(|err| LinkError::InvalidPack {
10877 message: format!("ZIP entry failed: {err}"),
10878 })?;
10879 if file.is_dir() {
10880 continue;
10881 }
10882 let path = file.name().to_string();
10883 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10884 return Err(LinkError::UnsafePath { path });
10885 }
10886 if file
10887 .unix_mode()
10888 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
10889 {
10890 return Err(LinkError::InvalidPack {
10891 message: format!("non-file entry `{path}`"),
10892 });
10893 }
10894 if !seen.insert(path.clone()) {
10895 return Err(LinkError::InvalidPack {
10896 message: format!("duplicate path `{path}`"),
10897 });
10898 }
10899 let remaining = MAX_STORE_BYTES.saturating_sub(total);
10900 if file.size() > remaining {
10901 return Err(LinkError::InvalidPack {
10902 message: "expanded content exceeds the 512 MB limit".to_string(),
10903 });
10904 }
10905 let mut content = Vec::new();
10906 (&mut file)
10907 .take(remaining + 1)
10908 .read_to_end(&mut content)
10909 .map_err(|err| LinkError::InvalidPack {
10910 message: format!("could not decompress `{path}`: {err}"),
10911 })?;
10912 if content.len() as u64 > remaining {
10913 return Err(LinkError::InvalidPack {
10914 message: "expanded content exceeds the 512 MB limit".to_string(),
10915 });
10916 }
10917 if content.len() as u64 != file.size() {
10918 return Err(LinkError::InvalidPack {
10919 message: format!("length mismatch for `{path}`"),
10920 });
10921 }
10922 total += content.len() as u64;
10923 entries.push((path, content));
10924 }
10925 if entries.is_empty() {
10926 return Err(LinkError::InvalidPack {
10927 message: "pack contains no files".to_string(),
10928 });
10929 }
10930 Ok(entries)
10931}
10932
10933fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
10934 let mut expected = std::collections::BTreeMap::new();
10935 for file in signed {
10936 if !safe_store_rel_path(&file.path) {
10937 return Err(LinkError::UnsafePath {
10938 path: file.path.clone(),
10939 });
10940 }
10941 if !is_sha256(&file.sha256)
10942 || expected
10943 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10944 .is_some()
10945 {
10946 return Err(invalid_feed(
10947 "signed snapshot manifest contains an invalid or duplicate file",
10948 ));
10949 }
10950 }
10951 if expected.len() != entries.len() {
10952 return Err(invalid_feed(
10953 "downloaded pack file set differs from the signed snapshot manifest",
10954 ));
10955 }
10956 for (path, bytes) in entries {
10957 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
10958 return Err(invalid_feed(format!(
10959 "downloaded pack contains unsigned path `{path}`"
10960 )));
10961 };
10962 if *declared_bytes != bytes.len() as u64
10963 || *sha256 != format!("{:x}", Sha256::digest(bytes))
10964 {
10965 return Err(invalid_feed(format!(
10966 "downloaded file `{path}` differs from its signed manifest"
10967 )));
10968 }
10969 }
10970 Ok(())
10971}
10972
10973pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
10980 require_hardened_filesystem("sync push")?;
10981 preflight_push_ownership(store)?;
10982 let mut out: Vec<(String, String)> = Vec::new();
10983 let mut total = 0u64;
10984
10985 let mut read_text = |rel: &str| -> LinkResult<String> {
10986 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
10987 total = total
10988 .checked_add(bytes.len() as u64)
10989 .ok_or_else(|| LinkError::PushTooLarge {
10990 detail: "uncompressed byte count overflow".to_string(),
10991 })?;
10992 if total > MAX_STORE_BYTES {
10993 return Err(LinkError::PushTooLarge {
10994 detail: format!("{total} uncompressed bytes"),
10995 });
10996 }
10997 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
10998 path: rel.to_string(),
10999 })
11000 };
11001
11002 out.push(("DB.md".to_string(), read_text("DB.md")?));
11003 if store
11004 .regular_file_exists(Path::new("assets.jsonl"))
11005 .unwrap_or(false)
11006 {
11007 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
11008 }
11009
11010 for rel in store.walk()? {
11011 let rel_str = rel.to_string_lossy().replace('\\', "/");
11012 if !safe_store_rel_path(&rel_str) {
11013 return Err(LinkError::UnsafePath { path: rel_str });
11016 }
11017 let content = read_text(&rel_str)?;
11018 out.push((rel_str, content));
11019 }
11020
11021 out.sort_by(|a, b| a.0.cmp(&b.0));
11022 Ok(out)
11023}
11024
11025fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
11029 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
11030 return Err(LinkError::from(std::io::Error::new(
11031 std::io::ErrorKind::PermissionDenied,
11032 format!("cannot push: nested db.md store at {}", nested.display()),
11033 )));
11034 }
11035
11036 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
11037 return Err(LinkError::from(std::io::Error::new(
11038 std::io::ErrorKind::PermissionDenied,
11039 format!(
11040 "cannot push: {} is a symlink outside the store ownership model",
11041 symlink.display()
11042 ),
11043 )));
11044 }
11045 Ok(())
11046}
11047
11048pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
11054 require_safe_ref(brain)?;
11055 let remote = verified_remote_head(cfg, brain, false)?;
11056 if files.len() > MAX_PUSH_FILES {
11057 return Err(LinkError::PushTooLarge {
11058 detail: format!("{} files", files.len()),
11059 });
11060 }
11061 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
11062 if raw_total > MAX_STORE_BYTES {
11063 return Err(LinkError::PushTooLarge {
11064 detail: format!("{raw_total} uncompressed bytes"),
11065 });
11066 }
11067
11068 if cfg.brain_key.is_none() {
11072 let body = json!({
11073 "files": files
11074 .iter()
11075 .map(|(p, c)| json!({ "path": p, "content": c }))
11076 .collect::<Vec<_>>(),
11077 });
11078 if body.to_string().len() <= MAX_PUSH_BYTES {
11079 let path = format!("/api/hub/brains/{brain}/push");
11080 let pushed = ensure_ok(
11081 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11082 "sync push",
11083 )?;
11084 return Ok(pushed);
11085 }
11086 }
11087
11088 let pack = build_store_pack(files)?;
11089 if pack.len() as u64 > MAX_PACK_BYTES {
11090 return Err(LinkError::PushTooLarge {
11091 detail: format!("{} pack bytes", pack.len()),
11092 });
11093 }
11094 let sha256 = format!("{:x}", Sha256::digest(&pack));
11095 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
11096 if let Some(key) = &cfg.brain_key {
11097 if !remote.head.verified {
11098 return Err(invalid_feed(
11099 "self-custody push requires a fully verified, unscoped feed head",
11100 ));
11101 }
11102 let identity = remote
11103 .identity
11104 .as_ref()
11105 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
11106 let current_multikey = format!("ed25519:{}", identity.fingerprint);
11107 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
11108 return Err(invalid_feed(
11109 "configured brain key is not the verified current brain identity",
11110 ));
11111 }
11112 let next_seq = remote
11115 .head
11116 .seq
11117 .checked_add(1)
11118 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
11119 let mut manifest: Vec<WireFeedFile> = files
11120 .iter()
11121 .map(|(path, content)| WireFeedFile {
11122 path: path.clone(),
11123 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
11124 bytes: content.len() as u64,
11125 })
11126 .collect();
11127 manifest.sort_by(|a, b| a.path.cmp(&b.path));
11128 let ts = crate::now()
11129 .with_timezone(&chrono::Utc)
11130 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
11131 .to_string();
11132 let entry = self_custody_entry(
11133 key,
11134 next_seq,
11135 ts,
11136 &sha256,
11137 &manifest,
11138 remote.head.feed_hash.as_deref(),
11139 )?;
11140 meta["entry"] = Value::String(entry);
11141 }
11142 let presigned = ensure_ok(
11143 request(
11144 cfg,
11145 "POST",
11146 &format!("/api/hub/brains/{brain}/packs/presign"),
11147 Some(&meta),
11148 Auth::Required,
11149 )?,
11150 "prepare pack upload",
11151 )?;
11152 let url = presigned
11153 .get("url")
11154 .and_then(Value::as_str)
11155 .ok_or_else(|| LinkError::InvalidPack {
11156 message: "the hub returned no upload URL".to_string(),
11157 })?;
11158 put_presigned(
11159 cfg,
11160 url,
11161 presigned.get("headers").unwrap_or(&Value::Null),
11162 &pack,
11163 )?;
11164 let committed = ensure_ok(
11165 request(
11166 cfg,
11167 "POST",
11168 &format!("/api/hub/brains/{brain}/packs/commit"),
11169 Some(&meta),
11170 Auth::Required,
11171 )?,
11172 "commit pack",
11173 )?;
11174 Ok(committed)
11175}
11176
11177fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
11178 const LOCAL_HEADER: u32 = 0x0403_4b50;
11179 const CENTRAL_HEADER: u32 = 0x0201_4b50;
11180 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
11181 const VERSION_20: u16 = 20;
11182 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
11183 const UTF8_FLAG: u16 = 1 << 11;
11184 const STORED: u16 = 0;
11185 const DOS_TIME_MIDNIGHT: u16 = 0;
11186 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
11187 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
11188
11189 struct CentralEntry<'a> {
11190 name: &'a [u8],
11191 crc32: u32,
11192 size: u32,
11193 local_offset: u32,
11194 }
11195
11196 fn push_u16(out: &mut Vec<u8>, value: u16) {
11197 out.extend_from_slice(&value.to_le_bytes());
11198 }
11199
11200 fn push_u32(out: &mut Vec<u8>, value: u32) {
11201 out.extend_from_slice(&value.to_le_bytes());
11202 }
11203
11204 if files.is_empty() {
11205 return Err(LinkError::InvalidPack {
11206 message: "cannot create an empty snapshot pack".to_string(),
11207 });
11208 }
11209 if files.len() > u16::MAX as usize {
11210 return Err(LinkError::PushTooLarge {
11211 detail: format!(
11212 "{} files (canonical ZIP32 packs cap at {})",
11213 files.len(),
11214 u16::MAX
11215 ),
11216 });
11217 }
11218
11219 let mut sorted: Vec<_> = files.iter().collect();
11220 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
11221 let mut previous: Option<&str> = None;
11222 for (path, content) in &sorted {
11223 if !safe_store_rel_path(path) {
11224 return Err(LinkError::UnsafePath {
11225 path: (*path).clone(),
11226 });
11227 }
11228 if previous == Some(path.as_str()) {
11229 return Err(LinkError::InvalidPack {
11230 message: format!("duplicate path `{path}`"),
11231 });
11232 }
11233 previous = Some(path.as_str());
11234 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
11235 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11236 })?;
11237 }
11238
11239 let mut out = Vec::new();
11240 let mut central = Vec::with_capacity(sorted.len());
11241 for (path, content) in sorted {
11242 let name = path.as_bytes();
11243 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
11244 message: format!("ZIP entry name is too long: `{path}`"),
11245 })?;
11246 let bytes = content.as_bytes();
11247 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
11248 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11249 })?;
11250 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11251 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11252 })?;
11253 let crc32 = crc32fast::hash(bytes);
11254
11255 push_u32(&mut out, LOCAL_HEADER);
11258 push_u16(&mut out, VERSION_20);
11259 push_u16(&mut out, UTF8_FLAG);
11260 push_u16(&mut out, STORED);
11261 push_u16(&mut out, DOS_TIME_MIDNIGHT);
11262 push_u16(&mut out, DOS_DATE_1980_01_01);
11263 push_u32(&mut out, crc32);
11264 push_u32(&mut out, size);
11265 push_u32(&mut out, size);
11266 push_u16(&mut out, name_len);
11267 push_u16(&mut out, 0); out.extend_from_slice(name);
11269 out.extend_from_slice(bytes);
11270
11271 central.push(CentralEntry {
11272 name,
11273 crc32,
11274 size,
11275 local_offset,
11276 });
11277 }
11278
11279 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11280 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11281 })?;
11282 for entry in ¢ral {
11283 push_u32(&mut out, CENTRAL_HEADER);
11284 push_u16(&mut out, MADE_BY_UNIX_20);
11285 push_u16(&mut out, VERSION_20);
11286 push_u16(&mut out, UTF8_FLAG);
11287 push_u16(&mut out, STORED);
11288 push_u16(&mut out, DOS_TIME_MIDNIGHT);
11289 push_u16(&mut out, DOS_DATE_1980_01_01);
11290 push_u32(&mut out, entry.crc32);
11291 push_u32(&mut out, entry.size);
11292 push_u32(&mut out, entry.size);
11293 push_u16(&mut out, entry.name.len() as u16);
11294 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);
11299 push_u32(&mut out, entry.local_offset);
11300 out.extend_from_slice(entry.name);
11301 }
11302 let central_size = u32::try_from(out.len())
11303 .ok()
11304 .and_then(|end| end.checked_sub(central_offset))
11305 .ok_or_else(|| LinkError::PushTooLarge {
11306 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
11307 })?;
11308 let entry_count = central.len() as u16;
11309
11310 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
11311 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
11314 push_u16(&mut out, entry_count);
11315 push_u32(&mut out, central_size);
11316 push_u32(&mut out, central_offset);
11317 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
11320 return Err(LinkError::PushTooLarge {
11321 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
11322 });
11323 }
11324 Ok(out)
11325}
11326
11327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11333pub enum Capability {
11334 Read,
11336 Write,
11338}
11339
11340impl Capability {
11341 pub fn as_str(self) -> &'static str {
11343 match self {
11344 Capability::Read => "read",
11345 Capability::Write => "write",
11346 }
11347 }
11348}
11349
11350pub fn grant_issue(
11356 cfg: &HubConfig,
11357 brain: &str,
11358 grantee: &str,
11359 can: Capability,
11360 scope: Option<&str>,
11361 until: Option<&str>,
11362) -> LinkResult<Value> {
11363 require_safe_ref(brain)?;
11364 let is_key_grantee = URL_SAFE_NO_PAD
11369 .decode(grantee)
11370 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
11371 .unwrap_or(false);
11372 if let Some(head) = v2_verified_head(cfg, brain)? {
11373 if is_key_grantee {
11374 let scope = scope.unwrap_or("");
11375 let preset = match can {
11376 Capability::Read => "viewer",
11377 Capability::Write => "editor",
11378 };
11379 let entropy = format!(
11380 "{}\0{}\0{}\0{}\0{}\0{}\0{}",
11381 normalized_origin(&cfg.hub)?,
11382 head.brain_id,
11383 head.control_revision,
11384 grantee,
11385 preset,
11386 scope,
11387 until.unwrap_or("")
11388 );
11389 let mut body = json!({
11390 "context": "external",
11391 "expected_control_revision": head.control_revision,
11392 "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
11393 "preset": preset,
11394 "principal_kind": "key",
11395 "public_key": grantee,
11396 "scope": scope,
11397 "scope_kind": "prefix",
11398 });
11399 if let Some(value) = until {
11400 body["expires_at"] = json!(value);
11401 }
11402 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11403 let response = ensure_ok(
11404 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11405 "v2 grant issue",
11406 )?;
11407 let expected_fingerprint = identity_fingerprint(grantee)?;
11408 if response.get("v").and_then(Value::as_u64) != Some(2)
11409 || response
11410 .get("id")
11411 .and_then(Value::as_str)
11412 .is_none_or(|id| !crate::ulid::is_ulid(id))
11413 || response.get("principal_kind").and_then(Value::as_str) != Some("key")
11414 || response.get("principal_id").and_then(Value::as_str)
11415 != Some(expected_fingerprint.as_str())
11416 || response
11417 .get("control_revision")
11418 .and_then(Value::as_str)
11419 .is_none_or(|value| !is_sha256(value))
11420 {
11421 return Err(invalid_feed(
11422 "v2 grant issue response is not authority-bound",
11423 ));
11424 }
11425 return Ok(response);
11426 }
11427 let mut body = json!({ "email": grantee, "capability": can.as_str() });
11433 if let Some(value) = scope {
11434 body["scopePrefix"] = json!(value);
11435 }
11436 if let Some(value) = until {
11437 body["expiresAt"] = json!(value);
11438 }
11439 let path = format!("/api/hub/brains/{}/grants", head.brain_id);
11440 return ensure_ok(
11441 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11442 "account grant issue",
11443 );
11444 }
11445 let _ = verified_remote_head(cfg, brain, false)?;
11446 let mut body = if is_key_grantee {
11447 json!({ "keySpki": grantee, "capability": can.as_str() })
11448 } else {
11449 json!({ "email": grantee, "capability": can.as_str() })
11450 };
11451 if let Some(s) = scope {
11452 body["scopePrefix"] = json!(s);
11453 }
11454 if let Some(u) = until {
11455 body["expiresAt"] = json!(u);
11456 }
11457 let path = format!("/api/hub/brains/{brain}/grants");
11458 ensure_ok(
11459 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11460 "grant issue",
11461 )
11462}
11463
11464pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
11466 require_safe_ref(brain)?;
11467 if let Some(head) = v2_verified_head(cfg, brain)? {
11468 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11469 let response = ensure_ok(
11470 request(cfg, "GET", &path, None, Auth::Required)?,
11471 "v2 grant list",
11472 )?;
11473 if response.get("v").and_then(Value::as_u64) != Some(2)
11474 || response.get("control_revision").and_then(Value::as_str)
11475 != Some(head.control_revision.as_str())
11476 || !response.get("grants").is_some_and(Value::is_array)
11477 {
11478 return Err(invalid_feed(
11479 "v2 grant list is not bound to the verified authority",
11480 ));
11481 }
11482 return Ok(response);
11483 }
11484 let _ = verified_remote_head(cfg, brain, false)?;
11485 let path = format!("/api/hub/brains/{brain}/grants");
11486 ensure_ok(
11487 request(cfg, "GET", &path, None, Auth::Required)?,
11488 "grant list",
11489 )
11490}
11491
11492pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
11495 require_safe_ref(brain)?;
11496 require_safe_grant_id(grant_id)?;
11497 if let Some(head) = v2_verified_head(cfg, brain)? {
11498 let entropy = format!(
11499 "{}\0{}\0{}\0{}",
11500 normalized_origin(&cfg.hub)?,
11501 head.brain_id,
11502 head.control_revision,
11503 grant_id
11504 );
11505 let body = json!({
11506 "expected_control_revision": head.control_revision,
11507 "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
11508 });
11509 let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
11510 let response = ensure_ok(
11511 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11512 "v2 grant revoke",
11513 )?;
11514 if response.get("v").and_then(Value::as_u64) != Some(2)
11515 || response.get("id").and_then(Value::as_str) != Some(grant_id)
11516 || response.get("revoked").and_then(Value::as_bool) != Some(true)
11517 || response
11518 .get("control_revision")
11519 .and_then(Value::as_str)
11520 .is_none_or(|value| !is_sha256(value))
11521 {
11522 return Err(invalid_feed(
11523 "v2 grant revocation response is not authority-bound",
11524 ));
11525 }
11526 return Ok(response);
11527 }
11528 let _ = verified_remote_head(cfg, brain, false)?;
11529 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
11530 ensure_ok(
11531 request(cfg, "DELETE", &path, None, Auth::Required)?,
11532 "grant revoke",
11533 )
11534}
11535
11536#[derive(Debug)]
11541struct VerifiedV2Proposal {
11542 value: Value,
11543 changes: Value,
11544 blobs: Vec<(String, u64, String)>,
11545}
11546
11547fn require_proposal_id(id: &str) -> LinkResult<()> {
11548 if crate::ulid::is_ulid(id) {
11549 Ok(())
11550 } else {
11551 Err(invalid_feed("proposal id is not a lowercase ULID"))
11552 }
11553}
11554
11555fn verified_v2_proposal(
11556 cfg: &HubConfig,
11557 head: &V2VerifiedHead,
11558 proposal_id: &str,
11559) -> LinkResult<VerifiedV2Proposal> {
11560 require_proposal_id(proposal_id)?;
11561 if head.view_kind != "full" {
11562 return Err(invalid_feed(
11563 "proposal review requires a full readable view",
11564 ));
11565 }
11566 let path = format!(
11567 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11568 head.brain_id
11569 );
11570 let value = ensure_ok(
11571 request_capped(
11572 cfg,
11573 "GET",
11574 &path,
11575 None,
11576 Auth::Required,
11577 MAX_FEED_RESPONSE_BYTES,
11578 )?,
11579 "v2 proposal",
11580 )?;
11581 verify_v2_proposal_value(head, proposal_id, value)
11582}
11583
11584fn verify_v2_proposal_value(
11585 head: &V2VerifiedHead,
11586 proposal_id: &str,
11587 value: Value,
11588) -> LinkResult<VerifiedV2Proposal> {
11589 if value.get("v").and_then(Value::as_u64) != Some(2) {
11590 return Err(invalid_feed("proposal response has an invalid version"));
11591 }
11592 let proposal = value
11593 .get("proposal")
11594 .and_then(Value::as_object)
11595 .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
11596 if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
11597 return Err(invalid_feed("proposal response changed its id"));
11598 }
11599 let payload_hash = proposal
11600 .get("payload_sha256")
11601 .and_then(Value::as_str)
11602 .filter(|hash| is_sha256(hash))
11603 .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
11604 let clear_hash = proposal
11605 .get("clear_sha256")
11606 .and_then(Value::as_str)
11607 .filter(|hash| is_sha256(hash))
11608 .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
11609 let submission_hash = proposal
11610 .get("submission_claim_sha256")
11611 .and_then(Value::as_str)
11612 .filter(|hash| is_sha256(hash))
11613 .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
11614 let submission = STANDARD
11615 .decode(
11616 proposal
11617 .get("submission_claim_base64")
11618 .and_then(Value::as_str)
11619 .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
11620 )
11621 .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
11622 let submission_value: Value = serde_json::from_slice(&submission)
11623 .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
11624 if crate::linkmd_v2::canonical_bytes(&submission_value)
11625 .map_err(|error| invalid_feed(error.to_string()))?
11626 != submission
11627 || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
11628 .map_err(|error| invalid_feed(error.to_string()))?
11629 != submission_hash
11630 {
11631 return Err(invalid_feed(
11632 "proposal submission claim is not canonical or addressed",
11633 ));
11634 }
11635 let envelope = submission_value
11636 .as_object()
11637 .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
11638 let claim = envelope
11639 .get("claim")
11640 .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
11641 let claim_object = claim
11642 .as_object()
11643 .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
11644 let actor_root = claim_object
11645 .get("actor_root")
11646 .and_then(Value::as_object)
11647 .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
11648 let public_key = envelope
11649 .get("public_key")
11650 .and_then(Value::as_str)
11651 .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
11652 let fingerprint = envelope
11653 .get("fingerprint")
11654 .and_then(Value::as_str)
11655 .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
11656 let signature = envelope
11657 .get("sig")
11658 .and_then(Value::as_str)
11659 .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
11660 let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
11661 .map_err(|error| invalid_feed(error.to_string()))?;
11662 let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
11663 let signer = format!("{fingerprint}:{public_key}");
11664 let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
11665 let grants = actor_root.get("grants").and_then(Value::as_array);
11666 let grants_are_canonical = grants.is_some_and(|items| {
11667 let mut prior: Option<&str> = None;
11668 items.iter().all(|item| {
11669 let Some(grant) = item.as_str() else {
11670 return false;
11671 };
11672 if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
11673 return false;
11674 }
11675 prior = Some(grant);
11676 true
11677 })
11678 });
11679 let optional_actor_field = |name: &str| {
11680 actor_root.get(name).is_some_and(|value| {
11681 value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
11682 })
11683 };
11684 let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
11685 if claim_object.get("v").and_then(Value::as_u64) != Some(2)
11686 || format!("{:x}", Sha256::digest(&der)) != fingerprint
11687 || head
11688 .trust
11689 .hub_signer
11690 .as_ref()
11691 .is_some_and(|known| known != &signer)
11692 || !matches!(
11693 actor_class,
11694 Some(
11695 "user"
11696 | "owned_agent"
11697 | "foreign_key"
11698 | "curation"
11699 | "inbox"
11700 | "restore"
11701 | "migration"
11702 | "operator_recovery"
11703 )
11704 )
11705 || actor_root
11706 .get("principal")
11707 .and_then(Value::as_str)
11708 .is_none_or(|value| value.is_empty())
11709 || actor_root
11710 .get("credential")
11711 .and_then(Value::as_str)
11712 .is_none_or(|value| value.is_empty())
11713 || !optional_actor_field("organization")
11714 || !optional_actor_field("role")
11715 || !grants_are_canonical
11716 || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
11717 || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11718 || !claim_object
11719 .get("mutation_id")
11720 .and_then(Value::as_str)
11721 .is_some_and(|value| {
11722 !value.is_empty()
11723 && value.len() <= 128
11724 && value.chars().enumerate().all(|(index, char)| {
11725 char.is_ascii_alphanumeric()
11726 || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
11727 })
11728 })
11729 || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
11730 || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
11731 || !claim_object
11732 .get("control_revision")
11733 .and_then(Value::as_str)
11734 .is_some_and(is_sha256)
11735 || submitted_at.is_none_or(|value| {
11736 chrono::DateTime::parse_from_rfc3339(value).is_err()
11737 || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
11738 })
11739 || !proposal
11740 .get("state")
11741 .and_then(Value::as_str)
11742 .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
11743 || proposal
11744 .get("expires_at")
11745 .and_then(Value::as_str)
11746 .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
11747 || proposal
11748 .get("proposer")
11749 .and_then(Value::as_object)
11750 .and_then(|value| value.get("class"))
11751 .and_then(Value::as_str)
11752 != actor_class
11753 {
11754 return Err(invalid_feed(
11755 "proposal submission claim does not bind the verified proposal",
11756 ));
11757 }
11758 let changes_b64 = proposal
11759 .get("changes_base64")
11760 .and_then(Value::as_str)
11761 .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
11762 let changes_bytes = STANDARD
11763 .decode(changes_b64)
11764 .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
11765 let changes: Value = serde_json::from_slice(&changes_bytes)
11766 .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
11767 if crate::linkmd_v2::canonical_bytes(&changes)
11768 .map_err(|error| invalid_feed(error.to_string()))?
11769 != changes_bytes
11770 || changes.get("v").and_then(Value::as_u64) != Some(2)
11771 || !changes.get("operations").is_some_and(Value::is_array)
11772 {
11773 return Err(invalid_feed("proposal changeset is not canonical v2"));
11774 }
11775 let blob_values = proposal
11776 .get("blobs")
11777 .and_then(Value::as_array)
11778 .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
11779 let mut blobs = Vec::with_capacity(blob_values.len());
11780 let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
11781 let mut prior_hash: Option<String> = None;
11782 for item in blob_values {
11783 let hash = item
11784 .get("sha256")
11785 .and_then(Value::as_str)
11786 .filter(|hash| is_sha256(hash))
11787 .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
11788 let bytes = item
11789 .get("bytes")
11790 .and_then(Value::as_u64)
11791 .filter(|bytes| *bytes <= MAX_STORE_BYTES)
11792 .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
11793 if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
11794 return Err(invalid_feed(
11795 "proposal blob declarations are not unique and sorted",
11796 ));
11797 }
11798 prior_hash = Some(hash.to_string());
11799 let endpoint = item
11800 .get("endpoint")
11801 .and_then(Value::as_str)
11802 .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
11803 let expected_endpoint = format!(
11804 "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
11805 head.brain_id
11806 );
11807 if endpoint != expected_endpoint {
11808 return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
11809 }
11810 descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
11811 blobs.push((hash.to_string(), bytes, endpoint.to_string()));
11812 }
11813 let descriptor = json!({
11814 "base": proposal.get("base").cloned().unwrap_or(Value::Null),
11815 "blobs": descriptor_blobs,
11816 "changes_base64": changes_b64,
11817 "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
11818 "v": 2,
11819 });
11820 let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
11821 .map_err(|error| invalid_feed(error.to_string()))?;
11822 if content_sha256(&descriptor_bytes) != clear_hash {
11823 return Err(invalid_feed(
11824 "proposal clear payload differs from its signed submission claim",
11825 ));
11826 }
11827 Ok(VerifiedV2Proposal {
11828 value,
11829 changes,
11830 blobs,
11831 })
11832}
11833
11834pub fn proposal_list(
11835 cfg: &HubConfig,
11836 brain: &str,
11837 state: &str,
11838 after: Option<&str>,
11839 limit: usize,
11840) -> LinkResult<Value> {
11841 require_safe_ref(brain)?;
11842 if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11843 return Err(invalid_feed("proposal state is invalid"));
11844 }
11845 if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11846 return Err(invalid_feed("proposal cursor is invalid"));
11847 }
11848 let head = v2_verified_head(cfg, brain)?
11849 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11850 let path = format!(
11851 "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11852 head.brain_id,
11853 limit.clamp(1, 100),
11854 after.map_or_else(String::new, |value| format!("&after={value}"))
11855 );
11856 ensure_ok(
11857 request_capped(
11858 cfg,
11859 "GET",
11860 &path,
11861 None,
11862 Auth::Required,
11863 MAX_FEED_RESPONSE_BYTES,
11864 )?,
11865 "v2 proposal list",
11866 )
11867}
11868
11869pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11870 require_safe_ref(brain)?;
11871 let head = v2_verified_head(cfg, brain)?
11872 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11873 Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11874}
11875
11876pub fn proposal_reject(
11877 cfg: &HubConfig,
11878 brain: &str,
11879 proposal_id: &str,
11880 mutation_id: &str,
11881 reason: &str,
11882) -> LinkResult<Value> {
11883 require_safe_ref(brain)?;
11884 require_proposal_id(proposal_id)?;
11885 let head = v2_verified_head(cfg, brain)?
11886 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11887 let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
11888 let body = json!({
11889 "mutation_id": mutation_id,
11890 "control_revision": head.control_revision,
11891 "reason": reason,
11892 });
11893 let path = format!(
11894 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11895 head.brain_id
11896 );
11897 ensure_ok(
11898 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11899 "v2 proposal rejection",
11900 )
11901}
11902
11903pub fn proposal_accept_exact(
11904 cfg: &HubConfig,
11905 brain: &str,
11906 proposal_id: &str,
11907 mutation_id: &str,
11908 reason: &str,
11909) -> LinkResult<Value> {
11910 require_safe_ref(brain)?;
11911 require_proposal_id(proposal_id)?;
11912 let head = v2_verified_head(cfg, brain)?
11913 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11914 let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
11915 let operations = proposal
11916 .changes
11917 .get("operations")
11918 .and_then(Value::as_array)
11919 .cloned()
11920 .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
11921 if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
11922 return Err(invalid_feed("proposal operation count is invalid"));
11923 }
11924 let mut downloaded = std::collections::BTreeMap::new();
11925 for (hash, bytes, endpoint) in &proposal.blobs {
11926 let body = ensure_raw_ok(
11927 request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
11928 "v2 proposal blob",
11929 )?;
11930 if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
11931 return Err(invalid_feed("proposal blob does not match its declaration"));
11932 }
11933 downloaded.insert(hash.clone(), body);
11934 }
11935 let remote = files_for_v2_view(
11936 &head,
11937 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
11938 );
11939 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
11940 let mut expected_candidate = remote.clone();
11941 let mut expected_candidate_assets = remote_assets;
11942 for operation in &operations {
11943 let op = operation
11944 .get("op")
11945 .and_then(Value::as_str)
11946 .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
11947 match op {
11948 "put" | "restore" => {
11949 let path = operation
11950 .get("path")
11951 .and_then(Value::as_str)
11952 .ok_or_else(|| invalid_feed("proposal write has no path"))?;
11953 crate::linkmd_v2::normalize_path(path)
11954 .map_err(|error| invalid_feed(error.to_string()))?;
11955 let hash = operation
11956 .get("blob")
11957 .and_then(Value::as_str)
11958 .filter(|hash| is_sha256(hash))
11959 .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
11960 let bytes = operation
11961 .get("bytes")
11962 .and_then(Value::as_u64)
11963 .ok_or_else(|| invalid_feed("proposal write has no size"))?;
11964 expected_candidate.insert(
11965 path.to_string(),
11966 V2BaselineFile {
11967 sha256: hash.to_string(),
11968 bytes,
11969 proof: None,
11970 },
11971 );
11972 }
11973 "delete" | "withdraw_from_hosting" => {
11974 let path = operation
11975 .get("path")
11976 .and_then(Value::as_str)
11977 .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
11978 crate::linkmd_v2::normalize_path(path)
11979 .map_err(|error| invalid_feed(error.to_string()))?;
11980 expected_candidate.remove(path);
11981 }
11982 "rename" => {
11983 let from = operation
11984 .get("from")
11985 .and_then(Value::as_str)
11986 .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
11987 let to = operation
11988 .get("to")
11989 .and_then(Value::as_str)
11990 .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
11991 crate::linkmd_v2::normalize_path(from)
11992 .and_then(|_| crate::linkmd_v2::normalize_path(to))
11993 .map_err(|error| invalid_feed(error.to_string()))?;
11994 let hash = operation
11995 .get("blob")
11996 .and_then(Value::as_str)
11997 .filter(|hash| is_sha256(hash))
11998 .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
11999 let bytes = operation
12000 .get("bytes")
12001 .and_then(Value::as_u64)
12002 .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
12003 expected_candidate.remove(from);
12004 expected_candidate.insert(
12005 to.to_string(),
12006 V2BaselineFile {
12007 sha256: hash.to_string(),
12008 bytes,
12009 proof: None,
12010 },
12011 );
12012 }
12013 "asset_delete" => {
12014 let path = operation
12015 .get("path")
12016 .and_then(Value::as_str)
12017 .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
12018 expected_candidate_assets.remove(path);
12019 }
12020 "asset_withdraw" => {
12021 let path = operation
12022 .get("path")
12023 .and_then(Value::as_str)
12024 .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
12025 let asset = expected_candidate_assets
12026 .get_mut(path)
12027 .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
12028 asset.disposition = "withheld".to_string();
12029 asset.leaf_hash.clear();
12030 }
12031 "asset_put" | "asset_resume" => {
12032 let path = operation
12033 .get("path")
12034 .and_then(Value::as_str)
12035 .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
12036 let asset = operation
12037 .get("asset")
12038 .and_then(Value::as_object)
12039 .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
12040 let blob_sha256 = asset
12041 .get("blob_sha256")
12042 .and_then(Value::as_str)
12043 .filter(|hash| is_sha256(hash))
12044 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12045 let bytes = asset
12046 .get("bytes")
12047 .and_then(Value::as_u64)
12048 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12049 let media_type = asset
12050 .get("media_type")
12051 .and_then(Value::as_str)
12052 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12053 let wrappers = asset
12054 .get("wrappers")
12055 .and_then(Value::as_array)
12056 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12057 .iter()
12058 .map(|wrapper| {
12059 wrapper
12060 .as_str()
12061 .map(str::to_string)
12062 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12063 })
12064 .collect::<LinkResult<Vec<_>>>()?;
12065 let required = asset
12066 .get("required")
12067 .and_then(Value::as_bool)
12068 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12069 let disposition = asset
12070 .get("disposition")
12071 .and_then(Value::as_str)
12072 .filter(|value| matches!(*value, "hosted" | "withheld"))
12073 .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
12074 expected_candidate_assets.insert(
12075 path.to_string(),
12076 V2BaselineAsset {
12077 blob_sha256: blob_sha256.to_string(),
12078 bytes,
12079 media_type: media_type.to_string(),
12080 wrappers,
12081 required,
12082 disposition: disposition.to_string(),
12083 leaf_hash: String::new(),
12084 },
12085 );
12086 }
12087 _ => return Err(invalid_feed("proposal operation kind is unsupported")),
12088 }
12089 }
12090 let base = head.pointer.as_ref().map(|pointer| {
12091 json!({
12092 "seq": pointer.seq,
12093 "commit_hash": pointer.commit_hash,
12094 "content_root": pointer.content_root,
12095 "asset_root": pointer.asset_root,
12096 })
12097 });
12098 let mut body = json!({
12099 "mutation_id": mutation_id,
12100 "base": base,
12101 "rebase": "strict",
12102 "reason": reason,
12103 "operations": operations,
12104 "blobs": downloaded
12105 .iter()
12106 .map(|(sha256, bytes)| json!({
12107 "sha256": sha256,
12108 "bytes": bytes.len(),
12109 "content_base64": STANDARD.encode(bytes),
12110 }))
12111 .collect::<Vec<_>>(),
12112 "proposal_id": proposal_id,
12113 "proposal_mode": "exact",
12114 });
12115 let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
12116 total
12117 .checked_add(bytes.len())
12118 .ok_or_else(|| LinkError::PushTooLarge {
12119 detail: "proposal changed-byte total overflow".to_string(),
12120 })
12121 })?;
12122 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
12123 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12124 for operation in &operations {
12125 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
12126 return Err(invalid_feed("proposal upload operation has no kind"));
12127 };
12128 let hash = match kind {
12129 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
12130 "asset_put" | "asset_resume" => operation
12131 .get("asset")
12132 .and_then(|asset| asset.get("blob_sha256"))
12133 .and_then(Value::as_str),
12134 _ => None,
12135 };
12136 let Some(hash) = hash else { continue };
12137 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
12138 if kind == "rename" {
12139 for field in ["from", "to"] {
12140 coordinates.insert(
12141 operation
12142 .get(field)
12143 .and_then(Value::as_str)
12144 .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
12145 .to_string(),
12146 );
12147 }
12148 } else {
12149 let path = operation
12150 .get("path")
12151 .and_then(Value::as_str)
12152 .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
12153 coordinates.insert(if kind.starts_with("asset_") {
12154 format!("assets/{path}")
12155 } else {
12156 path.to_string()
12157 });
12158 }
12159 }
12160 let declarations = downloaded
12161 .iter()
12162 .map(|(sha256, bytes)| {
12163 json!({
12164 "sha256": sha256,
12165 "bytes": bytes.len(),
12166 "coordinates": coordinates_by_hash
12167 .get(sha256)
12168 .into_iter()
12169 .flatten()
12170 .collect::<Vec<_>>(),
12171 })
12172 })
12173 .collect::<Vec<_>>();
12174 let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
12175 for batch in batch_upload_declarations(declarations) {
12176 let reserved = reserve_upload_window(
12177 cfg,
12178 &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
12179 &json!({ "blobs": batch }),
12180 "prepare proposal blob transport",
12181 )?;
12182 let reserved_items = reserved
12183 .get("uploads")
12184 .and_then(Value::as_array)
12185 .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
12186 items.extend(reserved_items.iter().cloned());
12187 }
12188 if items.len() != downloaded.len() {
12189 return Err(invalid_feed("proposal upload reservation changed the set"));
12190 }
12191 let mut references = Vec::with_capacity(items.len());
12192 for item in items {
12193 let hash = item
12194 .get("sha256")
12195 .and_then(Value::as_str)
12196 .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
12197 let bytes = downloaded
12198 .get(hash)
12199 .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
12200 let reservation_id = item
12201 .get("reservation_id")
12202 .and_then(Value::as_str)
12203 .filter(|id| crate::ulid::is_ulid(id))
12204 .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
12205 let expected_coordinates = coordinates_by_hash
12206 .get(hash)
12207 .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
12208 let returned_coordinates = item
12209 .get("coordinates")
12210 .and_then(Value::as_array)
12211 .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
12212 if returned_coordinates.len() != expected_coordinates.len()
12213 || returned_coordinates
12214 .iter()
12215 .zip(expected_coordinates)
12216 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
12217 {
12218 return Err(invalid_feed(
12219 "proposal upload reservation changed its coordinates",
12220 ));
12221 }
12222 match item.get("status").and_then(Value::as_str) {
12223 Some("upload") => put_presigned(
12224 cfg,
12225 item.get("url")
12226 .and_then(Value::as_str)
12227 .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
12228 item.get("headers").unwrap_or(&Value::Null),
12229 bytes,
12230 )?,
12231 Some("already_present") => {}
12232 _ => return Err(invalid_feed("proposal upload status is invalid")),
12233 }
12234 references.push(json!({
12235 "sha256": hash,
12236 "bytes": bytes.len(),
12237 "reservation_id": reservation_id,
12238 }));
12239 }
12240 body["blobs"] = Value::Array(references);
12241 }
12242 stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
12246 let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
12247 let mut result = ensure_ok(
12248 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
12249 "exact proposal acceptance",
12250 )?;
12251 let mut candidate_hub_signer = None;
12252 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
12253 let request_id = result
12254 .get("request_id")
12255 .and_then(Value::as_str)
12256 .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
12257 .to_string();
12258 let challenge = result
12259 .get("signing_challenge")
12260 .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
12261 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
12262 cfg,
12263 &head,
12264 &expected_candidate,
12265 &expected_candidate_assets,
12266 mutation_id,
12267 &v2_signed_request_view(&body, &operations),
12268 challenge,
12269 )?;
12270 body["signing_challenge_id"] = Value::String(challenge_id);
12271 body["signature_base64url"] = Value::String(signature);
12272 candidate_hub_signer = Some(actor_signer);
12273 result = ensure_ok(
12274 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
12275 "signed exact proposal acceptance",
12276 )?;
12277 }
12278 let refreshed = v2_verified_head(cfg, brain)?
12279 .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
12280 if candidate_hub_signer
12281 .as_ref()
12282 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
12283 || refreshed
12284 .pointer
12285 .as_ref()
12286 .map(|pointer| pointer.commit_hash.as_str())
12287 != result.get("commit_hash").and_then(Value::as_str)
12288 || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12289 || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
12290 {
12291 return Err(LinkError::RemoteAdvancedDuringSync);
12292 }
12293 accept_v2_head(cfg, &refreshed)?;
12294 Ok(result)
12295}
12296
12297pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
12308 require_valid_handle(handle)?;
12309 if body.len() as u64 > MAX_PROPOSE_BYTES {
12310 return Err(LinkError::ProposeTooLarge {
12311 bytes: body.len() as u64,
12312 });
12313 }
12314 let payload = json!({ "app": app, "body": body });
12315 let (path, auth) = if crate::ulid::is_ulid(handle) {
12320 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
12321 } else {
12322 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
12323 };
12324 ensure_ok(
12325 request(cfg, "POST", &path, Some(&payload), auth)?,
12326 "propose",
12327 )
12328}
12329
12330#[derive(Debug, serde::Serialize)]
12336pub struct Head {
12337 pub brain: String,
12339 pub seq: u64,
12341 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
12343 pub updated_at: Option<String>,
12344 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
12346 pub feed_hash: Option<String>,
12347 pub verified: bool,
12350}
12351
12352struct BoundedVecVisitor<T, const MAX: usize> {
12353 label: &'static str,
12354 marker: std::marker::PhantomData<T>,
12355}
12356
12357impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
12358where
12359 T: Deserialize<'de>,
12360{
12361 type Value = Vec<T>;
12362
12363 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12364 write!(formatter, "at most {MAX} {}", self.label)
12365 }
12366
12367 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
12368 where
12369 A: serde::de::SeqAccess<'de>,
12370 {
12371 if sequence.size_hint().is_some_and(|size| size > MAX) {
12372 return Err(serde::de::Error::custom(format!(
12373 "{} exceeds the {MAX}-item limit",
12374 self.label
12375 )));
12376 }
12377 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
12378 while let Some(value) = sequence.next_element()? {
12379 if values.len() == MAX {
12380 return Err(serde::de::Error::custom(format!(
12381 "{} exceeds the {MAX}-item limit",
12382 self.label
12383 )));
12384 }
12385 values.push(value);
12386 }
12387 Ok(values)
12388 }
12389}
12390
12391fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
12392 deserializer: D,
12393 label: &'static str,
12394) -> Result<Vec<T>, D::Error>
12395where
12396 D: serde::Deserializer<'de>,
12397 T: Deserialize<'de>,
12398{
12399 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
12400 label,
12401 marker: std::marker::PhantomData,
12402 })
12403}
12404
12405fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
12406where
12407 D: serde::Deserializer<'de>,
12408{
12409 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
12410}
12411
12412fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12413where
12414 D: serde::Deserializer<'de>,
12415{
12416 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
12417}
12418
12419fn deserialize_previous_identities<'de, D>(
12420 deserializer: D,
12421) -> Result<Vec<PreviousIdentity>, D::Error>
12422where
12423 D: serde::Deserializer<'de>,
12424{
12425 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
12426 deserializer,
12427 "previous identities",
12428 )
12429}
12430
12431fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12432where
12433 D: serde::Deserializer<'de>,
12434{
12435 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
12436 deserializer,
12437 "rotation statements",
12438 )
12439}
12440
12441fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
12442where
12443 D: serde::Deserializer<'de>,
12444{
12445 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
12446}
12447
12448#[derive(Debug, Clone, Deserialize, Serialize)]
12449struct FeedFile {
12450 path: String,
12451 sha256: String,
12452 bytes: u64,
12453}
12454
12455#[cfg(test)]
12456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12457enum V1DisclosureError {
12458 DuplicateFile,
12459 DuplicateRemoved,
12460 PushManifestMismatch,
12461 EditMissingChange,
12462 EditFalseFile,
12463 RemovedMismatch,
12464}
12465
12466#[cfg(test)]
12470fn verify_v1_manifest_disclosure(
12471 kind: &str,
12472 previous: &[FeedFile],
12473 resulting: &[FeedFile],
12474 files: &[FeedFile],
12475 removed: &[String],
12476) -> Result<(), V1DisclosureError> {
12477 fn as_map(
12478 files: &[FeedFile],
12479 ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
12480 let mut result = std::collections::BTreeMap::new();
12481 for file in files {
12482 if result
12483 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
12484 .is_some()
12485 {
12486 return Err(V1DisclosureError::DuplicateFile);
12487 }
12488 }
12489 Ok(result)
12490 }
12491 let previous = as_map(previous)?;
12492 let resulting = as_map(resulting)?;
12493 let disclosed = as_map(files)?;
12494 let removed_set: std::collections::BTreeSet<&str> =
12495 removed.iter().map(String::as_str).collect();
12496 if removed_set.len() != removed.len() {
12497 return Err(V1DisclosureError::DuplicateRemoved);
12498 }
12499 let expected_removed: std::collections::BTreeSet<&str> = previous
12500 .keys()
12501 .copied()
12502 .filter(|path| !resulting.contains_key(path))
12503 .collect();
12504 if removed_set != expected_removed {
12505 return Err(V1DisclosureError::RemovedMismatch);
12506 }
12507 if kind == "push" {
12508 return if disclosed == resulting {
12509 Ok(())
12510 } else {
12511 Err(V1DisclosureError::PushManifestMismatch)
12512 };
12513 }
12514 if kind != "edit" {
12515 return Err(V1DisclosureError::EditFalseFile);
12516 }
12517 if disclosed
12518 .iter()
12519 .any(|(path, value)| resulting.get(path) != Some(value))
12520 {
12521 return Err(V1DisclosureError::EditFalseFile);
12522 }
12523 for (path, value) in &resulting {
12524 if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
12525 return Err(V1DisclosureError::EditMissingChange);
12526 }
12527 }
12528 Ok(())
12529}
12530
12531#[derive(Debug, Clone, Deserialize, Serialize)]
12532struct FeedEntry {
12533 v: u8,
12534 seq: u64,
12535 ts: String,
12536 brain: String,
12537 public_key: String,
12538 kind: String,
12539 op: String,
12540 pack_sha256: String,
12541 #[serde(deserialize_with = "deserialize_feed_files")]
12542 files: Vec<FeedFile>,
12543 #[serde(deserialize_with = "deserialize_removed_paths")]
12544 removed: Vec<String>,
12545 prev_entry_hash: Option<String>,
12546 sig: String,
12547}
12548
12549#[derive(Serialize)]
12550struct UnsignedFeedEntry<'a> {
12551 v: u8,
12552 seq: u64,
12553 ts: &'a str,
12554 brain: &'a str,
12555 public_key: &'a str,
12556 kind: &'a str,
12557 op: &'a str,
12558 pack_sha256: &'a str,
12559 files: &'a [FeedFile],
12560 removed: &'a [String],
12561 prev_entry_hash: &'a Option<String>,
12562}
12563
12564#[derive(Debug, Clone, Deserialize, Serialize)]
12565struct FeedItem {
12566 hash: String,
12567 entry: FeedEntry,
12568}
12569
12570#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12571struct FeedIdentity {
12572 fingerprint: String,
12573 #[serde(rename = "publicKeySpki")]
12574 public_key_spki: String,
12575 #[serde(default, deserialize_with = "deserialize_previous_identities")]
12579 previous: Vec<PreviousIdentity>,
12580 #[serde(default, deserialize_with = "deserialize_rotations")]
12583 rotations: Vec<String>,
12584}
12585
12586#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12587struct PreviousIdentity {
12588 fingerprint: String,
12589 #[serde(rename = "publicKeySpki")]
12590 public_key_spki: String,
12591}
12592
12593#[derive(Debug, Deserialize)]
12594struct FeedResponse {
12595 #[serde(rename = "headSeq")]
12596 head_seq: u64,
12597 #[serde(rename = "feedHash")]
12598 feed_hash: Option<String>,
12599 identity: Option<FeedIdentity>,
12600 #[serde(deserialize_with = "deserialize_feed_items")]
12601 entries: Vec<FeedItem>,
12602 #[serde(rename = "scopeLimited")]
12603 scope_limited: bool,
12604}
12605
12606#[derive(Debug, Deserialize, Serialize)]
12607#[serde(deny_unknown_fields)]
12608struct RotationStatement {
12609 v: u8,
12610 op: String,
12611 brain: String,
12612 public_key: String,
12613 new_brain: String,
12614 new_public_key: String,
12615 prior_head_seq: u64,
12616 prior_feed_hash: Option<String>,
12617 ts: String,
12618 sig: String,
12619}
12620
12621#[derive(Debug, Clone, Deserialize, Serialize)]
12622struct TrustState {
12623 v: u8,
12624 origin: String,
12625 #[serde(default)]
12629 requested: String,
12630 brain: String,
12632 #[serde(default, skip_serializing_if = "Option::is_none")]
12635 home: Option<String>,
12636 anchor: String,
12637 current: String,
12638 #[serde(rename = "headSeq")]
12639 head_seq: u64,
12640 #[serde(rename = "feedHash")]
12641 feed_hash: Option<String>,
12642 #[serde(default)]
12646 rotations: Vec<String>,
12647 #[serde(default, skip_serializing_if = "Option::is_none")]
12650 hub_signer: Option<String>,
12651 #[serde(default, skip_serializing_if = "Option::is_none")]
12654 protocol_profile: Option<String>,
12655}
12656
12657fn accepted_as_v2(state: &TrustState) -> bool {
12658 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
12659}
12660
12661fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
12662 let directory = open_trust_dir(cfg)?;
12663 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
12664 return Ok(true);
12665 }
12666 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
12667 return Ok(false);
12668 };
12669 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
12670}
12671
12672#[derive(Debug, Clone, Deserialize, Serialize)]
12673struct AliasBinding {
12674 v: u8,
12675 origin: String,
12676 requested: String,
12677 brain: String,
12678 #[serde(default, skip_serializing_if = "Option::is_none")]
12679 home: Option<String>,
12680}
12681
12682struct VerifiedRemote {
12683 head: Head,
12684 identity: Option<FeedIdentity>,
12685 head_entry: Option<FeedItem>,
12686 entries: Vec<FeedItem>,
12688 anchor: Option<String>,
12689}
12690
12691fn invalid_feed(message: impl Into<String>) -> LinkError {
12692 LinkError::InvalidFeed {
12693 message: message.into(),
12694 }
12695}
12696
12697fn is_sha256(value: &str) -> bool {
12698 value.len() == 64
12699 && value
12700 .bytes()
12701 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
12702}
12703
12704fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
12705 let der = URL_SAFE_NO_PAD
12706 .decode(public_key_spki)
12707 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
12708 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
12709 return Err(invalid_feed(
12710 "identity public key is not a valid Ed25519 SPKI",
12711 ));
12712 }
12713 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
12714}
12715
12716fn verify_identity_chain(
12720 identity: &FeedIdentity,
12721 pinned: Option<&TrustState>,
12722) -> LinkResult<String> {
12723 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
12724 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
12725 {
12726 return Err(invalid_feed(
12727 "identity rotation history exceeds the client cap",
12728 ));
12729 }
12730 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
12731 return Err(invalid_feed(
12732 "current identity fingerprint does not match its public key",
12733 ));
12734 }
12735 for previous in &identity.previous {
12736 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
12737 return Err(invalid_feed(
12738 "previous identity fingerprint does not match its public key",
12739 ));
12740 }
12741 }
12742 if identity.rotations.len() != identity.previous.len() {
12743 return Err(invalid_feed(
12744 "identity history is missing an old-key-signed rotation statement",
12745 ));
12746 }
12747
12748 let mut chain: Vec<(&str, &str)> = identity
12752 .previous
12753 .iter()
12754 .rev()
12755 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
12756 .collect();
12757 chain.push((&identity.fingerprint, &identity.public_key_spki));
12758
12759 for (index, raw) in identity.rotations.iter().enumerate() {
12760 let statement: RotationStatement = serde_json::from_str(raw)
12761 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
12762 let (old_fingerprint, old_spki) = chain[index];
12763 let (new_fingerprint, new_spki) = chain[index + 1];
12764 if statement.v != 1
12765 || statement.op != "rotate"
12766 || statement.brain != format!("ed25519:{old_fingerprint}")
12767 || statement.public_key != old_spki
12768 || statement.new_brain != format!("ed25519:{new_fingerprint}")
12769 || statement.new_public_key != new_spki
12770 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
12771 || (statement.prior_head_seq > 0
12772 && statement
12773 .prior_feed_hash
12774 .as_deref()
12775 .is_none_or(|hash| !is_sha256(hash)))
12776 {
12777 return Err(invalid_feed(
12778 "rotation statement does not connect adjacent identities",
12779 ));
12780 }
12781 let unsigned = serde_json::to_string(&UnsignedRotation {
12782 v: statement.v,
12783 op: &statement.op,
12784 brain: &statement.brain,
12785 public_key: &statement.public_key,
12786 new_brain: &statement.new_brain,
12787 new_public_key: &statement.new_public_key,
12788 prior_head_seq: statement.prior_head_seq,
12789 prior_feed_hash: statement.prior_feed_hash.as_deref(),
12790 ts: statement.ts.clone(),
12791 })
12792 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
12793 let exact = format!(
12794 "{},\"sig\":\"{}\"}}",
12795 &unsigned[..unsigned.len() - 1],
12796 statement.sig
12797 );
12798 if exact != *raw {
12799 return Err(invalid_feed(
12800 "rotation statement is not in normative serialization",
12801 ));
12802 }
12803 let der = URL_SAFE_NO_PAD
12804 .decode(old_spki)
12805 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
12806 let signature = URL_SAFE_NO_PAD
12807 .decode(&statement.sig)
12808 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
12809 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
12810 .verify(unsigned.as_bytes(), &signature)
12811 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
12812 if index > 0 {
12813 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
12814 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
12815 if statement.prior_head_seq < prior.prior_head_seq {
12816 return Err(invalid_feed("rotation feed boundaries move backward"));
12817 }
12818 }
12819 }
12820
12821 let anchor = format!("ed25519:{}", chain[0].0);
12822 let current = format!("ed25519:{}", identity.fingerprint);
12823 if let Some(pin) = pinned {
12824 if pin.anchor != anchor {
12825 return Err(invalid_feed(
12826 "served identity chain does not descend from the pinned anchor",
12827 ));
12828 }
12829 if !chain
12830 .iter()
12831 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12832 {
12833 return Err(invalid_feed(
12834 "served identity chain forked away from the last pinned identity",
12835 ));
12836 }
12837 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
12838 return Err(invalid_feed("served identity discarded its rotation chain"));
12839 }
12840 if pin.v >= 2
12841 && (identity.rotations.len() < pin.rotations.len()
12842 || identity.rotations[..pin.rotations.len()] != pin.rotations)
12843 {
12844 return Err(invalid_feed(
12845 "served identity rewrote the locally accepted rotation history",
12846 ));
12847 }
12848 }
12849 Ok(anchor)
12850}
12851
12852fn verify_rotation_feed_boundaries(
12853 identity: &FeedIdentity,
12854 pinned: Option<&TrustState>,
12855 observed: &[FeedItem],
12856 advertised_seq: u64,
12857) -> LinkResult<()> {
12858 let mut chain: Vec<String> = identity
12859 .previous
12860 .iter()
12861 .rev()
12862 .map(|previous| format!("ed25519:{}", previous.fingerprint))
12863 .collect();
12864 chain.push(format!("ed25519:{}", identity.fingerprint));
12865 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
12866
12867 for (index, raw) in identity.rotations.iter().enumerate() {
12868 let rotation: RotationStatement = serde_json::from_str(raw)
12869 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12870 if rotation.prior_head_seq > advertised_seq {
12871 return Err(invalid_feed(
12872 "rotation claims a feed boundary beyond the advertised head",
12873 ));
12874 }
12875 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
12876 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
12877 return Err(invalid_feed(
12878 "newly disclosed rotation predates the local feed checkpoint",
12879 ));
12880 }
12881 }
12882 let actual = if rotation.prior_head_seq == 0 {
12883 None
12884 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
12885 pinned.and_then(|pin| pin.feed_hash.as_deref())
12886 } else {
12887 observed
12888 .iter()
12889 .find(|item| item.entry.seq == rotation.prior_head_seq)
12890 .map(|item| item.hash.as_str())
12891 };
12892 if let Some(actual) = actual {
12893 if rotation.prior_feed_hash.as_deref() != Some(actual) {
12894 return Err(invalid_feed(
12895 "rotation statement does not commit the verified feed boundary",
12896 ));
12897 }
12898 } else if rotation.prior_head_seq == 0 {
12899 } else if pinned.is_some_and(|pin| {
12902 pinned_index.is_some_and(|pin_index| index >= pin_index)
12903 || rotation.prior_head_seq >= pin.head_seq
12904 }) {
12905 return Err(invalid_feed(
12906 "rotation feed boundary was not present in the verified chain",
12907 ));
12908 }
12909 }
12910 Ok(())
12911}
12912
12913fn reject_retired_signer_after_checkpoint(
12918 identity: &FeedIdentity,
12919 pinned: Option<&TrustState>,
12920 item: &FeedItem,
12921) -> LinkResult<()> {
12922 let Some(pin) = pinned else {
12923 return Ok(());
12924 };
12925 if item.entry.seq <= pin.head_seq {
12926 return Ok(());
12927 }
12928 let mut chain: Vec<String> = identity
12929 .previous
12930 .iter()
12931 .rev()
12932 .map(|previous| format!("ed25519:{}", previous.fingerprint))
12933 .collect();
12934 chain.push(format!("ed25519:{}", identity.fingerprint));
12935 let pinned_index = chain
12936 .iter()
12937 .position(|key| key == &pin.current)
12938 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
12939 let signer_index = chain
12940 .iter()
12941 .position(|key| key == &item.entry.brain)
12942 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
12943 if signer_index < pinned_index {
12944 return Err(invalid_feed(
12945 "a retired identity attempted to sign after the local checkpoint",
12946 ));
12947 }
12948 Ok(())
12949}
12950
12951fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
12952 let origin = normalized_origin(&cfg.hub)?;
12953 let key = format!(
12954 "{:x}",
12955 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
12956 );
12957 Ok(format!("{key}.json"))
12958}
12959
12960fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
12961 let origin = normalized_origin(&cfg.hub)?;
12962 let key = format!(
12963 "{:x}",
12964 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
12965 );
12966 Ok(format!("alias-{key}.json"))
12967}
12968
12969#[cfg(any(unix, windows))]
12970struct TrustLock {
12971 _file: std::fs::File,
12972}
12973
12974#[cfg(unix)]
12975fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12976 use std::os::fd::{AsRawFd as _, FromRawFd as _};
12977
12978 let lock_string = format!(".{state_name}.lock");
12979 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
12980 let fd = unsafe {
12981 libc::openat(
12982 directory.as_raw_fd(),
12983 lock_name.as_ptr(),
12984 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12985 0o600,
12986 )
12987 };
12988 if fd < 0 {
12989 return Err(std::io::Error::last_os_error().into());
12990 }
12991 let file = unsafe { std::fs::File::from_raw_fd(fd) };
12992 if !file.metadata()?.is_file() {
12993 return Err(LinkError::UnsafePath { path: lock_string });
12994 }
12995 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
12996 return Err(std::io::Error::last_os_error().into());
12997 }
12998 Ok(TrustLock { _file: file })
12999}
13000
13001#[cfg(windows)]
13002fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13003 let lock_name = format!(".{state_name}.lock");
13004 let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
13005 Ok(TrustLock { _file: file })
13006}
13007
13008#[cfg(any(unix, windows))]
13009fn lock_trust_many(
13010 cfg: &HubConfig,
13011 directory: &std::fs::File,
13012 refs: &[&str],
13013) -> LinkResult<Vec<TrustLock>> {
13014 let mut names = refs
13015 .iter()
13016 .map(|reference| trust_file_name(cfg, reference))
13017 .collect::<LinkResult<Vec<_>>>()?;
13018 names.sort();
13019 names.dedup();
13020 names
13021 .iter()
13022 .map(|name| lock_trust_name(directory, name))
13023 .collect()
13024}
13025
13026#[cfg(not(any(unix, windows)))]
13027fn lock_trust_many(
13028 _cfg: &HubConfig,
13029 _directory: &TrustDirectory,
13030 _refs: &[&str],
13031) -> LinkResult<Vec<()>> {
13032 Err(LinkError::UnsupportedPlatform {
13033 operation: "verified link.md state",
13034 })
13035}
13036
13037#[cfg(any(unix, windows))]
13038type TrustDirectory = std::fs::File;
13039
13040#[cfg(not(any(unix, windows)))]
13041struct TrustDirectory;
13042
13043#[cfg(unix)]
13044fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13045 use std::os::fd::AsRawFd as _;
13046
13047 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
13048 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
13049 return Err(std::io::Error::last_os_error().into());
13050 }
13051 directory.sync_all()?;
13052 Ok(directory)
13053}
13054
13055#[cfg(windows)]
13056fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13057 let marker = cfg.state_dir.join("trust").join(".directory");
13058 crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
13059 Ok(crate::fsx::open_directory_nofollow(
13060 marker.parent().expect("trust marker has a parent"),
13061 )?)
13062}
13063
13064#[cfg(not(any(unix, windows)))]
13065fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13066 Err(LinkError::UnsupportedPlatform {
13067 operation: "verified link.md state",
13068 })
13069}
13070
13071#[cfg(unix)]
13072fn load_trust_in(
13073 cfg: &HubConfig,
13074 directory: &TrustDirectory,
13075 requested: &str,
13076) -> LinkResult<Option<TrustState>> {
13077 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13078
13079 let name_string = trust_file_name(cfg, requested)?;
13080 let name = c_name(name_string.as_bytes(), &name_string)?;
13081 let fd = unsafe {
13082 libc::openat(
13083 directory.as_raw_fd(),
13084 name.as_ptr(),
13085 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13086 )
13087 };
13088 if fd < 0 {
13089 let error = std::io::Error::last_os_error();
13090 if error.kind() == std::io::ErrorKind::NotFound {
13091 return Ok(None);
13092 }
13093 return Err(LinkError::UnsafePath { path: name_string });
13094 }
13095 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13096 if !file.metadata()?.is_file() {
13097 return Err(LinkError::UnsafePath { path: name_string });
13098 }
13099 let mut bytes = Vec::new();
13100 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
13101 if bytes.len() > 1024 * 1024 {
13102 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
13103 }
13104 let mut state: TrustState = serde_json::from_slice(&bytes)
13105 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13106 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13107 return Err(invalid_feed(
13108 "local identity/feed checkpoint does not match this hub and brain",
13109 ));
13110 }
13111 if state.v == 1 {
13112 if state.brain != requested {
13116 return Err(invalid_feed(
13117 "legacy checkpoint is not bound to the requested brain id",
13118 ));
13119 }
13120 state.requested = requested.to_string();
13121 } else if state.requested != requested {
13122 return Err(invalid_feed(
13123 "local identity/feed checkpoint is bound to a different requested ref",
13124 ));
13125 }
13126 Ok(Some(state))
13127}
13128
13129#[cfg(windows)]
13130fn load_trust_in(
13131 cfg: &HubConfig,
13132 directory: &TrustDirectory,
13133 requested: &str,
13134) -> LinkResult<Option<TrustState>> {
13135 let name = trust_file_name(cfg, requested)?;
13136 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13137 let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
13138 Ok(bytes) => bytes,
13139 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13140 Err(_) => return Err(LinkError::UnsafePath { path: name }),
13141 };
13142 let mut state: TrustState = serde_json::from_slice(&bytes)
13143 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13144 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13145 return Err(invalid_feed(
13146 "local identity/feed checkpoint does not match this hub and brain",
13147 ));
13148 }
13149 if state.v == 1 {
13150 if state.brain != requested {
13151 return Err(invalid_feed(
13152 "legacy checkpoint is not bound to the requested brain id",
13153 ));
13154 }
13155 state.requested = requested.to_string();
13156 } else if state.requested != requested {
13157 return Err(invalid_feed(
13158 "local identity/feed checkpoint is bound to a different requested ref",
13159 ));
13160 }
13161 Ok(Some(state))
13162}
13163
13164#[cfg(not(any(unix, windows)))]
13165fn load_trust_in(
13166 _cfg: &HubConfig,
13167 _directory: &TrustDirectory,
13168 _brain: &str,
13169) -> LinkResult<Option<TrustState>> {
13170 Err(LinkError::UnsupportedPlatform {
13171 operation: "verified link.md state",
13172 })
13173}
13174
13175#[cfg(all(test, any(unix, windows)))]
13176fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
13177 let directory = open_trust_dir(cfg)?;
13178 load_trust_in(cfg, &directory, requested)
13179}
13180
13181#[cfg(unix)]
13182fn save_trust_in(
13183 cfg: &HubConfig,
13184 directory: &TrustDirectory,
13185 state: &TrustState,
13186) -> LinkResult<()> {
13187 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13188
13189 let name_string = trust_file_name(cfg, &state.requested)?;
13190 let name = c_name(name_string.as_bytes(), &name_string)?;
13191 let mut bytes = serde_json::to_vec(state)
13192 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13193 bytes.push(b'\n');
13194
13195 let nonce = std::time::SystemTime::now()
13196 .duration_since(std::time::UNIX_EPOCH)
13197 .unwrap_or_default()
13198 .as_nanos();
13199 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13200 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13201 let fd = unsafe {
13202 libc::openat(
13203 directory.as_raw_fd(),
13204 temp.as_ptr(),
13205 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13206 0o600,
13207 )
13208 };
13209 if fd < 0 {
13210 return Err(std::io::Error::last_os_error().into());
13211 }
13212 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13213 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13214 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13215 return Err(error.into());
13216 }
13217 drop(file);
13218 if unsafe {
13219 libc::renameat(
13220 directory.as_raw_fd(),
13221 temp.as_ptr(),
13222 directory.as_raw_fd(),
13223 name.as_ptr(),
13224 )
13225 } != 0
13226 {
13227 let error = std::io::Error::last_os_error();
13228 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13229 return Err(error.into());
13230 }
13231 directory.sync_all()?;
13232 Ok(())
13233}
13234
13235#[cfg(windows)]
13236fn save_trust_in(
13237 cfg: &HubConfig,
13238 directory: &TrustDirectory,
13239 state: &TrustState,
13240) -> LinkResult<()> {
13241 let name = trust_file_name(cfg, &state.requested)?;
13242 let mut bytes = serde_json::to_vec(state)
13243 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13244 bytes.push(b'\n');
13245 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13246 Ok(())
13247}
13248
13249#[cfg(not(any(unix, windows)))]
13250fn save_trust_in(
13251 _cfg: &HubConfig,
13252 _directory: &TrustDirectory,
13253 _state: &TrustState,
13254) -> LinkResult<()> {
13255 Err(LinkError::UnsupportedPlatform {
13256 operation: "verified link.md state",
13257 })
13258}
13259
13260#[cfg(unix)]
13261fn load_alias_in(
13262 cfg: &HubConfig,
13263 directory: &TrustDirectory,
13264 requested: &str,
13265) -> LinkResult<Option<AliasBinding>> {
13266 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13267
13268 let name_string = alias_file_name(cfg, requested)?;
13269 let name = c_name(name_string.as_bytes(), &name_string)?;
13270 let fd = unsafe {
13271 libc::openat(
13272 directory.as_raw_fd(),
13273 name.as_ptr(),
13274 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13275 )
13276 };
13277 if fd < 0 {
13278 let error = std::io::Error::last_os_error();
13279 if error.kind() == std::io::ErrorKind::NotFound {
13280 return Ok(None);
13281 }
13282 return Err(LinkError::UnsafePath { path: name_string });
13283 }
13284 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13285 if !file.metadata()?.is_file() {
13286 return Err(LinkError::UnsafePath { path: name_string });
13287 }
13288 let mut bytes = Vec::new();
13289 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
13290 if bytes.len() > 64 * 1024 {
13291 return Err(invalid_feed("local alias binding is oversized"));
13292 }
13293 let alias: AliasBinding = serde_json::from_slice(&bytes)
13294 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13295 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13296 {
13297 return Err(invalid_feed(
13298 "local alias binding does not match this hub and requested ref",
13299 ));
13300 }
13301 Ok(Some(alias))
13302}
13303
13304#[cfg(windows)]
13305fn load_alias_in(
13306 cfg: &HubConfig,
13307 directory: &TrustDirectory,
13308 requested: &str,
13309) -> LinkResult<Option<AliasBinding>> {
13310 let name = alias_file_name(cfg, requested)?;
13311 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13312 let bytes = match reader.read(Path::new(&name), 64 * 1024) {
13313 Ok(bytes) => bytes,
13314 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13315 Err(_) => return Err(LinkError::UnsafePath { path: name }),
13316 };
13317 let alias: AliasBinding = serde_json::from_slice(&bytes)
13318 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13319 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13320 {
13321 return Err(invalid_feed(
13322 "local alias binding does not match this hub and requested ref",
13323 ));
13324 }
13325 Ok(Some(alias))
13326}
13327
13328#[cfg(not(any(unix, windows)))]
13329fn load_alias_in(
13330 _cfg: &HubConfig,
13331 _directory: &TrustDirectory,
13332 _requested: &str,
13333) -> LinkResult<Option<AliasBinding>> {
13334 Err(LinkError::UnsupportedPlatform {
13335 operation: "verified link.md state",
13336 })
13337}
13338
13339#[cfg(unix)]
13340fn save_alias_in(
13341 cfg: &HubConfig,
13342 directory: &TrustDirectory,
13343 alias: &AliasBinding,
13344) -> LinkResult<()> {
13345 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13346
13347 let name_string = alias_file_name(cfg, &alias.requested)?;
13348 let name = c_name(name_string.as_bytes(), &name_string)?;
13349 let mut bytes = serde_json::to_vec(alias)
13350 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13351 bytes.push(b'\n');
13352 let nonce = std::time::SystemTime::now()
13353 .duration_since(std::time::UNIX_EPOCH)
13354 .unwrap_or_default()
13355 .as_nanos();
13356 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13357 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13358 let fd = unsafe {
13359 libc::openat(
13360 directory.as_raw_fd(),
13361 temp.as_ptr(),
13362 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13363 0o600,
13364 )
13365 };
13366 if fd < 0 {
13367 return Err(std::io::Error::last_os_error().into());
13368 }
13369 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13370 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13371 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13372 return Err(error.into());
13373 }
13374 drop(file);
13375 if unsafe {
13376 libc::renameat(
13377 directory.as_raw_fd(),
13378 temp.as_ptr(),
13379 directory.as_raw_fd(),
13380 name.as_ptr(),
13381 )
13382 } != 0
13383 {
13384 let error = std::io::Error::last_os_error();
13385 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13386 return Err(error.into());
13387 }
13388 directory.sync_all()?;
13389 Ok(())
13390}
13391
13392#[cfg(windows)]
13393fn save_alias_in(
13394 cfg: &HubConfig,
13395 directory: &TrustDirectory,
13396 alias: &AliasBinding,
13397) -> LinkResult<()> {
13398 let name = alias_file_name(cfg, &alias.requested)?;
13399 let mut bytes = serde_json::to_vec(alias)
13400 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13401 bytes.push(b'\n');
13402 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13403 Ok(())
13404}
13405
13406#[cfg(not(any(unix, windows)))]
13407fn save_alias_in(
13408 _cfg: &HubConfig,
13409 _directory: &TrustDirectory,
13410 _alias: &AliasBinding,
13411) -> LinkResult<()> {
13412 Err(LinkError::UnsupportedPlatform {
13413 operation: "verified link.md state",
13414 })
13415}
13416
13417fn load_canonical_pin(
13422 cfg: &HubConfig,
13423 directory: &TrustDirectory,
13424 requested: &str,
13425 resolved_brain: &str,
13426) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
13427 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
13428 if requested == resolved_brain {
13429 return Ok((canonical, None));
13430 }
13431
13432 let mut alias = load_alias_in(cfg, directory, requested)?;
13433 if let Some(binding) = &alias {
13434 if binding.brain != resolved_brain {
13435 return Err(LinkError::AliasRebindRequired {
13436 alias: requested.to_string(),
13437 from: binding.brain.clone(),
13438 to: resolved_brain.to_string(),
13439 });
13440 }
13441 return Ok((canonical, alias));
13442 }
13443
13444 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
13448 if legacy.brain != resolved_brain {
13449 return Err(invalid_feed(
13450 "legacy alias checkpoint names a different canonical brain",
13451 ));
13452 }
13453 if let Some(existing) = &canonical {
13454 if existing.brain != legacy.brain
13455 || existing.anchor != legacy.anchor
13456 || existing.current != legacy.current
13457 || existing.head_seq != legacy.head_seq
13458 || existing.feed_hash != legacy.feed_hash
13459 || existing.rotations != legacy.rotations
13460 {
13461 return Err(invalid_feed(
13462 "legacy alias checkpoint conflicts with the canonical checkpoint",
13463 ));
13464 }
13465 } else {
13466 let mut promoted = legacy.clone();
13467 promoted.requested = resolved_brain.to_string();
13468 promoted.home = None;
13469 save_trust_in(cfg, directory, &promoted)?;
13470 canonical = Some(promoted);
13471 }
13472 alias = Some(AliasBinding {
13473 v: 1,
13474 origin: normalized_origin(&cfg.hub)?,
13475 requested: requested.to_string(),
13476 brain: resolved_brain.to_string(),
13477 home: legacy.home,
13478 });
13479 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
13480 }
13481 Ok((canonical, alias))
13482}
13483
13484pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
13489 require_hardened_filesystem("verified alias rebind")?;
13490 require_safe_ref(alias)?;
13491 require_safe_ref(from)?;
13492 require_safe_ref(to)?;
13493 if crate::ulid::is_ulid(alias)
13494 || !crate::ulid::is_ulid(from)
13495 || !crate::ulid::is_ulid(to)
13496 || from == to
13497 {
13498 return Err(LinkError::InvalidPack {
13499 message:
13500 "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
13501 .to_string(),
13502 });
13503 }
13504
13505 let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
13506 message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
13507 })?;
13508 accept_v2_head(cfg, &verified)?;
13509
13510 let alias_response = ensure_ok(
13511 request(
13512 cfg,
13513 "GET",
13514 &format!("/api/hub/brains/{alias}/v2/head"),
13515 None,
13516 Auth::Required,
13517 )?,
13518 "resolve alias for explicit rebind",
13519 )?;
13520 let resolved: V2HeadResponse = serde_json::from_value(alias_response)
13521 .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
13522 if resolved.v != 2 || resolved.brain_id != to {
13523 return Err(LinkError::RemoteAdvancedDuringSync);
13524 }
13525
13526 let directory = open_trust_dir(cfg)?;
13527 let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
13528 let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
13529 message: "the requested alias has no existing local binding to replace".to_string(),
13530 })?;
13531 if binding.brain != from {
13532 return Err(LinkError::AliasRebindRequired {
13533 alias: alias.to_string(),
13534 from: binding.brain,
13535 to: to.to_string(),
13536 });
13537 }
13538 save_alias_in(
13539 cfg,
13540 &directory,
13541 &AliasBinding {
13542 v: 1,
13543 origin: normalized_origin(&cfg.hub)?,
13544 requested: alias.to_string(),
13545 brain: to.to_string(),
13546 home: binding.home,
13547 },
13548 )?;
13549 Ok(json!({
13550 "v": 2,
13551 "alias": alias,
13552 "from": from,
13553 "to": to,
13554 "outcome": "alias_rebound",
13555 }))
13556}
13557
13558fn save_canonical_pin_and_alias(
13559 cfg: &HubConfig,
13560 directory: &TrustDirectory,
13561 requested: &str,
13562 resolved_brain: &str,
13563 mut state: TrustState,
13564 existing_alias: Option<&AliasBinding>,
13565) -> LinkResult<()> {
13566 state.requested = resolved_brain.to_string();
13567 state.brain = resolved_brain.to_string();
13568 state.home = None;
13569 save_trust_in(cfg, directory, &state)?;
13570 if requested != resolved_brain {
13571 save_alias_in(
13572 cfg,
13573 directory,
13574 &AliasBinding {
13575 v: 1,
13576 origin: normalized_origin(&cfg.hub)?,
13577 requested: requested.to_string(),
13578 brain: resolved_brain.to_string(),
13579 home: existing_alias.and_then(|alias| alias.home.clone()),
13580 },
13581 )?;
13582 }
13583 Ok(())
13584}
13585
13586fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
13587 const ED25519_SPKI_PREFIX: &[u8] = &[
13588 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13589 ];
13590 let entry = &item.entry;
13591 let public_der = URL_SAFE_NO_PAD
13592 .decode(&entry.public_key)
13593 .map_err(|_| invalid_feed("public key is not base64url"))?;
13594 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
13595 || !public_der.starts_with(ED25519_SPKI_PREFIX)
13596 {
13597 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
13598 }
13599 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
13600 if entry.brain != format!("ed25519:{fingerprint}") {
13601 return Err(invalid_feed(
13602 "brain fingerprint does not match its public key",
13603 ));
13604 }
13605 let _ = verify_identity_chain(identity, None)?;
13607 let mut chain: Vec<(&str, &str)> = identity
13608 .previous
13609 .iter()
13610 .rev()
13611 .map(|previous| {
13612 (
13613 previous.fingerprint.as_str(),
13614 previous.public_key_spki.as_str(),
13615 )
13616 })
13617 .collect();
13618 chain.push((&identity.fingerprint, &identity.public_key_spki));
13619 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
13620 *known_fingerprint == fingerprint && *spki == entry.public_key
13621 });
13622 let Some(signer_index) = signer_index else {
13623 return Err(invalid_feed(
13624 "entry signer is not this brain's identity (current or rotated-from)",
13625 ));
13626 };
13627 let lower_boundary = if signer_index == 0 {
13628 None
13629 } else {
13630 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
13631 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13632 Some(prior.prior_head_seq)
13633 };
13634 let upper_boundary = if signer_index == identity.rotations.len() {
13635 None
13636 } else {
13637 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
13638 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13639 Some(next.prior_head_seq)
13640 };
13641 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
13642 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
13643 {
13644 return Err(invalid_feed(
13645 "entry signer is outside its authenticated rotation epoch",
13646 ));
13647 }
13648 let unsigned = UnsignedFeedEntry {
13649 v: entry.v,
13650 seq: entry.seq,
13651 ts: &entry.ts,
13652 brain: &entry.brain,
13653 public_key: &entry.public_key,
13654 kind: &entry.kind,
13655 op: &entry.op,
13656 pack_sha256: &entry.pack_sha256,
13657 files: &entry.files,
13658 removed: &entry.removed,
13659 prev_entry_hash: &entry.prev_entry_hash,
13660 };
13661 let message =
13662 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
13663 let signature = URL_SAFE_NO_PAD
13664 .decode(&entry.sig)
13665 .map_err(|_| invalid_feed("signature is not base64url"))?;
13666 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
13667 .verify(&message, &signature)
13668 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
13669
13670 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
13671 exact.push(b'\n');
13672 let actual_hash = format!("{:x}", Sha256::digest(&exact));
13673 if actual_hash != item.hash {
13674 return Err(invalid_feed("entry SHA-256 does not match"));
13675 }
13676 Ok(())
13677}
13678
13679#[derive(Serialize)]
13685struct UnsignedRotation<'a> {
13686 v: u8,
13687 op: &'a str,
13688 brain: &'a str,
13689 public_key: &'a str,
13690 new_brain: &'a str,
13691 new_public_key: &'a str,
13692 prior_head_seq: u64,
13693 prior_feed_hash: Option<&'a str>,
13694 ts: String,
13695}
13696
13697#[derive(Debug, Deserialize, Serialize)]
13702#[serde(deny_unknown_fields)]
13703struct RotationJournal {
13704 v: u8,
13705 origin: String,
13706 brain: String,
13707 old_brain: String,
13708 new_brain: String,
13709 prior_head_seq: u64,
13710 prior_feed_hash: Option<String>,
13711 statement: String,
13712}
13713
13714fn rotation_journal_path(key_path: &Path) -> PathBuf {
13715 let mut path = key_path.as_os_str().to_os_string();
13716 path.push(".rotation.json");
13717 PathBuf::from(path)
13718}
13719
13720fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
13721 #[cfg(unix)]
13722 let file = {
13723 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13724 use std::os::unix::ffi::OsStrExt as _;
13725 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13726 .map_err(|error| {
13727 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
13728 })?;
13729 let leaf_name = path
13730 .file_name()
13731 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
13732 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
13733 let fd = unsafe {
13734 libc::openat(
13735 parent.as_raw_fd(),
13736 leaf.as_ptr(),
13737 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13738 )
13739 };
13740 if fd < 0 {
13741 return Err(bad_agent_key(
13742 "the rotation journal must be an existing regular file without symlink ancestors",
13743 ));
13744 }
13745 unsafe { std::fs::File::from_raw_fd(fd) }
13746 };
13747 #[cfg(not(unix))]
13748 let file = std::fs::File::open(path)
13749 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
13750 let metadata = file
13751 .metadata()
13752 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
13753 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
13754 return Err(bad_agent_key(
13755 "the rotation journal must be a bounded regular file",
13756 ));
13757 }
13758 #[cfg(unix)]
13759 {
13760 use std::os::unix::fs::PermissionsExt as _;
13761 if metadata.permissions().mode() & 0o077 != 0 {
13762 return Err(bad_agent_key(
13763 "the rotation journal is accessible to group/other; set mode 0600",
13764 ));
13765 }
13766 }
13767 serde_json::from_reader(file)
13768 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
13769}
13770
13771fn remove_rotation_journal(path: &Path) {
13772 #[cfg(unix)]
13773 {
13774 use std::os::fd::AsRawFd as _;
13775 use std::os::unix::ffi::OsStrExt as _;
13776 let Ok(parent) =
13777 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13778 else {
13779 return;
13780 };
13781 let Some(leaf_name) = path.file_name() else {
13782 return;
13783 };
13784 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
13785 return;
13786 };
13787 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
13788 let _ = parent.sync_all();
13789 }
13790 }
13791 #[cfg(not(unix))]
13792 {
13793 let _ = std::fs::remove_file(path);
13794 }
13795}
13796
13797fn validate_rotation_journal(
13798 journal: &RotationJournal,
13799 cfg: &HubConfig,
13800 canonical_brain: &str,
13801 old_key: &AgentSigningKey,
13802 new_key: &AgentSigningKey,
13803 head: &Head,
13804) -> LinkResult<()> {
13805 if journal.v != 1
13806 || journal.origin != normalized_origin(&cfg.hub)?
13807 || journal.brain != canonical_brain
13808 || journal.old_brain != old_key.multikey
13809 || journal.new_brain != new_key.multikey
13810 || journal.prior_head_seq != head.seq
13811 || journal.prior_feed_hash != head.feed_hash
13812 {
13813 return Err(invalid_feed(
13814 "rotation journal does not match the verified key and feed boundary",
13815 ));
13816 }
13817 let statement: RotationStatement = serde_json::from_str(&journal.statement)
13818 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
13819 if statement.prior_head_seq != journal.prior_head_seq
13820 || statement.prior_feed_hash != journal.prior_feed_hash
13821 || statement.brain != old_key.multikey
13822 || statement.public_key != old_key.public_key_spki
13823 || statement.new_brain != new_key.multikey
13824 || statement.new_public_key != new_key.public_key_spki
13825 {
13826 return Err(invalid_feed(
13827 "rotation journal statement does not match its durable intent",
13828 ));
13829 }
13830 let identity = FeedIdentity {
13831 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13832 public_key_spki: new_key.public_key_spki.clone(),
13833 previous: vec![PreviousIdentity {
13834 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
13835 public_key_spki: old_key.public_key_spki.clone(),
13836 }],
13837 rotations: vec![journal.statement.clone()],
13838 };
13839 verify_identity_chain(&identity, None)?;
13840 Ok(())
13841}
13842
13843#[derive(Debug, Serialize)]
13845pub struct RotationReport {
13846 pub brain: String,
13848 pub multikey: String,
13850 #[serde(rename = "keyFile")]
13852 pub key_file: String,
13853 pub previous: Vec<String>,
13855}
13856
13857pub fn rotate_brain_key(
13863 cfg: &HubConfig,
13864 brain: &str,
13865 old_key: &AgentSigningKey,
13866 out: &Path,
13867) -> LinkResult<RotationReport> {
13868 require_hardened_filesystem("key rotation")?;
13869 require_safe_ref(brain)?;
13870 let new_key = if out.exists() {
13874 load_signing_key(out)?
13875 } else {
13876 let rng = ring::rand::SystemRandom::new();
13877 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
13878 .map_err(|_| bad_agent_key("key generation failed"))?;
13879 let pair = agent_keypair(pkcs8.as_ref())?;
13880 let (public_key_spki, multikey) = public_identity_for(&pair);
13881 write_secret_new(
13882 out,
13883 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
13884 )?;
13885 AgentSigningKey {
13886 pkcs8: pkcs8.as_ref().to_vec(),
13887 multikey,
13888 public_key_spki,
13889 }
13890 };
13891 let new_spki = new_key.public_key_spki.clone();
13892 let new_multikey = new_key.multikey.clone();
13893 let journal_path = rotation_journal_path(out);
13894 let before_v2 = v2_verified_head(cfg, brain)?;
13895 let (canonical_brain, served_identity, observed_head, v2_profile) =
13896 if let Some(head) = before_v2 {
13897 let observed = Head {
13898 brain: head.brain_id.clone(),
13899 seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13900 updated_at: head
13901 .pointer
13902 .as_ref()
13903 .map(|pointer| pointer.signed_at.clone()),
13904 feed_hash: head
13905 .pointer
13906 .as_ref()
13907 .map(|pointer| pointer.feed_hash.clone()),
13908 verified: true,
13909 };
13910 let identity = v2_identity(&head.identity);
13911 let canonical = head.brain_id.clone();
13912 accept_v2_head(cfg, &head)?;
13913 (canonical, identity, observed, true)
13914 } else {
13915 let remote = verified_remote_head(cfg, brain, false)?;
13916 let identity = remote
13917 .identity
13918 .clone()
13919 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
13920 (remote.head.brain.clone(), identity, remote.head, false)
13921 };
13922 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
13923 let already_rotated = served_multikey == new_multikey;
13924 if already_rotated && !journal_path.exists() {
13929 remove_rotation_journal(&journal_path);
13930 return Ok(RotationReport {
13931 brain: brain.to_string(),
13932 multikey: new_multikey,
13933 key_file: out.display().to_string(),
13934 previous: served_identity
13935 .previous
13936 .iter()
13937 .map(|identity| format!("ed25519:{}", identity.fingerprint))
13938 .collect(),
13939 });
13940 }
13941 if !already_rotated && served_multikey != old_key.multikey {
13942 return Err(invalid_feed(
13943 "the supplied old key is not the brain's verified current identity",
13944 ));
13945 }
13946
13947 let journal = if journal_path.exists() {
13948 read_rotation_journal(&journal_path)?
13949 } else {
13950 let ts = crate::now()
13951 .with_timezone(&chrono::Utc)
13952 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
13953 .to_string();
13954 let unsigned = serde_json::to_string(&UnsignedRotation {
13955 v: 1,
13956 op: "rotate",
13957 brain: &old_key.multikey,
13958 public_key: &old_key.public_key_spki,
13959 new_brain: &new_multikey,
13960 new_public_key: &new_spki,
13961 prior_head_seq: observed_head.seq,
13962 prior_feed_hash: observed_head.feed_hash.as_deref(),
13963 ts,
13964 })
13965 .expect("serialize rotation");
13966 let old_pair = agent_keypair(&old_key.pkcs8)?;
13967 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
13968 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
13969 let journal = RotationJournal {
13970 v: 1,
13971 origin: normalized_origin(&cfg.hub)?,
13972 brain: canonical_brain.clone(),
13973 old_brain: old_key.multikey.clone(),
13974 new_brain: new_multikey.clone(),
13975 prior_head_seq: observed_head.seq,
13976 prior_feed_hash: observed_head.feed_hash.clone(),
13977 statement,
13978 };
13979 let mut exact = serde_json::to_vec(&journal)
13980 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
13981 exact.push(b'\n');
13982 if write_secret_new(&journal_path, &exact).is_err() {
13983 read_rotation_journal(&journal_path)?
13986 } else {
13987 journal
13988 }
13989 };
13990 validate_rotation_journal(
13991 &journal,
13992 cfg,
13993 &canonical_brain,
13994 old_key,
13995 &new_key,
13996 &observed_head,
13997 )?;
13998
13999 let body = json!({ "statement": journal.statement });
14000 let path = format!("/api/hub/brains/{brain}/rotate");
14001 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
14002 let attempted_failure = match attempted {
14003 Ok(response) if (200..300).contains(&response.status) => None,
14004 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
14005 Err(error) => Some(error),
14006 };
14007
14008 let identity = if v2_profile {
14012 match v2_verified_head(cfg, brain) {
14013 Ok(Some(after)) => {
14014 let identity = v2_identity(&after.identity);
14015 accept_v2_head(cfg, &after)?;
14016 identity
14017 }
14018 Ok(None) => {
14019 return Err(attempted_failure.unwrap_or_else(|| {
14020 invalid_feed("rotated v2 brain no longer serves a v2 head")
14021 }));
14022 }
14023 Err(error) => return Err(attempted_failure.unwrap_or(error)),
14024 }
14025 } else {
14026 match verified_remote_head(cfg, brain, false) {
14027 Ok(after) => after
14028 .identity
14029 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
14030 Err(error) => return Err(attempted_failure.unwrap_or(error)),
14031 }
14032 };
14033 if format!("ed25519:{}", identity.fingerprint) != new_multikey
14034 || identity.public_key_spki != new_spki
14035 {
14036 return Err(attempted_failure.unwrap_or_else(|| {
14037 invalid_feed("hub acknowledged rotation without committing the verified new identity")
14038 }));
14039 }
14040 if v2_profile {
14041 if let Some(error) = attempted_failure {
14042 return Err(error);
14047 }
14048 }
14049 let previous = identity
14050 .previous
14051 .iter()
14052 .map(|prior| format!("ed25519:{}", prior.fingerprint))
14053 .collect();
14054 remove_rotation_journal(&journal_path);
14055
14056 Ok(RotationReport {
14057 brain: brain.to_string(),
14058 multikey: new_multikey,
14059 key_file: out.display().to_string(),
14060 previous,
14061 })
14062}
14063
14064#[derive(Debug, Serialize)]
14070pub struct MirrorReport {
14071 pub brain: String,
14073 #[serde(rename = "headSeq")]
14075 pub head_seq: u64,
14076 #[serde(rename = "feedHash")]
14078 pub feed_hash: Option<String>,
14079 pub entries: u64,
14081 pub pinned: String,
14083 pub files: usize,
14085}
14086
14087pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
14089
14090#[derive(Debug)]
14092pub struct VerifiedMirrorMaterial {
14093 pub brain: String,
14094 pub head_seq: u64,
14095 pub feed_hash: Option<String>,
14096 pub identity: serde_json::Value,
14097 pub entries: Vec<(u64, String, String)>,
14099 pub pack_sha256: Option<String>,
14100}
14101
14102#[derive(Deserialize)]
14103#[serde(deny_unknown_fields)]
14104struct StoredMirrorHead {
14105 brain: String,
14106 #[serde(rename = "headSeq")]
14107 head_seq: u64,
14108 #[serde(rename = "feedHash")]
14109 feed_hash: Option<String>,
14110}
14111
14112pub fn verify_mirror_material(
14115 head_bytes: &[u8],
14116 identity_bytes: &[u8],
14117 feed_bytes: &[Vec<u8>],
14118 snapshot_pack: Option<&[u8]>,
14119 expected_anchor: &str,
14120) -> LinkResult<VerifiedMirrorMaterial> {
14121 let snapshot_hash = snapshot_pack
14122 .filter(|pack| !pack.is_empty())
14123 .map(content_sha256);
14124 verify_mirror_material_with_pack_hash(
14125 head_bytes,
14126 identity_bytes,
14127 feed_bytes,
14128 snapshot_hash.as_deref(),
14129 expected_anchor,
14130 )
14131}
14132
14133pub fn verify_mirror_material_with_pack_hash(
14137 head_bytes: &[u8],
14138 identity_bytes: &[u8],
14139 feed_bytes: &[Vec<u8>],
14140 snapshot_pack_sha256: Option<&str>,
14141 expected_anchor: &str,
14142) -> LinkResult<VerifiedMirrorMaterial> {
14143 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
14144 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
14145 require_safe_ref(&head.brain)?;
14146 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
14147 return Err(invalid_feed(
14148 "stored mirror feed count does not match its bounded head sequence",
14149 ));
14150 }
14151 let aggregate = feed_bytes
14152 .iter()
14153 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
14154 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
14155 if aggregate > MAX_FEED_REPLAY_BYTES {
14156 return Err(invalid_feed(
14157 "stored mirror feed metadata exceeds the aggregate limit",
14158 ));
14159 }
14160 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
14161 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
14162 let anchor = verify_identity_chain(&identity, None)?;
14163 if anchor != expected_anchor {
14164 return Err(invalid_feed(
14165 "stored mirror identity does not descend from the explicitly trusted anchor",
14166 ));
14167 }
14168
14169 let mut entries = Vec::with_capacity(feed_bytes.len());
14170 let mut items = Vec::with_capacity(feed_bytes.len());
14171 let mut previous_hash = None;
14172 let mut pack_sha256 = None;
14173 for (index, bytes) in feed_bytes.iter().enumerate() {
14174 let exact = bytes
14175 .strip_suffix(b"\n")
14176 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
14177 if exact.ends_with(b"\n") {
14178 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
14179 }
14180 let entry: FeedEntry = serde_json::from_slice(exact)
14181 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
14182 let expected_seq = index as u64 + 1;
14183 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
14184 return Err(invalid_feed(
14185 "stored mirror feed is not contiguous and hash-chained",
14186 ));
14187 }
14188 let canonical = serde_json::to_vec(&entry)
14189 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
14190 if canonical != exact {
14191 return Err(invalid_feed(
14192 "stored feed entry is not in normative serialization",
14193 ));
14194 }
14195 let hash = content_sha256(bytes);
14196 let item = FeedItem {
14197 hash: hash.clone(),
14198 entry,
14199 };
14200 verify_feed_item(&item, &identity)?;
14201 previous_hash = Some(hash.clone());
14202 if expected_seq == head.head_seq {
14203 pack_sha256 = Some(item.entry.pack_sha256.clone());
14204 }
14205 entries.push((
14206 expected_seq,
14207 std::str::from_utf8(exact)
14208 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
14209 .to_string(),
14210 hash,
14211 ));
14212 items.push(item);
14213 }
14214 if previous_hash != head.feed_hash {
14215 return Err(invalid_feed(
14216 "stored mirror feed does not converge on its advertised head",
14217 ));
14218 }
14219 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
14220 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
14221 (0, None, None) => {}
14222 (_, Some(actual), Some(expected)) if actual == expected => {}
14223 _ => {
14224 return Err(LinkError::InvalidPack {
14225 message: "stored snapshot pack does not match the signed head digest".to_string(),
14226 });
14227 }
14228 }
14229 let identity_value = serde_json::to_value(&identity)
14230 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
14231 Ok(VerifiedMirrorMaterial {
14232 brain: head.brain,
14233 head_seq: head.head_seq,
14234 feed_hash: head.feed_hash,
14235 identity: identity_value,
14236 entries,
14237 pack_sha256,
14238 })
14239}
14240
14241pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
14244 format!(
14245 "{:x}",
14246 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
14247 )
14248}
14249
14250pub fn content_sha256(bytes: &[u8]) -> String {
14253 format!("{:x}", Sha256::digest(bytes))
14254}
14255
14256pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
14258 let mut digest = Sha256::new();
14259 let mut buffer = [0u8; 64 * 1024];
14260 loop {
14261 let read = reader.read(&mut buffer)?;
14262 if read == 0 {
14263 break;
14264 }
14265 digest.update(&buffer[..read]);
14266 }
14267 Ok(format!("{:x}", digest.finalize()))
14268}
14269
14270#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
14278pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
14279 require_hardened_filesystem("mirror")?;
14280 require_safe_ref(brain)?;
14281 #[cfg(windows)]
14282 {
14283 let _ = (cfg, dest);
14284 return Err(LinkError::UnsupportedPlatform {
14285 operation: "atomic whole-mirror replacement on Windows",
14286 });
14287 }
14288 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
14289 let name = dest
14290 .file_name()
14291 .and_then(|name| name.to_str())
14292 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
14293 .ok_or_else(|| LinkError::UnsafePath {
14294 path: dest.display().to_string(),
14295 })?;
14296 #[cfg(unix)]
14297 let parent_dir = open_or_create_dir_nofollow(parent)?;
14298 #[cfg(unix)]
14299 use std::os::fd::AsRawFd as _;
14300 #[cfg(unix)]
14301 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
14302 #[cfg(unix)]
14303 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
14304 None => false,
14305 Some(true) => true,
14306 Some(false) => {
14307 return Err(LinkError::UnsafePath {
14308 path: dest.display().to_string(),
14309 });
14310 }
14311 };
14312
14313 #[cfg(unix)]
14316 let legacy_backup_name = c_name(
14317 format!(".{name}.dbmd-backup").as_bytes(),
14318 &dest.display().to_string(),
14319 )?;
14320 #[cfg(unix)]
14321 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
14322 return Err(LinkError::UnsafePath {
14323 path: parent
14324 .join(format!(".{name}.dbmd-backup"))
14325 .display()
14326 .to_string(),
14327 });
14328 }
14329
14330 let nonce = std::time::SystemTime::now()
14331 .duration_since(std::time::UNIX_EPOCH)
14332 .unwrap_or_default()
14333 .as_nanos();
14334 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
14335 #[cfg(unix)]
14336 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
14337 #[cfg(unix)]
14338 let stage_dir = create_dir_exclusive_at(
14339 parent_dir.as_raw_fd(),
14340 &stage_name,
14341 &dest.display().to_string(),
14342 )?;
14343
14344 let assembled = (|| -> LinkResult<MirrorReport> {
14345 let remote = verified_remote_head(cfg, brain, true)?;
14346 let brain_id = remote.head.brain.clone();
14347 let identity = remote
14348 .identity
14349 .as_ref()
14350 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
14351 let anchor = remote
14352 .anchor
14353 .clone()
14354 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
14355 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
14356 let snapshot_entries = parse_store_pack(pack.clone())?;
14357 let snapshot_count = snapshot_entries.len();
14358 let mut staged_entries = snapshot_entries;
14359 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
14360 for item in &remote.entries {
14361 let mut exact = serde_json::to_vec(&item.entry)
14362 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
14363 exact.push(b'\n');
14364 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
14365 return Err(invalid_feed(
14366 "serialized mirror entry differs from its verified hash",
14367 ));
14368 }
14369 staged_entries.push((
14370 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
14371 exact,
14372 ));
14373 }
14374 let mut identity_bytes = serde_json::to_vec(identity)
14375 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
14376 identity_bytes.push(b'\n');
14377 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
14378 let mut head_bytes = serde_json::to_vec(&json!({
14379 "brain": brain_id,
14380 "headSeq": remote.head.seq,
14381 "feedHash": remote.head.feed_hash,
14382 }))
14383 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
14384 head_bytes.push(b'\n');
14385 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
14386 staged_entries.push((
14387 CONFIG_REL_PATH.to_string(),
14388 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
14389 ));
14390 #[cfg(unix)]
14391 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
14392
14393 Ok(MirrorReport {
14394 brain: brain_id,
14395 head_seq: remote.head.seq,
14396 feed_hash: remote.head.feed_hash,
14397 entries: remote.entries.len() as u64,
14398 pinned: anchor,
14399 files: snapshot_count,
14400 })
14401 })();
14402
14403 let report = match assembled {
14404 Ok(report) => report,
14405 Err(error) => {
14406 #[cfg(unix)]
14407 let _ = remove_tree_at(
14408 parent_dir.as_raw_fd(),
14409 &stage_name,
14410 &dest.display().to_string(),
14411 );
14412 return Err(error);
14413 }
14414 };
14415
14416 #[cfg(unix)]
14417 if let Err(error) =
14418 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
14419 {
14420 let _ = remove_tree_at(
14421 parent_dir.as_raw_fd(),
14422 &stage_name,
14423 &dest.display().to_string(),
14424 );
14425 return Err(error);
14426 }
14427 #[cfg(unix)]
14430 if dest_exists {
14431 remove_tree_at(
14432 parent_dir.as_raw_fd(),
14433 &stage_name,
14434 &dest.display().to_string(),
14435 )?;
14436 }
14437 #[cfg(unix)]
14438 parent_dir.sync_all()?;
14439 Ok(report)
14440}
14441
14442fn verified_remote_head(
14443 cfg: &HubConfig,
14444 brain: &str,
14445 require_full_chain: bool,
14446) -> LinkResult<VerifiedRemote> {
14447 require_hardened_filesystem("verified link.md state")?;
14448 require_safe_ref(brain)?;
14449 let trust_directory = open_trust_dir(cfg)?;
14453 let path = format!("/api/hub/brains/{brain}");
14454 let body = ensure_ok(
14455 request(cfg, "GET", &path, None, Auth::Required)?,
14456 "subscribe",
14457 )?;
14458 let resolved_brain = body
14459 .get("id")
14460 .and_then(Value::as_str)
14461 .filter(|id| crate::ulid::is_ulid(id))
14462 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
14463 .to_string();
14464 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
14465 return Err(invalid_feed(
14466 "brain card id differs from the explicitly requested brain id",
14467 ));
14468 }
14469 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
14474 let (pinned, alias_binding) =
14475 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
14476 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
14477 let advertised_hash = body
14478 .get("feedHash")
14479 .and_then(Value::as_str)
14480 .map(str::to_string);
14481 let updated_at = body
14482 .get("updatedAt")
14483 .and_then(Value::as_str)
14484 .map(str::to_string);
14485 if let Some(pin) = &pinned {
14486 if seq < pin.head_seq {
14487 return Err(invalid_feed(format!(
14488 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
14489 pin.head_seq
14490 )));
14491 }
14492 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
14493 return Err(invalid_feed(
14494 "feed equivocation: the checkpoint sequence now has a different hash",
14495 ));
14496 }
14497 }
14498 if seq == 0 {
14499 if advertised_hash.is_some() {
14500 return Err(invalid_feed("an empty feed advertised a head hash"));
14501 }
14502 let identity: FeedIdentity = serde_json::from_value(
14503 body.get("identity")
14504 .cloned()
14505 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
14506 )
14507 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
14508 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
14509 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
14514 save_canonical_pin_and_alias(
14515 cfg,
14516 &trust_directory,
14517 brain,
14518 &resolved_brain,
14519 TrustState {
14520 v: 2,
14521 origin: normalized_origin(&cfg.hub)?,
14522 requested: resolved_brain.clone(),
14523 brain: resolved_brain.clone(),
14524 home: None,
14525 anchor: anchor.clone(),
14526 current: format!("ed25519:{}", identity.fingerprint),
14527 head_seq: 0,
14528 feed_hash: None,
14529 rotations: identity.rotations.clone(),
14530 hub_signer: None,
14531 protocol_profile: None,
14532 },
14533 alias_binding.as_ref(),
14534 )?;
14535 return Ok(VerifiedRemote {
14536 head: Head {
14537 brain: resolved_brain,
14538 seq,
14539 updated_at,
14540 feed_hash: None,
14541 verified: true,
14542 },
14543 identity: Some(identity),
14544 head_entry: None,
14545 entries: Vec::new(),
14546 anchor: Some(anchor),
14547 });
14548 }
14549 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
14550 return Err(invalid_feed(
14551 "non-empty feed did not advertise a valid SHA-256 head",
14552 ));
14553 }
14554
14555 let replay_head_only = !require_full_chain
14559 && pinned
14560 .as_ref()
14561 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
14562 let mut after = if replay_head_only {
14563 seq - 1
14564 } else if require_full_chain || pinned.is_none() {
14565 0
14566 } else {
14567 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
14568 };
14569 let mut expected_seq = after + 1;
14570 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
14571 None
14572 } else {
14573 pinned
14574 .as_ref()
14575 .and_then(|checkpoint| checkpoint.feed_hash.clone())
14576 };
14577 let mut identity: Option<FeedIdentity> = None;
14578 let mut anchor: Option<String> = None;
14579 let mut head_entry: Option<FeedItem> = None;
14580 let mut all_entries = Vec::new();
14581 let mut observed_entries = Vec::new();
14582 let replay_count = seq
14583 .checked_sub(after)
14584 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
14585 if replay_count > MAX_FEED_REPLAY_ENTRIES {
14586 return Err(invalid_feed(format!(
14587 "feed replay requires {replay_count} entries, over the client cap"
14588 )));
14589 }
14590 let mut replay_bytes = 0u64;
14591
14592 loop {
14593 let feed_bytes = ensure_raw_ok(
14594 request_raw(
14595 cfg,
14596 "GET",
14597 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
14598 None,
14599 Auth::Required,
14600 MAX_FEED_RESPONSE_BYTES,
14601 )?,
14602 "subscribe feed",
14603 )?;
14604 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
14605 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
14606 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
14607 return Err(invalid_feed("brain card and feed head disagree"));
14608 }
14609 if feed.entries.len() > FEED_PAGE_LIMIT {
14610 return Err(invalid_feed("feed page exceeds the requested entry limit"));
14611 }
14612 if feed.scope_limited {
14613 if require_full_chain {
14614 return Err(invalid_feed(
14615 "path-scoped grants cannot verify a full snapshot chain",
14616 ));
14617 }
14618 return Ok(VerifiedRemote {
14619 head: Head {
14620 brain: resolved_brain,
14621 seq,
14622 updated_at,
14623 feed_hash: advertised_hash,
14624 verified: false,
14625 },
14626 identity: None,
14627 head_entry: None,
14628 entries: Vec::new(),
14629 anchor: None,
14630 });
14631 }
14632 let page_identity = feed
14633 .identity
14634 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14635 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
14636 if identity
14637 .as_ref()
14638 .is_some_and(|existing| existing != &page_identity)
14639 {
14640 return Err(invalid_feed("identity changed while reading the feed"));
14641 }
14642 if anchor
14643 .as_ref()
14644 .is_some_and(|existing| existing != &page_anchor)
14645 {
14646 return Err(invalid_feed(
14647 "identity anchor changed while reading the feed",
14648 ));
14649 }
14650 identity = Some(page_identity.clone());
14651 if anchor.is_none() {
14652 anchor = Some(page_anchor);
14653 }
14654 if feed.entries.is_empty() {
14655 return Err(invalid_feed("feed page was empty before the signed head"));
14656 }
14657
14658 for item in feed.entries {
14659 if item.entry.seq != expected_seq {
14660 return Err(invalid_feed(format!(
14661 "expected entry {expected_seq}, feed served {}",
14662 item.entry.seq
14663 )));
14664 }
14665 if item.entry.seq > seq {
14666 return Err(invalid_feed("feed advanced past the card snapshot"));
14667 }
14668 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
14669 return Err(invalid_feed(format!(
14670 "entry {} does not chain to the local checkpoint",
14671 item.entry.seq
14672 )));
14673 }
14674 verify_feed_item(&item, &page_identity)?;
14675 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
14676 replay_bytes = replay_bytes.saturating_add(
14677 serde_json::to_vec(&item)
14678 .map_err(|_| invalid_feed("could not size feed entry"))?
14679 .len() as u64,
14680 );
14681 if replay_bytes > MAX_FEED_REPLAY_BYTES {
14682 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
14683 }
14684 previous_hash = Some(item.hash.clone());
14685 after = item.entry.seq;
14686 expected_seq = expected_seq
14687 .checked_add(1)
14688 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
14689 if require_full_chain {
14690 all_entries.push(item.clone());
14691 }
14692 observed_entries.push(item.clone());
14693 head_entry = Some(item);
14694 }
14695 if after == seq {
14696 break;
14697 }
14698 }
14699
14700 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
14701 return Err(invalid_feed(
14702 "verified chain does not converge on the advertised head",
14703 ));
14704 }
14705 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14706 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
14707 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
14708 save_canonical_pin_and_alias(
14709 cfg,
14710 &trust_directory,
14711 brain,
14712 &resolved_brain,
14713 TrustState {
14714 v: 2,
14715 origin: normalized_origin(&cfg.hub)?,
14716 requested: resolved_brain.clone(),
14717 brain: resolved_brain.clone(),
14718 home: None,
14719 anchor: anchor.clone(),
14720 current: format!("ed25519:{}", identity.fingerprint),
14721 head_seq: seq,
14722 feed_hash: advertised_hash.clone(),
14723 rotations: identity.rotations.clone(),
14724 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
14725 protocol_profile: pinned
14726 .as_ref()
14727 .and_then(|state| state.protocol_profile.clone()),
14728 },
14729 alias_binding.as_ref(),
14730 )?;
14731 Ok(VerifiedRemote {
14732 head: Head {
14733 brain: resolved_brain,
14734 seq,
14735 updated_at,
14736 feed_hash: advertised_hash,
14737 verified: true,
14738 },
14739 identity: Some(identity),
14740 head_entry,
14741 entries: all_entries,
14742 anchor: Some(anchor),
14743 })
14744}
14745
14746pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
14751 if let Some(verified) = v2_verified_head(cfg, brain)? {
14752 let observation = Head {
14753 brain: verified.brain_id.clone(),
14754 seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14755 updated_at: verified
14756 .pointer
14757 .as_ref()
14758 .map(|pointer| pointer.signed_at.clone()),
14759 feed_hash: verified
14760 .pointer
14761 .as_ref()
14762 .map(|pointer| pointer.feed_hash.clone()),
14763 verified: true,
14764 };
14765 accept_v2_head(cfg, &verified)?;
14766 return Ok(observation);
14767 }
14768 Ok(verified_remote_head(cfg, brain, false)?.head)
14769}
14770
14771#[cfg(test)]
14772mod tests {
14773 use super::*;
14774
14775 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
14776
14777 fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
14778 json!({
14779 "sha256": "a".repeat(64),
14780 "bytes": 10,
14781 "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
14782 })
14783 }
14784
14785 #[test]
14786 fn upload_reservations_batch_by_count_and_by_size() {
14787 let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
14791 let batches = batch_upload_declarations(declarations.clone());
14792
14793 assert!(batches.len() > 1, "5,000 blobs must not ride one request");
14794 for batch in &batches {
14795 assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
14796 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14797 .expect("batch serializes")
14798 .len();
14799 assert!(
14800 bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
14801 "batch body {bytes} exceeds the reservation budget"
14802 );
14803 }
14804 let flattened: Vec<Value> = batches.into_iter().flatten().collect();
14805 assert_eq!(
14806 flattened, declarations,
14807 "batching must preserve the set and order"
14808 );
14809 }
14810
14811 #[test]
14812 fn only_load_shaped_hub_answers_are_worth_asking_again() {
14813 for status in [408, 429, 500, 502, 503, 504] {
14818 assert!(is_retryable_hub_status(status), "{status} is load-shaped");
14819 }
14820 for status in [400, 401, 403, 404, 409, 413, 422] {
14821 assert!(
14822 !is_retryable_hub_status(status),
14823 "{status} states something about the request"
14824 );
14825 }
14826 let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
14828 assert!(total >= 60_000, "backoff totals only {total}ms");
14829 }
14830
14831 #[test]
14832 fn a_batch_shares_a_connection_only_within_one_authority() {
14833 let cfg = HubConfig {
14838 hub: "https://www.sevrahq.com".to_string(),
14839 key: Some("k".to_string()),
14840 agent_key: None,
14841 brain_key: None,
14842 state_dir: PathBuf::from("."),
14843 store_selected: false,
14844 };
14845 assert!(shared_staging_agent(&cfg, &[]).is_none());
14846 assert!(
14847 shared_staging_agent(
14848 &cfg,
14849 &[
14850 "https://one.example.com/a?sig=1",
14851 "https://two.example.com/b?sig=2",
14852 ]
14853 )
14854 .is_none(),
14855 "two authorities must not share a pinned pool"
14856 );
14857 assert!(
14858 shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
14859 "an unsafe object-store URL must not produce an agent"
14860 );
14861 assert!(
14862 shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
14863 "credentials in the URL must not produce an agent"
14864 );
14865 }
14866
14867 #[test]
14868 fn a_staged_change_states_only_operations_and_blobs() {
14869 let operations = vec![json!({
14873 "op": "put",
14874 "path": "records/a.md",
14875 "blob": "a".repeat(64),
14876 "bytes": 3,
14877 })];
14878 let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
14879 let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
14880 let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
14881 let keys: Vec<&str> = parsed
14882 .as_object()
14883 .expect("manifest is an object")
14884 .keys()
14885 .map(String::as_str)
14886 .collect();
14887 assert_eq!(keys, ["blobs", "operations"]);
14888 assert_eq!(parsed["operations"], Value::Array(operations));
14889 assert_eq!(parsed["blobs"], blobs);
14890 }
14891
14892 #[test]
14893 fn a_staged_push_signs_the_change_not_the_transport() {
14894 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14899 let staged = json!({
14900 "mutation_id": "dbmd-1",
14901 "rebase": "strict",
14902 "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
14903 });
14904 let view = v2_signed_request_view(&staged, &operations);
14905 assert_eq!(view["operations"], Value::Array(operations.clone()));
14906 assert!(view.get("staged_change").is_none());
14907 assert_eq!(view["mutation_id"], staged["mutation_id"]);
14908
14909 let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
14910 assert_eq!(v2_signed_request_view(&inline, &[]), inline);
14911 }
14912
14913 #[test]
14914 fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
14915 let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
14916 let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
14917 .expect_err("an oversized change must not be staged");
14918 assert!(
14919 matches!(error, LinkError::PushTooLarge { .. }),
14920 "expected a size refusal, got {error:?}"
14921 );
14922 }
14923
14924 #[test]
14925 fn a_push_that_fits_the_request_is_left_inline() {
14926 let cfg = HubConfig {
14930 hub: "http://127.0.0.1:9".to_string(),
14931 key: Some("k".to_string()),
14932 agent_key: None,
14933 brain_key: None,
14934 state_dir: PathBuf::from("."),
14935 store_selected: false,
14936 };
14937 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14938 let mut body = json!({
14939 "mutation_id": "dbmd-1",
14940 "operations": operations,
14941 "blobs": [],
14942 });
14943 stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
14944 assert!(body.get("staged_change").is_none());
14945 assert_eq!(body["operations"], Value::Array(operations));
14946 }
14947
14948 #[test]
14949 fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
14950 let declarations: Vec<Value> = (0..2_000)
14954 .map(|index| {
14955 json!({
14956 "sha256": "a".repeat(64),
14957 "bytes": 10,
14958 "coordinates": (0..24)
14959 .map(|slot| format!(
14960 "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
14961 ))
14962 .collect::<Vec<_>>(),
14963 })
14964 })
14965 .collect();
14966 let batches = batch_upload_declarations(declarations);
14967 assert!(
14968 batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
14969 "wide coordinate sets must bound the batch by size"
14970 );
14971 for batch in &batches {
14972 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14973 .expect("batch serializes")
14974 .len();
14975 assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
14976 }
14977 }
14978
14979 #[test]
14980 fn a_small_push_still_rides_exactly_one_request() {
14981 let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
14982 assert_eq!(batch_upload_declarations(declarations).len(), 1);
14983 assert!(batch_upload_declarations(Vec::new()).is_empty());
14984 }
14985
14986 #[test]
14987 fn exact_source_move_becomes_one_provenance_preserving_rename() {
14988 let hash = "a".repeat(64);
14989 let operations = vec![
14990 json!({
14991 "op": "put",
14992 "path": "sources/curated/item.md",
14993 "expected": { "kind": "absent" },
14994 "blob": hash,
14995 "bytes": 19,
14996 }),
14997 json!({
14998 "op": "delete",
14999 "path": "sources/inbox/item.md",
15000 "expected": { "kind": "blob", "hash": hash },
15001 }),
15002 ];
15003
15004 assert_eq!(
15005 infer_exact_source_promotions(operations),
15006 vec![json!({
15007 "op": "rename",
15008 "from": "sources/inbox/item.md",
15009 "to": "sources/curated/item.md",
15010 "expected_from": { "kind": "blob", "hash": hash },
15011 "expected_to": { "kind": "absent" },
15012 "blob": hash,
15013 "bytes": 19,
15014 })]
15015 );
15016 }
15017
15018 #[test]
15019 fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
15020 let hash = "b".repeat(64);
15021 let operations = vec![
15022 json!({
15023 "op": "delete",
15024 "path": "sources/inbox/a.md",
15025 "expected": { "kind": "blob", "hash": hash },
15026 }),
15027 json!({
15028 "op": "delete",
15029 "path": "sources/inbox/b.md",
15030 "expected": { "kind": "blob", "hash": hash },
15031 }),
15032 json!({
15033 "op": "put",
15034 "path": "sources/curated/item.md",
15035 "expected": { "kind": "absent" },
15036 "blob": hash,
15037 "bytes": 19,
15038 }),
15039 ];
15040
15041 assert_eq!(
15042 infer_exact_source_promotions(operations.clone()),
15043 operations,
15044 "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
15045 );
15046 }
15047
15048 #[test]
15049 fn accepted_source_promotion_advances_the_local_baseline_exactly() {
15050 let hash = "c".repeat(64);
15051 let mut candidate = std::collections::BTreeMap::from([(
15052 "sources/inbox/item.md".to_string(),
15053 V2BaselineFile {
15054 sha256: hash.clone(),
15055 bytes: 19,
15056 proof: None,
15057 },
15058 )]);
15059 let mut candidate_assets = std::collections::BTreeMap::new();
15060 let operations = vec![
15061 json!({
15062 "op": "rename",
15063 "from": "sources/inbox/item.md",
15064 "to": "sources/curated/item.md",
15065 "expected_from": { "kind": "blob", "hash": hash },
15066 "expected_to": { "kind": "absent" },
15067 "blob": hash,
15068 "bytes": 19,
15069 }),
15070 json!({
15071 "op": "put",
15072 "path": "records/rsvps/item.md",
15073 "expected": { "kind": "absent" },
15074 "blob": "d".repeat(64),
15075 "bytes": 23,
15076 }),
15077 ];
15078
15079 assert!(!apply_generated_v2_operations(
15080 &operations,
15081 &std::collections::BTreeMap::new(),
15082 &mut candidate,
15083 &mut candidate_assets,
15084 )
15085 .unwrap());
15086 assert!(!candidate.contains_key("sources/inbox/item.md"));
15087 assert_eq!(
15088 candidate
15089 .get("sources/curated/item.md")
15090 .map(|file| (&file.sha256, file.bytes)),
15091 Some((&hash, 19))
15092 );
15093 assert_eq!(
15094 candidate
15095 .get("records/rsvps/item.md")
15096 .map(|file| (file.sha256.as_str(), file.bytes)),
15097 Some((
15098 "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
15099 23
15100 ))
15101 );
15102 }
15103
15104 fn merge_fixture(
15105 base: Option<&str>,
15106 remote: Option<&str>,
15107 local: Option<&str>,
15108 keep_local: bool,
15109 ) -> V2PulledMerge<String> {
15110 let map = |value: Option<&str>| {
15111 value
15112 .map(|value| [("records/a.md".to_string(), value.to_string())])
15113 .into_iter()
15114 .flatten()
15115 .collect::<std::collections::BTreeMap<_, _>>()
15116 };
15117 merge_v2_pulled_records(
15118 &map(base),
15119 &map(remote),
15120 &map(local),
15121 |value, _| value.clone(),
15122 |value, _| value.clone(),
15123 |_| keep_local,
15124 )
15125 }
15126
15127 #[test]
15128 fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
15129 let path = "records/a.md".to_string();
15130
15131 let local_add = merge_fixture(None, None, Some("local"), false);
15132 assert_eq!(
15133 local_add.records.get(&path).map(String::as_str),
15134 Some("local")
15135 );
15136 assert!(local_add.accept_remote.is_empty());
15137 assert!(local_add.conflicts.is_empty());
15138
15139 let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
15140 assert_eq!(
15141 local_edit.records.get(&path).map(String::as_str),
15142 Some("local")
15143 );
15144 assert!(local_edit.accept_remote.is_empty());
15145 assert!(local_edit.conflicts.is_empty());
15146
15147 let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
15148 assert!(!local_delete.records.contains_key(&path));
15149 assert!(local_delete.accept_remote.is_empty());
15150 assert!(local_delete.conflicts.is_empty());
15151
15152 let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
15153 assert_eq!(
15154 remote_edit.records.get(&path).map(String::as_str),
15155 Some("remote")
15156 );
15157 assert!(remote_edit.accept_remote.contains(&path));
15158 assert!(remote_edit.conflicts.is_empty());
15159
15160 let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
15161 assert!(!remote_delete.records.contains_key(&path));
15162 assert!(remote_delete.accept_remote.contains(&path));
15163 assert!(remote_delete.conflicts.is_empty());
15164
15165 let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
15166 assert_eq!(
15167 same_edit.records.get(&path).map(String::as_str),
15168 Some("same")
15169 );
15170 assert!(same_edit.accept_remote.contains(&path));
15171 assert!(same_edit.conflicts.is_empty());
15172
15173 let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
15174 assert_eq!(conflict.conflicts, vec![path.clone()]);
15175 assert_eq!(
15176 conflict.records.get(&path).map(String::as_str),
15177 Some("local")
15178 );
15179 assert!(conflict.accept_remote.is_empty());
15180
15181 let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
15182 assert_eq!(
15183 kept_home.records.get(&path).map(String::as_str),
15184 Some("local")
15185 );
15186 assert!(kept_home.accept_remote.is_empty());
15187 assert!(kept_home.conflicts.is_empty());
15188 }
15189
15190 #[test]
15191 fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
15192 let path = "sources/report.pdf";
15193 let record = crate::AssetRecord {
15194 path: path.to_string(),
15195 sha256: "a".repeat(64),
15196 bytes: 42,
15197 media_type: "application/pdf".to_string(),
15198 wrappers: vec!["gzip".to_string()],
15199 required: true,
15200 };
15201 let mut remote = V2BaselineAsset {
15202 blob_sha256: record.sha256.clone(),
15203 bytes: record.bytes,
15204 media_type: record.media_type.clone(),
15205 wrappers: record.wrappers.clone(),
15206 required: record.required,
15207 disposition: "withheld".to_string(),
15208 leaf_hash: "b".repeat(64),
15209 };
15210
15211 assert!(v2_asset_resumes_hosting(
15212 Some(&remote),
15213 path,
15214 &record,
15215 "hosted"
15216 ));
15217 assert!(!v2_asset_resumes_hosting(
15218 Some(&remote),
15219 path,
15220 &record,
15221 "withheld"
15222 ));
15223
15224 remote.disposition = "hosted".to_string();
15225 assert!(!v2_asset_resumes_hosting(
15226 Some(&remote),
15227 path,
15228 &record,
15229 "hosted"
15230 ));
15231
15232 remote.disposition = "withheld".to_string();
15233 remote.blob_sha256 = "c".repeat(64);
15234 assert!(!v2_asset_resumes_hosting(
15235 Some(&remote),
15236 path,
15237 &record,
15238 "hosted"
15239 ));
15240 assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
15241 }
15242
15243 #[test]
15244 fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
15245 let path = "records/team/alpha.md".to_string();
15246 let deleted_path = "records/team/deleted.md".to_string();
15247 let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
15248 sha256,
15249 bytes,
15250 file: None,
15251 };
15252 let files = vec![
15253 V2ConflictFile {
15254 path: path.clone(),
15255 base: coordinate(None, None),
15256 local: coordinate(Some("b".repeat(64)), Some(7)),
15257 remote: coordinate(Some("a".repeat(64)), Some(5)),
15258 },
15259 V2ConflictFile {
15260 path: deleted_path.clone(),
15261 base: coordinate(Some("c".repeat(64)), Some(9)),
15262 local: coordinate(Some("d".repeat(64)), Some(11)),
15263 remote: coordinate(None, None),
15264 },
15265 ];
15266 let proven = V2BaselineFile {
15267 sha256: "a".repeat(64),
15268 bytes: 5,
15269 proof: None,
15270 };
15271 let current = [(path.clone(), proven.clone())]
15272 .into_iter()
15273 .collect::<std::collections::BTreeMap<_, _>>();
15274
15275 let (selected, deleted) = v2_take_remote_selection(&files, ¤t).unwrap();
15276 assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
15277 assert_eq!(deleted, vec![deleted_path.clone()]);
15278
15279 let changed = [(
15280 path.clone(),
15281 V2BaselineFile {
15282 sha256: "e".repeat(64),
15283 bytes: 5,
15284 proof: None,
15285 },
15286 )]
15287 .into_iter()
15288 .collect::<std::collections::BTreeMap<_, _>>();
15289 assert!(v2_take_remote_selection(&files, &changed).is_err());
15290
15291 let resurrected = [
15292 (path, proven),
15293 (
15294 deleted_path,
15295 V2BaselineFile {
15296 sha256: "f".repeat(64),
15297 bytes: 13,
15298 proof: None,
15299 },
15300 ),
15301 ]
15302 .into_iter()
15303 .collect::<std::collections::BTreeMap<_, _>>();
15304 assert!(v2_take_remote_selection(&files, &resurrected).is_err());
15305 }
15306
15307 #[cfg(target_os = "linux")]
15308 #[test]
15309 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
15310 use std::os::fd::AsRawFd as _;
15311
15312 let sandbox = tempfile::TempDir::new().unwrap();
15313 let parent = std::fs::File::open(sandbox.path()).unwrap();
15314 let stage = std::ffi::CString::new("stage").unwrap();
15315 let destination = std::ffi::CString::new("brain").unwrap();
15316
15317 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15318 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
15319 install_stage_at(
15320 parent.as_raw_fd(),
15321 stage.as_c_str(),
15322 destination.as_c_str(),
15323 false,
15324 )
15325 .unwrap();
15326 assert!(!sandbox.path().join("stage").exists());
15327 assert_eq!(
15328 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15329 b"created"
15330 );
15331
15332 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15333 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
15334 install_stage_at(
15335 parent.as_raw_fd(),
15336 stage.as_c_str(),
15337 destination.as_c_str(),
15338 true,
15339 )
15340 .unwrap();
15341 assert_eq!(
15342 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15343 b"replacement"
15344 );
15345 assert_eq!(
15346 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
15347 b"created",
15348 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
15349 );
15350 }
15351
15352 struct SignedRemoteFixture {
15353 card: String,
15354 feed: String,
15355 key: AgentSigningKey,
15356 identity: FeedIdentity,
15357 }
15358
15359 fn signed_remote_fixture() -> SignedRemoteFixture {
15360 let rng = ring::rand::SystemRandom::new();
15361 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15362 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15363 let (public_key, multikey) = public_identity_for(&pair);
15364 let identity = FeedIdentity {
15365 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15366 public_key_spki: public_key.clone(),
15367 previous: Vec::new(),
15368 rotations: Vec::new(),
15369 };
15370 let mut entry = FeedEntry {
15371 v: 1,
15372 seq: 1,
15373 ts: "2026-07-30T12:00:00.000Z".to_string(),
15374 brain: multikey.clone(),
15375 public_key: public_key.clone(),
15376 kind: "push".to_string(),
15377 op: "snapshot".to_string(),
15378 pack_sha256: "a".repeat(64),
15379 files: Vec::new(),
15380 removed: Vec::new(),
15381 prev_entry_hash: None,
15382 sig: String::new(),
15383 };
15384 let unsigned = UnsignedFeedEntry {
15385 v: entry.v,
15386 seq: entry.seq,
15387 ts: &entry.ts,
15388 brain: &entry.brain,
15389 public_key: &entry.public_key,
15390 kind: &entry.kind,
15391 op: &entry.op,
15392 pack_sha256: &entry.pack_sha256,
15393 files: &entry.files,
15394 removed: &entry.removed,
15395 prev_entry_hash: &entry.prev_entry_hash,
15396 };
15397 entry.sig =
15398 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15399 let mut exact = serde_json::to_vec(&entry).unwrap();
15400 exact.push(b'\n');
15401 let hash = content_sha256(&exact);
15402 let card = json!({
15403 "id": TEST_BRAIN_ID,
15404 "headSeq": 1,
15405 "feedHash": hash,
15406 "identity": identity.clone(),
15407 })
15408 .to_string();
15409 let feed = json!({
15410 "headSeq": 1,
15411 "feedHash": hash,
15412 "identity": identity.clone(),
15413 "entries": [{"hash": hash, "entry": entry}],
15414 "scopeLimited": false,
15415 })
15416 .to_string();
15417 SignedRemoteFixture {
15418 card,
15419 feed,
15420 key: AgentSigningKey {
15421 pkcs8: pkcs8.as_ref().to_vec(),
15422 multikey,
15423 public_key_spki: public_key,
15424 },
15425 identity,
15426 }
15427 }
15428
15429 #[test]
15430 fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
15431 let file = |path: &str, byte: char| FeedFile {
15432 path: path.to_string(),
15433 sha256: byte.to_string().repeat(64),
15434 bytes: 1,
15435 };
15436 let a0 = file("records/a.md", 'a');
15437 let a1 = file("records/a.md", 'b');
15438 let stable = file("records/stable.md", 'c');
15439 let added = file("records/added.md", 'd');
15440 let removed_file = file("records/removed.md", 'e');
15441 let previous = vec![a0, stable.clone(), removed_file.clone()];
15442 let resulting = vec![a1.clone(), stable.clone(), added.clone()];
15443 let removed = vec![removed_file.path.clone()];
15444
15445 assert_eq!(
15446 verify_v1_manifest_disclosure(
15447 "edit",
15448 &previous,
15449 &resulting,
15450 &[a1.clone(), added.clone()],
15451 &removed,
15452 ),
15453 Ok(())
15454 );
15455 assert_eq!(
15456 verify_v1_manifest_disclosure(
15457 "edit",
15458 &previous,
15459 &resulting,
15460 &[stable.clone(), added.clone(), a1.clone()],
15461 &removed,
15462 ),
15463 Ok(())
15464 );
15465 assert_eq!(
15466 verify_v1_manifest_disclosure(
15467 "edit",
15468 &previous,
15469 &resulting,
15470 std::slice::from_ref(&added),
15471 &removed,
15472 ),
15473 Err(V1DisclosureError::EditMissingChange)
15474 );
15475 assert_eq!(
15476 verify_v1_manifest_disclosure(
15477 "edit",
15478 &previous,
15479 &resulting,
15480 &[file("records/a.md", 'f'), added.clone()],
15481 &removed,
15482 ),
15483 Err(V1DisclosureError::EditFalseFile)
15484 );
15485 assert_eq!(
15486 verify_v1_manifest_disclosure(
15487 "edit",
15488 &previous,
15489 &resulting,
15490 &[a1.clone(), added.clone()],
15491 &[],
15492 ),
15493 Err(V1DisclosureError::RemovedMismatch)
15494 );
15495 assert_eq!(
15496 verify_v1_manifest_disclosure(
15497 "push",
15498 &previous,
15499 &resulting,
15500 &[added.clone(), stable, a1],
15501 &removed,
15502 ),
15503 Ok(())
15504 );
15505 assert_eq!(
15506 verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
15507 Err(V1DisclosureError::PushManifestMismatch)
15508 );
15509 }
15510
15511 #[test]
15512 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
15513 let fixture = signed_remote_fixture();
15514 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
15515 let item = feed["entries"][0].to_string();
15516 let oversized_page = format!(
15517 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
15518 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
15519 .collect::<Vec<_>>()
15520 .join(",")
15521 );
15522 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
15523
15524 let oversized_identity = format!(
15525 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
15526 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
15527 .collect::<Vec<_>>()
15528 .join(",")
15529 );
15530 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
15531
15532 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
15533 let oversized_entry = format!(
15534 "{{\"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\"}}",
15535 "a".repeat(64),
15536 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
15537 .collect::<Vec<_>>()
15538 .join(",")
15539 );
15540 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
15541 }
15542
15543 #[test]
15544 fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
15545 let id = "01arz3ndektsv4rrffq69g5fav";
15546 let digest = "a".repeat(64);
15547 assert_eq!(
15548 V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
15549 V2BulkConfirmation {
15550 id: id.to_string(),
15551 digest,
15552 }
15553 );
15554 for invalid in [
15555 "",
15556 "01arz3ndektsv4rrffq69g5fav",
15557 "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15558 "01arz3ndektsv4rrffq69g5fav:ABCDEF",
15559 "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15560 ] {
15561 assert!(matches!(
15562 V2BulkConfirmation::parse(invalid),
15563 Err(LinkError::InvalidPack { .. })
15564 ));
15565 }
15566 }
15567
15568 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
15569 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15570 use std::net::TcpListener;
15571
15572 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15573 let url = format!("http://{}", listener.local_addr().unwrap());
15574 let handle = std::thread::spawn(move || {
15575 for (status, body) in responses {
15576 let (stream, _) = listener.accept().unwrap();
15577 let mut reader = BufReader::new(stream);
15578 let mut line = String::new();
15579 reader.read_line(&mut line).unwrap();
15580 let mut content_length = 0usize;
15581 loop {
15582 line.clear();
15583 reader.read_line(&mut line).unwrap();
15584 if line == "\r\n" || line == "\n" || line.is_empty() {
15585 break;
15586 }
15587 if let Some((name, value)) = line.split_once(':') {
15588 if name.eq_ignore_ascii_case("content-length") {
15589 content_length = value.trim().parse().unwrap();
15590 }
15591 }
15592 }
15593 let mut request_body = vec![0_u8; content_length];
15594 reader.read_exact(&mut request_body).unwrap();
15595 let response = format!(
15596 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15597 body.len()
15598 );
15599 reader.get_mut().write_all(response.as_bytes()).unwrap();
15600 }
15601 });
15602 (url, handle)
15603 }
15604
15605 fn routed_json_hub(
15606 requests: usize,
15607 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
15608 ) -> (String, std::thread::JoinHandle<()>) {
15609 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15610 use std::net::TcpListener;
15611
15612 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15613 let url = format!("http://{}", listener.local_addr().unwrap());
15614 let handle = std::thread::spawn(move || {
15615 for _ in 0..requests {
15616 let (stream, _) = listener.accept().unwrap();
15617 let mut reader = BufReader::new(stream);
15618 let mut line = String::new();
15619 reader.read_line(&mut line).unwrap();
15620 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
15621 let mut content_length = 0usize;
15622 loop {
15623 line.clear();
15624 reader.read_line(&mut line).unwrap();
15625 if line == "\r\n" || line == "\n" || line.is_empty() {
15626 break;
15627 }
15628 if let Some((name, value)) = line.split_once(':') {
15629 if name.eq_ignore_ascii_case("content-length") {
15630 content_length = value.trim().parse().unwrap();
15631 }
15632 }
15633 }
15634 let mut request_body = vec![0_u8; content_length];
15635 reader.read_exact(&mut request_body).unwrap();
15636 let (status, body) = respond(&path);
15637 let response = format!(
15638 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15639 body.len()
15640 );
15641 reader.get_mut().write_all(response.as_bytes()).unwrap();
15642 }
15643 });
15644 (url, handle)
15645 }
15646
15647 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
15648 HubConfig {
15649 hub,
15650 key: Some("test-key".to_string()),
15651 agent_key: None,
15652 brain_key: None,
15653 state_dir,
15654 store_selected: false,
15655 }
15656 }
15657
15658 #[cfg(any(unix, windows))]
15659 #[test]
15660 fn an_expired_asset_capability_is_refreshed_without_losing_cached_progress() {
15661 use std::sync::{Arc, Mutex};
15662
15663 let bytes = b"immutable asset bytes".to_vec();
15664 let sha256 = content_sha256(&bytes);
15665 let commit_hash = "c".repeat(64);
15666 let base_url = Arc::new(Mutex::new(String::new()));
15667 let server_base = Arc::clone(&base_url);
15668 let object_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15669 let server_attempt = Arc::clone(&object_attempt);
15670 let response_bytes = bytes.clone();
15671 let response_sha = sha256.clone();
15672 let response_commit = commit_hash.clone();
15673 let (hub, server) = routed_json_hub(4, move |path| {
15674 if path.contains("/v2/assets/downloads") {
15675 let attempt = server_attempt.load(std::sync::atomic::Ordering::SeqCst) + 1;
15676 let url = format!("{}/asset/{attempt}", server_base.lock().unwrap());
15677 return (
15678 200,
15679 json!({
15680 "v": 2,
15681 "commit": response_commit,
15682 "downloads": [{
15683 "path": "assets/proof.bin",
15684 "sha256": response_sha,
15685 "bytes": response_bytes.len(),
15686 "url": url,
15687 "method": "GET"
15688 }]
15689 })
15690 .to_string(),
15691 );
15692 }
15693 let attempt = server_attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15694 if attempt == 0 {
15695 (403, "{}".to_string())
15696 } else {
15697 (200, String::from_utf8(response_bytes.clone()).unwrap())
15698 }
15699 });
15700 *base_url.lock().unwrap() = hub.clone();
15701
15702 let temp = tempfile::tempdir().unwrap();
15703 let cache = temp.path().join("cache");
15704 std::fs::create_dir(&cache).unwrap();
15705 let cfg = test_hub_config(hub, temp.path().to_path_buf());
15706 let pointer = V2PointerBody {
15707 v: 2,
15708 brain: TEST_BRAIN_ID.to_string(),
15709 seq: 1,
15710 commit_hash,
15711 feed_hash: "f".repeat(64),
15712 content_root: Some("a".repeat(64)),
15713 asset_root: Some("b".repeat(64)),
15714 materializer: "m".repeat(64),
15715 signer_epoch: 1,
15716 control_revision: "d".repeat(64),
15717 backup_preparation: "ready".to_string(),
15718 prior_pointer_hash: None,
15719 signed_at: "2026-08-23T00:00:00Z".to_string(),
15720 };
15721 let path = "assets/proof.bin".to_string();
15722 let asset = V2BaselineAsset {
15723 blob_sha256: sha256.clone(),
15724 bytes: bytes.len() as u64,
15725 media_type: "application/octet-stream".to_string(),
15726 wrappers: Vec::new(),
15727 required: true,
15728 disposition: "hosted".to_string(),
15729 leaf_hash: "e".repeat(64),
15730 };
15731
15732 let staged = stage_v2_asset_download_window(
15733 &cfg,
15734 TEST_BRAIN_ID,
15735 &pointer,
15736 &cache,
15737 &[(&path, &asset)],
15738 )
15739 .expect("a fresh authority-checked capability recovers an expired one");
15740 assert_eq!(staged.len(), 1);
15741 assert_eq!(staged[0].path, path);
15742 assert_eq!(std::fs::read(cache.join(sha256)).unwrap(), bytes);
15743 assert_eq!(object_attempt.load(std::sync::atomic::Ordering::SeqCst), 2);
15744 server.join().unwrap();
15745 }
15746
15747 #[cfg(any(unix, windows))]
15748 #[test]
15749 fn asset_capability_windows_cannot_exceed_the_worker_bound() {
15750 let temp = tempfile::tempdir().unwrap();
15751 let cfg = test_hub_config("http://127.0.0.1:1".to_string(), temp.path().to_path_buf());
15752 let pointer = V2PointerBody {
15753 v: 2,
15754 brain: TEST_BRAIN_ID.to_string(),
15755 seq: 1,
15756 commit_hash: "c".repeat(64),
15757 feed_hash: "f".repeat(64),
15758 content_root: Some("a".repeat(64)),
15759 asset_root: Some("b".repeat(64)),
15760 materializer: "m".repeat(64),
15761 signer_epoch: 1,
15762 control_revision: "d".repeat(64),
15763 backup_preparation: "ready".to_string(),
15764 prior_pointer_hash: None,
15765 signed_at: "2026-08-23T00:00:00Z".to_string(),
15766 };
15767 let paths = (0..=V2_DOWNLOAD_CAPABILITY_FILES)
15768 .map(|index| format!("assets/{index}.bin"))
15769 .collect::<Vec<_>>();
15770 let assets = paths
15771 .iter()
15772 .map(|_| V2BaselineAsset {
15773 blob_sha256: "a".repeat(64),
15774 bytes: 1,
15775 media_type: "application/octet-stream".to_string(),
15776 wrappers: Vec::new(),
15777 required: true,
15778 disposition: "hosted".to_string(),
15779 leaf_hash: "b".repeat(64),
15780 })
15781 .collect::<Vec<_>>();
15782 let pending = paths.iter().zip(&assets).collect::<Vec<_>>();
15783
15784 let error =
15785 stage_v2_asset_download_window(&cfg, TEST_BRAIN_ID, &pointer, temp.path(), &pending)
15786 .expect_err("an oversized window must fail before any network request");
15787 assert!(matches!(error, LinkError::InvalidFeed { .. }));
15788 }
15789
15790 #[test]
15791 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
15792 use ring::signature::KeyPair as _;
15793
15794 let rng = ring::rand::SystemRandom::new();
15795 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15796 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15797 let (spki, multikey) = public_identity_for(&pair);
15798 let key = AgentSigningKey {
15799 pkcs8: pkcs8.as_ref().to_vec(),
15800 multikey,
15801 public_key_spki: spki,
15802 };
15803 let header = linkmd_sig_header(
15804 &key,
15805 "https://hub-a.example",
15806 "post",
15807 "/api/hub/brains/brain/push?mode=exact",
15808 Some("{\"ok\":true}"),
15809 )
15810 .unwrap();
15811 assert!(header.starts_with("LinkMD-Sig v2,"));
15812 let ts = header
15813 .split(",ts=")
15814 .nth(1)
15815 .unwrap()
15816 .split(',')
15817 .next()
15818 .unwrap();
15819 let signature = URL_SAFE_NO_PAD
15820 .decode(header.rsplit(",sig=").next().unwrap())
15821 .unwrap();
15822 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
15823 let accepted = format!(
15824 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15825 );
15826 let replayed = format!(
15827 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15828 );
15829 let public = pair.public_key().as_ref();
15830 assert!(UnparsedPublicKey::new(&ED25519, public)
15831 .verify(accepted.as_bytes(), &signature)
15832 .is_ok());
15833 assert!(
15834 UnparsedPublicKey::new(&ED25519, public)
15835 .verify(replayed.as_bytes(), &signature)
15836 .is_err(),
15837 "a proof captured at hub A must not authenticate at hub B"
15838 );
15839 }
15840
15841 #[test]
15842 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
15843 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15844 let card = json!({
15845 "id": other,
15846 "headSeq": 0,
15847 "identity": signed_remote_fixture().identity,
15848 })
15849 .to_string();
15850 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15851 let state = tempfile::tempdir().unwrap();
15852 let cfg = test_hub_config(hub, state.path().to_path_buf());
15853 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15854 assert!(
15855 error.contains("differs from the explicitly requested"),
15856 "{error}"
15857 );
15858 server.join().unwrap();
15859 }
15860
15861 #[test]
15862 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
15863 let first = signed_remote_fixture().identity;
15864 let second = signed_remote_fixture().identity;
15865 let card = |identity: FeedIdentity| {
15866 json!({
15867 "id": TEST_BRAIN_ID,
15868 "headSeq": 0,
15869 "identity": identity,
15870 })
15871 .to_string()
15872 };
15873 let (hub, server) = scripted_json_hub(vec![
15874 (404, "{}".to_string()),
15875 (200, card(first)),
15876 (404, "{}".to_string()),
15877 (200, card(second)),
15878 ]);
15879 let state = tempfile::tempdir().unwrap();
15880 let cfg = test_hub_config(hub, state.path().to_path_buf());
15881 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15882 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15883 assert!(
15884 error.contains("pinned anchor") || error.contains("forked away"),
15885 "{error}"
15886 );
15887 server.join().unwrap();
15888 }
15889
15890 #[test]
15891 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
15892 let old = signed_remote_fixture();
15893 let new = signed_remote_fixture();
15894 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
15895 let unsigned = serde_json::to_string(&UnsignedRotation {
15896 v: 1,
15897 op: "rotate",
15898 brain: &old.key.multikey,
15899 public_key: &old.key.public_key_spki,
15900 new_brain: &new.key.multikey,
15901 new_public_key: &new.key.public_key_spki,
15902 prior_head_seq: 1,
15903 prior_feed_hash: Some(&"a".repeat(64)),
15904 ts: "2026-07-30T12:00:00.000Z".to_string(),
15905 })
15906 .unwrap();
15907 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
15908 let rotation = format!(
15909 "{},\"sig\":\"{}\"}}",
15910 &unsigned[..unsigned.len() - 1],
15911 signature
15912 );
15913 let identity = FeedIdentity {
15914 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
15915 public_key_spki: new.key.public_key_spki,
15916 previous: vec![PreviousIdentity {
15917 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
15918 public_key_spki: old.key.public_key_spki,
15919 }],
15920 rotations: vec![rotation],
15921 };
15922 let card = json!({
15923 "id": TEST_BRAIN_ID,
15924 "headSeq": 0,
15925 "feedHash": null,
15926 "identity": identity,
15927 })
15928 .to_string();
15929 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15930 let state = tempfile::tempdir().unwrap();
15931 let cfg = test_hub_config(hub, state.path().to_path_buf());
15932 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15933 assert!(
15934 error.contains("rotation claims a feed boundary beyond the advertised head"),
15935 "{error}"
15936 );
15937 assert!(
15938 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
15939 "an inconsistent empty-head identity must not become the TOFU checkpoint"
15940 );
15941 server.join().unwrap();
15942 }
15943
15944 #[test]
15945 fn trust_checkpoint_rejects_a_later_fork() {
15946 let fixture = signed_remote_fixture();
15947 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
15948 fork["feedHash"] = Value::String("b".repeat(64));
15949 let (hub, server) = scripted_json_hub(vec![
15950 (404, "{}".to_string()),
15951 (200, fixture.card),
15952 (200, fixture.feed),
15953 (404, "{}".to_string()),
15954 (200, fork.to_string()),
15955 ]);
15956 let state = tempfile::tempdir().unwrap();
15957 let cfg = test_hub_config(hub, state.path().to_path_buf());
15958 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15959 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
15960 server.join().unwrap();
15961 }
15962
15963 #[test]
15964 fn alias_and_canonical_id_share_one_identity_checkpoint() {
15965 let trusted = signed_remote_fixture();
15966 let attacker = signed_remote_fixture();
15967 let (hub, server) = scripted_json_hub(vec![
15968 (404, "{}".to_string()),
15969 (200, trusted.card),
15970 (200, trusted.feed),
15971 (404, "{}".to_string()),
15972 (200, attacker.card),
15973 ]);
15974 let state = tempfile::tempdir().unwrap();
15975 let cfg = test_hub_config(hub, state.path().to_path_buf());
15976 assert!(head(&cfg, "trusted-slug").unwrap().verified);
15977 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15978 assert!(
15979 error.contains("equivocation")
15980 || error.contains("pinned")
15981 || error.contains("identity"),
15982 "{error}"
15983 );
15984 server.join().unwrap();
15985 }
15986
15987 #[test]
15988 fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
15989 let state = tempfile::tempdir().unwrap();
15990 let cfg = test_hub_config(
15991 "https://hub.example".to_string(),
15992 state.path().to_path_buf(),
15993 );
15994 let directory = open_trust_dir(&cfg).unwrap();
15995 let old = TEST_BRAIN_ID;
15996 let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15997 save_alias_in(
15998 &cfg,
15999 &directory,
16000 &AliasBinding {
16001 v: 1,
16002 origin: normalized_origin(&cfg.hub).unwrap(),
16003 requested: "company-brain".to_string(),
16004 brain: old.to_string(),
16005 home: Some("company-brain".to_string()),
16006 },
16007 )
16008 .unwrap();
16009
16010 let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
16011 assert!(matches!(
16012 error,
16013 LinkError::AliasRebindRequired {
16014 alias,
16015 from,
16016 to
16017 } if alias == "company-brain" && from == old && to == new
16018 ));
16019 let unchanged = load_alias_in(&cfg, &directory, "company-brain")
16020 .unwrap()
16021 .unwrap();
16022 assert_eq!(unchanged.brain, old);
16023 assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
16024 }
16025
16026 #[test]
16027 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
16028 let alpha = signed_remote_fixture();
16029 let beta = signed_remote_fixture();
16030 let alpha_card = alpha.card.clone();
16031 let alpha_feed = alpha.feed.clone();
16032 let beta_card = beta.card.clone();
16033 let beta_feed = beta.feed.clone();
16034 let (hub, server) = routed_json_hub(5, move |path| {
16035 if path.ends_with("/v2/head") {
16036 (404, "{}".to_string())
16037 } else if path.contains("/alpha/feed?") {
16038 (200, alpha_feed.clone())
16039 } else if path.contains("/beta/feed?") {
16040 (200, beta_feed.clone())
16041 } else if path.ends_with("/alpha") {
16042 (200, alpha_card.clone())
16043 } else if path.ends_with("/beta") {
16044 (200, beta_card.clone())
16045 } else {
16046 (500, r#"{"error":"unexpected path"}"#.to_string())
16047 }
16048 });
16049 let state = tempfile::tempdir().unwrap();
16050 let cfg = test_hub_config(hub, state.path().to_path_buf());
16051 let alpha_cfg = cfg.clone();
16052 let beta_cfg = cfg;
16053 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
16054 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
16055 let results = [first.join().unwrap(), second.join().unwrap()];
16056 assert_eq!(
16057 results.iter().filter(|result| result.is_ok()).count(),
16058 1,
16059 "only one alias identity may establish canonical TOFU: {results:?}"
16060 );
16061 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
16062 server.join().unwrap();
16063 }
16064
16065 #[cfg(unix)]
16066 #[test]
16067 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
16068 use std::os::unix::fs::symlink;
16069
16070 let fixture = signed_remote_fixture();
16071 let card = json!({
16072 "id": TEST_BRAIN_ID,
16073 "headSeq": 0,
16074 "feedHash": Value::Null,
16075 "identity": fixture.identity,
16076 })
16077 .to_string();
16078 let work = tempfile::tempdir().unwrap();
16079 let outside = tempfile::tempdir().unwrap();
16080 let state = work.path().join("state");
16081 let moved = work.path().join("state-held");
16082 let swap_state = state.clone();
16083 let swap_moved = moved.clone();
16084 let outside_path = outside.path().to_path_buf();
16085 let (hub, server) = routed_json_hub(1, move |_| {
16086 std::fs::rename(&swap_state, &swap_moved).unwrap();
16088 symlink(&outside_path, &swap_state).unwrap();
16089 (200, card.clone())
16090 });
16091 let cfg = test_hub_config(hub, state);
16092
16093 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
16094 assert_eq!(verified.head.seq, 0);
16095 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
16096 assert!(std::fs::read_dir(moved.join("trust"))
16097 .unwrap()
16098 .flatten()
16099 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
16100 server.join().unwrap();
16101 }
16102
16103 #[test]
16104 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
16105 let remote = signed_remote_fixture();
16106 let unrelated = signed_remote_fixture().key;
16107 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
16108 let state = tempfile::tempdir().unwrap();
16109 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
16110 cfg.brain_key = Some(unrelated);
16111 let error = sync_push(
16112 &cfg,
16113 TEST_BRAIN_ID,
16114 &[("DB.md".to_string(), "signed local content".to_string())],
16115 )
16116 .unwrap_err()
16117 .to_string();
16118 assert!(
16119 error.contains("not the verified current brain identity"),
16120 "{error}"
16121 );
16122 server.join().unwrap();
16123 }
16124
16125 #[test]
16126 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
16127 let remote = signed_remote_fixture();
16128 let new = signed_remote_fixture().key;
16129 let state = tempfile::tempdir().unwrap();
16130 let new_file = state.path().join("new.key");
16131 std::fs::write(
16132 &new_file,
16133 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
16134 )
16135 .unwrap();
16136 #[cfg(unix)]
16137 {
16138 use std::os::unix::fs::PermissionsExt as _;
16139 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
16140 }
16141 let forged = json!({
16142 "brain": TEST_BRAIN_ID,
16143 "identity": {
16144 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
16145 "publicKeySpki": new.public_key_spki,
16146 }
16147 })
16148 .to_string();
16149 let (hub, server) = scripted_json_hub(vec![
16150 (404, "{}".to_string()),
16151 (200, remote.card.clone()),
16152 (200, remote.feed.clone()),
16153 (200, forged),
16154 (200, remote.card),
16155 (200, remote.feed),
16156 ]);
16157 let cfg = test_hub_config(hub, state.path().to_path_buf());
16158 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
16159 .unwrap_err()
16160 .to_string();
16161 assert!(
16162 error.contains("without committing the verified new identity"),
16163 "{error}"
16164 );
16165 server.join().unwrap();
16166 }
16167
16168 #[test]
16169 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
16170 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16171 let raw = format!(
16172 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16173 );
16174 let pack = build_store_pack(&[
16175 (
16176 "DB.md".to_string(),
16177 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
16178 ),
16179 ("records/clients/truth.md".to_string(), raw.clone()),
16180 ])
16181 .unwrap();
16182 let by_id = resolve_from_verified_pack(
16183 "01j5qc3v9k4ym8rwbn2tqe6f7d",
16184 &AddressTarget::Id(record_id.to_string()),
16185 pack.clone(),
16186 )
16187 .unwrap();
16188 assert_eq!(by_id["document"]["summary"], "Signed truth");
16189 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
16190 assert_eq!(
16191 by_id["document"]["contentSha"],
16192 content_sha256(raw.as_bytes())
16193 );
16194
16195 let by_path = resolve_from_verified_pack(
16196 "01j5qc3v9k4ym8rwbn2tqe6f7d",
16197 &AddressTarget::Path("records/clients/truth.md".to_string()),
16198 pack,
16199 )
16200 .unwrap();
16201 assert_eq!(by_path["document"]["id"], record_id);
16202 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
16203
16204 let wrong_id = resolve_from_verified_record_bytes(
16205 TEST_BRAIN_ID,
16206 &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
16207 "records/clients/truth.md".to_string(),
16208 raw.as_bytes().to_vec(),
16209 )
16210 .unwrap_err()
16211 .to_string();
16212 assert!(wrong_id.contains("id differs"), "{wrong_id}");
16213
16214 let wrong_path = resolve_from_verified_record_bytes(
16215 TEST_BRAIN_ID,
16216 &AddressTarget::Path("records/clients/other.md".to_string()),
16217 "records/clients/truth.md".to_string(),
16218 raw.into_bytes(),
16219 )
16220 .unwrap_err()
16221 .to_string();
16222 assert!(wrong_path.contains("path differs"), "{wrong_path}");
16223 }
16224
16225 #[test]
16226 fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
16227 let path = "records/clients/truth.md";
16228 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16229 let raw = format!(
16230 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16231 );
16232 let sha256 = content_sha256(raw.as_bytes());
16233 let mut nonce = 0_u128;
16234 let tree = crate::linkmd_v2::build_content_tree(
16235 &[crate::linkmd_v2::ContentFile {
16236 path: path.to_string(),
16237 blob_hash: sha256.clone(),
16238 bytes: raw.len() as u64,
16239 }],
16240 None,
16241 &mut || {
16242 nonce += 1;
16243 format!("{nonce:032x}")
16244 },
16245 )
16246 .unwrap();
16247 let root = tree.root.clone().unwrap();
16248 let mut directory_root = root.clone();
16249 let mut proof = Vec::new();
16250 for component in path.split('/') {
16251 let inclusion =
16252 crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
16253 let child = match &inclusion {
16254 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
16255 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
16256 panic!("fixture path must have an inclusion proof")
16257 }
16258 };
16259 proof.push(json!({
16260 "directory_root": directory_root,
16261 "component": component,
16262 "proof": inclusion,
16263 }));
16264 directory_root = child;
16265 }
16266 let commit_hash = "c".repeat(64);
16267 let pointer = V2PointerBody {
16268 v: 2,
16269 brain: TEST_BRAIN_ID.to_string(),
16270 seq: 1,
16271 commit_hash: commit_hash.clone(),
16272 feed_hash: "f".repeat(64),
16273 content_root: Some(root.clone()),
16274 asset_root: None,
16275 materializer: "dbmd-projection-v1".to_string(),
16276 signer_epoch: 1,
16277 control_revision: "d".repeat(64),
16278 backup_preparation: "e".repeat(64),
16279 prior_pointer_hash: None,
16280 signed_at: "2026-08-21T12:00:00.000Z".to_string(),
16281 };
16282 let manifest = json!({
16283 "v": 2,
16284 "commit": commit_hash,
16285 "content_root": root,
16286 "files": [{
16287 "path": path,
16288 "sha256": sha256,
16289 "bytes": raw.len(),
16290 "proof": proof,
16291 }],
16292 "next_cursor": Value::Null,
16293 })
16294 .to_string();
16295
16296 let path_manifest = manifest.clone();
16297 let (hub, server) = routed_json_hub(1, move |request| {
16298 assert_eq!(
16299 request,
16300 format!(
16301 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
16302 "c".repeat(64)
16303 )
16304 );
16305 (200, path_manifest.clone())
16306 });
16307 let state = tempfile::tempdir().unwrap();
16308 let cfg = test_hub_config(hub, state.path().to_path_buf());
16309 let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
16310 .unwrap()
16311 .unwrap();
16312 assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
16313 assert!(by_path.proof.is_some());
16314 server.join().unwrap();
16315
16316 let (hub, server) = routed_json_hub(1, move |request| {
16317 assert_eq!(
16318 request,
16319 format!(
16320 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
16321 "c".repeat(64)
16322 )
16323 );
16324 (404, r#"{"error":"File not found"}"#.to_string())
16325 });
16326 let state = tempfile::tempdir().unwrap();
16327 let cfg = test_hub_config(hub, state.path().to_path_buf());
16328 assert!(
16329 v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
16330 .unwrap()
16331 .is_none()
16332 );
16333 server.join().unwrap();
16334
16335 let id_manifest = manifest;
16336 let (hub, server) = routed_json_hub(1, move |request| {
16337 assert_eq!(
16338 request,
16339 format!(
16340 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
16341 "c".repeat(64)
16342 )
16343 );
16344 (200, id_manifest.clone())
16345 });
16346 let state = tempfile::tempdir().unwrap();
16347 let cfg = test_hub_config(hub, state.path().to_path_buf());
16348 let (located_path, by_id) =
16349 v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
16350 assert_eq!(located_path, path);
16351 assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
16352 server.join().unwrap();
16353 }
16354
16355 #[test]
16356 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
16357 let unsorted = vec![
16358 ("records/a.md".to_string(), "alpha\n".to_string()),
16359 ("DB.md".to_string(), "# db\n".to_string()),
16360 ];
16361 let sorted = vec![
16362 ("DB.md".to_string(), "# db\n".to_string()),
16363 ("records/a.md".to_string(), "alpha\n".to_string()),
16364 ];
16365 let pack = build_store_pack(&unsorted).unwrap();
16366
16367 assert_eq!(pack.len(), 219);
16372 assert_eq!(
16373 content_sha256(&pack),
16374 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
16375 );
16376 assert_eq!(pack, build_store_pack(&sorted).unwrap());
16377 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
16378 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
16379 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
16380
16381 assert_eq!(
16382 parse_store_pack(pack).unwrap(),
16383 vec![
16384 ("DB.md".to_string(), b"# db\n".to_vec()),
16385 ("records/a.md".to_string(), b"alpha\n".to_vec()),
16386 ]
16387 );
16388 }
16389
16390 #[test]
16391 fn canonical_store_pack_validates_every_path_before_writing() {
16392 let duplicate = vec![
16393 ("DB.md".to_string(), "first".to_string()),
16394 ("DB.md".to_string(), "second".to_string()),
16395 ];
16396 assert!(build_store_pack(&duplicate)
16397 .unwrap_err()
16398 .to_string()
16399 .contains("duplicate path"));
16400 assert!(matches!(
16401 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
16402 Err(LinkError::UnsafePath { .. })
16403 ));
16404 }
16405
16406 #[test]
16407 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
16408 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16409 let mut bytes = vec![0_u8];
16412 let zip64_offset = bytes.len() as u64;
16413 bytes.extend_from_slice(b"PK\x06\x06");
16414 bytes.extend_from_slice(&44_u64.to_le_bytes());
16415 bytes.extend_from_slice(&[0_u8; 12]);
16416 bytes.extend_from_slice(&COUNT.to_le_bytes());
16417 bytes.extend_from_slice(&COUNT.to_le_bytes());
16418 bytes.extend_from_slice(&1_u64.to_le_bytes());
16419 bytes.extend_from_slice(&0_u64.to_le_bytes());
16420 bytes.extend_from_slice(b"PK\x06\x07");
16421 bytes.extend_from_slice(&0_u32.to_le_bytes());
16422 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16423 bytes.extend_from_slice(&1_u32.to_le_bytes());
16424 bytes.extend_from_slice(b"PK\x05\x06");
16425 bytes.extend_from_slice(&0_u16.to_le_bytes());
16426 bytes.extend_from_slice(&0_u16.to_le_bytes());
16427 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16428 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16429 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16430 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16431 bytes.extend_from_slice(&0_u16.to_le_bytes());
16432
16433 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16434 .unwrap_err()
16435 .to_string();
16436 assert!(error.contains("invalid file count"), "{error}");
16437 }
16438
16439 #[test]
16440 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
16441 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16442 let mut bytes = vec![0_u8];
16443 let zip64_offset = bytes.len() as u64;
16444 bytes.extend_from_slice(b"PK\x06\x06");
16445 bytes.extend_from_slice(&44_u64.to_le_bytes());
16446 bytes.extend_from_slice(&[0_u8; 12]);
16447 bytes.extend_from_slice(&COUNT.to_le_bytes());
16448 bytes.extend_from_slice(&COUNT.to_le_bytes());
16449 bytes.extend_from_slice(&1_u64.to_le_bytes());
16450 bytes.extend_from_slice(&0_u64.to_le_bytes());
16451 bytes.extend_from_slice(b"PK\x06\x07");
16452 bytes.extend_from_slice(&0_u32.to_le_bytes());
16453 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16454 bytes.extend_from_slice(&1_u32.to_le_bytes());
16455 bytes.extend_from_slice(b"PK\x05\x06");
16456 bytes.extend_from_slice(&0_u16.to_le_bytes());
16457 bytes.extend_from_slice(&0_u16.to_le_bytes());
16458 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16459 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16460 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16461 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16462 bytes.extend_from_slice(&0_u16.to_le_bytes());
16463 let fake_eocd = bytes.len() as u32;
16467 bytes.extend_from_slice(b"PK\x05\x06");
16468 bytes.extend_from_slice(&0_u16.to_le_bytes());
16469 bytes.extend_from_slice(&0_u16.to_le_bytes());
16470 bytes.extend_from_slice(&1_u16.to_le_bytes());
16471 bytes.extend_from_slice(&1_u16.to_le_bytes());
16472 bytes.extend_from_slice(&0_u32.to_le_bytes());
16473 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
16474 bytes.extend_from_slice(&0_u16.to_le_bytes());
16475
16476 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16477 .unwrap_err()
16478 .to_string();
16479 assert!(error.contains("central directory"), "{error}");
16480 }
16481
16482 #[test]
16483 fn strict_http_status_handling_rejects_redirects_without_panicking() {
16484 let error = ensure_ok(
16485 HubResponse {
16486 status: 302,
16487 body: Some(json!({"redirect": "/elsewhere"})),
16488 },
16489 "mutation",
16490 )
16491 .unwrap_err();
16492 assert!(matches!(error, LinkError::Http { status: 302, .. }));
16493
16494 let error = ensure_raw_ok(
16495 RawHubResponse {
16496 status: 302,
16497 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
16498 },
16499 "feed",
16500 )
16501 .unwrap_err();
16502 assert!(matches!(error, LinkError::Http { status: 302, .. }));
16503 }
16504
16505 #[cfg(unix)]
16506 #[test]
16507 fn collect_push_files_refuses_external_symlink_and_nested_store() {
16508 use std::os::unix::fs::symlink;
16509
16510 let root = tempfile::tempdir().unwrap();
16511 std::fs::write(
16512 root.path().join("DB.md"),
16513 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
16514 )
16515 .unwrap();
16516 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
16517
16518 let external = tempfile::tempdir().unwrap();
16519 let secret = external.path().join("secret.md");
16520 std::fs::write(&secret, "TOP SECRET").unwrap();
16521 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
16522
16523 let store = Store::open_strict(root.path()).unwrap();
16524 let err = collect_push_files(&store).unwrap_err().to_string();
16525 assert!(err.contains("cannot push"), "{err}");
16526 assert!(
16527 !err.contains("TOP SECRET"),
16528 "external bytes must never leak"
16529 );
16530
16531 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
16532 let nested = root.path().join("records/nested");
16533 std::fs::create_dir_all(&nested).unwrap();
16534 std::fs::write(
16535 nested.join("DB.md"),
16536 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
16537 )
16538 .unwrap();
16539 let err = collect_push_files(&store).unwrap_err().to_string();
16540 assert!(err.contains("nested db.md store"), "{err}");
16541 }
16542
16543 #[cfg(unix)]
16544 #[test]
16545 fn remote_push_uses_opened_root_after_path_replacement() {
16546 use std::os::unix::fs::symlink;
16547
16548 let sandbox = tempfile::tempdir().unwrap();
16549 let root = sandbox.path().join("store");
16550 std::fs::create_dir_all(root.join("records/notes")).unwrap();
16551 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16552 std::fs::write(
16553 root.join("records/notes/owned.md"),
16554 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
16555 )
16556 .unwrap();
16557 let store = Store::open_strict(&root).unwrap();
16558 let detached = sandbox.path().join("detached");
16559 std::fs::rename(&root, &detached).unwrap();
16560
16561 let replacement = sandbox.path().join("replacement");
16562 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
16563 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16564 std::fs::write(
16565 replacement.join("records/notes/secret.md"),
16566 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
16567 )
16568 .unwrap();
16569 symlink(&replacement, &root).unwrap();
16570
16571 let files = collect_push_files(&store).unwrap();
16572 let wire_text = files
16573 .iter()
16574 .map(|(path, content)| format!("{path}\n{content}"))
16575 .collect::<Vec<_>>()
16576 .join("\n");
16577 assert!(wire_text.contains("owned upload"));
16578 assert!(!wire_text.contains("replacement sentinel"));
16579 assert!(!wire_text.contains("records/notes/secret.md"));
16580
16581 let remote = signed_remote_fixture();
16582 let (hub, server) = scripted_json_hub(vec![
16583 (200, remote.card),
16584 (200, remote.feed),
16585 (200, json!({"ok": true}).to_string()),
16586 ]);
16587 let state = tempfile::tempdir().unwrap();
16588 let cfg = test_hub_config(hub, state.path().to_path_buf());
16589 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
16590 assert_eq!(pushed, json!({"ok": true}));
16591 server.join().unwrap();
16592 }
16593
16594 #[test]
16595 fn signed_feed_item_verifies_identity_hash_and_signature() {
16596 use ring::rand::SystemRandom;
16597 use ring::signature::{Ed25519KeyPair, KeyPair};
16598
16599 const PREFIX: &[u8] = &[
16600 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
16601 ];
16602 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
16603 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16604 let mut spki = PREFIX.to_vec();
16605 spki.extend_from_slice(pair.public_key().as_ref());
16606 let public_key = URL_SAFE_NO_PAD.encode(&spki);
16607 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
16608 let mut entry = FeedEntry {
16609 v: 1,
16610 seq: 1,
16611 ts: "2026-07-14T00:00:00.000Z".to_string(),
16612 brain: format!("ed25519:{fingerprint}"),
16613 public_key: public_key.clone(),
16614 kind: "push".to_string(),
16615 op: "snapshot".to_string(),
16616 pack_sha256: "a".repeat(64),
16617 files: vec![FeedFile {
16618 path: "DB.md".to_string(),
16619 sha256: "b".repeat(64),
16620 bytes: 3,
16621 }],
16622 removed: vec![],
16623 prev_entry_hash: None,
16624 sig: String::new(),
16625 };
16626 let unsigned = UnsignedFeedEntry {
16627 v: entry.v,
16628 seq: entry.seq,
16629 ts: &entry.ts,
16630 brain: &entry.brain,
16631 public_key: &entry.public_key,
16632 kind: &entry.kind,
16633 op: &entry.op,
16634 pack_sha256: &entry.pack_sha256,
16635 files: &entry.files,
16636 removed: &entry.removed,
16637 prev_entry_hash: &entry.prev_entry_hash,
16638 };
16639 entry.sig =
16640 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16641 let mut exact = serde_json::to_vec(&entry).unwrap();
16642 exact.push(b'\n');
16643 let item = FeedItem {
16644 hash: format!("{:x}", Sha256::digest(&exact)),
16645 entry,
16646 };
16647 let identity = FeedIdentity {
16648 fingerprint,
16649 public_key_spki: public_key,
16650 previous: Vec::new(),
16651 rotations: Vec::new(),
16652 };
16653 assert!(verify_feed_item(&item, &identity).is_ok());
16654 let mut tampered = item;
16655 tampered.entry.pack_sha256 = "c".repeat(64);
16656 assert!(verify_feed_item(&tampered, &identity).is_err());
16657 }
16658
16659 #[test]
16660 fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
16661 let rng = ring::rand::SystemRandom::new();
16662 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16663 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16664 let (spki, multikey) = public_identity_for(&pair);
16665 let identity = V2HeadIdentity {
16666 custody: "self".to_string(),
16667 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16668 public_key_spki: spki.clone(),
16669 previous: Vec::new(),
16670 rotations: Vec::new(),
16671 };
16672 let unsigned = json!({
16673 "actor_ref": "a".repeat(64),
16674 "asset_root": Value::Null,
16675 "brain": multikey,
16676 "changes_sha256": "b".repeat(64),
16677 "control_revision": "c".repeat(64),
16678 "materializer": "dbmd-projection-v1",
16679 "op": "changeset",
16680 "parent_asset_root": Value::Null,
16681 "parent_commit": Value::Null,
16682 "parent_root": Value::Null,
16683 "prev_entry_hash": Value::Null,
16684 "public_key": spki,
16685 "seq": 1,
16686 "signer_epoch": 1,
16687 "state_root": "d".repeat(64),
16688 "ts": "2026-08-19T12:00:00.000Z",
16689 "v": 2,
16690 "v1_bridge": {
16691 "feed_hash": "e".repeat(64),
16692 "head_seq": 7,
16693 "pack_sha256": "f".repeat(64),
16694 },
16695 });
16696 let sign_value = |value: Value| {
16697 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16698 let mut object = value.as_object().unwrap().clone();
16699 object.insert(
16700 "sig".to_string(),
16701 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16702 );
16703 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16704 };
16705 assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
16706
16707 let mut extra = unsigned.clone();
16708 extra
16709 .as_object_mut()
16710 .unwrap()
16711 .insert("future".to_string(), Value::Bool(true));
16712 assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
16713
16714 let mut missing = unsigned.clone();
16715 missing.as_object_mut().unwrap().remove("v1_bridge");
16716 assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
16717
16718 let mut invalid_bridge = unsigned;
16719 invalid_bridge.as_object_mut().unwrap().insert(
16720 "v1_bridge".to_string(),
16721 json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
16722 );
16723 assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
16724 }
16725
16726 #[test]
16727 fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
16728 let vector: Value = serde_json::from_str(include_str!(
16729 "../tests/vectors/linkmd-v2-commit-bridge.json"
16730 ))
16731 .unwrap();
16732 let identity_value = vector.get("identity").unwrap();
16733 let identity = V2HeadIdentity {
16734 custody: "self".to_string(),
16735 fingerprint: identity_value
16736 .get("fingerprint")
16737 .and_then(Value::as_str)
16738 .unwrap()
16739 .to_string(),
16740 public_key_spki: identity_value
16741 .get("public_key_spki")
16742 .and_then(Value::as_str)
16743 .unwrap()
16744 .to_string(),
16745 previous: Vec::new(),
16746 rotations: Vec::new(),
16747 };
16748 let private = URL_SAFE_NO_PAD
16749 .decode(
16750 identity_value
16751 .get("private_key_pkcs8")
16752 .and_then(Value::as_str)
16753 .unwrap(),
16754 )
16755 .unwrap();
16756 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
16757 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
16758 .unwrap();
16759 let base = vector.get("body").unwrap().as_object().unwrap();
16760
16761 for item in vector.get("valid").unwrap().as_array().unwrap() {
16762 let mut body = base.clone();
16763 body.insert(
16764 "v1_bridge".to_string(),
16765 item.get("v1_bridge").unwrap().clone(),
16766 );
16767 body.insert(
16768 "sig".to_string(),
16769 item.get("signature_base64url").unwrap().clone(),
16770 );
16771 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16772 assert!(verified_v2_commit_object(&signed, &identity).is_ok());
16773 assert_eq!(
16774 crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
16775 item.get("commit_hash").and_then(Value::as_str).unwrap()
16776 );
16777 assert_eq!(
16778 format!("{:x}", Sha256::digest(&signed)),
16779 item.get("feed_hash").and_then(Value::as_str).unwrap()
16780 );
16781 }
16782
16783 for item in vector.get("invalid").unwrap().as_array().unwrap() {
16784 let mut body = base.clone();
16785 if let Some(remove) = item.get("remove").and_then(Value::as_array) {
16786 for field in remove {
16787 body.remove(field.as_str().unwrap());
16788 }
16789 }
16790 if let Some(set) = item.get("set").and_then(Value::as_object) {
16791 for (field, value) in set {
16792 body.insert(field.clone(), value.clone());
16793 }
16794 }
16795 let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
16796 body.insert(
16797 "sig".to_string(),
16798 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16799 );
16800 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16801 assert!(
16802 verified_v2_commit_object(&signed, &identity).is_err(),
16803 "accepted invalid shared vector {}",
16804 item.get("reason").and_then(Value::as_str).unwrap()
16805 );
16806 }
16807 }
16808
16809 #[test]
16810 fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
16811 let vector: Value = serde_json::from_str(include_str!(
16812 "../tests/vectors/linkmd-v2-changeset-withheld.json"
16813 ))
16814 .unwrap();
16815 assert_eq!(
16816 vector.get("profile").and_then(Value::as_str),
16817 Some("link.md-v2-changeset-withheld")
16818 );
16819 let canonical =
16820 crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
16821 let expected = STANDARD
16822 .decode(
16823 vector
16824 .get("canonical_base64")
16825 .and_then(Value::as_str)
16826 .unwrap(),
16827 )
16828 .unwrap();
16829 assert_eq!(canonical, expected);
16830 assert_eq!(
16831 crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
16832 vector.get("domain_hash").and_then(Value::as_str).unwrap()
16833 );
16834 }
16835
16836 #[test]
16837 fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
16838 let remote = signed_remote_fixture();
16839 let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
16840 let legacy_item = legacy.entries.first().unwrap();
16841 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
16842 let body = json!({
16843 "actor_ref": "a".repeat(64),
16844 "asset_root": Value::Null,
16845 "brain": remote.key.multikey,
16846 "changes_sha256": "b".repeat(64),
16847 "control_revision": "c".repeat(64),
16848 "materializer": "dbmd-projection-v1",
16849 "op": "changeset",
16850 "parent_asset_root": Value::Null,
16851 "parent_commit": Value::Null,
16852 "parent_root": Value::Null,
16853 "prev_entry_hash": Value::Null,
16854 "public_key": remote.key.public_key_spki,
16855 "seq": 1,
16856 "signer_epoch": 1,
16857 "state_root": "d".repeat(64),
16858 "ts": "2026-08-19T12:00:00.000Z",
16859 "v": 2,
16860 "v1_bridge": {
16861 "feed_hash": legacy_item.hash,
16862 "head_seq": legacy_item.entry.seq,
16863 "pack_sha256": legacy_item.entry.pack_sha256,
16864 },
16865 });
16866 let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
16867 let mut signed = body.as_object().unwrap().clone();
16868 signed.insert(
16869 "sig".to_string(),
16870 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16871 );
16872 let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
16873 let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
16874 let feed_hash = content_sha256(&raw);
16875 let pointer = V2PointerBody {
16876 v: 2,
16877 brain: TEST_BRAIN_ID.to_string(),
16878 seq: 1,
16879 commit_hash: commit_hash.clone(),
16880 feed_hash: feed_hash.clone(),
16881 content_root: Some("d".repeat(64)),
16882 asset_root: None,
16883 materializer: "dbmd-projection-v1".to_string(),
16884 signer_epoch: 1,
16885 control_revision: "c".repeat(64),
16886 backup_preparation: "e".repeat(64),
16887 prior_pointer_hash: None,
16888 signed_at: "2026-08-19T12:00:00.000Z".to_string(),
16889 };
16890 let v2_page = json!({
16891 "v": 2,
16892 "head_seq": 1,
16893 "head_commit_hash": commit_hash,
16894 "head_feed_hash": feed_hash,
16895 "entries": [{
16896 "seq": 1,
16897 "commit_hash": pointer.commit_hash,
16898 "feed_hash": pointer.feed_hash,
16899 "bytes_base64": STANDARD.encode(&raw),
16900 }],
16901 "next_after": 1,
16902 "complete": true,
16903 })
16904 .to_string();
16905 let identity = V2HeadIdentity {
16906 custody: "self".to_string(),
16907 fingerprint: remote.identity.fingerprint.clone(),
16908 public_key_spki: remote.identity.public_key_spki.clone(),
16909 previous: Vec::new(),
16910 rotations: Vec::new(),
16911 };
16912 let checkpoint = TrustState {
16913 v: 2,
16914 origin: "unused".to_string(),
16915 requested: TEST_BRAIN_ID.to_string(),
16916 brain: TEST_BRAIN_ID.to_string(),
16917 home: None,
16918 anchor: remote.key.multikey.clone(),
16919 current: remote.key.multikey,
16920 head_seq: legacy_item.entry.seq,
16921 feed_hash: Some(legacy_item.hash.clone()),
16922 rotations: Vec::new(),
16923 hub_signer: None,
16924 protocol_profile: None,
16925 };
16926 let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
16927 let state = tempfile::tempdir().unwrap();
16928 let cfg = test_hub_config(hub, state.path().to_path_buf());
16929 verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
16930 server.join().unwrap();
16931
16932 let mut wrong = checkpoint;
16933 wrong.feed_hash = Some("0".repeat(64));
16934 let (hub, server) = scripted_json_hub(vec![(
16935 200,
16936 json!({
16937 "v": 2,
16938 "head_seq": 1,
16939 "head_commit_hash": pointer.commit_hash,
16940 "head_feed_hash": pointer.feed_hash,
16941 "entries": [{
16942 "seq": 1,
16943 "commit_hash": pointer.commit_hash,
16944 "feed_hash": pointer.feed_hash,
16945 "bytes_base64": STANDARD.encode(&raw),
16946 }],
16947 "next_after": 1,
16948 "complete": true,
16949 })
16950 .to_string(),
16951 )]);
16952 let state = tempfile::tempdir().unwrap();
16953 let cfg = test_hub_config(hub, state.path().to_path_buf());
16954 assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
16955 server.join().unwrap();
16956 }
16957
16958 #[test]
16959 fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
16960 let rng = ring::rand::SystemRandom::new();
16961 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16962 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
16963 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16964 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
16965 let (old_spki, old_multikey) = public_identity_for(&old);
16966 let (new_spki, new_multikey) = public_identity_for(&new);
16967 let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
16968 v: 1,
16969 op: "rotate",
16970 brain: &old_multikey,
16971 public_key: &old_spki,
16972 new_brain: &new_multikey,
16973 new_public_key: &new_spki,
16974 prior_head_seq: 1,
16975 prior_feed_hash: Some(&"9".repeat(64)),
16976 ts: "2026-08-19T12:01:00.000Z".to_string(),
16977 })
16978 .unwrap();
16979 let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
16980 let rotation = format!(
16981 "{},\"sig\":\"{}\"}}",
16982 &rotation_unsigned[..rotation_unsigned.len() - 1],
16983 rotation_sig
16984 );
16985 let identity = V2HeadIdentity {
16986 custody: "self".to_string(),
16987 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
16988 public_key_spki: new_spki.clone(),
16989 previous: vec![V2PreviousIdentity {
16990 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
16991 public_key_spki: old_spki.clone(),
16992 }],
16993 rotations: vec![rotation],
16994 };
16995 let commit = |seq: u64,
16996 epoch: u64,
16997 multikey: &str,
16998 spki: &str,
16999 pair: &ring::signature::Ed25519KeyPair| {
17000 let value = json!({
17001 "actor_ref": "a".repeat(64),
17002 "asset_root": Value::Null,
17003 "brain": multikey,
17004 "changes_sha256": "b".repeat(64),
17005 "control_revision": "c".repeat(64),
17006 "materializer": "dbmd-projection-v1",
17007 "op": "changeset",
17008 "parent_asset_root": Value::Null,
17009 "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
17010 "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
17011 "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
17012 "public_key": spki,
17013 "seq": seq,
17014 "signer_epoch": epoch,
17015 "state_root": "1".repeat(64),
17016 "ts": "2026-08-19T12:00:00.000Z",
17017 "v": 2,
17018 "v1_bridge": Value::Null,
17019 });
17020 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
17021 let mut object = value.as_object().unwrap().clone();
17022 object.insert(
17023 "sig".to_string(),
17024 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17025 );
17026 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
17027 };
17028
17029 assert!(verified_v2_commit_object(
17030 &commit(1, 1, &old_multikey, &old_spki, &old),
17031 &identity,
17032 )
17033 .is_ok());
17034 assert!(verified_v2_commit_object(
17035 &commit(2, 2, &new_multikey, &new_spki, &new),
17036 &identity,
17037 )
17038 .is_ok());
17039 assert!(verified_v2_commit_object(
17040 &commit(2, 1, &old_multikey, &old_spki, &old),
17041 &identity,
17042 )
17043 .is_err());
17044 assert!(verified_v2_commit_object(
17045 &commit(1, 2, &new_multikey, &new_spki, &new),
17046 &identity,
17047 )
17048 .is_err());
17049 }
17050
17051 #[test]
17052 fn a_self_custody_entry_verifies_like_any_hub_entry() {
17053 let rng = ring::rand::SystemRandom::new();
17054 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17055 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17056 let (spki, multikey) = public_identity_for(&pair);
17057 let key = AgentSigningKey {
17058 pkcs8: pkcs8.as_ref().to_vec(),
17059 multikey: multikey.clone(),
17060 public_key_spki: spki.clone(),
17061 };
17062 let files = vec![WireFeedFile {
17063 path: "DB.md".to_string(),
17064 sha256: "a".repeat(64),
17065 bytes: 3,
17066 }];
17067 let raw = self_custody_entry(
17068 &key,
17069 1,
17070 "2026-07-23T12:00:00.000Z".to_string(),
17071 &"c".repeat(64),
17072 &files,
17073 None,
17074 )
17075 .unwrap();
17076 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
17080 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
17081 let item = FeedItem { hash, entry };
17082 let identity = FeedIdentity {
17083 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
17084 public_key_spki: spki,
17085 previous: Vec::new(),
17086 rotations: Vec::new(),
17087 };
17088 assert!(verify_feed_item(&item, &identity).is_ok());
17089 }
17090
17091 #[test]
17092 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
17093 let rng = ring::rand::SystemRandom::new();
17094 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17095 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17096 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17097 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17098 let (old_spki, old_multikey) = public_identity_for(&old);
17099 let (new_spki, new_multikey) = public_identity_for(&new);
17100 let unsigned = serde_json::to_string(&UnsignedRotation {
17101 v: 1,
17102 op: "rotate",
17103 brain: &old_multikey,
17104 public_key: &old_spki,
17105 new_brain: &new_multikey,
17106 new_public_key: &new_spki,
17107 prior_head_seq: 1,
17108 prior_feed_hash: Some(&"a".repeat(64)),
17109 ts: "2026-07-30T12:00:00.000Z".to_string(),
17110 })
17111 .unwrap();
17112 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
17113 let rotation = format!(
17114 "{},\"sig\":\"{}\"}}",
17115 &unsigned[..unsigned.len() - 1],
17116 signature
17117 );
17118 let identity = FeedIdentity {
17119 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17120 public_key_spki: new_spki,
17121 previous: vec![PreviousIdentity {
17122 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17123 public_key_spki: old_spki,
17124 }],
17125 rotations: vec![rotation],
17126 };
17127 let pin = TrustState {
17128 v: 2,
17129 origin: "https://hub.example".to_string(),
17130 requested: "brain".to_string(),
17131 brain: "brain".to_string(),
17132 home: None,
17133 anchor: old_multikey.clone(),
17134 current: old_multikey.clone(),
17135 head_seq: 1,
17136 feed_hash: Some("a".repeat(64)),
17137 rotations: Vec::new(),
17138 hub_signer: None,
17139 protocol_profile: None,
17140 };
17141 assert_eq!(
17142 verify_identity_chain(&identity, Some(&pin)).unwrap(),
17143 old_multikey
17144 );
17145 let mut accepted = pin.clone();
17146 accepted.current = new_multikey.clone();
17147 accepted.rotations = identity.rotations.clone();
17148 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
17149 v: 1,
17150 op: "rotate",
17151 brain: &old_multikey,
17152 public_key: &identity.previous[0].public_key_spki,
17153 new_brain: &new_multikey,
17154 new_public_key: &identity.public_key_spki,
17155 prior_head_seq: 1,
17156 prior_feed_hash: Some(&"a".repeat(64)),
17157 ts: "2026-07-30T12:00:01.000Z".to_string(),
17158 })
17159 .unwrap();
17160 let alternate_signature =
17161 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
17162 let mut rewritten = identity.clone();
17163 rewritten.rotations[0] = format!(
17164 "{},\"sig\":\"{}\"}}",
17165 &alternate_unsigned[..alternate_unsigned.len() - 1],
17166 alternate_signature
17167 );
17168 assert!(
17169 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
17170 "an alternate valid statement must not rewrite accepted history"
17171 );
17172
17173 let mut stale_entry = FeedEntry {
17174 v: 1,
17175 seq: 2,
17176 ts: "2026-07-30T12:01:00.000Z".to_string(),
17177 brain: pin.current.clone(),
17178 public_key: identity.previous[0].public_key_spki.clone(),
17179 kind: "push".to_string(),
17180 op: "snapshot".to_string(),
17181 pack_sha256: "b".repeat(64),
17182 files: Vec::new(),
17183 removed: Vec::new(),
17184 prev_entry_hash: pin.feed_hash.clone(),
17185 sig: String::new(),
17186 };
17187 let stale_unsigned = UnsignedFeedEntry {
17188 v: stale_entry.v,
17189 seq: stale_entry.seq,
17190 ts: &stale_entry.ts,
17191 brain: &stale_entry.brain,
17192 public_key: &stale_entry.public_key,
17193 kind: &stale_entry.kind,
17194 op: &stale_entry.op,
17195 pack_sha256: &stale_entry.pack_sha256,
17196 files: &stale_entry.files,
17197 removed: &stale_entry.removed,
17198 prev_entry_hash: &stale_entry.prev_entry_hash,
17199 };
17200 stale_entry.sig = URL_SAFE_NO_PAD.encode(
17201 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
17202 .as_ref(),
17203 );
17204 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
17205 stale_exact.push(b'\n');
17206 let stale_item = FeedItem {
17207 hash: content_sha256(&stale_exact),
17208 entry: stale_entry,
17209 };
17210 assert!(
17211 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
17212 .is_err(),
17213 "a key retired before the checkpoint must never append after it"
17214 );
17215 assert!(
17216 verify_feed_item(&stale_item, &identity).is_err(),
17217 "an old key must never append after its signed rotation boundary"
17218 );
17219
17220 let mut missing = identity.clone();
17221 missing.rotations.clear();
17222 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
17223
17224 let mut tampered = identity;
17225 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
17226 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
17227 }
17228
17229 #[cfg(unix)]
17230 #[test]
17231 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
17232 use std::os::unix::fs::symlink;
17233
17234 let dir = tempfile::tempdir().unwrap();
17235 let target = dir.path().join("valuable.txt");
17236 let planted = dir.path().join("agent.key");
17237 std::fs::write(&target, "do not overwrite").unwrap();
17238 symlink(&target, &planted).unwrap();
17239
17240 assert!(matches!(
17241 generate_agent_key(&planted),
17242 Err(LinkError::BadAgentKey { .. })
17243 ));
17244 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
17245 }
17246
17247 #[cfg(unix)]
17248 #[test]
17249 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
17250 use std::os::unix::fs::symlink;
17251
17252 let root = tempfile::tempdir().unwrap();
17253 let outside = tempfile::tempdir().unwrap();
17254 symlink(outside.path(), root.path().join("redirect")).unwrap();
17255
17256 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
17257 assert!(!outside.path().join("agent.key").exists());
17258 }
17259
17260 #[test]
17263 fn address_bare_brain_with_and_without_sigil() {
17264 for raw in ["@acme-ops", "acme-ops"] {
17265 let a = Address::parse(raw).expect(raw);
17266 assert_eq!(a.brain, "acme-ops");
17267 assert_eq!(a.target, None);
17268 }
17269 }
17270
17271 #[test]
17272 fn address_ulid_target_parses_as_id() {
17273 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
17274 assert_eq!(a.brain, "acme");
17275 assert_eq!(
17276 a.target,
17277 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
17278 );
17279 }
17280
17281 #[test]
17282 fn address_md_path_target_parses_as_path() {
17283 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
17284 assert_eq!(
17285 a.target,
17286 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
17287 );
17288 }
17289
17290 #[test]
17291 fn address_rejects_malformed_forms() {
17292 for raw in [
17293 "",
17294 "@",
17295 "@/x",
17296 "@acme/",
17297 "@acme/../etc/passwd",
17298 "@acme/records/.hidden.md",
17299 "@ACME", "@acme/notes/x.txt", "@a b", ] {
17303 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
17304 }
17305 }
17306
17307 #[test]
17310 fn safe_paths_accept_store_shapes_and_reject_escapes() {
17311 for ok in [
17312 "DB.md",
17313 "assets.jsonl",
17314 "records/clients/lumio.md",
17315 "sources/emails/2026/07/x.md",
17316 ] {
17317 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
17318 }
17319 for bad in [
17320 "",
17321 "/etc/passwd",
17322 "../up.md",
17323 "records/../../up.md",
17324 "records//x.md",
17325 ".dbmd/config",
17326 "records/.hidden/x.md",
17327 "records/a b.md",
17328 "records\\win.md",
17329 ] {
17330 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
17331 }
17332 }
17333
17334 #[cfg(unix)]
17335 #[test]
17336 fn opened_destination_capability_survives_an_ancestor_path_swap() {
17337 use std::os::unix::fs::symlink;
17338
17339 let work = tempfile::tempdir().unwrap();
17340 let outside = tempfile::tempdir().unwrap();
17341 let original = work.path().join("destination");
17342 let moved = work.path().join("destination-moved");
17343 let directory = open_or_create_dir_nofollow(&original).unwrap();
17344
17345 std::fs::rename(&original, &moved).unwrap();
17346 symlink(outside.path(), &original).unwrap();
17347 write_pull_entries_beneath_dir(
17348 &directory,
17349 &[("records/note.md".to_string(), b"held inode".to_vec())],
17350 )
17351 .unwrap();
17352
17353 assert_eq!(
17354 std::fs::read(moved.join("records/note.md")).unwrap(),
17355 b"held inode"
17356 );
17357 assert!(!outside.path().join("records/note.md").exists());
17358 }
17359
17360 #[test]
17364 fn hub_config_flag_beats_file_and_requires_some_source() {
17365 let dir = tempfile::tempdir().unwrap();
17366 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
17367 std::fs::write(
17368 dir.path().join(CONFIG_REL_PATH),
17369 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
17370 )
17371 .unwrap();
17372
17373 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
17374 assert_eq!(from_flag.hub, "https://flag.example.com");
17375
17376 let from_file = hub_config(None, dir.path()).unwrap();
17377 assert_eq!(from_file.hub, "https://file.example.com");
17378
17379 let none = hub_config(None, tempfile::tempdir().unwrap().path());
17380 assert!(matches!(none, Err(LinkError::NoHub)));
17381 }
17382
17383 #[test]
17384 fn https_guard_allows_loopback_only_for_plain_http() {
17385 assert!(assert_safe_hub("https://hub.example.com").is_ok());
17386 assert!(assert_safe_hub("http://localhost:3000").is_ok());
17387 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
17388 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
17389 assert!(matches!(
17390 assert_safe_hub("http://hub.example.com"),
17391 Err(LinkError::UnsafeHub { .. })
17392 ));
17393 assert!(matches!(
17394 assert_safe_hub("hub.example.com"),
17395 Err(LinkError::UnsafeHub { .. })
17396 ));
17397 assert!(matches!(
17398 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
17399 Err(LinkError::UnsafeHub { .. })
17400 ));
17401 assert!(matches!(
17402 assert_safe_hub("https://hub.example.com@attacker.example"),
17403 Err(LinkError::UnsafeHub { .. })
17404 ));
17405 assert!(matches!(
17406 assert_safe_hub("https://hub.example.com/base"),
17407 Err(LinkError::UnsafeHub { .. })
17408 ));
17409 }
17410
17411 #[test]
17412 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
17413 for blocked in [
17414 "127.0.0.1",
17415 "10.0.0.1",
17416 "100.64.0.1",
17417 "169.254.169.254",
17418 "172.16.0.1",
17419 "192.168.0.1",
17420 "192.88.99.1",
17421 "198.18.0.1",
17422 "203.0.113.1",
17423 "::1",
17424 "fe80::1",
17425 "fd00::1",
17426 "2001:db8::1",
17427 "2001:1::1",
17428 "2002:7f00:1::",
17429 "3fff::1",
17430 ] {
17431 assert!(
17432 !is_public_registry_ip(blocked.parse().unwrap()),
17433 "must block {blocked}"
17434 );
17435 }
17436 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
17437 assert!(is_public_registry_ip(
17438 "2606:4700:4700::1111".parse().unwrap()
17439 ));
17440 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
17441 }
17442
17443 #[test]
17444 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
17445 use ureq::Resolver as _;
17446
17447 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
17448 let resolver = PinnedRegistryResolver {
17449 netloc: "home.example:443".to_string(),
17450 addresses: vec![pinned],
17451 };
17452 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
17453 assert!(resolver.resolve("127.0.0.1:443").is_err());
17454 assert_eq!(
17455 resolver.resolve("home.example:443").unwrap(),
17456 vec![pinned],
17457 "subsequent connects reuse the validated answer instead of DNS"
17458 );
17459 }
17460
17461 #[test]
17462 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
17463 let cfg = HubConfig {
17464 hub: "https://hub.example".to_string(),
17465 key: None,
17466 agent_key: None,
17467 brain_key: None,
17468 state_dir: tempfile::tempdir().unwrap().keep(),
17469 store_selected: false,
17470 };
17471 assert!(
17472 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
17473 "a production hub must not turn its presigned URL into an SSRF primitive"
17474 );
17475
17476 let store_selected = HubConfig {
17477 hub: "https://127.0.0.1".to_string(),
17478 store_selected: true,
17479 ..cfg
17480 };
17481 assert!(
17482 hub_agent(&store_selected).is_err(),
17483 "bytes in a cloned store must not select a private-network hub"
17484 );
17485 }
17486
17487 #[test]
17488 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
17489 assert_eq!(
17490 one_past_bounded_limit(MAX_PACK_BYTES),
17491 Some(MAX_PACK_BYTES + 1),
17492 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
17493 );
17494 assert_eq!(
17495 presigned_download_read_limit(),
17496 MAX_PACK_BYTES + 1,
17497 "the presigned reader is capped by the client constant, not a hub response"
17498 );
17499 assert_eq!(
17500 one_past_bounded_limit(u64::MAX),
17501 None,
17502 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
17503 );
17504 }
17505
17506 #[test]
17507 fn https_guard_matches_the_scheme_case_insensitively() {
17508 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
17511 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
17512 assert!(matches!(
17514 assert_safe_hub("HTTP://hub.example.com"),
17515 Err(LinkError::UnsafeHub { .. })
17516 ));
17517 }
17518
17519 #[test]
17520 fn clean_key_refuses_paste_artifacts_without_echoing() {
17521 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
17522 for bad in ["vc account", "vc\naccount", "ключ", ""] {
17523 let err = clean_key(bad).unwrap_err();
17524 assert!(matches!(err, LinkError::BadKey));
17525 assert!(
17526 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
17527 "error must not echo the key"
17528 );
17529 }
17530 }
17531
17532 fn dead_hub() -> HubConfig {
17538 HubConfig {
17539 hub: "http://127.0.0.1:9".to_string(),
17540 key: Some("k".to_string()),
17541 agent_key: None,
17542 brain_key: None,
17543 state_dir: PathBuf::from("."),
17544 store_selected: false,
17545 }
17546 }
17547
17548 #[test]
17549 fn request_retries_a_connection_failure_before_sending() {
17550 use std::io::{Read as _, Write as _};
17551 use std::net::TcpListener;
17552 use std::thread;
17553 use std::time::Duration;
17554
17555 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
17556 let address = probe.local_addr().unwrap();
17557 drop(probe);
17558 let server = thread::spawn(move || {
17559 thread::sleep(Duration::from_millis(40));
17560 let listener = TcpListener::bind(address).unwrap();
17561 let (mut stream, _) = listener.accept().unwrap();
17562 let mut request_bytes = [0_u8; 1024];
17563 let _ = stream.read(&mut request_bytes).unwrap();
17564 stream
17565 .write_all(
17566 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17567 )
17568 .unwrap();
17569 });
17570 let cfg = HubConfig {
17571 hub: format!("http://{address}"),
17572 key: None,
17573 agent_key: None,
17574 brain_key: None,
17575 state_dir: tempfile::tempdir().unwrap().keep(),
17576 store_selected: false,
17577 };
17578
17579 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
17580 assert_eq!(response.status, 200);
17581 assert_eq!(response.body, Some(json!({ "ok": true })));
17582 server.join().unwrap();
17583 }
17584
17585 #[test]
17586 fn a_commit_goes_back_for_a_receipt_it_lost() {
17587 use std::io::{Read as _, Write as _};
17588 use std::net::TcpListener;
17589 use std::thread;
17590
17591 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17597 let address = listener.local_addr().unwrap();
17598 let server = thread::spawn(move || {
17599 let (mut first, _) = listener.accept().unwrap();
17601 let mut bytes = [0_u8; 4096];
17602 let _ = first.read(&mut bytes).unwrap();
17603 first
17604 .write_all(
17605 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
17606 )
17607 .unwrap();
17608 drop(first);
17609 let (mut second, _) = listener.accept().unwrap();
17611 let _ = second.read(&mut bytes).unwrap();
17612 second
17613 .write_all(
17614 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\"}",
17615 )
17616 .unwrap();
17617 });
17618 let cfg = HubConfig {
17619 hub: format!("http://{address}"),
17620 key: Some("k".to_string()),
17621 agent_key: None,
17622 brain_key: None,
17623 state_dir: tempfile::tempdir().unwrap().keep(),
17624 store_selected: false,
17625 };
17626
17627 let response = request_patient(
17628 &cfg,
17629 "POST",
17630 "/api/hub/brains/b/v2/commits",
17631 Some(&json!({ "mutation_id": "dbmd-1" })),
17632 Auth::Required,
17633 )
17634 .expect("the receipt is collected on the second ask");
17635 assert_eq!(response.status, 200);
17636 assert_eq!(
17637 response
17638 .body
17639 .as_ref()
17640 .and_then(|value| value.get("outcome"))
17641 .and_then(Value::as_str),
17642 Some("converged"),
17643 "an already-applied mutation answers with its receipt"
17644 );
17645 server.join().unwrap();
17646 }
17647
17648 #[test]
17649 fn a_mutation_body_that_dies_mid_stream_is_a_transport_failure() {
17650 use std::io::{Read as _, Write as _};
17651 use std::net::TcpListener;
17652 use std::thread;
17653
17654 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17660 let address = listener.local_addr().unwrap();
17661 let server = thread::spawn(move || {
17662 let (mut stream, _) = listener.accept().unwrap();
17663 let mut request_bytes = [0_u8; 1024];
17664 let _ = stream.read(&mut request_bytes).unwrap();
17665 stream
17667 .write_all(
17668 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17669 )
17670 .unwrap();
17671 });
17672 let cfg = HubConfig {
17673 hub: format!("http://{address}"),
17674 key: None,
17675 agent_key: None,
17676 brain_key: None,
17677 state_dir: tempfile::tempdir().unwrap().keep(),
17678 store_selected: false,
17679 };
17680
17681 let error = request(&cfg, "POST", "/truncated", None, Auth::None)
17682 .expect_err("a truncated body must not read as success");
17683 match error {
17684 LinkError::Transport { hub, .. } => {
17685 assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
17686 }
17687 other => panic!("expected a transport failure, got {other:?}"),
17688 }
17689 server.join().unwrap();
17690 }
17691
17692 #[test]
17693 fn a_safe_get_retries_when_its_body_dies_mid_stream() {
17694 use std::io::{Read as _, Write as _};
17695 use std::net::{TcpListener, TcpStream};
17696 use std::thread;
17697
17698 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17699 let address = listener.local_addr().unwrap();
17700 let server = thread::spawn(move || {
17701 let read_request = |stream: &mut TcpStream| {
17702 let mut request = Vec::new();
17703 let mut bytes = [0_u8; 1024];
17704 loop {
17705 let read = stream.read(&mut bytes).unwrap();
17706 if read == 0 {
17707 break;
17708 }
17709 request.extend_from_slice(&bytes[..read]);
17710 let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17711 else {
17712 continue;
17713 };
17714 let headers = String::from_utf8_lossy(&request[..header_end]);
17715 let content_length = headers
17716 .lines()
17717 .find_map(|line| {
17718 let (name, value) = line.split_once(':')?;
17719 name.eq_ignore_ascii_case("content-length")
17720 .then(|| value.trim().parse::<usize>().ok())
17721 .flatten()
17722 })
17723 .unwrap_or(0);
17724 if request.len() >= header_end + 4 + content_length {
17725 break;
17726 }
17727 }
17728 };
17729 let (mut first, _) = listener.accept().unwrap();
17730 read_request(&mut first);
17731 first
17732 .write_all(
17733 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17734 )
17735 .unwrap();
17736 drop(first);
17737
17738 let (mut second, _) = listener.accept().unwrap();
17739 read_request(&mut second);
17740 second
17741 .write_all(
17742 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17743 )
17744 .unwrap();
17745 });
17746 let cfg = HubConfig {
17747 hub: format!("http://{address}"),
17748 key: None,
17749 agent_key: None,
17750 brain_key: None,
17751 state_dir: tempfile::tempdir().unwrap().keep(),
17752 store_selected: false,
17753 };
17754
17755 let response = request(&cfg, "GET", "/retry-body", None, Auth::None)
17756 .expect("a safe read retries the interrupted body");
17757 assert_eq!(response.status, 200);
17758 assert_eq!(response.body, Some(json!({ "ok": true })));
17759 server.join().unwrap();
17760 }
17761
17762 #[test]
17763 fn an_explicit_read_only_post_retries_when_its_body_dies_mid_stream() {
17764 use std::io::{Read as _, Write as _};
17765 use std::net::{TcpListener, TcpStream};
17766 use std::thread;
17767
17768 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17769 let address = listener.local_addr().unwrap();
17770 let server = thread::spawn(move || {
17771 let read_request = |stream: &mut TcpStream| {
17772 let mut request = Vec::new();
17773 let mut bytes = [0_u8; 1024];
17774 loop {
17775 let read = stream.read(&mut bytes).unwrap();
17776 if read == 0 {
17777 break;
17778 }
17779 request.extend_from_slice(&bytes[..read]);
17780 let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17781 else {
17782 continue;
17783 };
17784 let headers = String::from_utf8_lossy(&request[..header_end]);
17785 let content_length = headers
17786 .lines()
17787 .find_map(|line| {
17788 let (name, value) = line.split_once(':')?;
17789 name.eq_ignore_ascii_case("content-length")
17790 .then(|| value.trim().parse::<usize>().ok())
17791 .flatten()
17792 })
17793 .unwrap_or(0);
17794 if request.len() >= header_end + 4 + content_length {
17795 break;
17796 }
17797 }
17798 };
17799 let (mut first, _) = listener.accept().unwrap();
17800 read_request(&mut first);
17801 first
17802 .write_all(
17803 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17804 )
17805 .unwrap();
17806 drop(first);
17807
17808 let (mut second, _) = listener.accept().unwrap();
17809 read_request(&mut second);
17810 second
17811 .write_all(
17812 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17813 )
17814 .unwrap();
17815 });
17816 let cfg = HubConfig {
17817 hub: format!("http://{address}"),
17818 key: None,
17819 agent_key: None,
17820 brain_key: None,
17821 state_dir: tempfile::tempdir().unwrap().keep(),
17822 store_selected: false,
17823 };
17824
17825 let response = request_raw_retryable_read(
17826 &cfg,
17827 "POST",
17828 "/v2/stream",
17829 Some(&json!({ "files": ["proof"] })),
17830 Auth::None,
17831 1_024,
17832 )
17833 .expect("an explicitly safe POST retries the interrupted body");
17834 assert_eq!(response.status, 200);
17835 assert_eq!(
17836 serde_json::from_slice::<Value>(&response.body).unwrap(),
17837 json!({ "ok": true })
17838 );
17839 server.join().unwrap();
17840 }
17841
17842 #[test]
17843 fn object_store_transport_errors_never_render_presigned_urls() {
17844 use std::net::TcpListener;
17845
17846 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17847 let address = listener.local_addr().unwrap();
17848 drop(listener);
17849 let signature = "do-not-render-this-presigned-signature";
17850 let raw =
17851 format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
17852 let error = ureq::get(&raw)
17853 .timeout(std::time::Duration::from_millis(250))
17854 .call()
17855 .expect_err("the closed local port must fail");
17856 let ureq::Error::Transport(transport) = error else {
17857 panic!("expected a transport failure");
17858 };
17859
17860 let rendered = object_store_transport_error(transport).to_string();
17861 assert!(rendered.contains("the object store"));
17862 assert!(rendered.contains("network error"));
17863 assert!(!rendered.contains(&raw));
17864 assert!(!rendered.contains(signature));
17865 assert!(!rendered.contains("X-Amz-"));
17866 }
17867
17868 #[test]
17869 fn endpoint_cap_refuses_a_body_before_json_parsing() {
17870 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
17871 let cfg = HubConfig {
17872 hub,
17873 key: None,
17874 agent_key: None,
17875 brain_key: None,
17876 state_dir: tempfile::tempdir().unwrap().keep(),
17877 store_selected: false,
17878 };
17879
17880 assert!(matches!(
17881 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
17882 Err(LinkError::ResponseTooLarge { .. })
17883 ));
17884 server.join().unwrap();
17885 }
17886
17887 #[test]
17888 fn overall_deadline_stops_a_dribbled_response_body() {
17889 use std::io::{Read as _, Write as _};
17890 use std::net::TcpListener;
17891 use std::time::{Duration, Instant};
17892
17893 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17894 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
17895 let server = std::thread::spawn(move || {
17896 let (mut stream, _) = listener.accept().unwrap();
17897 let mut request = [0_u8; 1024];
17898 let _ = stream.read(&mut request);
17899 stream
17900 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
17901 .unwrap();
17902 for byte in [b'x'; 32] {
17903 if stream.write_all(&[byte]).is_err() {
17904 break;
17905 }
17906 std::thread::sleep(Duration::from_millis(40));
17907 }
17908 });
17909 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17910 let started = Instant::now();
17911 let response = http.get(&url).call().unwrap();
17912 let mut body = Vec::new();
17913 let error = response
17914 .into_reader()
17915 .read_to_end(&mut body)
17916 .expect_err("per-read progress must not reset the overall deadline");
17917 assert!(
17918 started.elapsed() < Duration::from_millis(700),
17919 "dribbled body exceeded the wall-clock budget: {error}"
17920 );
17921 server.join().unwrap();
17922 }
17923
17924 #[test]
17925 fn overall_deadline_stops_a_stalled_upload() {
17926 use std::net::TcpListener;
17927 use std::time::{Duration, Instant};
17928
17929 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17930 let url = format!("http://{}/upload", listener.local_addr().unwrap());
17931 let server = std::thread::spawn(move || {
17932 let (_stream, _) = listener.accept().unwrap();
17933 std::thread::sleep(Duration::from_millis(600));
17936 });
17937 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17938 let body = vec![0x5a; 32 * 1024 * 1024];
17939 let started = Instant::now();
17940 let error = http
17941 .put(&url)
17942 .send_bytes(&body)
17943 .expect_err("stalled request-body writes must time out");
17944 assert!(
17945 started.elapsed() < Duration::from_millis(700),
17946 "stalled upload exceeded the wall-clock budget: {error}"
17947 );
17948 server.join().unwrap();
17949 }
17950
17951 #[test]
17952 fn presigned_source_retries_share_one_upload_deadline() {
17953 use std::net::TcpListener;
17954 use std::time::{Duration, Instant};
17955
17956 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17957 let address = listener.local_addr().unwrap();
17958 let signature = "do-not-render-this-stalled-upload-signature";
17959 let url = format!(
17960 "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
17961 );
17962 let server = std::thread::spawn(move || {
17963 let (_stream, _) = listener.accept().unwrap();
17964 std::thread::sleep(Duration::from_millis(600));
17968 });
17969
17970 let directory = tempfile::tempdir().unwrap();
17971 std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17972 std::fs::create_dir(directory.path().join("records")).unwrap();
17973 let relative = "records/stalled.bin";
17974 let bytes = vec![0x5a; 32 * 1024 * 1024];
17975 std::fs::write(directory.path().join(relative), &bytes).unwrap();
17976 let store = Store::open_strict(directory.path()).unwrap();
17977 let cfg = HubConfig {
17978 hub: format!("http://{address}"),
17979 key: None,
17980 agent_key: None,
17981 brain_key: None,
17982 state_dir: tempfile::tempdir().unwrap().keep(),
17983 store_selected: false,
17984 };
17985 let source = V2UploadSource {
17986 path: relative.to_string(),
17987 bytes: bytes.len() as u64,
17988 };
17989
17990 let started = Instant::now();
17991 let error = put_presigned_source_with_budget(
17992 &cfg,
17993 &url,
17994 &json!({ "content-length": source.bytes.to_string() }),
17995 &store,
17996 &source,
17997 None,
17998 Duration::from_millis(150),
17999 )
18000 .expect_err("a black-holed upload must leave at its shared deadline");
18001 assert!(
18002 started.elapsed() < Duration::from_millis(700),
18003 "presigned retries exceeded their shared budget: {error}"
18004 );
18005 let rendered = error.to_string();
18006 assert!(rendered.contains("the object store"));
18007 assert!(!rendered.contains(&url));
18008 assert!(!rendered.contains(signature));
18009 server.join().unwrap();
18010 }
18011
18012 #[test]
18013 fn verb_entry_gates_accept_the_hub_ref_shapes() {
18014 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
18015 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
18016 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
18017 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
18018 }
18019 }
18020
18021 #[test]
18022 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
18023 let cfg = dead_hub();
18024 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
18025 assert!(
18026 matches!(
18027 sync_pull(&cfg, bad, None),
18028 Err(LinkError::BadAddress { .. })
18029 ),
18030 "sync_pull must refuse {bad:?}"
18031 );
18032 assert!(
18033 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
18034 "sync_push must refuse {bad:?}"
18035 );
18036 assert!(
18037 matches!(
18038 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
18039 Err(LinkError::BadAddress { .. })
18040 ),
18041 "grant_issue must refuse {bad:?}"
18042 );
18043 assert!(
18044 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
18045 "grant_list must refuse {bad:?}"
18046 );
18047 assert!(
18048 matches!(
18049 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
18050 Err(LinkError::BadAddress { .. })
18051 ),
18052 "grant_revoke must refuse brain {bad:?}"
18053 );
18054 assert!(
18055 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
18056 "head must refuse {bad:?}"
18057 );
18058 }
18059 }
18060
18061 #[test]
18062 fn grant_revoke_refuses_url_reshaping_grant_ids() {
18063 let cfg = dead_hub();
18064 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
18065 assert!(
18066 matches!(
18067 grant_revoke(&cfg, "acme", bad),
18068 Err(LinkError::BadGrantId { .. })
18069 ),
18070 "grant_revoke must refuse grant id {bad:?}"
18071 );
18072 }
18073 }
18074
18075 #[test]
18076 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
18077 let cfg = dead_hub();
18078 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
18079 assert!(
18080 matches!(
18081 propose(&cfg, bad, "intake", "hi"),
18082 Err(LinkError::BadAddress { .. })
18083 ),
18084 "propose must refuse handle {bad:?}"
18085 );
18086 }
18087 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
18088 assert!(matches!(
18089 propose(&cfg, "acme-site", "intake", &oversize),
18090 Err(LinkError::ProposeTooLarge { .. })
18091 ));
18092 assert!(matches!(
18095 propose(&cfg, "acme-site", "intake", "hi"),
18096 Err(LinkError::Transport { .. })
18097 ));
18098 }
18099
18100 #[test]
18101 fn resolve_refuses_a_hand_built_unsafe_address() {
18102 let cfg = dead_hub();
18103 for brain in ["../up", "a/b", "a?x", "a#f"] {
18104 let addr = Address {
18105 brain: brain.to_string(),
18106 target: None,
18107 };
18108 assert!(
18109 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18110 "resolve must refuse brain {brain:?}"
18111 );
18112 }
18113 for target in [
18114 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
18115 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
18117 AddressTarget::Path("records/x.md#frag".to_string()),
18118 ] {
18119 let addr = Address {
18120 brain: "acme".to_string(),
18121 target: Some(target.clone()),
18122 };
18123 assert!(
18124 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18125 "resolve must refuse target {target:?}"
18126 );
18127 }
18128 }
18129
18130 #[test]
18131 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
18132 let mut local = std::collections::BTreeMap::new();
18133 local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
18134 local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
18135 let mut remote = std::collections::BTreeMap::new();
18136 remote.insert(
18137 "records/a.md".to_string(),
18138 V2BaselineFile {
18139 sha256: "c".repeat(64),
18140 bytes: 1,
18141 proof: None,
18142 },
18143 );
18144 remote.insert(
18145 "records/b.md".to_string(),
18146 V2BaselineFile {
18147 sha256: "b".repeat(64),
18148 bytes: 1,
18149 proof: None,
18150 },
18151 );
18152 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18153 }
18154
18155 #[test]
18156 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
18157 let local = std::collections::BTreeMap::new();
18158 let mut remote = std::collections::BTreeMap::new();
18159 remote.insert(
18160 "private/local.md".to_string(),
18161 V2BaselineFile {
18162 sha256: "d".repeat(64),
18163 bytes: 1,
18164 proof: None,
18165 },
18166 );
18167 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
18168 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18169 }
18170
18171 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
18172 V2VerifiedHead {
18173 requested: TEST_BRAIN_ID.to_string(),
18174 brain_id: TEST_BRAIN_ID.to_string(),
18175 view_kind: "scoped".to_string(),
18176 view_revision: revision.to_string(),
18177 control_revision: revision.to_string(),
18178 identity: V2HeadIdentity {
18179 custody: "hub".to_string(),
18180 fingerprint: "test".to_string(),
18181 public_key_spki: "test".to_string(),
18182 previous: Vec::new(),
18183 rotations: Vec::new(),
18184 },
18185 pointer: None,
18186 trust: TrustState {
18187 v: 2,
18188 origin: "https://hub.example".to_string(),
18189 requested: TEST_BRAIN_ID.to_string(),
18190 brain: TEST_BRAIN_ID.to_string(),
18191 home: None,
18192 anchor: "ed25519:test".to_string(),
18193 current: "ed25519:test".to_string(),
18194 head_seq: 0,
18195 feed_hash: None,
18196 rotations: Vec::new(),
18197 hub_signer: None,
18198 protocol_profile: Some("link-v2".to_string()),
18199 },
18200 alias: None,
18201 }
18202 }
18203
18204 #[test]
18205 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
18206 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
18207 assert!(accepted_as_v2(&trust));
18208
18209 trust.protocol_profile = None;
18210 trust.hub_signer = Some("ed25519:hub".to_string());
18211 assert!(accepted_as_v2(&trust));
18212
18213 trust.hub_signer = None;
18214 assert!(!accepted_as_v2(&trust));
18215 }
18216
18217 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
18218 V2SyncBaseline {
18219 v: 2,
18220 origin: "https://hub.example".to_string(),
18221 brain: TEST_BRAIN_ID.to_string(),
18222 checkout_id: Some("c".repeat(64)),
18223 head_seq: Some(0),
18224 commit_hash: None,
18225 content_root: None,
18226 asset_root: None,
18227 assets: std::collections::BTreeMap::new(),
18228 view_kind: Some("scoped".to_string()),
18229 view_revision: Some(revision.to_string()),
18230 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
18231 files: std::collections::BTreeMap::new(),
18232 local_policy_digest: None,
18233 local_eligibility: std::collections::BTreeMap::new(),
18234 remote_copy_remains: std::collections::BTreeMap::new(),
18235 }
18236 }
18237
18238 #[test]
18239 fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
18240 let cfg = test_hub_config(
18241 "https://hub.example".to_string(),
18242 tempfile::tempdir().unwrap().keep(),
18243 );
18244 let mut baseline = scoped_test_baseline(&"a".repeat(64));
18245 baseline.assets.insert(
18246 "assets/archive.bin".to_string(),
18247 V2BaselineAsset {
18248 blob_sha256: "b".repeat(64),
18249 bytes: MAX_STORE_BYTES + 1,
18250 media_type: "application/octet-stream".to_string(),
18251 wrappers: vec!["records/archive.md".to_string()],
18252 required: true,
18253 disposition: "hosted".to_string(),
18254 leaf_hash: "c".repeat(64),
18255 },
18256 );
18257
18258 let accepted = serde_json::to_vec(&baseline).unwrap();
18259 assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
18260
18261 baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
18262 let refused = serde_json::to_vec(&baseline).unwrap();
18263 assert!(matches!(
18264 parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
18265 Err(LinkError::InvalidFeed { .. })
18266 ));
18267 }
18268
18269 #[test]
18270 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
18271 let directory = tempfile::tempdir().unwrap();
18272 std::fs::write(
18273 directory.path().join("DB.md"),
18274 scoped_projection_bytes(TEST_BRAIN_ID),
18275 )
18276 .unwrap();
18277 let store = Store::open_strict(directory.path()).unwrap();
18278 let head = scoped_test_head(&"a".repeat(64));
18279 let baseline = scoped_test_baseline(&"a".repeat(64));
18280 let mut view = v2_local_files(&store).unwrap();
18281 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
18282 assert!(!view.riding.contains_key("DB.md"));
18283 assert!(!view.eligibility.contains_key("DB.md"));
18284 }
18285
18286 #[test]
18287 fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
18288 let directory = tempfile::tempdir().unwrap();
18289 std::fs::write(
18290 directory.path().join("DB.md"),
18291 scoped_projection_bytes(TEST_BRAIN_ID),
18292 )
18293 .unwrap();
18294 let store = Store::open_strict(directory.path()).unwrap();
18295 let head = scoped_test_head(&"a".repeat(64));
18296 let baseline = scoped_test_baseline(&"a".repeat(64));
18297
18298 let mut carried = v2_local_files(&store).unwrap();
18299 remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
18300 let handed_off =
18301 local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
18302 assert!(!handed_off.riding.contains_key("DB.md"));
18303
18304 let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
18305 assert!(!freshly_scanned.riding.contains_key("DB.md"));
18306
18307 std::fs::write(
18308 directory.path().join("DB.md"),
18309 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18310 )
18311 .unwrap();
18312 let tampered = Store::open_strict(directory.path()).unwrap();
18313 assert!(matches!(
18314 local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
18315 Err(LinkError::ScopedProjectionModified)
18316 ));
18317 }
18318
18319 #[test]
18320 fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
18321 let directory = tempfile::tempdir().unwrap();
18322 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18323 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18324 std::fs::write(
18325 directory.path().join("DB.md"),
18326 b"---\nname: Kept home test\n---\n",
18327 )
18328 .unwrap();
18329 std::fs::write(
18330 directory.path().join("records/notes/a.md"),
18331 b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
18332 )
18333 .unwrap();
18334 std::fs::write(
18335 directory.path().join("sources/private/secret.md"),
18336 b"---\ntype: note\n---\nlocal only\n",
18337 )
18338 .unwrap();
18339 std::fs::write(
18340 directory.path().join("sources/private/unlinked.md"),
18341 b"---\ntype: note\n---\nnot disclosed\n",
18342 )
18343 .unwrap();
18344 std::fs::write(
18345 directory.path().join(".sevralocal"),
18346 b"sources/private/**\n",
18347 )
18348 .unwrap();
18349
18350 let store = Store::open_strict(directory.path()).unwrap();
18351 let view = v2_local_files(&store).unwrap();
18352 assert!(!view.riding.contains_key("sources/private/secret.md"));
18353 assert_eq!(
18354 view.withheld_links,
18355 vec![V2WithheldLink {
18356 source: "records/notes/a.md".to_string(),
18357 target: "sources/private/secret.md".to_string(),
18358 }]
18359 );
18360 }
18361
18362 #[test]
18363 fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
18364 let directory = tempfile::tempdir().unwrap();
18369 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18370 std::fs::write(
18371 directory.path().join("DB.md"),
18372 b"---\nname: Restored export\n---\n",
18373 )
18374 .unwrap();
18375 std::fs::write(
18376 directory.path().join("records/notes/a.md"),
18377 b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
18378 )
18379 .unwrap();
18380 std::fs::write(
18381 directory.path().join(".sevralocal"),
18382 b"sources/private/**\n",
18383 )
18384 .unwrap();
18385
18386 let store = Store::open_strict(directory.path()).unwrap();
18387 let view = v2_local_files(&store).unwrap();
18388 assert_eq!(
18389 view.withheld_links,
18390 vec![V2WithheldLink {
18391 source: "records/notes/a.md".to_string(),
18392 target: "sources/private/absent.md".to_string(),
18393 }]
18394 );
18395 std::fs::write(
18397 directory.path().join("records/notes/b.md"),
18398 b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
18399 )
18400 .unwrap();
18401 let store = Store::open_strict(directory.path()).unwrap();
18402 let view = v2_local_files(&store).unwrap();
18403 assert!(
18404 !view
18405 .withheld_links
18406 .iter()
18407 .any(|link| link.target == "records/notes/nowhere.md"),
18408 "an unclaimed dangling target must not be declared withheld"
18409 );
18410 }
18411
18412 #[test]
18413 fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
18414 let directory = tempfile::tempdir().unwrap();
18415 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18416 std::fs::write(
18417 directory.path().join("DB.md"),
18418 b"---\nname: Withdrawal test\n---\n",
18419 )
18420 .unwrap();
18421 let source = b"---\ntype: note\n---\nlocal evidence\n";
18422 std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
18423 std::fs::write(
18424 directory.path().join(".sevralocal"),
18425 b"sources/private/**\n",
18426 )
18427 .unwrap();
18428 let store = Store::open_strict(directory.path()).unwrap();
18429 let view = v2_local_files(&store).unwrap();
18430 let mut remote = std::collections::BTreeMap::new();
18431 remote.insert(
18432 "sources/private/evidence.md".to_string(),
18433 V2BaselineFile {
18434 sha256: content_sha256(source),
18435 bytes: source.len() as u64,
18436 proof: None,
18437 },
18438 );
18439 assert_eq!(
18440 v2_content_withdrawal_operation(
18441 &store,
18442 &view,
18443 &remote,
18444 "sources/private/evidence.md",
18445 "approved retention change",
18446 )
18447 .unwrap(),
18448 json!({
18449 "op": "withdraw_from_hosting",
18450 "path": "sources/private/evidence.md",
18451 "expected": { "kind": "blob", "hash": content_sha256(source) },
18452 "reason": "approved retention change",
18453 })
18454 );
18455
18456 std::fs::write(
18457 directory.path().join("sources/private/evidence.md"),
18458 b"changed after review",
18459 )
18460 .unwrap();
18461 assert!(matches!(
18462 v2_content_withdrawal_operation(
18463 &store,
18464 &view,
18465 &remote,
18466 "sources/private/evidence.md",
18467 "approved retention change",
18468 ),
18469 Err(LinkError::InvalidPack { .. })
18470 ));
18471 }
18472
18473 #[test]
18474 fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
18475 let directory = tempfile::tempdir().unwrap();
18476 std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
18477 std::fs::write(
18478 directory.path().join("DB.md"),
18479 b"---\nname: Asset withdrawal test\n---\n",
18480 )
18481 .unwrap();
18482 let bytes = b"private binary";
18483 std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
18484 std::fs::write(
18485 directory.path().join(".sevralocal"),
18486 b"sources/files/private.pdf\n",
18487 )
18488 .unwrap();
18489 let store = Store::open_strict(directory.path()).unwrap();
18490 let view = v2_local_files(&store).unwrap();
18491 let local = crate::AssetRecord {
18492 path: "sources/files/private.pdf".to_string(),
18493 sha256: content_sha256(bytes),
18494 bytes: bytes.len() as u64,
18495 media_type: "application/pdf".to_string(),
18496 wrappers: vec!["sources/files/private.md".to_string()],
18497 required: true,
18498 };
18499 let current = V2BaselineAsset {
18500 blob_sha256: local.sha256.clone(),
18501 bytes: local.bytes,
18502 media_type: local.media_type.clone(),
18503 wrappers: local.wrappers.clone(),
18504 required: local.required,
18505 disposition: "hosted".to_string(),
18506 leaf_hash: "d".repeat(64),
18507 };
18508 assert_eq!(
18509 v2_asset_withdrawal_operation(
18510 &store,
18511 &view,
18512 &local.path,
18513 &local,
18514 ¤t,
18515 "approved retention change",
18516 )
18517 .unwrap(),
18518 json!({
18519 "op": "asset_withdraw",
18520 "path": local.path,
18521 "expected": { "kind": "asset", "hash": "d".repeat(64) },
18522 "reason": "approved retention change",
18523 })
18524 );
18525
18526 let mut mismatched = current.clone();
18527 mismatched.required = false;
18528 assert!(matches!(
18529 v2_asset_withdrawal_operation(
18530 &store,
18531 &view,
18532 &local.path,
18533 &local,
18534 &mismatched,
18535 "approved retention change",
18536 ),
18537 Err(LinkError::InvalidPack { .. })
18538 ));
18539 }
18540
18541 #[test]
18542 fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
18543 let first = v2_checkout_id(None).unwrap();
18544 assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
18545 assert_ne!(first, v2_checkout_id(None).unwrap());
18546 assert!(is_sha256(&first));
18547 }
18548
18549 #[test]
18550 fn scoped_projection_edit_and_scope_transition_fail_closed() {
18551 let directory = tempfile::tempdir().unwrap();
18552 std::fs::write(
18553 directory.path().join("DB.md"),
18554 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
18555 )
18556 .unwrap();
18557 let store = Store::open_strict(directory.path()).unwrap();
18558 let head = scoped_test_head(&"a".repeat(64));
18559 let baseline = scoped_test_baseline(&"a".repeat(64));
18560 let mut view = v2_local_files(&store).unwrap();
18561 assert!(matches!(
18562 remove_scoped_projection(&head, Some(&baseline), &mut view),
18563 Err(LinkError::ScopedProjectionModified)
18564 ));
18565
18566 let changed = scoped_test_head(&"b".repeat(64));
18567 assert!(matches!(
18568 ensure_v2_view_compatible(&changed, Some(&baseline)),
18569 Err(LinkError::ScopedViewChanged)
18570 ));
18571
18572 let mut same_view_new_control = head.clone();
18573 same_view_new_control.control_revision = "c".repeat(64);
18574 assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
18575 assert!(!same_v2_head(&head, &same_view_new_control));
18576 }
18577
18578 #[test]
18579 fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
18580 let scoped = scoped_test_head(&"a".repeat(64));
18581 let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
18582 assert!(matches!(
18583 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
18584 Err(LinkError::ScopedProjectionModified)
18585 ));
18586
18587 let mut full = scoped.clone();
18588 full.view_kind = "full".to_string();
18589 let mut full_baseline = scoped_baseline.clone();
18590 full_baseline.view_kind = Some("full".to_string());
18591 full_baseline.projection_sha256 = None;
18592 assert!(matches!(
18593 ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
18594 Err(LinkError::InvalidPack { .. })
18595 ));
18596
18597 assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
18598 assert!(
18599 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
18600 );
18601 }
18602
18603 #[test]
18604 fn scoped_view_metadata_is_explicitly_non_authoritative() {
18605 let head = scoped_test_head(&"a".repeat(64));
18606 let value: Value =
18607 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
18608 assert_eq!(value["kind"], "link.md-scoped-view");
18609 assert_eq!(value["authoritative"], false);
18610 assert_eq!(value["visible_files"], 7);
18611 assert_eq!(value["brain"], TEST_BRAIN_ID);
18612 }
18613
18614 #[test]
18615 fn local_scoped_marker_requires_the_exact_generated_projection() {
18616 let directory = tempfile::tempdir().unwrap();
18617 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
18618 std::fs::write(
18619 directory.path().join("DB.md"),
18620 scoped_projection_bytes(TEST_BRAIN_ID),
18621 )
18622 .unwrap();
18623 let head = scoped_test_head(&"a".repeat(64));
18624 std::fs::write(
18625 directory.path().join(".dbmd/view.json"),
18626 scoped_view_metadata(&head, 0).unwrap(),
18627 )
18628 .unwrap();
18629 let store = Store::open_strict(directory.path()).unwrap();
18630 assert!(has_verified_local_scoped_view(&store));
18631
18632 std::fs::write(
18633 directory.path().join("DB.md"),
18634 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18635 )
18636 .unwrap();
18637 let altered = Store::open_strict(directory.path()).unwrap();
18638 assert!(!has_verified_local_scoped_view(&altered));
18639 }
18640
18641 fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
18642 use ring::signature::KeyPair as _;
18643
18644 let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
18645 let rng = ring::rand::SystemRandom::new();
18646 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18647 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18648 let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
18649 let public_key = URL_SAFE_NO_PAD.encode(&public_der);
18650 let fingerprint = format!("{:x}", Sha256::digest(&public_der));
18651 let blob = b"new";
18652 let blob_hash = content_sha256(blob);
18653 let changes = json!({
18654 "mutation_id": "sync:proposal-fixture",
18655 "operations": [{
18656 "blob": blob_hash,
18657 "bytes": blob.len(),
18658 "expected": null,
18659 "op": "put",
18660 "path": "records/new.md",
18661 }],
18662 "reason": "fixture",
18663 "v": 2,
18664 });
18665 let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
18666 let changes_base64 = STANDARD.encode(&changes_bytes);
18667 let descriptor = json!({
18668 "base": null,
18669 "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
18670 "changes_base64": changes_base64,
18671 "rebase": "strict",
18672 "v": 2,
18673 });
18674 let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
18675 let payload_hash = "b".repeat(64);
18676 let submitted_at = "2026-08-19T12:00:00.000Z";
18677 let claim = json!({
18678 "actor_root": {
18679 "actor_class": "foreign_key",
18680 "credential": "ed25519:fixture",
18681 "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
18682 "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
18683 "principal": "key:fixture",
18684 "role": null,
18685 },
18686 "brain": TEST_BRAIN_ID,
18687 "clear_sha256": clear_hash,
18688 "control_revision": "c".repeat(64),
18689 "mutation_id": "sync:proposal-fixture",
18690 "payload_sha256": payload_hash,
18691 "proposal_id": proposal_id,
18692 "submitted_at": submitted_at,
18693 "v": 2,
18694 });
18695 let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
18696 let envelope = json!({
18697 "claim": claim,
18698 "fingerprint": fingerprint,
18699 "public_key": public_key,
18700 "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
18701 });
18702 let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18703 let submission_hash =
18704 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
18705 let mut head = scoped_test_head(&"c".repeat(64));
18706 head.view_kind = "full".to_string();
18707 head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
18708 let value = json!({
18709 "proposal": {
18710 "base": null,
18711 "blobs": [{
18712 "bytes": blob.len(),
18713 "endpoint": format!(
18714 "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
18715 ),
18716 "sha256": blob_hash,
18717 }],
18718 "changes_base64": changes_base64,
18719 "clear_sha256": clear_hash,
18720 "expires_at": "2026-08-26T12:00:00.000Z",
18721 "id": proposal_id,
18722 "payload_sha256": payload_hash,
18723 "proposer": { "class": "foreign_key" },
18724 "rebase": "strict",
18725 "state": "pending",
18726 "submission_claim_base64": STANDARD.encode(envelope_bytes),
18727 "submission_claim_sha256": submission_hash,
18728 "submitted_at": submitted_at,
18729 },
18730 "v": 2,
18731 });
18732 (head, proposal_id, value)
18733 }
18734
18735 #[test]
18736 fn v2_proposal_verifier_accepts_exact_signed_payload() {
18737 let (head, proposal_id, value) = signed_proposal_fixture();
18738 let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
18739 assert_eq!(verified.blobs.len(), 1);
18740 assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
18741 }
18742
18743 #[test]
18744 fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
18745 let (head, proposal_id, value) = signed_proposal_fixture();
18746
18747 let mut changed = value.clone();
18748 changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
18749 assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
18750
18751 let mut redirected = value.clone();
18752 redirected["proposal"]["blobs"][0]["endpoint"] =
18753 Value::String("https://attacker.example/blob".to_string());
18754 assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
18755
18756 let mut forged = value;
18757 let encoded = forged["proposal"]["submission_claim_base64"]
18758 .as_str()
18759 .unwrap();
18760 let mut envelope: Value =
18761 serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
18762 envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
18763 let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18764 forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
18765 forged["proposal"]["submission_claim_sha256"] = Value::String(
18766 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
18767 );
18768 assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
18769 }
18770
18771 #[cfg(unix)]
18772 #[test]
18773 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
18774 let sandbox = tempfile::tempdir().unwrap();
18775 let destination = sandbox.path().join("brain");
18776 let entries = vec![
18777 (
18778 "DB.md".to_string(),
18779 scoped_projection_bytes(TEST_BRAIN_ID),
18780 ),
18781 (
18782 "records/contacts/a.md".to_string(),
18783 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
18784 .to_vec(),
18785 ),
18786 ];
18787 install_pulled_delta(&destination, &entries, &[], true).unwrap();
18788 assert!(destination.join("index.md").is_file());
18789 assert!(destination.join("records/index.md").is_file());
18790 assert!(destination.join("records/contacts/index.md").is_file());
18791 assert!(destination.join("records/contacts/index.jsonl").is_file());
18792 }
18793
18794 #[cfg(unix)]
18795 #[test]
18796 fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
18797 let sandbox = tempfile::tempdir().unwrap();
18798 let destination = sandbox.path().join("brain");
18799 let cache = sandbox.path().join("cache");
18800 std::fs::create_dir(&cache).unwrap();
18801 let db = scoped_projection_bytes(TEST_BRAIN_ID);
18802 let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
18803 let db_source = cache.join("db");
18804 let shared_source = cache.join("shared");
18805 crate::fsx::write_atomic(&db_source, &db).unwrap();
18806 crate::fsx::write_atomic(&shared_source, shared).unwrap();
18807 let mut entries = vec![V2StagedFile {
18808 path: "DB.md".to_string(),
18809 source: db_source,
18810 sha256: content_sha256(&db),
18811 bytes: db.len() as u64,
18812 }];
18813 for index in 0..512 {
18814 entries.push(V2StagedFile {
18815 path: format!("records/items/{index:05}.md"),
18816 source: shared_source.clone(),
18817 sha256: content_sha256(shared),
18818 bytes: shared.len() as u64,
18819 });
18820 }
18821 install_pulled_delta_sources(
18822 &destination,
18823 &entries,
18824 &[],
18825 false,
18826 None,
18827 &scoped_test_head(&"c".repeat(64)),
18828 )
18829 .unwrap();
18830 assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
18831 for index in 0..512 {
18832 assert_eq!(
18833 std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
18834 shared
18835 );
18836 }
18837 assert!(
18838 std::fs::read_dir(sandbox.path())
18839 .unwrap()
18840 .all(|entry| !entry
18841 .unwrap()
18842 .file_name()
18843 .to_string_lossy()
18844 .contains("pull-stage")),
18845 "the private stage must be atomically installed or removed"
18846 );
18847 }
18848
18849 #[cfg(unix)]
18850 #[test]
18851 fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
18852 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
18853
18854 let sandbox = tempfile::tempdir().unwrap();
18855 let root = sandbox.path().join("brain");
18856 std::fs::create_dir_all(root.join("records/items")).unwrap();
18857 let db = scoped_projection_bytes(TEST_BRAIN_ID);
18858 let old = b"---\ntype: note\n---\n\nold\n";
18859 let new = b"---\ntype: note\n---\n\nnew\n";
18860 let removed = b"---\ntype: note\n---\n\nremove me\n";
18861 std::fs::write(root.join("DB.md"), &db).unwrap();
18862 std::fs::write(root.join("records/items/change.md"), old).unwrap();
18863 std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
18864 for index in 0..512 {
18865 std::fs::write(
18866 root.join(format!("records/items/untouched-{index:04}.md")),
18867 old,
18868 )
18869 .unwrap();
18870 }
18871 let untouched = root.join("records/items/untouched-0256.md");
18872 let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
18873 let source = sandbox.path().join("changed-source");
18874 crate::fsx::write_atomic(&source, new).unwrap();
18875 let same_source = sandbox.path().join("unchanged-source");
18876 crate::fsx::write_atomic(&same_source, old).unwrap();
18877 let same_entry = V2StagedFile {
18878 path: "records/items/change.md".to_string(),
18879 source: same_source,
18880 sha256: content_sha256(old),
18881 bytes: old.len() as u64,
18882 };
18883 let entry = V2StagedFile {
18884 path: "records/items/change.md".to_string(),
18885 source,
18886 sha256: content_sha256(new),
18887 bytes: new.len() as u64,
18888 };
18889 let head = scoped_test_head(&"c".repeat(64));
18890
18891 install_established_v2_delta(
18895 Store::open_strict(&root).unwrap(),
18896 &[same_entry],
18897 &["records/items/already-absent.md".to_string()],
18898 true,
18899 None,
18900 &head,
18901 )
18902 .unwrap();
18903 assert_eq!(
18904 std::fs::metadata(&untouched).unwrap().ino(),
18905 untouched_inode
18906 );
18907 assert!(!root.join(V2_PULL_JOURNAL).exists());
18908
18909 install_established_v2_delta(
18910 Store::open_strict(&root).unwrap(),
18911 &[entry],
18912 &["records/items/delete.md".to_string()],
18913 false,
18914 None,
18915 &head,
18916 )
18917 .unwrap();
18918 assert_eq!(
18919 std::fs::read(root.join("records/items/change.md")).unwrap(),
18920 new
18921 );
18922 assert!(!root.join("records/items/delete.md").exists());
18923 assert_eq!(
18924 std::fs::metadata(&untouched).unwrap().ino(),
18925 untouched_inode
18926 );
18927 assert!(root.join(V2_PULL_JOURNAL).is_file());
18928 assert_eq!(
18929 std::fs::metadata(root.join(V2_PULL_JOURNAL))
18930 .unwrap()
18931 .permissions()
18932 .mode()
18933 & 0o777,
18934 0o600
18935 );
18936 let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
18937 .unwrap()
18938 .unwrap();
18939 assert_eq!(
18940 std::fs::metadata(root.join(&journal.backup_dir))
18941 .unwrap()
18942 .permissions()
18943 .mode()
18944 & 0o777,
18945 0o700
18946 );
18947 for entry in &journal.entries {
18948 if let Some(backup) = &entry.backup {
18949 assert_eq!(
18950 std::fs::metadata(root.join(&journal.backup_dir).join(backup))
18951 .unwrap()
18952 .permissions()
18953 .mode()
18954 & 0o777,
18955 0o600
18956 );
18957 }
18958 }
18959
18960 let cfg = test_hub_config(
18961 "https://example.test".to_string(),
18962 sandbox.path().join("state"),
18963 );
18964 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18965 assert_eq!(
18966 std::fs::read(root.join("records/items/change.md")).unwrap(),
18967 old
18968 );
18969 assert_eq!(
18970 std::fs::read(root.join("records/items/delete.md")).unwrap(),
18971 removed
18972 );
18973 assert_eq!(
18974 std::fs::metadata(&untouched).unwrap().ino(),
18975 untouched_inode
18976 );
18977 assert!(!root.join(V2_PULL_JOURNAL).exists());
18978 }
18979
18980 #[test]
18981 fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
18982 let body = b"bounded bytes";
18983 let path = "records/example.md".to_string();
18984 let file = V2BaselineFile {
18985 sha256: content_sha256(body),
18986 bytes: body.len() as u64,
18987 proof: None,
18988 };
18989 let header = serde_json::to_vec(&json!({
18990 "bytes": body.len(),
18991 "path": path,
18992 "sha256": file.sha256,
18993 "v": 2,
18994 }))
18995 .unwrap();
18996 let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
18997 stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
18998 stream.extend_from_slice(&header);
18999 stream.extend_from_slice(body);
19000 stream.extend_from_slice(&0_u32.to_be_bytes());
19001 let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
19002 assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
19003
19004 let mut tampered = stream.clone();
19005 let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
19006 tampered[body_offset] ^= 1;
19007 assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
19008
19009 let mut trailing = stream;
19010 trailing.push(0);
19011 assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
19012 }
19013
19014 #[test]
19015 fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
19016 let sandbox = tempfile::TempDir::new().unwrap();
19017 let root = sandbox.path().join("brain");
19018 std::fs::create_dir_all(&root).unwrap();
19019 std::fs::write(
19020 root.join("DB.md"),
19021 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19022 )
19023 .unwrap();
19024 let store = Store::open_strict(&root).unwrap();
19025 let incomplete = crate::ulid::mint();
19026 store
19027 .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
19028 .unwrap();
19029 let expired = crate::ulid::mint();
19030 store
19031 .create_dir_all(&v2_conflict_relative(&expired, "files"))
19032 .unwrap();
19033 let plan = V2ConflictPlan {
19034 v: 2,
19035 class: "content_resolution_required".to_string(),
19036 bundle: expired.clone(),
19037 brain: TEST_BRAIN_ID.to_string(),
19038 origin: "https://example.test".to_string(),
19039 created_unix: 0,
19040 expires_unix: 0,
19041 base_seq: None,
19042 base_commit: None,
19043 remote_seq: 0,
19044 remote_commit: None,
19045 remote_content_root: None,
19046 view_kind: "full".to_string(),
19047 view_revision: "a".repeat(64),
19048 files: vec![V2ConflictFile {
19049 path: "records/value.md".to_string(),
19050 base: V2ConflictCoordinate {
19051 sha256: None,
19052 bytes: None,
19053 file: None,
19054 },
19055 local: V2ConflictCoordinate {
19056 sha256: None,
19057 bytes: None,
19058 file: None,
19059 },
19060 remote: V2ConflictCoordinate {
19061 sha256: None,
19062 bytes: None,
19063 file: None,
19064 },
19065 }],
19066 };
19067 let mut bytes = serde_json::to_vec(&plan).unwrap();
19068 bytes.push(b'\n');
19069 store
19070 .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
19071 .unwrap();
19072
19073 let listed = sync_conflicts(&root, false, false).unwrap();
19074 assert_eq!(listed["bundles"], 2);
19075 assert_eq!(listed["pruned"], 0);
19076 let pruned = sync_conflicts(&root, true, false).unwrap();
19077 assert_eq!(pruned["bundles"], 0);
19078 assert_eq!(pruned["pruned"], 2);
19079 assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
19080 assert!(!root.join(".dbmd/conflicts").join(expired).exists());
19081 }
19082
19083 #[test]
19084 fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
19085 let sandbox = tempfile::TempDir::new().unwrap();
19086 let root = sandbox.path().join("brain");
19087 std::fs::create_dir_all(&root).unwrap();
19088 std::fs::write(
19089 root.join("DB.md"),
19090 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19091 )
19092 .unwrap();
19093 let store = Store::open_strict(&root).unwrap();
19094 let bundle = crate::ulid::mint();
19095 store
19096 .create_dir_all(&v2_conflict_relative(&bundle, "files"))
19097 .unwrap();
19098 store
19099 .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
19100 .unwrap();
19101
19102 assert!(sync_conflicts(&root, true, false).is_err());
19103 assert!(sync_conflicts(&root, false, true).is_err());
19104 let pruned = sync_conflicts(&root, true, true).unwrap();
19105 assert_eq!(pruned["pruned"], 1);
19106 assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
19107 }
19108
19109 #[test]
19110 fn ready_pull_journal_rolls_back_exact_preimages() {
19111 let sandbox = tempfile::TempDir::new().unwrap();
19112 let root = sandbox.path().join("brain");
19113 std::fs::create_dir_all(root.join("records")).unwrap();
19114 std::fs::write(
19115 root.join("DB.md"),
19116 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19117 )
19118 .unwrap();
19119 let path = "records/value.md";
19120 let old = b"---\ntype: note\n---\n\nold\n";
19121 let new = b"---\ntype: note\n---\n\nnew\n";
19122 std::fs::write(root.join(path), old).unwrap();
19123 let store = Store::open_strict(&root).unwrap();
19124 let bundle = crate::ulid::mint();
19125 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19126 store
19127 .create_private_dir_all(Path::new(&backup_dir))
19128 .unwrap();
19129 store
19130 .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
19131 .unwrap();
19132 let journal = V2PullJournal {
19133 v: 1,
19134 phase: V2PullPhase::Ready,
19135 brain: TEST_BRAIN_ID.to_string(),
19136 previous: V2PullCoordinate {
19137 head_seq: None,
19138 commit_hash: None,
19139 view_kind: None,
19140 view_revision: None,
19141 },
19142 next: V2PullCoordinate {
19143 head_seq: Some(2),
19144 commit_hash: Some("c".repeat(64)),
19145 view_kind: Some("full".to_string()),
19146 view_revision: Some("d".repeat(64)),
19147 },
19148 backup_dir: backup_dir.clone(),
19149 entries: vec![V2PullJournalEntry {
19150 path: path.to_string(),
19151 old: Some(V2PullFileCoordinate {
19152 sha256: content_sha256(old),
19153 bytes: old.len() as u64,
19154 }),
19155 new: Some(V2PullFileCoordinate {
19156 sha256: content_sha256(new),
19157 bytes: new.len() as u64,
19158 }),
19159 backup: Some("00000000".to_string()),
19160 }],
19161 };
19162 validate_v2_pull_journal(&journal).unwrap();
19163 store
19164 .write_private_atomic_new(
19165 Path::new(V2_PULL_JOURNAL),
19166 &v2_pull_journal_bytes(&journal).unwrap(),
19167 )
19168 .unwrap();
19169 store.write_atomic(Path::new(path), new).unwrap();
19170
19171 let cfg = test_hub_config(
19172 "https://example.test".to_string(),
19173 sandbox.path().join("state"),
19174 );
19175 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19176 assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
19177 assert!(!root.join(V2_PULL_JOURNAL).exists());
19178 assert!(!root.join(backup_dir).exists());
19179 }
19180
19181 #[test]
19182 fn preparing_pull_journal_discards_only_private_staging() {
19183 let sandbox = tempfile::TempDir::new().unwrap();
19184 let root = sandbox.path().join("brain");
19185 std::fs::create_dir_all(root.join(".dbmd")).unwrap();
19186 std::fs::write(
19187 root.join("DB.md"),
19188 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19189 )
19190 .unwrap();
19191 let store = Store::open_strict(&root).unwrap();
19192 let bundle = crate::ulid::mint();
19193 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19194 store
19195 .create_private_dir_all(Path::new(&backup_dir))
19196 .unwrap();
19197 let journal = V2PullJournal {
19198 v: 1,
19199 phase: V2PullPhase::Preparing,
19200 brain: TEST_BRAIN_ID.to_string(),
19201 previous: V2PullCoordinate {
19202 head_seq: None,
19203 commit_hash: None,
19204 view_kind: None,
19205 view_revision: None,
19206 },
19207 next: V2PullCoordinate {
19208 head_seq: Some(1),
19209 commit_hash: Some("a".repeat(64)),
19210 view_kind: Some("full".to_string()),
19211 view_revision: Some("b".repeat(64)),
19212 },
19213 backup_dir: backup_dir.clone(),
19214 entries: vec![V2PullJournalEntry {
19215 path: "records/new.md".to_string(),
19216 old: None,
19217 new: Some(V2PullFileCoordinate {
19218 sha256: "c".repeat(64),
19219 bytes: 1,
19220 }),
19221 backup: None,
19222 }],
19223 };
19224 store
19225 .write_private_atomic_new(
19226 Path::new(V2_PULL_JOURNAL),
19227 &v2_pull_journal_bytes(&journal).unwrap(),
19228 )
19229 .unwrap();
19230 let cfg = test_hub_config(
19231 "https://example.test".to_string(),
19232 sandbox.path().join("state"),
19233 );
19234
19235 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19236
19237 assert!(root.join("DB.md").is_file());
19238 assert!(!root.join(V2_PULL_JOURNAL).exists());
19239 assert!(!root.join(backup_dir).exists());
19240 }
19241
19242 #[test]
19243 fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
19244 let sandbox = tempfile::TempDir::new().unwrap();
19245 let root = sandbox.path().join("brain");
19246 std::fs::create_dir_all(root.join("records")).unwrap();
19247 std::fs::write(
19248 root.join("DB.md"),
19249 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19250 )
19251 .unwrap();
19252 let new = b"---\ntype: note\n---\n\nnew\n";
19253 std::fs::write(root.join("records/value.md"), new).unwrap();
19254 let store = Store::open_strict(&root).unwrap();
19255 let bundle = crate::ulid::mint();
19256 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19257 store
19258 .create_private_dir_all(Path::new(&backup_dir))
19259 .unwrap();
19260 let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
19261 store.create_private_dir_all(Path::new(&orphan)).unwrap();
19262 let next = V2PullCoordinate {
19263 head_seq: Some(2),
19264 commit_hash: Some("c".repeat(64)),
19265 view_kind: Some("full".to_string()),
19266 view_revision: Some("d".repeat(64)),
19267 };
19268 let journal = V2PullJournal {
19269 v: 1,
19270 phase: V2PullPhase::Ready,
19271 brain: TEST_BRAIN_ID.to_string(),
19272 previous: V2PullCoordinate {
19273 head_seq: Some(1),
19274 commit_hash: Some("a".repeat(64)),
19275 view_kind: Some("full".to_string()),
19276 view_revision: Some("b".repeat(64)),
19277 },
19278 next: next.clone(),
19279 backup_dir: backup_dir.clone(),
19280 entries: vec![V2PullJournalEntry {
19281 path: "records/value.md".to_string(),
19282 old: Some(V2PullFileCoordinate {
19283 sha256: "e".repeat(64),
19284 bytes: new.len() as u64,
19285 }),
19286 new: Some(V2PullFileCoordinate {
19287 sha256: content_sha256(new),
19288 bytes: new.len() as u64,
19289 }),
19290 backup: Some("00000000".to_string()),
19291 }],
19292 };
19293 store
19294 .write_private_atomic_new(
19295 Path::new(V2_PULL_JOURNAL),
19296 &v2_pull_journal_bytes(&journal).unwrap(),
19297 )
19298 .unwrap();
19299 let cfg = test_hub_config(
19300 "https://example.test".to_string(),
19301 sandbox.path().join("state"),
19302 );
19303 save_v2_baseline(
19304 &cfg,
19305 TEST_BRAIN_ID,
19306 &root,
19307 &V2SyncBaseline {
19308 v: 2,
19309 origin: "https://example.test".to_string(),
19310 brain: TEST_BRAIN_ID.to_string(),
19311 checkout_id: Some("c".repeat(64)),
19312 head_seq: next.head_seq,
19313 commit_hash: next.commit_hash.clone(),
19314 content_root: Some("f".repeat(64)),
19315 asset_root: None,
19316 assets: Default::default(),
19317 view_kind: next.view_kind.clone(),
19318 view_revision: next.view_revision.clone(),
19319 projection_sha256: None,
19320 files: Default::default(),
19321 local_policy_digest: None,
19322 local_eligibility: Default::default(),
19323 remote_copy_remains: Default::default(),
19324 },
19325 )
19326 .unwrap();
19327
19328 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19329
19330 assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
19331 assert!(!root.join(V2_PULL_JOURNAL).exists());
19332 assert!(!root.join(backup_dir).exists());
19333 assert!(!root.join(orphan).exists());
19334 }
19335}