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)]
3667 control_revision: Option<String>,
3668 #[serde(default)]
3669 projection_sha256: Option<String>,
3670 files: std::collections::BTreeMap<String, V2BaselineFile>,
3671 #[serde(default)]
3672 local_policy_digest: Option<String>,
3673 #[serde(default)]
3674 local_eligibility: std::collections::BTreeMap<String, bool>,
3675 #[serde(default)]
3676 remote_copy_remains: std::collections::BTreeMap<String, String>,
3677}
3678
3679struct V2LocalView {
3680 riding: std::collections::BTreeMap<String, (String, u64)>,
3681 eligibility: std::collections::BTreeMap<String, bool>,
3682 policy: crate::linkmd_sync_policy::SyncPolicy,
3683 withheld_links: Vec<V2WithheldLink>,
3684}
3685
3686#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3687struct V2WithheldLink {
3688 source: String,
3689 target: String,
3690}
3691
3692#[derive(Debug, Clone, Deserialize, Serialize)]
3693struct V2ProofStep {
3694 directory_root: String,
3695 component: String,
3696 proof: crate::linkmd_v2::HamtProof,
3697}
3698
3699#[derive(Debug, Deserialize)]
3700struct V2ManifestFile {
3701 path: String,
3702 sha256: String,
3703 bytes: u64,
3704 proof: Vec<V2ProofStep>,
3705}
3706
3707#[derive(Debug, Deserialize)]
3708struct V2ManifestPage {
3709 v: u8,
3710 commit: String,
3711 content_root: Option<String>,
3712 files: Vec<V2ManifestFile>,
3713 next_cursor: Option<String>,
3714}
3715
3716#[derive(Debug, Clone, Deserialize, Serialize)]
3717struct V2BaselineAsset {
3718 blob_sha256: String,
3719 bytes: u64,
3720 media_type: String,
3721 wrappers: Vec<String>,
3722 required: bool,
3723 disposition: String,
3724 leaf_hash: String,
3725}
3726
3727#[derive(Debug, Deserialize)]
3728struct V2AssetManifestItem {
3729 path: String,
3730 blob_sha256: String,
3731 bytes: u64,
3732 media_type: String,
3733 wrappers: Vec<String>,
3734 required: bool,
3735 disposition: String,
3736 leaf_hash: String,
3737 proof: crate::linkmd_v2::HamtProof,
3738}
3739
3740#[derive(Debug, Deserialize)]
3741struct V2AssetManifestPage {
3742 v: u8,
3743 commit: String,
3744 asset_root: Option<String>,
3745 assets: Vec<V2AssetManifestItem>,
3746 next_cursor: Option<String>,
3747}
3748
3749#[derive(Debug, Deserialize)]
3750struct V2SigningCandidate {
3751 seq: u64,
3752 content_root: Option<String>,
3753 asset_root: Option<String>,
3754 signing_bytes_base64: String,
3755 changes_base64: String,
3756 actor_claim_base64: String,
3757}
3758
3759#[derive(Debug, Deserialize)]
3760struct V2SigningCandidatePage {
3761 v: u8,
3762 challenge_id: String,
3763 mutation_id: String,
3764 request_hash: String,
3765 parent: V2SigningParent,
3766 candidate: V2SigningCandidate,
3767 files: Vec<V2ManifestFile>,
3768 #[serde(default)]
3769 assets: Vec<V2AssetManifestItem>,
3770 next_cursor: Option<String>,
3771 expires_at: String,
3772}
3773
3774#[derive(Debug, Deserialize)]
3775struct V2SigningParent {
3776 seq: u64,
3777 commit_hash: Option<String>,
3778}
3779
3780fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3781 let normalized = crate::linkmd_v2::normalize_path(&file.path)
3782 .map_err(|error| invalid_feed(error.to_string()))?;
3783 let components = normalized.split('/').collect::<Vec<_>>();
3784 if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3785 return Err(invalid_feed("v2 file proof has the wrong shape"));
3786 }
3787 let mut directory_root = root.to_string();
3788 for (index, step) in file.proof.iter().enumerate() {
3789 if step.directory_root != directory_root || step.component != components[index] {
3790 return Err(invalid_feed(
3791 "v2 file proof path chain differs from its manifest",
3792 ));
3793 }
3794 if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3795 .map_err(|error| invalid_feed(error.to_string()))?
3796 {
3797 return Err(invalid_feed("v2 file proof failed verification"));
3798 }
3799 let entry = match &step.proof {
3800 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3801 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3802 return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3803 }
3804 };
3805 if index + 1 == components.len() {
3806 if entry.kind != crate::linkmd_v2::EntryKind::Blob
3807 || entry.child_hash != file.sha256
3808 || entry.bytes != Some(file.bytes)
3809 {
3810 return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3811 }
3812 } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3813 return Err(invalid_feed("v2 file proof traversed a non-directory"));
3814 } else {
3815 directory_root = entry.child_hash.clone();
3816 }
3817 }
3818 Ok(())
3819}
3820
3821fn v2_manifest(
3822 cfg: &HubConfig,
3823 brain: &str,
3824 pointer: Option<&V2PointerBody>,
3825) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3826 let Some(pointer) = pointer else {
3827 return Ok(std::collections::BTreeMap::new());
3828 };
3829 let Some(root) = pointer.content_root.as_deref() else {
3830 return Ok(std::collections::BTreeMap::new());
3831 };
3832 let mut files = std::collections::BTreeMap::new();
3833 let mut after = String::new();
3834 loop {
3835 let encoded_after: String =
3836 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3837 let path = format!(
3838 "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3839 pointer.commit_hash
3840 );
3841 let value = ensure_ok(
3842 request_capped(
3843 cfg,
3844 "GET",
3845 &path,
3846 None,
3847 Auth::Required,
3848 MAX_FEED_RESPONSE_BYTES,
3849 )?,
3850 "v2 file manifest",
3851 )?;
3852 let page: V2ManifestPage = serde_json::from_value(value)
3853 .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3854 if page.v != 2
3855 || page.commit != pointer.commit_hash
3856 || page.content_root.as_deref() != Some(root)
3857 || page.files.len() > 500
3858 {
3859 return Err(invalid_feed(
3860 "v2 file manifest is not bound to the verified head",
3861 ));
3862 }
3863 for file in page.files {
3864 verify_v2_file_proof(root, &file)?;
3865 if files
3866 .insert(
3867 file.path.clone(),
3868 V2BaselineFile {
3869 sha256: file.sha256,
3870 bytes: file.bytes,
3871 proof: Some(file.proof),
3872 },
3873 )
3874 .is_some()
3875 {
3876 return Err(invalid_feed("v2 file manifest repeats a path"));
3877 }
3878 if files.len() > MAX_PUSH_FILES {
3879 return Err(invalid_feed(
3880 "v2 file manifest exceeds the file-count bound",
3881 ));
3882 }
3883 }
3884 match page.next_cursor {
3885 None => break,
3886 Some(next) if next > after => after = next,
3887 Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3888 }
3889 }
3890 Ok(files)
3891}
3892
3893fn v2_manifest_file(
3898 cfg: &HubConfig,
3899 brain: &str,
3900 pointer: &V2PointerBody,
3901 path: &str,
3902) -> LinkResult<Option<V2BaselineFile>> {
3903 let Some(root) = pointer.content_root.as_deref() else {
3904 return Ok(None);
3905 };
3906 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
3907 path: error.to_string(),
3908 })?;
3909 let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
3910 let response = request_capped(
3911 cfg,
3912 "GET",
3913 &format!(
3914 "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
3915 pointer.commit_hash
3916 ),
3917 None,
3918 Auth::Required,
3919 MAX_FEED_RESPONSE_BYTES,
3920 )?;
3921 if response.status == 404 {
3925 return Ok(None);
3926 }
3927 let value = ensure_ok(response, "v2 exact file proof")?;
3928 let mut page: V2ManifestPage = serde_json::from_value(value)
3929 .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
3930 if page.v != 2
3931 || page.commit != pointer.commit_hash
3932 || page.content_root.as_deref() != Some(root)
3933 || page.next_cursor.is_some()
3934 || page.files.len() != 1
3935 || page.files[0].path != path
3936 {
3937 return Err(invalid_feed(
3938 "v2 exact file proof is not bound to the requested signed path",
3939 ));
3940 }
3941 let file = page.files.pop().expect("exactly one file was checked");
3942 verify_v2_file_proof(root, &file)?;
3943 Ok(Some(V2BaselineFile {
3944 sha256: file.sha256,
3945 bytes: file.bytes,
3946 proof: Some(file.proof),
3947 }))
3948}
3949
3950fn v2_manifest_file_by_id(
3955 cfg: &HubConfig,
3956 brain: &str,
3957 pointer: &V2PointerBody,
3958 id: &str,
3959) -> LinkResult<(String, V2BaselineFile)> {
3960 let root = pointer
3961 .content_root
3962 .as_deref()
3963 .ok_or_else(|| LinkError::Http {
3964 what: "resolve",
3965 status: 404,
3966 message: "record not found".to_string(),
3967 code: Some("NOT_FOUND".to_string()),
3968 details: None,
3969 })?;
3970 if !crate::ulid::is_ulid(id) {
3971 return Err(LinkError::BadAddress {
3972 given: id.to_string(),
3973 reason: BAD_TARGET_REASON.to_string(),
3974 });
3975 }
3976 let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
3977 let value = ensure_ok(
3978 request_capped(
3979 cfg,
3980 "GET",
3981 &format!(
3982 "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
3983 pointer.commit_hash
3984 ),
3985 None,
3986 Auth::Required,
3987 MAX_FEED_RESPONSE_BYTES,
3988 )?,
3989 "v2 exact id proof",
3990 )?;
3991 let mut page: V2ManifestPage = serde_json::from_value(value)
3992 .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
3993 if page.v != 2
3994 || page.commit != pointer.commit_hash
3995 || page.content_root.as_deref() != Some(root)
3996 || page.next_cursor.is_some()
3997 || page.files.len() != 1
3998 {
3999 return Err(invalid_feed(
4000 "v2 exact id proof is not bound to one signed path",
4001 ));
4002 }
4003 let file = page.files.pop().expect("exactly one file was checked");
4004 if !safe_store_rel_path(&file.path)
4005 || !file.path.ends_with(".md")
4006 || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
4007 {
4008 return Err(invalid_feed("v2 exact id proof has an invalid record path"));
4009 }
4010 verify_v2_file_proof(root, &file)?;
4011 Ok((
4012 file.path,
4013 V2BaselineFile {
4014 sha256: file.sha256,
4015 bytes: file.bytes,
4016 proof: Some(file.proof),
4017 },
4018 ))
4019}
4020
4021fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
4022 crate::linkmd_v2::normalize_path(&item.path)
4023 .map_err(|error| invalid_feed(error.to_string()))?;
4024 if !is_sha256(&item.blob_sha256)
4025 || !is_sha256(&item.leaf_hash)
4026 || item.bytes > MAX_ASSET_BYTES
4027 || item.wrappers.is_empty()
4028 || !matches!(item.disposition.as_str(), "hosted" | "withheld")
4029 || item
4030 .wrappers
4031 .iter()
4032 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4033 {
4034 return Err(invalid_feed("v2 asset manifest item is invalid"));
4035 }
4036 let leaf = json!({
4037 "blob_sha256": item.blob_sha256,
4038 "bytes": item.bytes,
4039 "disposition": item.disposition,
4040 "media_type": item.media_type,
4041 "path": item.path,
4042 "required": item.required,
4043 "v": 2,
4044 "wrappers": item.wrappers,
4045 });
4046 if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
4047 .map_err(|error| invalid_feed(error.to_string()))?
4048 != item.leaf_hash
4049 || !crate::linkmd_v2::verify_proof_with_domain(
4050 root,
4051 &item.path,
4052 &item.proof,
4053 crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
4054 )
4055 .map_err(|error| invalid_feed(error.to_string()))?
4056 {
4057 return Err(invalid_feed("v2 asset inclusion proof failed"));
4058 }
4059 match &item.proof {
4060 crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
4061 if entry.name == item.path
4062 && entry.kind == crate::linkmd_v2::EntryKind::Blob
4063 && entry.child_hash == item.leaf_hash
4064 && entry.bytes == Some(item.bytes) =>
4065 {
4066 Ok(())
4067 }
4068 _ => Err(invalid_feed(
4069 "v2 asset proof leaf differs from its manifest",
4070 )),
4071 }
4072}
4073
4074fn v2_asset_manifest(
4075 cfg: &HubConfig,
4076 brain: &str,
4077 pointer: Option<&V2PointerBody>,
4078) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
4079 let Some(pointer) = pointer else {
4080 return Ok(std::collections::BTreeMap::new());
4081 };
4082 let Some(root) = pointer.asset_root.as_deref() else {
4083 return Ok(std::collections::BTreeMap::new());
4084 };
4085 let mut assets = std::collections::BTreeMap::new();
4086 let mut after = String::new();
4087 loop {
4088 let encoded_after: String =
4089 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4090 let path = format!(
4091 "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4092 pointer.commit_hash
4093 );
4094 let value = ensure_ok(
4095 request_capped(
4096 cfg,
4097 "GET",
4098 &path,
4099 None,
4100 Auth::Required,
4101 MAX_FEED_RESPONSE_BYTES,
4102 )?,
4103 "v2 asset manifest",
4104 )?;
4105 let page: V2AssetManifestPage = serde_json::from_value(value)
4106 .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4107 if page.v != 2
4108 || page.commit != pointer.commit_hash
4109 || page.asset_root.as_deref() != Some(root)
4110 || page.assets.len() > 500
4111 {
4112 return Err(invalid_feed(
4113 "v2 asset manifest is not bound to the verified head",
4114 ));
4115 }
4116 for item in page.assets {
4117 verify_v2_asset_proof(root, &item)?;
4118 let path = item.path.clone();
4119 if assets
4120 .insert(
4121 path,
4122 V2BaselineAsset {
4123 blob_sha256: item.blob_sha256,
4124 bytes: item.bytes,
4125 media_type: item.media_type,
4126 wrappers: item.wrappers,
4127 required: item.required,
4128 disposition: item.disposition,
4129 leaf_hash: item.leaf_hash,
4130 },
4131 )
4132 .is_some()
4133 {
4134 return Err(invalid_feed("v2 asset manifest repeats a path"));
4135 }
4136 if assets.len() > MAX_PUSH_FILES {
4137 return Err(invalid_feed(
4138 "v2 asset manifest exceeds the item-count bound",
4139 ));
4140 }
4141 }
4142 match page.next_cursor {
4143 None => break,
4144 Some(next) if next > after => after = next,
4145 Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4146 }
4147 }
4148 Ok(assets)
4149}
4150
4151fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4152 crate::AssetRecord {
4153 path: path.to_string(),
4154 sha256: asset.blob_sha256.clone(),
4155 bytes: asset.bytes,
4156 media_type: asset.media_type.clone(),
4157 wrappers: asset.wrappers.clone(),
4158 required: asset.required,
4159 }
4160}
4161
4162fn v2_asset_resumes_hosting(
4163 remote: Option<&V2BaselineAsset>,
4164 path: &str,
4165 record: &crate::AssetRecord,
4166 disposition: &str,
4167) -> bool {
4168 remote.is_some_and(|asset| {
4169 asset.disposition == "withheld"
4170 && disposition == "hosted"
4171 && v2_asset_record(asset, path) == *record
4172 })
4173}
4174
4175fn v2_asset_record_manifest_bytes(
4176 assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4177) -> LinkResult<Vec<u8>> {
4178 let mut bytes = Vec::new();
4179 for (path, asset) in assets {
4180 if asset.path != *path {
4181 return Err(invalid_feed(
4182 "local asset manifest key differs from its record path",
4183 ));
4184 }
4185 serde_json::to_writer(&mut bytes, asset)
4186 .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4187 bytes.push(b'\n');
4188 }
4189 Ok(bytes)
4190}
4191
4192fn v2_local_asset_records(
4193 store: &Store,
4194) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4195 let assets = crate::assets::read_manifest(store)
4196 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?;
4197 if assets.iter().any(|asset| asset.bytes > MAX_ASSET_BYTES) {
4198 return Err(LinkError::InvalidPack {
4199 message: "local asset exceeds the link.md v2 per-blob limit".to_string(),
4200 });
4201 }
4202 Ok(assets
4203 .into_iter()
4204 .map(|asset| (asset.path.clone(), asset))
4205 .collect())
4206}
4207
4208fn v2_asset_records_match_remote(
4209 local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4210 remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4211) -> bool {
4212 local.len() == remote.len()
4213 && remote
4214 .iter()
4215 .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4216}
4217
4218#[derive(Debug, Clone, PartialEq, Eq)]
4219struct V2PulledMerge<T> {
4220 records: std::collections::BTreeMap<String, T>,
4221 accept_remote: std::collections::BTreeSet<String>,
4222 conflicts: Vec<String>,
4223}
4224
4225fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4231 base: &std::collections::BTreeMap<String, Base>,
4232 remote: &std::collections::BTreeMap<String, Remote>,
4233 local: &std::collections::BTreeMap<String, Record>,
4234 base_record: BaseRecord,
4235 remote_record: RemoteRecord,
4236 keep_local: KeepLocal,
4237) -> V2PulledMerge<Record>
4238where
4239 Record: Clone + Eq,
4240 BaseRecord: Fn(&Base, &str) -> Record,
4241 RemoteRecord: Fn(&Remote, &str) -> Record,
4242 KeepLocal: Fn(&str) -> bool,
4243{
4244 let paths = base
4245 .keys()
4246 .chain(remote.keys())
4247 .chain(local.keys())
4248 .cloned()
4249 .collect::<std::collections::BTreeSet<_>>();
4250 let mut records = local.clone();
4251 let mut accept_remote = std::collections::BTreeSet::new();
4252 let mut conflicts = Vec::new();
4253 for path in paths {
4254 if keep_local(&path) {
4255 continue;
4256 }
4257 let base_value = base.get(&path).map(|value| base_record(value, &path));
4258 let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4259 let local_value = local.get(&path).cloned();
4260 if local_value != base_value && remote_value != base_value && local_value != remote_value {
4261 conflicts.push(path);
4262 continue;
4263 }
4264 if local_value == base_value || local_value == remote_value {
4265 accept_remote.insert(path.clone());
4266 match remote_value {
4267 Some(value) => {
4268 records.insert(path, value);
4269 }
4270 None => {
4271 records.remove(&path);
4272 }
4273 }
4274 }
4275 }
4276 V2PulledMerge {
4277 records,
4278 accept_remote,
4279 conflicts,
4280 }
4281}
4282
4283fn sign_verified_v2_candidate(
4284 cfg: &HubConfig,
4285 head: &V2VerifiedHead,
4286 expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4287 expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4288 mutation_id: &str,
4289 request_body: &Value,
4290 challenge_value: &Value,
4291) -> LinkResult<(String, String, String)> {
4292 if head.view_kind != "full" {
4293 return Err(invalid_feed(
4294 "a scoped self-custody writer must use the proposal workflow",
4295 ));
4296 }
4297 if head.identity.custody != "self" {
4298 return Err(invalid_feed(
4299 "a hub-custodied brain unexpectedly requested an external signature",
4300 ));
4301 }
4302 let key = cfg
4303 .brain_key
4304 .as_ref()
4305 .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4306 if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4307 || key.public_key_spki != head.identity.public_key_spki
4308 {
4309 return Err(bad_agent_key(
4310 "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4311 ));
4312 }
4313 let challenge_id = challenge_value
4314 .get("id")
4315 .and_then(Value::as_str)
4316 .filter(|id| crate::ulid::is_ulid(id))
4317 .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4318 let expected_endpoint = format!(
4319 "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4320 head.brain_id
4321 );
4322 if challenge_value
4323 .get("candidate_endpoint")
4324 .and_then(Value::as_str)
4325 != Some(expected_endpoint.as_str())
4326 {
4327 return Err(invalid_feed(
4328 "self-custody challenge candidate endpoint is not origin-bound",
4329 ));
4330 }
4331
4332 let mut files = std::collections::BTreeMap::new();
4333 let mut after = String::new();
4334 type CandidateCoordinate = (
4335 String,
4336 String,
4337 String,
4338 String,
4339 Option<String>,
4340 Option<String>,
4341 u64,
4342 Option<String>,
4343 );
4344 let mut pinned: Option<CandidateCoordinate> = None;
4345 loop {
4346 let encoded_after: String =
4347 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4348 let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4349 let value = ensure_ok(
4350 request_capped(
4351 cfg,
4352 "GET",
4353 &path,
4354 None,
4355 Auth::Required,
4356 MAX_FEED_RESPONSE_BYTES,
4357 )?,
4358 "v2 self-custody candidate",
4359 )?;
4360 let page: V2SigningCandidatePage = serde_json::from_value(value)
4361 .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4362 if page.v != 2
4363 || page.challenge_id != challenge_id
4364 || page.mutation_id != mutation_id
4365 || page.candidate.seq != page.parent.seq + 1
4366 || page.files.len() > 500
4367 || page.expires_at.is_empty()
4368 {
4369 return Err(invalid_feed(
4370 "self-custody candidate is not bound to this mutation",
4371 ));
4372 }
4373 let coordinate = (
4374 page.request_hash.clone(),
4375 page.candidate.signing_bytes_base64.clone(),
4376 page.candidate.changes_base64.clone(),
4377 page.candidate.actor_claim_base64.clone(),
4378 page.candidate.content_root.clone(),
4379 page.candidate.asset_root.clone(),
4380 page.parent.seq,
4381 page.parent.commit_hash.clone(),
4382 );
4383 if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4384 return Err(invalid_feed(
4385 "self-custody candidate changed between manifest pages",
4386 ));
4387 }
4388 pinned = Some(coordinate);
4389 let root = page
4390 .candidate
4391 .content_root
4392 .as_deref()
4393 .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4394 for file in page.files {
4395 verify_v2_file_proof(root, &file)?;
4396 if files
4397 .insert(
4398 file.path.clone(),
4399 V2BaselineFile {
4400 sha256: file.sha256,
4401 bytes: file.bytes,
4402 proof: Some(file.proof),
4403 },
4404 )
4405 .is_some()
4406 {
4407 return Err(invalid_feed(
4408 "self-custody candidate repeats a manifest path",
4409 ));
4410 }
4411 if files.len() > MAX_PUSH_FILES {
4412 return Err(invalid_feed(
4413 "self-custody candidate exceeds the file-count bound",
4414 ));
4415 }
4416 }
4417 match page.next_cursor {
4418 None => break,
4419 Some(next) if next > after => after = next,
4420 Some(_) => {
4421 return Err(invalid_feed(
4422 "self-custody candidate cursor did not advance",
4423 ))
4424 }
4425 }
4426 }
4427 if files.len() != expected.len()
4428 || files.iter().any(|(path, file)| {
4429 expected.get(path).is_none_or(|expected| {
4430 expected.sha256 != file.sha256 || expected.bytes != file.bytes
4431 })
4432 })
4433 {
4434 return Err(invalid_feed(
4435 "self-custody candidate contains an unexpected file mutation",
4436 ));
4437 }
4438 let mut assets = std::collections::BTreeMap::new();
4439 after.clear();
4440 loop {
4441 let encoded_after: String =
4442 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4443 let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4444 let value = ensure_ok(
4445 request_capped(
4446 cfg,
4447 "GET",
4448 &path,
4449 None,
4450 Auth::Required,
4451 MAX_FEED_RESPONSE_BYTES,
4452 )?,
4453 "v2 self-custody asset candidate",
4454 )?;
4455 let page: V2SigningCandidatePage = serde_json::from_value(value)
4456 .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4457 let coordinate = (
4458 page.request_hash.clone(),
4459 page.candidate.signing_bytes_base64.clone(),
4460 page.candidate.changes_base64.clone(),
4461 page.candidate.actor_claim_base64.clone(),
4462 page.candidate.content_root.clone(),
4463 page.candidate.asset_root.clone(),
4464 page.parent.seq,
4465 page.parent.commit_hash.clone(),
4466 );
4467 if page.v != 2
4468 || page.challenge_id != challenge_id
4469 || page.mutation_id != mutation_id
4470 || page.assets.len() > 500
4471 || pinned.as_ref() != Some(&coordinate)
4472 {
4473 return Err(invalid_feed(
4474 "self-custody asset candidate changed or is not bound",
4475 ));
4476 }
4477 let root = page.candidate.asset_root.as_deref();
4478 if !page.assets.is_empty() && root.is_none() {
4479 return Err(invalid_feed("asset candidate has no asset root"));
4480 }
4481 for item in page.assets {
4482 verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4483 if assets
4484 .insert(
4485 item.path.clone(),
4486 V2BaselineAsset {
4487 blob_sha256: item.blob_sha256,
4488 bytes: item.bytes,
4489 media_type: item.media_type,
4490 wrappers: item.wrappers,
4491 required: item.required,
4492 disposition: item.disposition,
4493 leaf_hash: item.leaf_hash,
4494 },
4495 )
4496 .is_some()
4497 {
4498 return Err(invalid_feed("self-custody candidate repeats an asset"));
4499 }
4500 }
4501 match page.next_cursor {
4502 None => break,
4503 Some(next) if next > after => after = next,
4504 Some(_) => {
4505 return Err(invalid_feed(
4506 "self-custody asset candidate cursor did not advance",
4507 ))
4508 }
4509 }
4510 }
4511 if assets.len() != expected_assets.len()
4512 || assets.iter().any(|(path, asset)| {
4513 expected_assets.get(path).is_none_or(|expected| {
4514 asset.blob_sha256 != expected.blob_sha256
4515 || asset.bytes != expected.bytes
4516 || asset.media_type != expected.media_type
4517 || asset.wrappers != expected.wrappers
4518 || asset.required != expected.required
4519 || asset.disposition != expected.disposition
4520 })
4521 })
4522 {
4523 return Err(invalid_feed(
4524 "self-custody candidate contains an unexpected asset mutation",
4525 ));
4526 }
4527 let Some((
4528 request_hash,
4529 signing_b64,
4530 changes_b64,
4531 actor_b64,
4532 root,
4533 asset_root,
4534 parent_seq,
4535 parent,
4536 )) = pinned
4537 else {
4538 return Err(invalid_feed("self-custody candidate has no manifest"));
4539 };
4540 let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4541 let current_commit = head
4542 .pointer
4543 .as_ref()
4544 .map(|pointer| pointer.commit_hash.clone());
4545 if parent_seq != current_seq || parent != current_commit {
4546 return Err(LinkError::RemoteAdvancedDuringSync);
4547 }
4548 let changes = STANDARD
4549 .decode(changes_b64)
4550 .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4551 let mut expected_changes = json!({
4552 "mutation_id": mutation_id,
4553 "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4554 "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4555 "v": 2,
4556 });
4557 if let Some(withheld_links) = request_body.get("withheld_links") {
4558 expected_changes["withheld_links"] = withheld_links.clone();
4559 }
4560 if let Some(checkout_id) = request_body.get("checkout_id") {
4561 expected_changes["checkout_id"] = checkout_id.clone();
4562 }
4563 let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4564 .map_err(|error| invalid_feed(error.to_string()))?;
4565 if changes != expected_changes_bytes {
4566 return Err(invalid_feed(
4567 "self-custody changeset differs from the requested mutation",
4568 ));
4569 }
4570 let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4571 .map_err(|error| invalid_feed(error.to_string()))?;
4572 let request_value = json!({
4573 "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4574 "brain": head.brain_id,
4575 "changes_sha256": changes_hash,
4576 "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4577 "v": 2,
4578 "v1_bridge": Value::Null,
4579 });
4580 let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4581 .map_err(|error| invalid_feed(error.to_string()))?;
4582 if request_hash != expected_request_hash {
4583 return Err(invalid_feed(
4584 "self-custody request hash differs from the requested mutation",
4585 ));
4586 }
4587 let actor = STANDARD
4588 .decode(actor_b64)
4589 .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4590 let actor_value: Value = serde_json::from_slice(&actor)
4591 .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4592 if crate::linkmd_v2::canonical_bytes(&actor_value)
4593 .map_err(|error| invalid_feed(error.to_string()))?
4594 != actor
4595 {
4596 return Err(invalid_feed("self-custody actor claim is not canonical"));
4597 }
4598 let actor_object = actor_value
4599 .as_object()
4600 .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4601 let actor_claim = actor_object
4602 .get("claim")
4603 .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4604 let actor_public_key = actor_object
4605 .get("public_key")
4606 .and_then(Value::as_str)
4607 .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4608 let actor_fingerprint = actor_object
4609 .get("fingerprint")
4610 .and_then(Value::as_str)
4611 .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4612 let actor_signature = actor_object
4613 .get("sig")
4614 .and_then(Value::as_str)
4615 .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4616 let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4617 .map_err(|error| invalid_feed(error.to_string()))?;
4618 let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4619 let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4620 let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4621 let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4622 let impact = actor_claim
4623 .get("result")
4624 .and_then(|result| result.get("impact"))
4625 .and_then(Value::as_object);
4626 let impact_fields = [
4627 "creates",
4628 "updates",
4629 "deletes",
4630 "withdrawals",
4631 "renames",
4632 "restores",
4633 "asset_changes",
4634 "public_expansions",
4635 "executable_activations",
4636 ];
4637 let impact_is_valid = impact.is_some_and(|impact| {
4638 impact.len() == impact_fields.len() + 1
4639 && impact.get("v").and_then(Value::as_u64) == Some(1)
4640 && impact_fields
4641 .iter()
4642 .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4643 });
4644 if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4645 || head
4646 .trust
4647 .hub_signer
4648 .as_ref()
4649 .is_some_and(|known| known != &expected_actor_signer)
4650 || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4651 || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4652 || actor_claim
4653 .get("candidate")
4654 .and_then(|candidate| candidate.get("changes_sha256"))
4655 .and_then(Value::as_str)
4656 != Some(changes_hash.as_str())
4657 || actor_claim
4658 .get("candidate")
4659 .and_then(|candidate| candidate.get("state_root"))
4660 != Some(&expected_actor_root)
4661 || actor_claim
4662 .get("candidate")
4663 .and_then(|candidate| candidate.get("asset_root"))
4664 != Some(&expected_actor_asset_root)
4665 || actor_claim
4666 .get("candidate")
4667 .and_then(|candidate| candidate.get("control_revision"))
4668 .and_then(Value::as_str)
4669 != Some(head.control_revision.as_str())
4670 || !impact_is_valid
4671 {
4672 return Err(invalid_feed(
4673 "self-custody actor claim does not bind the verified authority",
4674 ));
4675 }
4676 let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4677 .map_err(|error| invalid_feed(error.to_string()))?;
4678 let signing = STANDARD
4679 .decode(signing_b64)
4680 .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4681 let signing_value: Value = serde_json::from_slice(&signing)
4682 .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4683 if crate::linkmd_v2::canonical_bytes(&signing_value)
4684 .map_err(|error| invalid_feed(error.to_string()))?
4685 != signing
4686 {
4687 return Err(invalid_feed("self-custody signing bytes are not canonical"));
4688 }
4689 let pointer = head.pointer.as_ref();
4690 let expected_materializer = pointer
4691 .map(|value| value.materializer.as_str())
4692 .unwrap_or("dbmd-projection-v1");
4693 let expected_parent_commit = request_body
4694 .get("base")
4695 .and_then(|base| base.get("commit_hash"))
4696 .cloned()
4697 .unwrap_or(Value::Null);
4698 let expected_parent_root = request_body
4699 .get("base")
4700 .and_then(|base| base.get("content_root"))
4701 .cloned()
4702 .unwrap_or(Value::Null);
4703 let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4704 let expected_parent_asset_root = request_body
4705 .get("base")
4706 .and_then(|base| base.get("asset_root"))
4707 .cloned()
4708 .unwrap_or(Value::Null);
4709 let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4710 let expected_prev_entry = pointer
4711 .map(|value| Value::String(value.feed_hash.clone()))
4712 .unwrap_or(Value::Null);
4713 let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4714 .map_err(|_| invalid_feed("brain identity history is too large"))?
4715 + 1;
4716 if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4717 || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4718 || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4719 || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4720 || signing_value.get("public_key").and_then(Value::as_str)
4721 != Some(key.public_key_spki.as_str())
4722 || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4723 || signing_value.get("parent_root") != Some(&expected_parent_root)
4724 || signing_value.get("state_root") != Some(&expected_state_root)
4725 || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4726 || signing_value.get("asset_root") != Some(&expected_asset_root)
4727 || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4728 || signing_value.get("changes_sha256").and_then(Value::as_str)
4729 != Some(changes_hash.as_str())
4730 || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4731 || signing_value
4732 .get("control_revision")
4733 .and_then(Value::as_str)
4734 != Some(head.control_revision.as_str())
4735 || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4736 || signing_value.get("v1_bridge") != Some(&Value::Null)
4737 || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4738 {
4739 return Err(invalid_feed(
4740 "self-custody signing bytes do not bind the verified candidate",
4741 ));
4742 }
4743 let pair = agent_keypair(&key.pkcs8)?;
4744 let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4745 Ok((challenge_id.to_string(), signature, expected_actor_signer))
4746}
4747
4748fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4749 let origin = normalized_origin(&cfg.hub)?;
4750 let absolute = if checkout.is_absolute() {
4751 checkout.to_path_buf()
4752 } else {
4753 std::env::current_dir()?.join(checkout)
4754 };
4755 Ok(format!(
4756 "sync-{}.json",
4757 content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4758 ))
4759}
4760
4761fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4762 if let Some(value) = existing {
4763 if !is_sha256(value) {
4764 return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4765 }
4766 return Ok(value.to_string());
4767 }
4768 use ring::rand::SecureRandom as _;
4769 let mut random = [0_u8; 32];
4770 ring::rand::SystemRandom::new()
4771 .fill(&mut random)
4772 .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4773 Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4774}
4775
4776#[cfg(any(unix, windows))]
4777fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4778 let directory = open_trust_dir(cfg)?;
4779 let origin = normalized_origin(&cfg.hub)?;
4780 let name = format!(
4781 "operation-{}.lock",
4782 content_sha256(format!("{origin}\0{brain}").as_bytes())
4783 );
4784 lock_trust_name(&directory, &name)
4785}
4786
4787#[cfg(not(any(unix, windows)))]
4788fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4789 Err(LinkError::UnsupportedPlatform {
4790 operation: "serialized link.md v2 sync",
4791 })
4792}
4793
4794fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4795 left.brain_id == right.brain_id
4796 && left.view_kind == right.view_kind
4797 && left.view_revision == right.view_revision
4798 && left.control_revision == right.control_revision
4799 && match (&left.pointer, &right.pointer) {
4800 (None, None) => true,
4801 (Some(left), Some(right)) => {
4802 left.seq == right.seq
4803 && left.commit_hash == right.commit_hash
4804 && left.content_root == right.content_root
4805 && left.asset_root == right.asset_root
4806 && left.feed_hash == right.feed_hash
4807 }
4808 _ => false,
4809 }
4810}
4811
4812fn v2_baseline_matches_head(head: &V2VerifiedHead, baseline: &V2SyncBaseline) -> bool {
4818 let pointer = head.pointer.as_ref();
4819 baseline.head_seq == Some(pointer.map_or(0, |value| value.seq))
4820 && baseline.commit_hash.as_deref() == pointer.map(|value| value.commit_hash.as_str())
4821 && baseline.content_root.as_deref()
4822 == pointer.and_then(|value| value.content_root.as_deref())
4823 && baseline.asset_root.as_deref() == pointer.and_then(|value| value.asset_root.as_deref())
4824 && baseline.view_kind.as_deref() == Some(head.view_kind.as_str())
4825 && baseline.view_revision.as_deref() == Some(head.view_revision.as_str())
4826 && baseline.control_revision.as_deref() == Some(head.control_revision.as_str())
4827}
4828
4829fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4830 format!(
4831 "---\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"
4832 )
4833 .into_bytes()
4834}
4835
4836fn scoped_projection_sha256(brain: &str) -> String {
4837 content_sha256(&scoped_projection_bytes(brain))
4838}
4839
4840#[derive(Deserialize)]
4841struct LocalScopedViewMarker {
4842 v: u8,
4843 kind: String,
4844 authoritative: bool,
4845 brain: String,
4846 projection_sha256: String,
4847}
4848
4849pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4853 let marker = store
4854 .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4855 .ok()
4856 .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4857 let Some(marker) = marker else {
4858 return false;
4859 };
4860 if marker.v != 1
4861 || marker.kind != "link.md-scoped-view"
4862 || marker.authoritative
4863 || !crate::ulid::is_ulid(&marker.brain)
4864 || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4865 {
4866 return false;
4867 }
4868 store
4869 .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4870 .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4871}
4872
4873fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4874 let mut bytes = serde_json::to_vec_pretty(&json!({
4875 "v": 1,
4876 "kind": "link.md-scoped-view",
4877 "authoritative": false,
4878 "brain": head.brain_id,
4879 "view_revision": head.view_revision,
4880 "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4881 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4882 "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4883 "visible_files": files,
4884 "projection_sha256": scoped_projection_sha256(&head.brain_id),
4885 }))
4886 .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4887 bytes.push(b'\n');
4888 Ok(bytes)
4889}
4890
4891fn refresh_scoped_view_marker(
4892 store: &Store,
4893 head: &V2VerifiedHead,
4894 files: usize,
4895) -> LinkResult<()> {
4896 if head.view_kind == "scoped" {
4897 store.write_atomic(
4898 Path::new(".dbmd/view.json"),
4899 &scoped_view_metadata(head, files)?,
4900 )?;
4901 }
4902 Ok(())
4903}
4904
4905fn ensure_v2_view_compatible(
4906 head: &V2VerifiedHead,
4907 baseline: Option<&V2SyncBaseline>,
4908) -> LinkResult<()> {
4909 let Some(baseline) = baseline else {
4910 return Ok(());
4911 };
4912 match (
4913 baseline.view_kind.as_deref(),
4914 baseline.view_revision.as_deref(),
4915 ) {
4916 (None, None) if head.view_kind == "full" => Ok(()),
4917 (Some(kind), Some(revision))
4918 if kind == head.view_kind && revision == head.view_revision =>
4919 {
4920 Ok(())
4921 }
4922 _ => Err(LinkError::ScopedViewChanged),
4923 }
4924}
4925
4926fn ensure_established_v2_checkout_opened(
4927 head: &V2VerifiedHead,
4928 baseline: Option<&V2SyncBaseline>,
4929 opened: bool,
4930) -> LinkResult<()> {
4931 if baseline.is_none() || opened {
4932 return Ok(());
4933 }
4934 if head.view_kind == "scoped" {
4935 return Err(LinkError::ScopedProjectionModified);
4936 }
4937 Err(LinkError::InvalidPack {
4938 message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4939 })
4940}
4941
4942fn remove_scoped_projection(
4943 head: &V2VerifiedHead,
4944 baseline: Option<&V2SyncBaseline>,
4945 view: &mut V2LocalView,
4946) -> LinkResult<()> {
4947 if head.view_kind != "scoped" {
4948 return Ok(());
4949 }
4950 let expected = scoped_projection_sha256(&head.brain_id);
4951 if baseline
4952 .and_then(|state| state.projection_sha256.as_deref())
4953 .is_some_and(|pinned| pinned != expected)
4954 {
4955 return Err(LinkError::ScopedViewChanged);
4956 }
4957 if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4958 return Err(LinkError::ScopedProjectionModified);
4959 }
4960 view.riding.remove("DB.md");
4961 view.eligibility.remove("DB.md");
4962 Ok(())
4963}
4964
4965fn local_view_for_v2_push(
4966 store: &Store,
4967 head: &V2VerifiedHead,
4968 baseline: Option<&V2SyncBaseline>,
4969 carried: Option<V2LocalView>,
4970) -> LinkResult<V2LocalView> {
4971 match carried {
4972 Some(view) => Ok(view),
4977 None => {
4978 let mut view = v2_local_files(store)?;
4979 remove_scoped_projection(head, baseline, &mut view)?;
4980 Ok(view)
4981 }
4982 }
4983}
4984
4985fn files_for_v2_view(
4986 head: &V2VerifiedHead,
4987 mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4988) -> std::collections::BTreeMap<String, V2BaselineFile> {
4989 if head.view_kind == "scoped" {
4990 files.remove("DB.md");
4994 }
4995 files
4996}
4997
4998fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4999 let baseline: V2SyncBaseline =
5000 serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
5001 if baseline.v != 2
5002 || baseline.origin != normalized_origin(&cfg.hub)?
5003 || baseline.brain != brain
5004 || baseline
5005 .commit_hash
5006 .as_deref()
5007 .is_some_and(|hash| !is_sha256(hash))
5008 || baseline
5009 .content_root
5010 .as_deref()
5011 .is_some_and(|hash| !is_sha256(hash))
5012 || baseline
5013 .asset_root
5014 .as_deref()
5015 .is_some_and(|hash| !is_sha256(hash))
5016 || baseline
5017 .local_policy_digest
5018 .as_deref()
5019 .is_some_and(|hash| !is_sha256(hash))
5020 || baseline
5021 .view_kind
5022 .as_deref()
5023 .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
5024 || baseline
5025 .view_revision
5026 .as_deref()
5027 .is_some_and(|hash| !is_sha256(hash))
5028 || baseline
5029 .control_revision
5030 .as_deref()
5031 .is_some_and(|hash| !is_sha256(hash))
5032 || baseline
5033 .projection_sha256
5034 .as_deref()
5035 .is_some_and(|hash| !is_sha256(hash))
5036 || (baseline.view_kind.as_deref() == Some("scoped")
5037 && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
5038 || baseline.files.len() > MAX_PUSH_FILES
5039 || baseline.assets.len() > MAX_PUSH_FILES
5040 || baseline.local_eligibility.len() > MAX_PUSH_FILES
5041 || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
5042 || baseline.files.iter().any(|(path, file)| {
5043 crate::linkmd_v2::normalize_path(path).is_err()
5044 || !is_sha256(&file.sha256)
5045 || file.bytes > MAX_STORE_BYTES
5046 })
5047 || baseline.assets.iter().any(|(path, asset)| {
5048 crate::linkmd_v2::normalize_path(path).is_err()
5049 || !is_sha256(&asset.blob_sha256)
5050 || !is_sha256(&asset.leaf_hash)
5051 || asset.bytes > MAX_ASSET_BYTES
5052 || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
5053 || asset.wrappers.is_empty()
5054 || asset
5055 .wrappers
5056 .iter()
5057 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
5058 })
5059 || baseline
5060 .local_eligibility
5061 .keys()
5062 .chain(baseline.remote_copy_remains.keys())
5063 .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
5064 || baseline
5065 .remote_copy_remains
5066 .values()
5067 .any(|hash| !is_sha256(hash))
5068 || baseline
5069 .checkout_id
5070 .as_deref()
5071 .is_some_and(|checkout_id| !is_sha256(checkout_id))
5072 {
5073 return Err(invalid_feed("v2 sync baseline failed validation"));
5074 }
5075 Ok(baseline)
5076}
5077
5078#[cfg(unix)]
5079fn load_v2_baseline(
5080 cfg: &HubConfig,
5081 brain: &str,
5082 checkout: &Path,
5083) -> LinkResult<Option<V2SyncBaseline>> {
5084 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5085 let directory = open_trust_dir(cfg)?;
5086 let name_string = v2_baseline_name(cfg, brain, checkout)?;
5087 let _lock = lock_trust_name(&directory, &name_string)?;
5088 let name = c_name(name_string.as_bytes(), &name_string)?;
5089 let fd = unsafe {
5090 libc::openat(
5091 directory.as_raw_fd(),
5092 name.as_ptr(),
5093 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5094 )
5095 };
5096 if fd < 0 {
5097 let error = std::io::Error::last_os_error();
5098 return if error.kind() == std::io::ErrorKind::NotFound {
5099 Ok(None)
5100 } else {
5101 Err(LinkError::UnsafePath { path: name_string })
5102 };
5103 }
5104 let file = unsafe { std::fs::File::from_raw_fd(fd) };
5105 let mut bytes = Vec::new();
5106 file.take(MAX_FEED_RESPONSE_BYTES + 1)
5107 .read_to_end(&mut bytes)?;
5108 if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
5109 return Err(invalid_feed("v2 sync baseline is oversized"));
5110 }
5111 Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
5112}
5113
5114#[cfg(windows)]
5115fn load_v2_baseline(
5116 cfg: &HubConfig,
5117 brain: &str,
5118 checkout: &Path,
5119) -> LinkResult<Option<V2SyncBaseline>> {
5120 let directory = open_trust_dir(cfg)?;
5121 let name = v2_baseline_name(cfg, brain, checkout)?;
5122 let _lock = lock_trust_name(&directory, &name)?;
5123 let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
5124 match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
5125 Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5126 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5127 Err(_) => Err(LinkError::UnsafePath { path: name }),
5128 }
5129}
5130
5131#[cfg(not(any(unix, windows)))]
5132fn load_v2_baseline(
5133 _cfg: &HubConfig,
5134 _brain: &str,
5135 _checkout: &Path,
5136) -> LinkResult<Option<V2SyncBaseline>> {
5137 Err(LinkError::UnsupportedPlatform {
5138 operation: "verified link.md v2 baseline",
5139 })
5140}
5141
5142#[cfg(unix)]
5143fn save_v2_baseline(
5144 cfg: &HubConfig,
5145 brain: &str,
5146 checkout: &Path,
5147 baseline: &V2SyncBaseline,
5148) -> LinkResult<()> {
5149 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5150 let directory = open_trust_dir(cfg)?;
5151 let name_string = v2_baseline_name(cfg, brain, checkout)?;
5152 let _lock = lock_trust_name(&directory, &name_string)?;
5153 let name = c_name(name_string.as_bytes(), &name_string)?;
5154 let mut bytes = serde_json::to_vec(baseline)
5155 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5156 bytes.push(b'\n');
5157 let temp_string = format!(
5158 ".{name_string}.tmp.{}-{}",
5159 std::process::id(),
5160 std::time::SystemTime::now()
5161 .duration_since(std::time::UNIX_EPOCH)
5162 .unwrap_or_default()
5163 .as_nanos()
5164 );
5165 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5166 let fd = unsafe {
5167 libc::openat(
5168 directory.as_raw_fd(),
5169 temp.as_ptr(),
5170 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5171 0o600,
5172 )
5173 };
5174 if fd < 0 {
5175 return Err(std::io::Error::last_os_error().into());
5176 }
5177 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5178 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5179 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5180 return Err(error.into());
5181 }
5182 drop(file);
5183 if unsafe {
5184 libc::renameat(
5185 directory.as_raw_fd(),
5186 temp.as_ptr(),
5187 directory.as_raw_fd(),
5188 name.as_ptr(),
5189 )
5190 } != 0
5191 {
5192 let error = std::io::Error::last_os_error();
5193 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5194 return Err(error.into());
5195 }
5196 directory.sync_all()?;
5197 Ok(())
5198}
5199
5200#[cfg(windows)]
5201fn save_v2_baseline(
5202 cfg: &HubConfig,
5203 brain: &str,
5204 checkout: &Path,
5205 baseline: &V2SyncBaseline,
5206) -> LinkResult<()> {
5207 let directory = open_trust_dir(cfg)?;
5208 let name = v2_baseline_name(cfg, brain, checkout)?;
5209 let _lock = lock_trust_name(&directory, &name)?;
5210 let mut bytes = serde_json::to_vec(baseline)
5211 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5212 bytes.push(b'\n');
5213 crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5214 Ok(())
5215}
5216
5217#[cfg(not(any(unix, windows)))]
5218fn save_v2_baseline(
5219 _cfg: &HubConfig,
5220 _brain: &str,
5221 _checkout: &Path,
5222 _baseline: &V2SyncBaseline,
5223) -> LinkResult<()> {
5224 Err(LinkError::UnsupportedPlatform {
5225 operation: "verified link.md v2 baseline",
5226 })
5227}
5228
5229fn v2_baseline_from_head(
5230 cfg: &HubConfig,
5231 head: &V2VerifiedHead,
5232 files: std::collections::BTreeMap<String, V2BaselineFile>,
5233 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5234 local: Option<&V2LocalView>,
5235 checkout_id: Option<&str>,
5236) -> LinkResult<V2SyncBaseline> {
5237 let mut local_eligibility = local
5238 .map(|view| view.eligibility.clone())
5239 .unwrap_or_default();
5240 if let Some(view) = local {
5241 for path in files.keys() {
5242 local_eligibility
5243 .entry(path.clone())
5244 .or_insert_with(|| !view.policy.keeps_home(path));
5245 }
5246 }
5247 let remote_copy_remains = local_eligibility
5248 .iter()
5249 .filter(|(_, riding)| !**riding)
5250 .filter_map(|(path, _)| {
5251 files
5252 .get(path)
5253 .map(|file| (path.clone(), file.sha256.clone()))
5254 })
5255 .collect();
5256 Ok(V2SyncBaseline {
5257 v: 2,
5258 origin: normalized_origin(&cfg.hub)?,
5259 brain: head.brain_id.clone(),
5260 checkout_id: Some(v2_checkout_id(checkout_id)?),
5261 head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5262 commit_hash: head
5263 .pointer
5264 .as_ref()
5265 .map(|pointer| pointer.commit_hash.clone()),
5266 content_root: head
5267 .pointer
5268 .as_ref()
5269 .and_then(|pointer| pointer.content_root.clone()),
5270 asset_root: head
5271 .pointer
5272 .as_ref()
5273 .and_then(|pointer| pointer.asset_root.clone()),
5274 assets,
5275 view_kind: Some(head.view_kind.clone()),
5276 view_revision: Some(head.view_revision.clone()),
5277 control_revision: Some(head.control_revision.clone()),
5278 projection_sha256: (head.view_kind == "scoped")
5279 .then(|| scoped_projection_sha256(&head.brain_id)),
5280 files,
5281 local_policy_digest: local.map(|view| view.policy.digest.clone()),
5282 local_eligibility,
5283 remote_copy_remains,
5284 })
5285}
5286
5287fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5288 let policy = crate::linkmd_sync_policy::load(store)
5289 .map_err(|message| LinkError::InvalidPack { message })?;
5290 let asset_paths = crate::assets::read_manifest(store)
5291 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5292 .into_iter()
5293 .map(|asset| asset.path)
5294 .collect::<std::collections::BTreeSet<_>>();
5295 let mut result = std::collections::BTreeMap::new();
5296 let mut eligibility = std::collections::BTreeMap::new();
5297 let mut riding_links = Vec::<(String, Vec<String>)>::new();
5298 let mut total = 0_u64;
5299 let mut paths = vec![PathBuf::from("DB.md")];
5300 paths.extend(store.walk()?);
5301 for relative in paths {
5302 let path = relative.to_string_lossy().replace('\\', "/");
5303 if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5305 continue;
5306 }
5307 if asset_paths.contains(&path) {
5308 continue;
5309 }
5310 crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5311 path: error.to_string(),
5312 })?;
5313 let riding = !policy.keeps_home(&path);
5314 eligibility.insert(path.clone(), riding);
5315 if !riding {
5316 continue;
5317 }
5318 let remaining = MAX_STORE_BYTES.saturating_sub(total);
5319 let bytes = store.read_bounded(&relative, remaining)?;
5320 total = total
5321 .checked_add(bytes.len() as u64)
5322 .ok_or_else(|| LinkError::PushTooLarge {
5323 detail: "v2 local byte count overflow".to_string(),
5324 })?;
5325 if total > MAX_STORE_BYTES {
5326 return Err(LinkError::PushTooLarge {
5327 detail: format!("{total} uncompressed bytes"),
5328 });
5329 }
5330 if std::str::from_utf8(&bytes).is_err() {
5331 return Err(LinkError::NotUtf8 { path });
5332 }
5333 let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5334 riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
5335 result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
5336 }
5337 let kept_home = eligibility
5338 .iter()
5339 .filter(|(_, riding)| !**riding)
5340 .map(|(path, _)| path.clone())
5341 .collect::<std::collections::BTreeSet<_>>();
5342 let mut withheld_links = riding_links
5343 .into_iter()
5344 .flat_map(|(source, targets)| {
5345 let kept_home = &kept_home;
5346 let policy = &policy;
5347 targets.into_iter().filter_map(move |target| {
5348 let target = format!("{target}.md");
5349 (kept_home.contains(&target) || policy.keeps_home(&target)).then_some(
5359 V2WithheldLink {
5360 source: source.clone(),
5361 target,
5362 },
5363 )
5364 })
5365 })
5366 .collect::<Vec<_>>();
5367 withheld_links.sort();
5368 withheld_links.dedup();
5369 Ok(V2LocalView {
5370 riding: result,
5371 eligibility,
5372 policy,
5373 withheld_links,
5374 })
5375}
5376
5377#[derive(Debug, Clone, Deserialize)]
5378struct V2DownloadItem {
5379 path: String,
5380 sha256: String,
5381 bytes: u64,
5382 url: String,
5383 method: String,
5384}
5385
5386#[derive(Debug, Deserialize)]
5387struct V2DownloadWindow {
5388 v: u8,
5389 commit: String,
5390 downloads: Vec<V2DownloadItem>,
5391}
5392
5393#[derive(Debug, Deserialize)]
5394struct V2BulkStreamHeader {
5395 v: u8,
5396 path: String,
5397 sha256: String,
5398 bytes: u64,
5399}
5400
5401fn parse_v2_bulk_stream(
5402 bytes: &[u8],
5403 expected: &[(&String, &V2BaselineFile)],
5404) -> LinkResult<Vec<(String, Vec<u8>)>> {
5405 if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5406 return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5407 }
5408 let mut cursor = V2_BULK_STREAM_MAGIC.len();
5409 let mut result = Vec::with_capacity(expected.len());
5410 for (expected_path, expected_file) in expected {
5411 let length_bytes = bytes
5412 .get(cursor..cursor + 4)
5413 .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5414 cursor += 4;
5415 let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5416 if header_len == 0 || header_len > 4 * 1024 {
5417 return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5418 }
5419 let header_bytes = bytes
5420 .get(cursor..cursor + header_len)
5421 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5422 cursor += header_len;
5423 let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5424 .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5425 if header.v != 2
5426 || &header.path != *expected_path
5427 || header.sha256 != expected_file.sha256
5428 || header.bytes != expected_file.bytes
5429 || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5430 {
5431 return Err(invalid_feed(
5432 "v2 bulk stream frame differs from its proven manifest entry",
5433 ));
5434 }
5435 let body_len = usize::try_from(header.bytes)
5436 .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5437 let body = bytes
5438 .get(cursor..cursor + body_len)
5439 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5440 cursor += body_len;
5441 if content_sha256(body) != header.sha256 {
5442 return Err(invalid_feed(
5443 "v2 bulk stream file differs from its proven manifest entry",
5444 ));
5445 }
5446 result.push((header.path, body.to_vec()));
5447 }
5448 if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5449 return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5450 }
5451 cursor += 4;
5452 if cursor != bytes.len() {
5453 return Err(invalid_feed("v2 bulk stream carries trailing data"));
5454 }
5455 Ok(result)
5456}
5457
5458fn download_v2_bulk_stream(
5459 cfg: &HubConfig,
5460 brain: &str,
5461 pointer: &V2PointerBody,
5462 pending: &[(&String, &V2BaselineFile)],
5463) -> LinkResult<Vec<(String, Vec<u8>)>> {
5464 let claims = pending
5465 .iter()
5466 .map(|(path, file)| {
5467 Ok(json!({
5468 "path": path,
5469 "sha256": file.sha256,
5470 "bytes": file.bytes,
5471 "proof": file.proof.as_ref().ok_or_else(|| {
5472 invalid_feed("v2 manifest omitted a bulk-stream proof")
5473 })?,
5474 }))
5475 })
5476 .collect::<LinkResult<Vec<_>>>()?;
5477 let raw = request_raw_retryable_read(
5478 cfg,
5479 "POST",
5480 &format!("/api/hub/brains/{brain}/v2/stream"),
5481 Some(&json!({
5482 "commit": pointer.commit_hash,
5483 "files": claims,
5484 })),
5485 Auth::Required,
5486 V2_BULK_STREAM_RESPONSE_BYTES,
5487 )?;
5488 let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5489 parse_v2_bulk_stream(&body, pending)
5490}
5491
5492fn request_capped_retryable_read(
5493 cfg: &HubConfig,
5494 method: &str,
5495 path: &str,
5496 body: Option<&Value>,
5497 auth: Auth,
5498 max_response_bytes: u64,
5499) -> LinkResult<HubResponse> {
5500 let raw = request_raw_retryable_read(cfg, method, path, body, auth, max_response_bytes)?;
5501 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
5502 Ok(HubResponse {
5503 status: raw.status,
5504 body: parsed,
5505 })
5506}
5507
5508fn prepare_v2_downloads(
5509 cfg: &HubConfig,
5510 brain: &str,
5511 pointer: &V2PointerBody,
5512 pending: &[(&String, &V2BaselineFile)],
5513) -> LinkResult<Vec<V2DownloadItem>> {
5514 let mut result = Vec::with_capacity(pending.len());
5515 for chunk in pending.chunks(128) {
5516 let claims = chunk
5517 .iter()
5518 .map(|(path, file)| {
5519 Ok(json!({
5520 "path": path,
5521 "sha256": file.sha256,
5522 "bytes": file.bytes,
5523 "proof": file.proof.as_ref().ok_or_else(|| {
5524 invalid_feed("v2 manifest omitted a download proof")
5525 })?,
5526 }))
5527 })
5528 .collect::<LinkResult<Vec<_>>>()?;
5529 let value = ensure_ok(
5530 request_capped_retryable_read(
5531 cfg,
5532 "POST",
5533 &format!("/api/hub/brains/{brain}/v2/downloads"),
5534 Some(&json!({
5535 "commit": pointer.commit_hash,
5536 "files": claims,
5537 })),
5538 Auth::Required,
5539 MAX_FEED_RESPONSE_BYTES,
5540 )?,
5541 "prepare v2 blob downloads",
5542 )?;
5543 let window: V2DownloadWindow = serde_json::from_value(value)
5544 .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5545 if window.v != 2
5546 || window.commit != pointer.commit_hash
5547 || window.downloads.len() != chunk.len()
5548 {
5549 return Err(invalid_feed(
5550 "v2 download window is not bound to the requested files",
5551 ));
5552 }
5553 let mut by_path = window
5554 .downloads
5555 .into_iter()
5556 .map(|item| (item.path.clone(), item))
5557 .collect::<std::collections::BTreeMap<_, _>>();
5558 if by_path.len() != chunk.len() {
5559 return Err(invalid_feed("v2 download window repeats a path"));
5560 }
5561 for (path, file) in chunk {
5562 let item = by_path
5563 .remove(*path)
5564 .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5565 if item.method != "GET"
5566 || item.sha256 != file.sha256
5567 || item.bytes != file.bytes
5568 || item.url.is_empty()
5569 {
5570 return Err(invalid_feed(
5571 "v2 download capability differs from its proven file",
5572 ));
5573 }
5574 result.push(item);
5575 }
5576 }
5577 Ok(result)
5578}
5579
5580fn prepare_v2_asset_downloads(
5581 cfg: &HubConfig,
5582 brain: &str,
5583 pointer: &V2PointerBody,
5584 pending: &[(&String, &V2BaselineAsset)],
5585) -> LinkResult<Vec<V2DownloadItem>> {
5586 let mut result = Vec::with_capacity(pending.len());
5587 for chunk in pending.chunks(128) {
5588 let claims = chunk
5589 .iter()
5590 .map(|(path, asset)| {
5591 json!({
5592 "path": path,
5593 "sha256": asset.blob_sha256,
5594 "bytes": asset.bytes,
5595 "leaf_hash": asset.leaf_hash,
5596 })
5597 })
5598 .collect::<Vec<_>>();
5599 let value = ensure_ok(
5600 request_capped_retryable_read(
5601 cfg,
5602 "POST",
5603 &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5604 Some(&json!({
5605 "commit": pointer.commit_hash,
5606 "assets": claims,
5607 })),
5608 Auth::Required,
5609 MAX_FEED_RESPONSE_BYTES,
5610 )?,
5611 "prepare v2 asset downloads",
5612 )?;
5613 let window: V2DownloadWindow = serde_json::from_value(value)
5614 .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5615 if window.v != 2
5616 || window.commit != pointer.commit_hash
5617 || window.downloads.len() != chunk.len()
5618 {
5619 return Err(invalid_feed(
5620 "v2 asset download window is not bound to the requested assets",
5621 ));
5622 }
5623 let mut by_path = window
5624 .downloads
5625 .into_iter()
5626 .map(|item| (item.path.clone(), item))
5627 .collect::<std::collections::BTreeMap<_, _>>();
5628 if by_path.len() != chunk.len() {
5629 return Err(invalid_feed("v2 asset download window repeats a path"));
5630 }
5631 for (path, asset) in chunk {
5632 let item = by_path
5633 .remove(*path)
5634 .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5635 if item.method != "GET"
5636 || item.sha256 != asset.blob_sha256
5637 || item.bytes != asset.bytes
5638 || item.url.is_empty()
5639 {
5640 return Err(invalid_feed(
5641 "v2 asset download capability differs from its signed leaf",
5642 ));
5643 }
5644 result.push(item);
5645 }
5646 }
5647 Ok(result)
5648}
5649
5650#[cfg(any(unix, windows))]
5651fn stage_v2_asset_download_window(
5652 cfg: &HubConfig,
5653 brain: &str,
5654 pointer: &V2PointerBody,
5655 cache_dir: &Path,
5656 pending: &[(&String, &V2BaselineAsset)],
5657) -> LinkResult<Vec<V2StagedFile>> {
5658 if pending.is_empty() {
5659 return Ok(Vec::new());
5660 }
5661 if pending.len() > V2_DOWNLOAD_CAPABILITY_FILES {
5662 return Err(invalid_feed("v2 asset capability window is oversized"));
5663 }
5664
5665 let mut last_error = None;
5666 for retry_delay in V2_DOWNLOAD_CAPABILITY_BACKOFF_MS
5667 .iter()
5668 .copied()
5669 .map(Some)
5670 .chain(std::iter::once(None))
5671 {
5672 let downloads = prepare_v2_asset_downloads(cfg, brain, pointer, pending)?;
5677 let mut unique = std::collections::BTreeMap::<String, V2DownloadItem>::new();
5678 for item in downloads {
5679 match unique.get(&item.sha256) {
5680 Some(prior) if prior.bytes != item.bytes => {
5681 return Err(invalid_feed(
5682 "one v2 asset hash has conflicting byte lengths",
5683 ));
5684 }
5685 Some(_) => {}
5686 None => {
5687 unique.insert(item.sha256.clone(), item);
5688 }
5689 }
5690 }
5691 let downloads = unique.into_values().collect::<Vec<_>>();
5692 let next = std::sync::atomic::AtomicUsize::new(0);
5693 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5694 let mut results = std::iter::repeat_with(|| None)
5695 .take(downloads.len())
5696 .collect::<Vec<Option<LinkResult<PathBuf>>>>();
5697 std::thread::scope(|scope| {
5698 let (sender, receiver) = std::sync::mpsc::channel();
5699 for _ in 0..worker_count {
5700 let sender = sender.clone();
5701 let downloads = &downloads;
5702 let next = &next;
5703 scope.spawn(move || loop {
5704 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5705 let Some(item) = downloads.get(index) else {
5706 break;
5707 };
5708 let result = download_presigned_to_cache(
5709 cfg,
5710 &item.url,
5711 cache_dir,
5712 &item.sha256,
5713 item.bytes,
5714 );
5715 if sender.send((index, result)).is_err() {
5716 break;
5717 }
5718 });
5719 }
5720 drop(sender);
5721 for (index, result) in receiver {
5722 results[index] = Some(result);
5723 }
5724 });
5725
5726 let mut failed = None;
5727 for result in results {
5728 match result {
5729 Some(Ok(_)) => {}
5730 Some(Err(error)) if failed.is_none() => failed = Some(error),
5731 Some(Err(_)) => {}
5732 None if failed.is_none() => {
5733 failed = Some(LinkError::Transport {
5734 hub: cfg.hub.clone(),
5735 message: "a bounded v2 asset worker stopped before reporting its result"
5736 .to_string(),
5737 });
5738 }
5739 None => {}
5740 }
5741 }
5742 if let Some(error) = failed {
5743 last_error = Some(error);
5744 if let Some(milliseconds) = retry_delay {
5745 std::thread::sleep(std::time::Duration::from_millis(milliseconds));
5746 continue;
5747 }
5748 break;
5749 }
5750
5751 return pending
5752 .iter()
5753 .map(|(path, asset)| {
5754 let source = cache_dir.join(&asset.blob_sha256);
5755 if !cached_blob_is_exact(&source, &asset.blob_sha256, asset.bytes)? {
5756 return Err(invalid_feed(
5757 "v2 asset download cache omitted a proven blob",
5758 ));
5759 }
5760 Ok(V2StagedFile {
5761 path: (*path).clone(),
5762 source,
5763 sha256: asset.blob_sha256.clone(),
5764 bytes: asset.bytes,
5765 })
5766 })
5767 .collect();
5768 }
5769 Err(last_error
5770 .unwrap_or_else(|| invalid_feed("v2 asset capability window made no download attempt")))
5771}
5772
5773fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5774 let bytes = get_presigned(cfg, &item.url)?;
5775 if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5776 return Err(invalid_feed("v2 blob differs from its proven path entry"));
5777 }
5778 Ok(bytes)
5779}
5780
5781#[derive(Debug, Clone)]
5782struct V2StagedFile {
5783 path: String,
5784 source: PathBuf,
5785 sha256: String,
5786 bytes: u64,
5787}
5788
5789#[cfg(unix)]
5790fn v2_download_cache_dir(
5791 cfg: &HubConfig,
5792 brain: &str,
5793 pointer: &V2PointerBody,
5794) -> LinkResult<PathBuf> {
5795 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5796}
5797
5798#[cfg(unix)]
5799fn v2_download_cache_dir_for(
5800 cfg: &HubConfig,
5801 brain: &str,
5802 transaction: &str,
5803) -> LinkResult<PathBuf> {
5804 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5805 return Err(invalid_feed("v2 download cache address is invalid"));
5806 }
5807 let path = cfg
5808 .state_dir
5809 .join("downloads")
5810 .join(brain)
5811 .join(transaction);
5812 let directory = open_or_create_dir_nofollow(&path)?;
5813 use std::os::fd::AsRawFd as _;
5814 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5815 return Err(std::io::Error::last_os_error().into());
5816 }
5817 directory.sync_all()?;
5818 Ok(path)
5819}
5820
5821#[cfg(windows)]
5822fn v2_download_cache_dir(
5823 cfg: &HubConfig,
5824 brain: &str,
5825 pointer: &V2PointerBody,
5826) -> LinkResult<PathBuf> {
5827 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5828}
5829
5830#[cfg(windows)]
5831fn v2_download_cache_dir_for(
5832 cfg: &HubConfig,
5833 brain: &str,
5834 transaction: &str,
5835) -> LinkResult<PathBuf> {
5836 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5837 return Err(invalid_feed("v2 download cache address is invalid"));
5838 }
5839 let path = cfg
5840 .state_dir
5841 .join("downloads")
5842 .join(brain)
5843 .join(transaction);
5844 crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5845 crate::fsx::open_directory_nofollow(&path)?;
5846 Ok(path)
5847}
5848
5849#[cfg(unix)]
5850fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5851 use std::os::fd::AsRawFd as _;
5852 let parent = cfg.state_dir.join("downloads").join(brain);
5853 let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5854 return;
5855 };
5856 let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5857 return;
5858 };
5859 let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5860 let _ = directory.sync_all();
5861}
5862
5863#[cfg(windows)]
5864fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5865 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5866 return;
5867 }
5868 let parent = cfg.state_dir.join("downloads").join(brain);
5869 let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5870 return;
5871 };
5872 let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5873}
5874
5875#[cfg(not(any(unix, windows)))]
5876fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5877
5878#[cfg(not(any(unix, windows)))]
5879fn v2_download_cache_dir_for(
5880 _cfg: &HubConfig,
5881 _brain: &str,
5882 _transaction: &str,
5883) -> LinkResult<PathBuf> {
5884 Err(LinkError::UnsupportedPlatform {
5885 operation: "resumable v2 download staging",
5886 })
5887}
5888
5889#[cfg(any(unix, windows))]
5890fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5891 let file = match crate::fsx::open_regular_nofollow(path) {
5892 Ok(file) => file,
5893 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5894 Err(error) => return Err(error.into()),
5895 };
5896 if file.metadata()?.len() != bytes {
5897 return Ok(false);
5898 }
5899 Ok(content_sha256_reader(file)? == sha256)
5900}
5901
5902#[cfg(any(unix, windows))]
5903fn cache_v2_blob_bytes(
5904 cache_dir: &Path,
5905 sha256: &str,
5906 expected_bytes: u64,
5907 bytes: &[u8],
5908) -> LinkResult<PathBuf> {
5909 if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5910 return Err(invalid_feed("v2 cached blob differs from its declaration"));
5911 }
5912 let path = cache_dir.join(sha256);
5913 if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5914 crate::fsx::write_atomic(&path, bytes)?;
5915 }
5916 Ok(path)
5917}
5918
5919#[cfg(not(any(unix, windows)))]
5920fn cache_v2_blob_bytes(
5921 _cache_dir: &Path,
5922 _sha256: &str,
5923 _expected_bytes: u64,
5924 _bytes: &[u8],
5925) -> LinkResult<PathBuf> {
5926 Err(LinkError::UnsupportedPlatform {
5927 operation: "resumable v2 download staging",
5928 })
5929}
5930
5931#[cfg(unix)]
5932fn download_presigned_to_cache(
5933 cfg: &HubConfig,
5934 url: &str,
5935 cache_dir: &Path,
5936 sha256: &str,
5937 expected_bytes: u64,
5938) -> LinkResult<PathBuf> {
5939 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5940
5941 let target = cache_dir.join(sha256);
5942 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5943 return Ok(target);
5944 }
5945 let directory = open_existing_dir_nofollow(cache_dir)?;
5946 let mut nonce = [0_u8; 16];
5947 ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5948 .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5949 let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5950 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5951 let fd = unsafe {
5952 libc::openat(
5953 directory.as_raw_fd(),
5954 temp.as_ptr(),
5955 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5956 0o600,
5957 )
5958 };
5959 if fd < 0 {
5960 return Err(std::io::Error::last_os_error().into());
5961 }
5962 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5963 let response = match presigned_agent(cfg, url)?.get(url).call() {
5964 Ok(response) => response,
5965 Err(ureq::Error::Status(_, response)) => {
5966 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5967 return Err(LinkError::Http {
5968 what: "v2 direct download",
5969 status: response.status(),
5970 message: "object store rejected the download".to_string(),
5971 code: None,
5972 details: None,
5973 });
5974 }
5975 Err(ureq::Error::Transport(error)) => {
5976 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5977 return Err(LinkError::Transport {
5978 hub: cfg.hub.clone(),
5979 message: error.to_string(),
5980 });
5981 }
5982 };
5983 let mut reader = response
5984 .into_reader()
5985 .take(expected_bytes.saturating_add(1));
5986 let mut digest = Sha256::new();
5987 let mut total = 0_u64;
5988 let mut buffer = [0_u8; 64 * 1024];
5989 let write_result = (|| -> LinkResult<()> {
5994 loop {
5995 let read = reader
5996 .read(&mut buffer)
5997 .map_err(|error| LinkError::Transport {
5998 hub: cfg.hub.clone(),
5999 message: error.to_string(),
6000 })?;
6001 if read == 0 {
6002 break;
6003 }
6004 total = total.saturating_add(read as u64);
6005 digest.update(&buffer[..read]);
6006 output.write_all(&buffer[..read])?;
6007 }
6008 output.sync_all().map_err(LinkError::from)
6009 })();
6010 if let Err(error) = write_result {
6011 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6012 return Err(error);
6013 }
6014 drop(output);
6015 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6016 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6017 return Err(invalid_feed(
6018 "v2 direct download failed integrity verification",
6019 ));
6020 }
6021 let target_name = c_name(sha256.as_bytes(), sha256)?;
6022 if unsafe {
6025 libc::renameat(
6026 directory.as_raw_fd(),
6027 temp.as_ptr(),
6028 directory.as_raw_fd(),
6029 target_name.as_ptr(),
6030 )
6031 } != 0
6032 {
6033 let error = std::io::Error::last_os_error();
6034 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6035 return Err(error.into());
6036 }
6037 directory.sync_all()?;
6038 Ok(target)
6039}
6040
6041#[cfg(windows)]
6042fn download_presigned_to_cache(
6043 cfg: &HubConfig,
6044 url: &str,
6045 cache_dir: &Path,
6046 sha256: &str,
6047 expected_bytes: u64,
6048) -> LinkResult<PathBuf> {
6049 use std::fs::OpenOptions;
6050
6051 let target = cache_dir.join(sha256);
6052 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6053 return Ok(target);
6054 }
6055 let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
6059 let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
6060 let mut output = OpenOptions::new()
6061 .write(true)
6062 .create_new(true)
6063 .open(&temp)?;
6064 let response = match presigned_agent(cfg, url)?.get(url).call() {
6065 Ok(response) => response,
6066 Err(ureq::Error::Status(_, response)) => {
6067 let _ = std::fs::remove_file(&temp);
6068 return Err(LinkError::Http {
6069 what: "v2 direct download",
6070 status: response.status(),
6071 message: "object store rejected the download".to_string(),
6072 code: None,
6073 details: None,
6074 });
6075 }
6076 Err(ureq::Error::Transport(error)) => {
6077 let _ = std::fs::remove_file(&temp);
6078 return Err(LinkError::Transport {
6079 hub: cfg.hub.clone(),
6080 message: error.to_string(),
6081 });
6082 }
6083 };
6084 let mut reader = response
6085 .into_reader()
6086 .take(expected_bytes.saturating_add(1));
6087 let mut digest = Sha256::new();
6088 let mut total = 0_u64;
6089 let mut buffer = [0_u8; 64 * 1024];
6090 let copied = (|| -> LinkResult<()> {
6092 loop {
6093 let read = reader
6094 .read(&mut buffer)
6095 .map_err(|error| LinkError::Transport {
6096 hub: cfg.hub.clone(),
6097 message: error.to_string(),
6098 })?;
6099 if read == 0 {
6100 break;
6101 }
6102 total = total.saturating_add(read as u64);
6103 digest.update(&buffer[..read]);
6104 output.write_all(&buffer[..read])?;
6105 }
6106 output.sync_all()?;
6107 Ok(())
6108 })();
6109 if let Err(error) = copied {
6110 let _ = std::fs::remove_file(&temp);
6111 return Err(error);
6112 }
6113 drop(output);
6114 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6115 let _ = std::fs::remove_file(&temp);
6116 return Err(invalid_feed(
6117 "v2 direct download failed integrity verification",
6118 ));
6119 }
6120 if target.exists() {
6121 std::fs::remove_file(&target)?;
6122 }
6123 if let Err(error) = std::fs::rename(&temp, &target) {
6124 let _ = std::fs::remove_file(&temp);
6125 return Err(error.into());
6126 }
6127 Ok(target)
6128}
6129
6130#[cfg(not(any(unix, windows)))]
6131fn download_presigned_to_cache(
6132 _cfg: &HubConfig,
6133 _url: &str,
6134 _cache_dir: &Path,
6135 _sha256: &str,
6136 _expected_bytes: u64,
6137) -> LinkResult<PathBuf> {
6138 Err(LinkError::UnsupportedPlatform {
6139 operation: "resumable v2 download staging",
6140 })
6141}
6142
6143fn download_v2_blobs(
6144 cfg: &HubConfig,
6145 brain: &str,
6146 pointer: &V2PointerBody,
6147 pending: Vec<(&String, &V2BaselineFile)>,
6148) -> LinkResult<Vec<(String, Vec<u8>)>> {
6149 if pending.is_empty() {
6150 return Ok(Vec::new());
6151 }
6152 let expected_order = pending
6153 .iter()
6154 .map(|(path, _)| (*path).clone())
6155 .collect::<Vec<_>>();
6156 let mut streamed = std::collections::BTreeMap::new();
6157 let mut direct = Vec::new();
6158 let mut window = Vec::new();
6159 let mut window_bytes = 0_u64;
6160 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6161 window_bytes: &mut u64,
6162 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
6163 -> LinkResult<()> {
6164 if window.is_empty() {
6165 return Ok(());
6166 }
6167 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
6168 if streamed.insert(path, bytes).is_some() {
6169 return Err(invalid_feed("v2 bulk streams repeated a path"));
6170 }
6171 }
6172 window.clear();
6173 *window_bytes = 0;
6174 Ok(())
6175 };
6176 for &(path, file) in &pending {
6177 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6178 flush(&mut window, &mut window_bytes, &mut streamed)?;
6179 direct.push((path, file));
6180 continue;
6181 }
6182 if window.len() == V2_BULK_STREAM_FILES
6183 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6184 {
6185 flush(&mut window, &mut window_bytes, &mut streamed)?;
6186 }
6187 window.push((path, file));
6188 window_bytes += file.bytes;
6189 }
6190 flush(&mut window, &mut window_bytes, &mut streamed)?;
6191
6192 let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
6193 let next = std::sync::atomic::AtomicUsize::new(0);
6194 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6195 let mut results = std::iter::repeat_with(|| None)
6196 .take(downloads.len())
6197 .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
6198 std::thread::scope(|scope| {
6199 let (sender, receiver) = std::sync::mpsc::channel();
6200 for _ in 0..worker_count {
6201 let sender = sender.clone();
6202 let downloads = &downloads;
6203 let next = &next;
6204 scope.spawn(move || loop {
6205 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6206 let Some(item) = downloads.get(index) else {
6207 break;
6208 };
6209 let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
6210 if sender.send((index, result)).is_err() {
6211 break;
6212 }
6213 });
6214 }
6215 drop(sender);
6216 for (index, result) in receiver {
6217 results[index] = Some(result);
6218 }
6219 });
6220 for result in results.into_iter().map(|result| {
6221 result.ok_or_else(|| LinkError::Transport {
6222 hub: cfg.hub.clone(),
6223 message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
6224 })?
6225 }) {
6226 let (path, bytes) = result?;
6227 if streamed.insert(path, bytes).is_some() {
6228 return Err(invalid_feed("v2 download lanes repeated a path"));
6229 }
6230 }
6231 expected_order
6232 .into_iter()
6233 .map(|path| {
6234 streamed
6235 .remove(&path)
6236 .map(|bytes| (path, bytes))
6237 .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
6238 })
6239 .collect()
6240}
6241
6242#[cfg(any(unix, windows))]
6246fn stage_v2_blobs(
6247 cfg: &HubConfig,
6248 brain: &str,
6249 pointer: &V2PointerBody,
6250 pending: Vec<(&String, &V2BaselineFile)>,
6251) -> LinkResult<Vec<V2StagedFile>> {
6252 let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6253 let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6254 let mut direct = Vec::new();
6255 let mut window = Vec::new();
6256 let mut window_bytes = 0_u64;
6257 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6258 window_bytes: &mut u64,
6259 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
6260 -> LinkResult<()> {
6261 if window.is_empty() {
6262 return Ok(());
6263 }
6264 let missing = window
6265 .iter()
6266 .filter_map(|(path, file)| {
6267 let target = cache_dir.join(&file.sha256);
6268 match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6269 Ok(true) => {
6270 staged.insert(
6271 (*path).clone(),
6272 V2StagedFile {
6273 path: (*path).clone(),
6274 source: target,
6275 sha256: file.sha256.clone(),
6276 bytes: file.bytes,
6277 },
6278 );
6279 None
6280 }
6281 Ok(false) => Some(Ok((*path, *file))),
6282 Err(error) => Some(Err(error)),
6283 }
6284 })
6285 .collect::<LinkResult<Vec<_>>>()?;
6286 if !missing.is_empty() {
6287 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
6288 let file = missing
6289 .iter()
6290 .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
6291 .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
6292 let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
6293 staged.insert(
6294 path.clone(),
6295 V2StagedFile {
6296 path,
6297 source,
6298 sha256: file.sha256.clone(),
6299 bytes: file.bytes,
6300 },
6301 );
6302 }
6303 }
6304 window.clear();
6305 *window_bytes = 0;
6306 Ok(())
6307 };
6308 for &(path, file) in &pending {
6309 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6310 flush(&mut window, &mut window_bytes, &mut staged)?;
6311 direct.push((path, file));
6312 continue;
6313 }
6314 if window.len() == V2_BULK_STREAM_FILES
6315 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6316 {
6317 flush(&mut window, &mut window_bytes, &mut staged)?;
6318 }
6319 window.push((path, file));
6320 window_bytes += file.bytes;
6321 }
6322 flush(&mut window, &mut window_bytes, &mut staged)?;
6323 for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6324 let source =
6325 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6326 staged.insert(
6327 item.path.clone(),
6328 V2StagedFile {
6329 path: item.path,
6330 source,
6331 sha256: item.sha256,
6332 bytes: item.bytes,
6333 },
6334 );
6335 }
6336 pending
6337 .into_iter()
6338 .map(|(path, _)| {
6339 staged
6340 .remove(path)
6341 .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6342 })
6343 .collect()
6344}
6345
6346#[cfg(not(any(unix, windows)))]
6347fn stage_v2_blobs(
6348 _cfg: &HubConfig,
6349 _brain: &str,
6350 _pointer: &V2PointerBody,
6351 _pending: Vec<(&String, &V2BaselineFile)>,
6352) -> LinkResult<Vec<V2StagedFile>> {
6353 Err(LinkError::UnsupportedPlatform {
6354 operation: "resumable v2 download staging",
6355 })
6356}
6357
6358const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6359const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6360const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6361
6362#[derive(Debug, Clone, Deserialize, Serialize)]
6363struct V2ConflictCoordinate {
6364 sha256: Option<String>,
6365 bytes: Option<u64>,
6366 file: Option<String>,
6367}
6368
6369#[derive(Debug, Clone, Deserialize, Serialize)]
6370struct V2ConflictFile {
6371 path: String,
6372 base: V2ConflictCoordinate,
6373 local: V2ConflictCoordinate,
6374 remote: V2ConflictCoordinate,
6375}
6376
6377#[derive(Debug, Clone, Deserialize, Serialize)]
6378struct V2ConflictPlan {
6379 v: u8,
6380 class: String,
6381 bundle: String,
6382 brain: String,
6383 origin: String,
6384 created_unix: u64,
6385 expires_unix: u64,
6386 base_seq: Option<u64>,
6387 base_commit: Option<String>,
6388 remote_seq: u64,
6389 remote_commit: Option<String>,
6390 remote_content_root: Option<String>,
6391 view_kind: String,
6392 view_revision: String,
6393 files: Vec<V2ConflictFile>,
6394}
6395
6396fn v2_take_remote_selection(
6397 files: &[V2ConflictFile],
6398 current: &std::collections::BTreeMap<String, V2BaselineFile>,
6399) -> LinkResult<(
6400 std::collections::BTreeMap<String, V2BaselineFile>,
6401 Vec<String>,
6402)> {
6403 let mut selected = std::collections::BTreeMap::new();
6404 let mut deleted = Vec::new();
6405 for file in files {
6406 match (&file.remote.sha256, file.remote.bytes) {
6407 (Some(sha256), Some(bytes)) => {
6408 let proven = current.get(&file.path).ok_or_else(|| {
6409 invalid_feed("conflict remote coordinate disappeared from the exact head")
6410 })?;
6411 if proven.sha256 != *sha256 || proven.bytes != bytes {
6412 return Err(invalid_feed(
6413 "conflict remote coordinate differs from the exact head",
6414 ));
6415 }
6416 if selected.insert(file.path.clone(), proven.clone()).is_some() {
6417 return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6418 }
6419 }
6420 (None, None) => {
6421 if current.contains_key(&file.path) {
6422 return Err(invalid_feed(
6423 "conflict remote deletion differs from the exact head",
6424 ));
6425 }
6426 deleted.push(file.path.clone());
6427 }
6428 _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6429 }
6430 }
6431 Ok((selected, deleted))
6432}
6433
6434fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6435 PathBuf::from(".dbmd")
6436 .join("conflicts")
6437 .join(bundle)
6438 .join(suffix)
6439}
6440
6441fn read_historical_conflict_blob(
6442 cfg: &HubConfig,
6443 brain: &str,
6444 baseline: &V2SyncBaseline,
6445 path: &str,
6446 file: &V2BaselineFile,
6447) -> LinkResult<Option<Vec<u8>>> {
6448 let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6449 return Ok(None);
6450 };
6451 if seq == 0 {
6452 return Ok(None);
6453 }
6454 let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6455 let endpoint = format!(
6456 "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6457 file.sha256
6458 );
6459 let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
6460 if raw.status == 404 || raw.status == 403 {
6461 return Ok(None);
6462 }
6463 let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6464 if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6465 return Err(invalid_feed(
6466 "v2 conflict base failed integrity verification",
6467 ));
6468 }
6469 Ok(Some(bytes))
6470}
6471
6472fn create_v2_conflict_bundle(
6475 cfg: &HubConfig,
6476 store: &Store,
6477 head: &V2VerifiedHead,
6478 baseline: Option<&V2SyncBaseline>,
6479 local: &std::collections::BTreeMap<String, (String, u64)>,
6480 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6481 paths: &[String],
6482) -> LinkResult<(String, Vec<String>)> {
6483 let conflicts_root = Path::new(".dbmd/conflicts");
6484 store.create_dir_all(conflicts_root)?;
6485 let completed = store
6486 .directory_names(conflicts_root)?
6487 .into_iter()
6488 .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6489 .count();
6490 if completed >= V2_CONFLICT_BUNDLE_MAX {
6491 return Err(LinkError::InvalidPack {
6492 message: format!(
6493 "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6494 ),
6495 });
6496 }
6497
6498 let mut selected_paths = Vec::new();
6502 let mut selected_remote_bytes = 0_u64;
6503 for path in paths {
6504 let bytes = remote.get(path).map_or(0, |file| file.bytes);
6505 if !selected_paths.is_empty()
6506 && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6507 {
6508 break;
6509 }
6510 selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6511 selected_paths.push(path.clone());
6512 if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6513 break;
6514 }
6515 }
6516 if selected_paths.is_empty() {
6517 return Err(invalid_feed("content conflict set is empty"));
6518 }
6519 let bundle = crate::ulid::mint();
6520 let bundle_root = v2_conflict_relative(&bundle, "");
6521 store.create_dir_all(&bundle_root.join("files"))?;
6522 let pointer = head.pointer.as_ref();
6523 let remote_bytes = match pointer {
6524 Some(pointer) => download_v2_blobs(
6525 cfg,
6526 &head.brain_id,
6527 pointer,
6528 selected_paths
6529 .iter()
6530 .filter_map(|path| {
6531 remote
6532 .get(path)
6533 .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6534 .map(|file| (path, file))
6535 })
6536 .collect(),
6537 )?
6538 .into_iter()
6539 .collect::<std::collections::BTreeMap<_, _>>(),
6540 None => std::collections::BTreeMap::new(),
6541 };
6542
6543 let mut files = Vec::with_capacity(selected_paths.len());
6544 for (index, path) in selected_paths.iter().enumerate() {
6545 let base_file = baseline.and_then(|state| state.files.get(path));
6546 let base_bytes = match (baseline, base_file) {
6547 (Some(state), Some(file)) => {
6548 read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
6549 }
6550 _ => None,
6551 };
6552 let local_file = local.get(path);
6553 let remote_file = remote.get(path);
6554 let remote_content = remote_bytes.get(path);
6555 let prefix = format!("files/{index:04}");
6556 let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6557 let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6558 let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6559 if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6560 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6561 }
6562 if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6563 let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6564 if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6565 return Err(LinkError::InvalidPack {
6566 message: format!("local conflict path `{path}` changed while bundling"),
6567 });
6568 }
6569 store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6570 }
6571 if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6572 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6573 }
6574 files.push(V2ConflictFile {
6575 path: path.clone(),
6576 base: V2ConflictCoordinate {
6577 sha256: base_file.map(|file| file.sha256.clone()),
6578 bytes: base_file.map(|file| file.bytes),
6579 file: base_name,
6580 },
6581 local: V2ConflictCoordinate {
6582 sha256: local_file.map(|(sha256, _)| sha256.clone()),
6583 bytes: local_file.map(|(_, bytes)| *bytes),
6584 file: local_name,
6585 },
6586 remote: V2ConflictCoordinate {
6587 sha256: remote_file.map(|file| file.sha256.clone()),
6588 bytes: remote_file.map(|file| file.bytes),
6589 file: remote_name,
6590 },
6591 });
6592 }
6593 let now = SystemTime::now()
6594 .duration_since(UNIX_EPOCH)
6595 .unwrap_or_default()
6596 .as_secs();
6597 let plan = V2ConflictPlan {
6598 v: 2,
6599 class: "content_resolution_required".to_string(),
6600 bundle: bundle.clone(),
6601 brain: head.brain_id.clone(),
6602 origin: normalized_origin(&cfg.hub)?,
6603 created_unix: now,
6604 expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6605 base_seq: baseline.and_then(|state| state.head_seq),
6606 base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6607 remote_seq: pointer.map_or(0, |value| value.seq),
6608 remote_commit: pointer.map(|value| value.commit_hash.clone()),
6609 remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6610 view_kind: head.view_kind.clone(),
6611 view_revision: head.view_revision.clone(),
6612 files,
6613 };
6614 let mut bytes = serde_json::to_vec_pretty(&plan)
6615 .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6616 bytes.push(b'\n');
6617 store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6618 Ok((bundle, selected_paths))
6619}
6620
6621fn v2_sync_pull_with_resolution(
6622 cfg: &HubConfig,
6623 requested_brain: &str,
6624 expected_head: V2VerifiedHead,
6625 out: Option<&Path>,
6626 take_remote: Option<&std::collections::BTreeSet<String>>,
6627) -> LinkResult<V2PulledSnapshot> {
6628 let dest = out
6629 .map(Path::to_path_buf)
6630 .unwrap_or_else(|| PathBuf::from(requested_brain));
6631 let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6632 recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6633 let head = v2_verified_head(cfg, requested_brain)?
6634 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6635 if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6636 return Err(LinkError::RemoteAdvancedDuringSync);
6637 }
6638 let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6639 ensure_v2_view_compatible(&head, baseline.as_ref())?;
6640 let (remote, remote_assets) = match baseline
6641 .as_ref()
6642 .filter(|state| v2_baseline_matches_head(&head, state))
6643 {
6644 Some(state) => (state.files.clone(), state.assets.clone()),
6645 None => (
6646 files_for_v2_view(
6647 &head,
6648 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6649 ),
6650 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6651 ),
6652 };
6653 let local_store = Store::open_strict(&dest).ok();
6654 ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6659 let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6660 if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6661 return Err(LinkError::ScopedViewChanged);
6662 }
6663 if let Some(view) = local_view.as_mut() {
6664 remove_scoped_projection(&head, baseline.as_ref(), view)?;
6665 }
6666 let empty_local = std::collections::BTreeMap::new();
6667 let local = local_view
6668 .as_ref()
6669 .map_or(&empty_local, |view| &view.riding);
6670 let kept_home = |path: &str| {
6671 local_view
6672 .as_ref()
6673 .is_some_and(|view| view.policy.keeps_home(path))
6674 };
6675 let empty_base = std::collections::BTreeMap::new();
6676 let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6677 let empty_base_assets = std::collections::BTreeMap::new();
6678 let base_assets = baseline
6679 .as_ref()
6680 .map_or(&empty_base_assets, |state| &state.assets);
6681 let mut local_assets = local_store
6682 .as_ref()
6683 .map(v2_local_asset_records)
6684 .transpose()?
6685 .unwrap_or_default();
6686 let mut content_merge = merge_v2_pulled_records(
6687 base,
6688 &remote,
6689 local,
6690 |file, _| (file.sha256.clone(), file.bytes),
6691 |file, _| (file.sha256.clone(), file.bytes),
6692 kept_home,
6693 );
6694 if let Some(selected) = take_remote {
6695 for path in selected {
6696 if let Some(position) = content_merge
6697 .conflicts
6698 .iter()
6699 .position(|conflict| conflict == path)
6700 {
6701 content_merge.conflicts.remove(position);
6702 content_merge.accept_remote.insert(path.clone());
6703 match remote.get(path) {
6704 Some(file) => {
6705 content_merge
6706 .records
6707 .insert(path.clone(), (file.sha256.clone(), file.bytes));
6708 }
6709 None => {
6710 content_merge.records.remove(path);
6711 }
6712 }
6713 } else if !content_merge.accept_remote.contains(path) {
6714 return Err(LinkError::InvalidPack {
6715 message: format!(
6716 "take-remote path `{path}` is no longer at its conflict coordinate"
6717 ),
6718 });
6719 }
6720 }
6721 }
6722 if !content_merge.conflicts.is_empty() {
6723 let mut conflicts = content_merge.conflicts.clone();
6724 conflicts.truncate(100);
6725 if let Some(store) = local_store.as_ref() {
6726 let (bundle, paths) = create_v2_conflict_bundle(
6727 cfg,
6728 store,
6729 &head,
6730 baseline.as_ref(),
6731 local,
6732 &remote,
6733 &conflicts,
6734 )?;
6735 return Err(LinkError::ConflictBundle { bundle, paths });
6736 }
6737 return Err(LinkError::Conflict { paths: conflicts });
6738 }
6739 let asset_merge = merge_v2_pulled_records(
6740 base_assets,
6741 &remote_assets,
6742 &local_assets,
6743 v2_asset_record,
6744 v2_asset_record,
6745 |_| false,
6746 );
6747 if !asset_merge.conflicts.is_empty() {
6748 let mut conflicts = asset_merge.conflicts.clone();
6749 conflicts.truncate(100);
6750 return Err(LinkError::Conflict { paths: conflicts });
6751 }
6752 let pointer = head.pointer.as_ref();
6753 let cache_transaction = pointer.map_or_else(
6754 || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6755 |value| value.commit_hash.clone(),
6756 );
6757 let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6758 let mut changed = match pointer {
6759 Some(pointer) => stage_v2_blobs(
6760 cfg,
6761 &head.brain_id,
6762 pointer,
6763 remote
6764 .iter()
6765 .filter(|(path, file)| {
6766 content_merge.accept_remote.contains(*path)
6767 && local.get(*path).map(|value| value.0.as_str())
6768 != Some(file.sha256.as_str())
6769 })
6770 .collect(),
6771 )?,
6772 None => Vec::new(),
6773 };
6774 let mut deleted = content_merge
6775 .accept_remote
6776 .iter()
6777 .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6778 .cloned()
6779 .collect::<Vec<_>>();
6780 if local_assets != asset_merge.records {
6781 if asset_merge.records.is_empty() {
6782 deleted.push("assets.jsonl".to_string());
6783 } else {
6784 let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6785 let sha256 = content_sha256(&bytes);
6786 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6787 changed.push(V2StagedFile {
6788 path: "assets.jsonl".to_string(),
6789 source,
6790 sha256,
6791 bytes: bytes.len() as u64,
6792 });
6793 }
6794 }
6795 if let Some(pointer) = pointer {
6796 let mut pending_assets = Vec::new();
6797 for (path, asset) in &remote_assets {
6798 if asset.disposition != "hosted"
6799 || kept_home(path)
6800 || !asset_merge.accept_remote.contains(path)
6801 {
6802 continue;
6803 }
6804 let already_current = local_store.as_ref().is_some_and(|store| {
6805 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6806 && store
6807 .read_bounded(Path::new(path), asset.bytes)
6808 .ok()
6809 .is_some_and(|bytes| {
6810 bytes.len() as u64 == asset.bytes
6811 && content_sha256(&bytes) == asset.blob_sha256
6812 })
6813 });
6814 if !already_current {
6815 pending_assets.push((path, asset));
6816 }
6817 }
6818 let mut window = Vec::new();
6819 let mut window_bytes = 0_u64;
6820 let flush = |window: &mut Vec<(&String, &V2BaselineAsset)>,
6821 window_bytes: &mut u64,
6822 changed: &mut Vec<V2StagedFile>|
6823 -> LinkResult<()> {
6824 changed.extend(stage_v2_asset_download_window(
6825 cfg,
6826 &head.brain_id,
6827 pointer,
6828 &cache_dir,
6829 window,
6830 )?);
6831 window.clear();
6832 *window_bytes = 0;
6833 Ok(())
6834 };
6835 for item @ (_, asset) in pending_assets {
6836 if !window.is_empty()
6837 && (window.len() == V2_DOWNLOAD_CAPABILITY_FILES
6838 || window_bytes.saturating_add(asset.bytes) > V2_DOWNLOAD_CAPABILITY_BYTES)
6839 {
6840 flush(&mut window, &mut window_bytes, &mut changed)?;
6841 }
6842 window.push(item);
6843 window_bytes = window_bytes.saturating_add(asset.bytes);
6844 if window_bytes >= V2_DOWNLOAD_CAPABILITY_BYTES {
6845 flush(&mut window, &mut window_bytes, &mut changed)?;
6846 }
6847 }
6848 flush(&mut window, &mut window_bytes, &mut changed)?;
6849 }
6850 for (path, prior) in base_assets {
6851 if remote_assets.contains_key(path)
6852 || kept_home(path)
6853 || !asset_merge.accept_remote.contains(path)
6854 {
6855 continue;
6856 }
6857 let unchanged = local_store.as_ref().is_some_and(|store| {
6858 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6859 && store
6860 .read_bounded(Path::new(path), prior.bytes)
6861 .ok()
6862 .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6863 });
6864 if unchanged {
6865 deleted.push(path.clone());
6866 }
6867 }
6868 let extra_local = content_merge
6869 .records
6870 .keys()
6871 .filter(|path| !remote.contains_key(*path))
6872 .cloned()
6873 .collect::<Vec<_>>();
6874 if head.view_kind == "scoped" {
6875 for (path, bytes) in [
6876 ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6877 (
6878 ".dbmd/view.json".to_string(),
6879 scoped_view_metadata(&head, remote.len())?,
6880 ),
6881 ] {
6882 let sha256 = content_sha256(&bytes);
6883 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6884 changed.push(V2StagedFile {
6885 path,
6886 source,
6887 sha256,
6888 bytes: bytes.len() as u64,
6889 });
6890 }
6891 }
6892 let install_changed = !changed.is_empty() || !deleted.is_empty();
6893 install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6894 let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6895 let installed_store =
6896 Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6897 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6898 })?;
6899 let installed_local = if install_changed {
6900 let mut scanned = v2_local_files(&installed_store)?;
6901 remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6902 scanned
6903 } else {
6904 local_view
6905 .take()
6906 .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6907 };
6908 if installed_local.riding != content_merge.records {
6909 return Err(LinkError::InvalidPack {
6910 message: "local content changed while installing the v2 pull".to_string(),
6911 });
6912 }
6913 let installed_assets = if install_changed {
6914 v2_local_asset_records(&installed_store)?
6915 } else {
6916 std::mem::take(&mut local_assets)
6917 };
6918 if installed_assets != asset_merge.records {
6919 return Err(LinkError::InvalidPack {
6920 message: "local assets changed while installing the v2 pull".to_string(),
6921 });
6922 }
6923 let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6924 installed_local.policy.keeps_home(path)
6925 })
6926 || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6927 let final_head = v2_verified_head(cfg, requested_brain)?
6928 .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6929 if !same_v2_head(&head, &final_head) {
6930 return Err(LinkError::RemoteAdvancedDuringSync);
6931 }
6932 accept_v2_head(cfg, &final_head)?;
6933 save_v2_baseline(
6934 cfg,
6935 &head.brain_id,
6936 &dest,
6937 &v2_baseline_from_head(
6938 cfg,
6939 &head,
6940 remote.clone(),
6941 remote_assets.clone(),
6942 Some(&installed_local),
6943 baseline
6944 .as_ref()
6945 .and_then(|current| current.checkout_id.as_deref()),
6946 )?,
6947 )?;
6948 complete_v2_pull(&dest)?;
6949 Ok((local_dirty, installed_local, installed_assets))
6950 })();
6951 let (local_dirty, installed_local, installed_assets) = match finalized {
6952 Ok(value) => value,
6953 Err(error) => {
6954 if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6955 return Err(LinkError::InvalidPack {
6956 message: format!("{error}; durable pull recovery also failed: {recovery}"),
6957 });
6958 }
6959 return Err(error);
6960 }
6961 };
6962 cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6963 let report = PullReport {
6964 brain: head.brain_id.clone(),
6965 slug: requested_brain.to_string(),
6966 head_seq: pointer.map_or(0, |value| value.seq),
6967 files: remote.len() + remote_assets.len(),
6968 dest: dest.to_string_lossy().into_owned(),
6969 extra_local,
6970 sync_status: if local_dirty {
6971 "local_dirty_after_install".to_string()
6972 } else {
6973 "synced".to_string()
6974 },
6975 };
6976 Ok(V2PulledSnapshot {
6977 report,
6978 head,
6979 files: remote,
6980 assets: remote_assets,
6981 local: installed_local,
6982 local_assets: installed_assets,
6983 })
6984}
6985
6986fn v2_sync_pull(
6987 cfg: &HubConfig,
6988 requested_brain: &str,
6989 head: V2VerifiedHead,
6990 out: Option<&Path>,
6991) -> LinkResult<PullReport> {
6992 Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
6993}
6994
6995fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
6996 match remote {
6997 Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
6998 None => json!({ "kind": "absent" }),
6999 }
7000}
7001
7002fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
7003 match remote {
7004 Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
7005 None => json!({ "kind": "absent" }),
7006 }
7007}
7008
7009fn v2_content_withdrawal_operation(
7010 store: &Store,
7011 local_view: &V2LocalView,
7012 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7013 path: &str,
7014 reason: &str,
7015) -> LinkResult<Value> {
7016 if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
7017 || path == "DB.md"
7018 {
7019 return Err(LinkError::InvalidPack {
7020 message: format!("content withdrawal path `{path}` is not a record or source"),
7021 });
7022 }
7023 if !local_view.policy.keeps_home(path)
7024 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7025 {
7026 return Err(LinkError::InvalidPack {
7027 message: format!(
7028 "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7029 ),
7030 });
7031 }
7032 let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
7033 message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
7034 })?;
7035 verify_v2_upload_source(store, path, ¤t.sha256, current.bytes)?;
7036 Ok(json!({
7037 "op": "withdraw_from_hosting",
7038 "path": path,
7039 "expected": { "kind": "blob", "hash": current.sha256 },
7040 "reason": reason,
7041 }))
7042}
7043
7044fn v2_asset_withdrawal_operation(
7045 store: &Store,
7046 local_view: &V2LocalView,
7047 path: &str,
7048 local: &crate::AssetRecord,
7049 current: &V2BaselineAsset,
7050 reason: &str,
7051) -> LinkResult<Value> {
7052 if !local_view.policy.keeps_home(path)
7053 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7054 {
7055 return Err(LinkError::InvalidPack {
7056 message: format!(
7057 "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7058 ),
7059 });
7060 }
7061 verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
7062 if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
7063 return Err(LinkError::InvalidPack {
7064 message: format!(
7065 "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
7066 ),
7067 });
7068 }
7069 Ok(json!({
7070 "op": "asset_withdraw",
7071 "path": path,
7072 "expected": v2_asset_expected(Some(current)),
7073 "reason": reason,
7074 }))
7075}
7076
7077fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
7084 let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
7085 let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
7086 for (index, operation) in operations.iter().enumerate() {
7087 match operation.get("op").and_then(Value::as_str) {
7088 Some("delete") => {
7089 let Some(path) = operation.get("path").and_then(Value::as_str) else {
7090 continue;
7091 };
7092 let Some(hash) = operation
7093 .get("expected")
7094 .and_then(|value| value.get("hash"))
7095 .and_then(Value::as_str)
7096 else {
7097 continue;
7098 };
7099 if path.starts_with("sources/") {
7100 deletes
7101 .entry(hash.to_string())
7102 .or_default()
7103 .push((index, path.to_string()));
7104 }
7105 }
7106 Some("put") => {
7107 let Some(path) = operation.get("path").and_then(Value::as_str) else {
7108 continue;
7109 };
7110 let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
7111 continue;
7112 };
7113 let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
7114 continue;
7115 };
7116 let destination_absent = operation
7117 .get("expected")
7118 .and_then(|value| value.get("kind"))
7119 .and_then(Value::as_str)
7120 == Some("absent");
7121 if path.starts_with("sources/") && destination_absent {
7122 puts.entry(hash.to_string()).or_default().push((
7123 index,
7124 path.to_string(),
7125 bytes,
7126 ));
7127 }
7128 }
7129 _ => {}
7130 }
7131 }
7132 let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
7133 let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
7134 for (hash, source) in deletes {
7135 let Some(destination) = puts.get(&hash) else {
7136 continue;
7137 };
7138 if source.len() != 1 || destination.len() != 1 {
7139 continue;
7140 }
7141 let (delete_index, from) = &source[0];
7142 let (put_index, to, bytes) = &destination[0];
7143 if from == to {
7144 continue;
7145 }
7146 rename_at.insert(
7147 *delete_index,
7148 json!({
7149 "op": "rename",
7150 "from": from,
7151 "to": to,
7152 "expected_from": { "kind": "blob", "hash": hash },
7153 "expected_to": { "kind": "absent" },
7154 "blob": hash,
7155 "bytes": bytes,
7156 }),
7157 );
7158 consumed_puts.insert(*put_index);
7159 }
7160 operations
7161 .into_iter()
7162 .enumerate()
7163 .filter_map(|(index, operation)| {
7164 if let Some(rename) = rename_at.remove(&index) {
7165 Some(rename)
7166 } else if consumed_puts.contains(&index) {
7167 None
7168 } else {
7169 Some(operation)
7170 }
7171 })
7172 .collect()
7173}
7174
7175fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
7176 json!({
7177 "blob_sha256": record.sha256,
7178 "bytes": record.bytes,
7179 "media_type": record.media_type,
7180 "wrappers": record.wrappers,
7181 "required": record.required,
7182 "disposition": disposition,
7183 })
7184}
7185
7186fn apply_generated_v2_operations(
7190 operations: &[Value],
7191 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
7192 candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
7193 candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
7194) -> LinkResult<bool> {
7195 let mut asset_changed = false;
7196 for operation in operations {
7197 match operation.get("op").and_then(Value::as_str) {
7198 Some("put") => {
7199 let path = operation
7200 .get("path")
7201 .and_then(Value::as_str)
7202 .ok_or_else(|| invalid_feed("v2 put has no path"))?;
7203 let sha256 = operation
7204 .get("blob")
7205 .and_then(Value::as_str)
7206 .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
7207 let bytes = operation
7208 .get("bytes")
7209 .and_then(Value::as_u64)
7210 .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
7211 candidate.insert(
7212 path.to_string(),
7213 V2BaselineFile {
7214 sha256: sha256.to_string(),
7215 bytes,
7216 proof: None,
7217 },
7218 );
7219 }
7220 Some("rename") => {
7221 let from = operation
7222 .get("from")
7223 .and_then(Value::as_str)
7224 .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
7225 let to = operation
7226 .get("to")
7227 .and_then(Value::as_str)
7228 .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
7229 let sha256 = operation
7230 .get("blob")
7231 .and_then(Value::as_str)
7232 .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
7233 let bytes = operation
7234 .get("bytes")
7235 .and_then(Value::as_u64)
7236 .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
7237 let expected_from = operation
7238 .get("expected_from")
7239 .and_then(|expected| expected.get("hash"))
7240 .and_then(Value::as_str);
7241 let expected_to_absent = operation
7242 .get("expected_to")
7243 .and_then(|expected| expected.get("kind"))
7244 .and_then(Value::as_str)
7245 == Some("absent");
7246 if from == to
7247 || !from.starts_with("sources/")
7248 || !to.starts_with("sources/")
7249 || expected_from != Some(sha256)
7250 || !expected_to_absent
7251 || candidate.contains_key(to)
7252 {
7253 return Err(invalid_feed("generated v2 source rename is malformed"));
7254 }
7255 let source = candidate
7256 .remove(from)
7257 .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
7258 if source.sha256 != sha256 || source.bytes != bytes {
7259 return Err(invalid_feed(
7260 "v2 rename source differs from its exact-byte claim",
7261 ));
7262 }
7263 candidate.insert(
7264 to.to_string(),
7265 V2BaselineFile {
7266 sha256: sha256.to_string(),
7267 bytes,
7268 proof: None,
7269 },
7270 );
7271 }
7272 Some("delete" | "withdraw_from_hosting") => {
7273 let path = operation
7274 .get("path")
7275 .and_then(Value::as_str)
7276 .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7277 candidate.remove(path);
7278 }
7279 Some("asset_delete") => {
7280 let path = operation
7281 .get("path")
7282 .and_then(Value::as_str)
7283 .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7284 candidate_assets.remove(path);
7285 asset_changed = true;
7286 }
7287 Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7288 let path = operation
7289 .get("path")
7290 .and_then(Value::as_str)
7291 .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7292 let record = local_assets
7293 .get(path)
7294 .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7295 let disposition =
7296 if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7297 "withheld"
7298 } else {
7299 operation
7300 .get("asset")
7301 .and_then(|asset| asset.get("disposition"))
7302 .and_then(Value::as_str)
7303 .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7304 };
7305 candidate_assets.insert(
7306 path.to_string(),
7307 V2BaselineAsset {
7308 blob_sha256: record.sha256.clone(),
7309 bytes: record.bytes,
7310 media_type: record.media_type.clone(),
7311 wrappers: record.wrappers.clone(),
7312 required: record.required,
7313 disposition: disposition.to_string(),
7314 leaf_hash: String::new(),
7317 },
7318 );
7319 asset_changed = true;
7320 }
7321 _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7322 }
7323 }
7324 Ok(asset_changed)
7325}
7326
7327fn v2_riding_matches_remote(
7328 local: &std::collections::BTreeMap<String, (String, u64)>,
7329 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7330 keeps_home: impl Fn(&str) -> bool,
7331) -> bool {
7332 remote.iter().all(|(path, file)| {
7333 keeps_home(path)
7334 || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7335 }) && local.iter().all(|(path, (hash, _))| {
7336 remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7337 })
7338}
7339
7340fn v2_initial_content_conflicts(
7341 local: &std::collections::BTreeMap<String, (String, u64)>,
7342 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7343 resolving: bool,
7344) -> Vec<String> {
7345 if resolving {
7346 return Vec::new();
7354 }
7355 remote
7356 .iter()
7357 .filter(|(path, file)| {
7358 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7359 })
7360 .map(|(path, _)| path.clone())
7361 .collect()
7362}
7363
7364fn v2_resolution_allows_path(
7365 resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7366 path: &str,
7367 remote_present: bool,
7368) -> bool {
7369 resolution.is_none_or(|allowed| allowed.contains_key(path) || !remote_present)
7370}
7371
7372#[derive(Debug, Clone)]
7373struct V2ResolutionOverride {
7374 expected_remote: Option<String>,
7375 selected_local: Option<String>,
7376}
7377
7378#[derive(Debug, Clone)]
7379struct V2UploadSource {
7380 path: String,
7381 bytes: u64,
7382}
7383
7384struct V2SyncPushOptions<'a> {
7385 resume_local_policy: bool,
7386 bulk_confirmation: Option<&'a V2BulkConfirmation>,
7387 resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7388 pulled: Option<V2PulledSnapshot>,
7389 withdrawal_paths: &'a [String],
7390 withdrawal_reason: Option<&'a str>,
7391}
7392
7393fn verify_v2_upload_source(
7394 store: &Store,
7395 path: &str,
7396 sha256: &str,
7397 expected_bytes: u64,
7398) -> LinkResult<()> {
7399 let file = store.open_regular(Path::new(path))?;
7400 if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7401 return Err(LinkError::InvalidPack {
7402 message: format!("local path `{path}` changed during sync planning"),
7403 });
7404 }
7405 Ok(())
7406}
7407
7408struct V2PendingUpload<'a> {
7411 url: String,
7412 headers: Value,
7413 sha256: String,
7414 source: &'a V2UploadSource,
7415}
7416
7417const V2_UPLOAD_CONCURRENCY: usize = 16;
7424
7425fn upload_v2_batch_concurrently(
7429 cfg: &HubConfig,
7430 store: &Store,
7431 pending: &[V2PendingUpload<'_>],
7432) -> LinkResult<()> {
7433 if pending.is_empty() {
7434 return Ok(());
7435 }
7436 let urls = pending
7437 .iter()
7438 .map(|task| task.url.as_str())
7439 .collect::<Vec<_>>();
7440 let shared = shared_staging_agent(cfg, &urls);
7441 if pending.len() == 1 {
7442 let task = &pending[0];
7443 put_presigned_source(
7444 cfg,
7445 &task.url,
7446 &task.headers,
7447 store,
7448 task.source,
7449 shared.as_ref(),
7450 )?;
7451 return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7452 }
7453 let next = std::sync::atomic::AtomicUsize::new(0);
7454 let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7455 let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7456 std::thread::scope(|scope| {
7457 for _ in 0..workers {
7458 scope.spawn(|| loop {
7459 if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7460 return;
7461 }
7462 let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7463 let Some(task) = pending.get(index) else {
7464 return;
7465 };
7466 let outcome = put_presigned_source(
7467 cfg,
7468 &task.url,
7469 &task.headers,
7470 store,
7471 task.source,
7472 shared.as_ref(),
7473 )
7474 .and_then(|()| {
7475 verify_v2_upload_source(
7476 store,
7477 &task.source.path,
7478 &task.sha256,
7479 task.source.bytes,
7480 )
7481 });
7482 if let Err(error) = outcome {
7483 if let Ok(mut guard) = failure.lock() {
7484 guard.get_or_insert(error);
7485 }
7486 return;
7487 }
7488 });
7489 }
7490 });
7491 match failure.into_inner() {
7492 Ok(Some(error)) => Err(error),
7493 Ok(None) => Ok(()),
7494 Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7495 }
7496}
7497
7498fn put_presigned_source(
7499 cfg: &HubConfig,
7500 raw: &str,
7501 headers: &Value,
7502 store: &Store,
7503 source: &V2UploadSource,
7504 shared: Option<&ureq::Agent>,
7505) -> LinkResult<()> {
7506 put_presigned_source_with_budget(
7507 cfg,
7508 raw,
7509 headers,
7510 store,
7511 source,
7512 shared,
7513 std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
7514 )
7515}
7516
7517fn put_presigned_source_with_budget(
7518 cfg: &HubConfig,
7519 raw: &str,
7520 headers: &Value,
7521 store: &Store,
7522 source: &V2UploadSource,
7523 shared: Option<&ureq::Agent>,
7524 total_budget: std::time::Duration,
7525) -> LinkResult<()> {
7526 let owned = match shared {
7529 Some(_) => {
7530 checked_presigned_url(cfg, raw)?;
7531 None
7532 }
7533 None => Some(presigned_agent(cfg, raw)?),
7534 };
7535 let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7536 let deadline = std::time::Instant::now()
7537 .checked_add(total_budget)
7538 .ok_or_else(upload_deadline_error)?;
7539 let mut attempt = 0;
7540 let result = loop {
7541 let file = store.open_regular(Path::new(&source.path))?;
7542 if file.metadata()?.len() != source.bytes {
7543 return Err(LinkError::InvalidPack {
7544 message: format!("local path `{}` changed before upload", source.path),
7545 });
7546 }
7547 let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
7552 let mut has_content_length = false;
7553 if let Some(map) = headers.as_object() {
7554 for (name, value) in map {
7555 if let Some(value) = value.as_str() {
7556 has_content_length |= name.eq_ignore_ascii_case("content-length");
7557 req = req.set(name, value);
7558 }
7559 }
7560 }
7561 if !has_content_length {
7562 req = req.set("Content-Length", &source.bytes.to_string());
7563 }
7564 match req.send(file) {
7565 Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
7571 attempt += 1;
7572 }
7573 Err(ureq::Error::Status(status, _))
7579 if status != 412
7580 && is_retryable_upload_status(status)
7581 && wait_for_upload_retry(deadline, attempt) =>
7582 {
7583 attempt += 1;
7584 }
7585 result => break result,
7586 }
7587 };
7588 match result {
7589 Ok(response) if (200..300).contains(&response.status()) => {
7590 drain_presigned_response(response);
7591 Ok(())
7592 }
7593 Ok(response) => {
7594 let status = response.status();
7599 let detail = response
7600 .into_string()
7601 .ok()
7602 .map(|body| body.chars().take(400).collect::<String>())
7603 .filter(|body| !body.trim().is_empty());
7604 Err(LinkError::Http {
7605 what: "v2 changed-byte upload",
7606 status,
7607 message: match detail {
7608 Some(body) => format!(
7609 "object store rejected the upload of `{}`: {}",
7610 source.path,
7611 body.replace('\n', " ")
7612 ),
7613 None => format!("object store rejected the upload of `{}`", source.path),
7614 },
7615 code: None,
7616 details: None,
7617 })
7618 }
7619 Err(error) => match error {
7620 ureq::Error::Status(412, _) => Ok(()),
7621 ureq::Error::Status(_, response) => {
7622 let status = response.status();
7623 let detail = response
7624 .into_string()
7625 .ok()
7626 .map(|body| body.chars().take(400).collect::<String>())
7627 .filter(|body| !body.trim().is_empty());
7628 Err(LinkError::Http {
7629 what: "v2 changed-byte upload",
7630 status,
7631 message: match detail {
7632 Some(body) => format!(
7633 "object store rejected the upload of `{}`: {}",
7634 source.path,
7635 body.replace('\n', " ")
7636 ),
7637 None => {
7638 format!("object store rejected the upload of `{}`", source.path)
7639 }
7640 },
7641 code: None,
7642 details: None,
7643 })
7644 }
7645 ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
7646 },
7647 }
7648}
7649
7650fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
7654 if body.get("operations").is_some() {
7655 return body.clone();
7656 }
7657 let mut value = body.clone();
7658 if let Some(map) = value.as_object_mut() {
7659 map.remove("staged_change");
7660 map.insert("operations".to_string(), Value::Array(operations.to_vec()));
7661 }
7662 value
7663}
7664
7665fn reserve_upload_window(
7669 cfg: &HubConfig,
7670 path: &str,
7671 body: &Value,
7672 what: &'static str,
7673) -> LinkResult<Value> {
7674 let mut attempt = 0;
7675 loop {
7676 let pause = |attempt: usize| {
7677 std::thread::sleep(std::time::Duration::from_millis(
7678 RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
7679 ));
7680 };
7681 match request(cfg, "POST", path, Some(body), Auth::Required) {
7682 Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
7687 pause(attempt);
7688 attempt += 1;
7689 }
7690 Err(error) => return Err(error),
7691 Ok(response) => {
7692 if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
7693 pause(attempt);
7694 attempt += 1;
7695 continue;
7696 }
7697 return ensure_ok(response, what);
7698 }
7699 }
7700 }
7701}
7702
7703fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
7707 let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
7708 .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
7709 if bytes.len() > MAX_STAGED_CHANGE_BYTES {
7710 return Err(LinkError::PushTooLarge {
7711 detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
7712 });
7713 }
7714 Ok(bytes)
7715}
7716
7717fn stage_v2_change(
7727 cfg: &HubConfig,
7728 requested_brain: &str,
7729 operations: &[Value],
7730 blobs: Value,
7731) -> LinkResult<Value> {
7732 let bytes = v2_change_manifest(operations, blobs)?;
7733 let sha256 = content_sha256(&bytes);
7734 let reserved = reserve_upload_window(
7735 cfg,
7736 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7737 &json!({
7738 "blobs": [{
7739 "sha256": sha256,
7740 "bytes": bytes.len(),
7741 "kind": "staged_change",
7742 }],
7743 }),
7744 "stage the v2 change",
7745 )?;
7746 let items = reserved
7747 .get("uploads")
7748 .and_then(Value::as_array)
7749 .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
7750 let [item] = items.as_slice() else {
7751 return Err(invalid_feed(
7752 "v2 change staging response changed the requested set",
7753 ));
7754 };
7755 let reservation_id = item
7756 .get("reservation_id")
7757 .and_then(Value::as_str)
7758 .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
7759 if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
7760 || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
7761 || !crate::ulid::is_ulid(reservation_id)
7762 {
7763 return Err(invalid_feed("v2 change staging item is inconsistent"));
7764 }
7765 match item.get("status").and_then(Value::as_str) {
7766 Some("upload") => put_presigned(
7767 cfg,
7768 item.get("url")
7769 .and_then(Value::as_str)
7770 .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
7771 item.get("headers").unwrap_or(&Value::Null),
7772 &bytes,
7773 )?,
7774 Some("already_present") => {}
7775 _ => return Err(invalid_feed("v2 change staging has an unknown status")),
7776 }
7777 Ok(json!({
7778 "sha256": sha256,
7779 "bytes": bytes.len(),
7780 "reservation_id": reservation_id,
7781 }))
7782}
7783
7784fn stage_oversized_v2_change(
7788 cfg: &HubConfig,
7789 requested_brain: &str,
7790 operations: &[Value],
7791 body: &mut Value,
7792) -> LinkResult<()> {
7793 if body.to_string().len() <= MAX_PUSH_BYTES {
7794 return Ok(());
7795 }
7796 let staged = stage_v2_change(
7797 cfg,
7798 requested_brain,
7799 operations,
7800 body.get("blobs")
7801 .cloned()
7802 .unwrap_or(Value::Array(Vec::new())),
7803 )?;
7804 let map = body
7805 .as_object_mut()
7806 .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
7807 map.remove("operations");
7808 map.remove("blobs");
7809 map.insert("staged_change".to_string(), staged);
7810 Ok(())
7811}
7812
7813fn v2_sync_push(
7814 cfg: &HubConfig,
7815 requested_brain: &str,
7816 store: &Store,
7817 head: V2VerifiedHead,
7818 options: V2SyncPushOptions<'_>,
7819) -> LinkResult<Value> {
7820 let V2SyncPushOptions {
7821 resume_local_policy,
7822 bulk_confirmation,
7823 resolution,
7824 pulled,
7825 withdrawal_paths,
7826 withdrawal_reason,
7827 } = options;
7828 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
7829 let head = v2_verified_head(cfg, requested_brain)?
7830 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7831 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
7832 ensure_v2_view_compatible(&head, baseline.as_ref())?;
7833 let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
7834 match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
7835 Some(snapshot) => (
7836 snapshot.files,
7837 snapshot.assets,
7838 Some(snapshot.local),
7839 Some(snapshot.local_assets),
7840 ),
7841 None => match baseline
7842 .as_ref()
7843 .filter(|state| v2_baseline_matches_head(&head, state))
7844 {
7845 Some(state) => (state.files.clone(), state.assets.clone(), None, None),
7846 None => (
7847 files_for_v2_view(
7848 &head,
7849 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7850 ),
7851 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7852 None,
7853 None,
7854 ),
7855 },
7856 };
7857 if head.view_kind == "scoped" && baseline.is_none() {
7858 return Err(LinkError::ScopedViewChanged);
7859 }
7860 let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
7861 let local = &local_view.riding;
7862 let local_assets = match carried_local_assets {
7863 Some(assets) => assets,
7864 None => v2_local_asset_records(store)?,
7865 };
7866 if withdrawal_paths.len() > MAX_PUSH_FILES {
7867 return Err(LinkError::PushTooLarge {
7868 detail: "too many explicit withdrawal paths".to_string(),
7869 });
7870 }
7871 let withdrawal_reason = if withdrawal_paths.is_empty() {
7872 None
7873 } else {
7874 let reason = withdrawal_reason
7875 .map(str::trim)
7876 .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
7877 .ok_or_else(|| LinkError::InvalidPack {
7878 message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
7879 })?;
7880 Some(reason)
7881 };
7882 let mut withdrawals = withdrawal_paths
7883 .iter()
7884 .map(|path| {
7885 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
7886 path: error.to_string(),
7887 })
7888 })
7889 .collect::<LinkResult<Vec<_>>>()?;
7890 withdrawals.sort();
7891 withdrawals.dedup();
7892 if withdrawals.len() != withdrawal_paths.len() {
7893 return Err(LinkError::InvalidPack {
7894 message: "explicit withdrawal paths must be unique".to_string(),
7895 });
7896 }
7897 let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
7898 let mut consumed_withdrawals = BTreeSet::new();
7899 if let Some(previous) = baseline.as_ref() {
7900 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
7901 && !resume_local_policy
7902 {
7903 let mut newly_eligible = previous
7904 .local_eligibility
7905 .iter()
7906 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7907 .map(|(path, _)| path.clone())
7908 .collect::<Vec<_>>();
7909 if !newly_eligible.is_empty() {
7910 newly_eligible.truncate(100);
7911 return Err(LinkError::LocalPolicyTransition {
7912 paths: newly_eligible,
7913 });
7914 }
7915 }
7916 }
7917 let base = match baseline.as_ref() {
7918 Some(state) => &state.files,
7919 None if remote.is_empty() => &remote,
7920 None => {
7921 let mut conflicts = v2_initial_content_conflicts(local, &remote, resolution.is_some());
7922 if !conflicts.is_empty() {
7923 conflicts.truncate(100);
7924 let (bundle, paths) =
7925 create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7926 return Err(LinkError::ConflictBundle { bundle, paths });
7927 }
7928 &remote
7929 }
7930 };
7931 let all_paths = base
7932 .keys()
7933 .chain(remote.keys())
7934 .chain(local.keys())
7935 .cloned()
7936 .collect::<std::collections::BTreeSet<_>>();
7937 let mut conflicts = Vec::new();
7938 let mut operations = Vec::new();
7939 let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7940 for path in all_paths {
7941 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7942 let remote_file = remote.get(&path);
7943 let remote_hash = remote_file.map(|file| file.sha256.as_str());
7944 let local_file = local.get(&path);
7945 let local_hash = local_file.map(|file| file.0.as_str());
7946 if local_hash == base_hash {
7947 continue;
7948 }
7949 if !v2_resolution_allows_path(resolution, &path, remote_file.is_some()) {
7950 continue;
7951 }
7952 if local_view.policy.keeps_home(&path) {
7953 continue;
7956 }
7957 if remote_hash != base_hash && local_hash != remote_hash {
7958 let explicitly_resolved = resolution
7959 .and_then(|allowed| allowed.get(&path))
7960 .is_some_and(|selected| {
7961 selected.expected_remote.as_deref() == remote_hash
7962 && selected.selected_local.as_deref() == local_hash
7963 });
7964 if !explicitly_resolved {
7965 conflicts.push(path);
7966 continue;
7967 }
7968 }
7969 match local_file {
7970 Some((sha256, byte_count)) => {
7971 verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7972 operations.push(json!({
7973 "op": "put",
7974 "path": path,
7975 "expected": v2_expected(remote_file),
7976 "blob": sha256,
7977 "bytes": byte_count,
7978 }));
7979 upload_sources
7980 .entry(sha256.clone())
7981 .or_insert_with(|| V2UploadSource {
7982 path: path.clone(),
7983 bytes: *byte_count,
7984 });
7985 }
7986 None => {
7987 let Some(current) = remote_file else {
7988 continue;
7989 };
7990 operations.push(json!({
7991 "op": "delete",
7992 "path": path,
7993 "expected": { "kind": "blob", "hash": current.sha256 },
7994 }));
7995 }
7996 }
7997 }
7998 operations = infer_exact_source_promotions(operations);
7999 for path in &withdrawals {
8000 if local_assets.contains_key(path) {
8001 continue;
8002 }
8003 operations.push(v2_content_withdrawal_operation(
8004 store,
8005 &local_view,
8006 &remote,
8007 path,
8008 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8009 )?);
8010 consumed_withdrawals.insert(path.clone());
8011 }
8012 if !conflicts.is_empty() {
8013 conflicts.truncate(100);
8014 let (bundle, paths) = create_v2_conflict_bundle(
8015 cfg,
8016 store,
8017 &head,
8018 baseline.as_ref(),
8019 local,
8020 &remote,
8021 &conflicts,
8022 )?;
8023 return Err(LinkError::ConflictBundle { bundle, paths });
8024 }
8025 let base_assets = match baseline.as_ref() {
8026 Some(state) => &state.assets,
8027 None if remote_assets.is_empty() => &remote_assets,
8028 None => {
8029 let mismatched = remote_assets.iter().any(|(path, remote)| {
8030 local_assets.get(path) != Some(&v2_asset_record(remote, path))
8031 }) || local_assets.len() != remote_assets.len();
8032 if mismatched {
8033 return Err(LinkError::Conflict {
8034 paths: vec!["assets.jsonl".to_string()],
8035 });
8036 }
8037 &remote_assets
8038 }
8039 };
8040 let asset_paths = base_assets
8041 .keys()
8042 .chain(remote_assets.keys())
8043 .chain(local_assets.keys())
8044 .cloned()
8045 .collect::<std::collections::BTreeSet<_>>();
8046 let mut asset_policy_transitions = Vec::new();
8047 for path in asset_paths {
8048 let base_record = base_assets
8049 .get(&path)
8050 .map(|asset| v2_asset_record(asset, &path));
8051 let remote = remote_assets.get(&path);
8052 let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
8053 let local_record = local_assets.get(&path);
8054 if withdrawal_set.contains(&path) {
8055 let record = local_record.ok_or_else(|| LinkError::InvalidPack {
8056 message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
8057 })?;
8058 let current = remote.ok_or_else(|| LinkError::InvalidPack {
8059 message: format!(
8060 "asset withdrawal path `{path}` has no readable hosted coordinate"
8061 ),
8062 })?;
8063 operations.push(v2_asset_withdrawal_operation(
8064 store,
8065 &local_view,
8066 &path,
8067 record,
8068 current,
8069 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8070 )?);
8071 consumed_withdrawals.insert(path.clone());
8072 continue;
8073 }
8074 let mut raw_present = false;
8075 let mut disposition = "withheld";
8076 let mut resumes_hosting = false;
8077 if let Some(record) = local_record {
8078 crate::linkmd_v2::normalize_path(&record.path)
8079 .map_err(|error| invalid_feed(error.to_string()))?;
8080 let kept_home = local_view.policy.keeps_home(&path);
8081 raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
8082 disposition = if kept_home || !raw_present {
8083 "withheld"
8084 } else {
8085 "hosted"
8086 };
8087 if !raw_present && record.required && !kept_home {
8088 return Err(LinkError::InvalidPack {
8089 message: format!("required asset {path} is missing"),
8090 });
8091 }
8092 resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
8093 }
8094 if local_record == base_record.as_ref() && !resumes_hosting {
8095 continue;
8096 }
8097 if remote_record != base_record && local_record != remote_record.as_ref() {
8098 conflicts.push(path);
8099 continue;
8100 }
8101 let Some(record) = local_record else {
8102 if let Some(remote) = remote {
8103 operations.push(json!({
8104 "op": "asset_delete",
8105 "path": path,
8106 "expected": v2_asset_expected(Some(remote)),
8107 }));
8108 }
8109 continue;
8110 };
8111 let raw = if raw_present {
8112 verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
8113 Some(())
8114 } else {
8115 None
8116 };
8117 let op = if resumes_hosting {
8118 if !resume_local_policy {
8119 asset_policy_transitions.push(path);
8120 continue;
8121 }
8122 "asset_resume"
8123 } else {
8124 "asset_put"
8125 };
8126 operations.push(json!({
8127 "op": op,
8128 "path": path,
8129 "expected": v2_asset_expected(remote),
8130 "asset": v2_asset_value(record, disposition),
8131 }));
8132 if disposition == "hosted" {
8133 raw.expect("hosted asset was checked present");
8134 upload_sources
8135 .entry(record.sha256.clone())
8136 .or_insert_with(|| V2UploadSource {
8137 path: path.clone(),
8138 bytes: record.bytes,
8139 });
8140 }
8141 }
8142 if consumed_withdrawals != withdrawal_set {
8143 let missing = withdrawal_set
8144 .difference(&consumed_withdrawals)
8145 .next()
8146 .expect("different withdrawal sets have one member");
8147 return Err(LinkError::InvalidPack {
8148 message: format!(
8149 "withdrawal path `{missing}` is not a readable content or asset coordinate"
8150 ),
8151 });
8152 }
8153 if !conflicts.is_empty() {
8154 conflicts.truncate(100);
8155 return Err(LinkError::Conflict { paths: conflicts });
8156 }
8157 if !asset_policy_transitions.is_empty() {
8158 asset_policy_transitions.truncate(100);
8159 return Err(LinkError::LocalPolicyTransition {
8160 paths: asset_policy_transitions,
8161 });
8162 }
8163 let touched_sources = operations
8164 .iter()
8165 .filter_map(
8166 |operation| match operation.get("op").and_then(Value::as_str) {
8167 Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
8168 Some("rename") => operation.get("to").and_then(Value::as_str),
8169 _ => None,
8170 },
8171 )
8172 .collect::<std::collections::BTreeSet<_>>();
8173 let withheld_links = local_view
8174 .withheld_links
8175 .iter()
8176 .filter(|link| touched_sources.contains(link.source.as_str()))
8177 .collect::<Vec<_>>();
8178 let checkout_pseudonym = v2_checkout_id(
8179 baseline
8180 .as_ref()
8181 .and_then(|current| current.checkout_id.as_deref()),
8182 )?;
8183 let checkout_id = if withheld_links.is_empty() {
8184 None
8185 } else {
8186 Some(checkout_pseudonym.clone())
8187 };
8188 if operations.is_empty() {
8189 let final_head = v2_verified_head(cfg, requested_brain)?
8190 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
8191 if !same_v2_head(&head, &final_head) {
8192 return Err(LinkError::RemoteAdvancedDuringSync);
8193 }
8194 let mut final_local = v2_local_files(store)?;
8195 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8196 let final_assets = v2_local_asset_records(store)?;
8197 let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
8198 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
8199 final_local.policy.keeps_home(path)
8200 }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
8201 let next = v2_baseline_from_head(
8202 cfg,
8203 &head,
8204 remote,
8205 remote_assets,
8206 Some(&final_local),
8207 Some(&checkout_pseudonym),
8208 )?;
8209 let split_count = next.remote_copy_remains.len();
8210 accept_v2_head(cfg, &final_head)?;
8211 if !local_changed && !remote_ahead {
8212 refresh_scoped_view_marker(store, &head, next.files.len())?;
8213 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8214 }
8215 return Ok(json!({
8216 "v": 2,
8217 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
8218 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
8219 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8220 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8221 "local_policy": {
8222 "remote_copy_remains": split_count,
8223 },
8224 }));
8225 }
8226 let includes_contract = operations
8227 .iter()
8228 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
8229 let rebase = if head.pointer.is_none() || includes_contract {
8230 "strict"
8231 } else {
8232 "disjoint"
8233 };
8234 let base_value = head.pointer.as_ref().map(|pointer| {
8235 json!({
8236 "seq": pointer.seq,
8237 "commit_hash": pointer.commit_hash,
8238 "content_root": pointer.content_root,
8239 "asset_root": pointer.asset_root,
8240 })
8241 });
8242 let entropy = format!(
8246 "{}\0{}\0{}\0{}\0{}\0{}",
8247 normalized_origin(&cfg.hub)?,
8248 head.brain_id,
8249 serde_json::to_string(&base_value).unwrap_or_default(),
8250 serde_json::to_string(&operations).unwrap_or_default(),
8251 serde_json::to_string(&withheld_links).unwrap_or_default(),
8252 checkout_id.as_deref().unwrap_or("")
8253 );
8254 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
8255 let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
8256 total
8257 .checked_add(source.bytes)
8258 .ok_or_else(|| LinkError::PushTooLarge {
8259 detail: "v2 changed-byte total overflow".to_string(),
8260 })
8261 })?;
8262 let inline = changed_bytes <= 3 * 1024 * 1024;
8263 let inline_blobs = if inline {
8264 upload_sources
8265 .iter()
8266 .map(|(sha256, source)| {
8267 let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
8268 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
8269 return Err(LinkError::InvalidPack {
8270 message: format!("local path `{}` changed before upload", source.path),
8271 });
8272 }
8273 Ok(json!({
8274 "sha256": sha256,
8275 "bytes": source.bytes,
8276 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
8277 }))
8278 })
8279 .collect::<LinkResult<Vec<_>>>()?
8280 } else {
8281 Vec::new()
8282 };
8283 let mut body = json!({
8284 "mutation_id": mutation_id,
8285 "base": base_value,
8286 "rebase": rebase,
8287 "reason": "dbmd sync",
8288 "operations": operations,
8289 "blobs": inline_blobs,
8290 });
8291 if !withheld_links.is_empty() {
8292 body["withheld_links"] = serde_json::to_value(&withheld_links)
8293 .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
8294 body["checkout_id"] =
8295 Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
8296 }
8297 if let Some(confirmation) = bulk_confirmation {
8298 if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
8299 return Err(LinkError::InvalidPack {
8300 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
8301 .to_string(),
8302 });
8303 }
8304 body["rebase"] = Value::String("strict".to_string());
8308 body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8309 body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8310 }
8311 if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8312 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8313 for operation in &operations {
8314 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8315 return Err(invalid_feed("v2 upload operation has no kind"));
8316 };
8317 let hash = match kind {
8318 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
8319 "asset_put" | "asset_resume" => operation
8320 .get("asset")
8321 .and_then(|asset| asset.get("blob_sha256"))
8322 .and_then(Value::as_str),
8323 _ => None,
8324 };
8325 let Some(hash) = hash else { continue };
8326 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8327 if kind == "rename" {
8328 for field in ["from", "to"] {
8329 coordinates.insert(
8330 operation
8331 .get(field)
8332 .and_then(Value::as_str)
8333 .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8334 .to_string(),
8335 );
8336 }
8337 } else {
8338 let path = operation
8339 .get("path")
8340 .and_then(Value::as_str)
8341 .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8342 coordinates.insert(if kind.starts_with("asset_") {
8343 format!("assets/{path}")
8344 } else {
8345 path.to_string()
8346 });
8347 }
8348 }
8349 let declarations = upload_sources
8350 .iter()
8351 .map(|(sha256, source)| {
8352 json!({
8353 "sha256": sha256,
8354 "bytes": source.bytes,
8355 "coordinates": coordinates_by_hash
8356 .get(sha256)
8357 .into_iter()
8358 .flatten()
8359 .collect::<Vec<_>>(),
8360 })
8361 })
8362 .collect::<Vec<_>>();
8363 let mut references = Vec::with_capacity(upload_sources.len());
8364 let mut seen = std::collections::BTreeSet::new();
8365 let mut reserved_count = 0usize;
8366 let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8367 for batch in batch_upload_declarations(declarations) {
8371 let batch_len = batch.len();
8372 let reserved = reserve_upload_window(
8373 cfg,
8374 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8375 &json!({ "blobs": batch }),
8376 "prepare v2 changed-byte uploads",
8377 )?;
8378 let items = reserved
8379 .get("uploads")
8380 .and_then(Value::as_array)
8381 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8382 if items.len() != batch_len {
8383 return Err(invalid_feed(
8384 "v2 upload reservation response changed the requested set",
8385 ));
8386 }
8387 reserved_count += items.len();
8388 for item in items {
8389 let sha256 = item
8390 .get("sha256")
8391 .and_then(Value::as_str)
8392 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8393 let source = upload_sources
8394 .get(sha256)
8395 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8396 let declared_bytes = item
8397 .get("bytes")
8398 .and_then(Value::as_u64)
8399 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8400 let reservation_id = item
8401 .get("reservation_id")
8402 .and_then(Value::as_str)
8403 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8404 let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8405 invalid_feed("v2 upload reservation has no coordinate binding")
8406 })?;
8407 let returned_coordinates = item
8408 .get("coordinates")
8409 .and_then(Value::as_array)
8410 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8411 if declared_bytes != source.bytes
8412 || !crate::ulid::is_ulid(reservation_id)
8413 || !seen.insert(sha256.to_string())
8414 || returned_coordinates.len() != expected_coordinates.len()
8415 || returned_coordinates
8416 .iter()
8417 .zip(expected_coordinates)
8418 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8419 {
8420 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8421 }
8422 match item.get("status").and_then(Value::as_str) {
8423 Some("upload") => {
8424 let url = item
8425 .get("url")
8426 .and_then(Value::as_str)
8427 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8428 pending_uploads.push(V2PendingUpload {
8429 url: url.to_string(),
8430 headers: item.get("headers").cloned().unwrap_or(Value::Null),
8431 sha256: sha256.to_string(),
8432 source,
8433 });
8434 }
8435 Some("already_present") => {}
8436 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
8437 }
8438 references.push(json!({
8439 "sha256": sha256,
8440 "bytes": source.bytes,
8441 "reservation_id": reservation_id,
8442 }));
8443 }
8444 upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
8450 pending_uploads.clear();
8451 }
8452 if reserved_count != upload_sources.len() {
8453 return Err(invalid_feed(
8454 "v2 upload reservation response changed the requested set",
8455 ));
8456 }
8457 body["blobs"] = Value::Array(references);
8458 }
8459 stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
8460 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
8461 let mut candidate_hub_signer: Option<String> = None;
8462 let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8463 let bulk_preview_required = !(200..300).contains(&response.status)
8464 && response.body.as_ref().is_some_and(|value| {
8465 value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
8466 || value
8467 .get("details")
8468 .and_then(|details| details.get("code"))
8469 .and_then(Value::as_str)
8470 == Some("bulk_preview_required")
8471 });
8472 if bulk_preview_required && bulk_confirmation.is_none() {
8473 body["rebase"] = Value::String("strict".to_string());
8474 body["preview_only"] = Value::Bool(true);
8475 let preview = ensure_ok(
8476 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
8477 "v2 bulk preview",
8478 )?;
8479 let preview_code = preview.get("code").and_then(Value::as_str);
8480 let required = preview.get("required").and_then(Value::as_bool);
8481 if preview.get("v").and_then(Value::as_u64) != Some(2)
8482 || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
8483 || !matches!(
8484 preview_code,
8485 Some("bulk_preview_created" | "bulk_preview_not_required")
8486 )
8487 || required.is_none()
8488 {
8489 return Err(invalid_feed(
8490 "bulk preview response is not bound to the requested mutation",
8491 ));
8492 }
8493 if required == Some(true) {
8494 let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
8495 let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
8496 if preview_code != Some("bulk_preview_created")
8497 || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
8498 || preview_digest.is_none_or(|value| !is_sha256(value))
8499 || preview.get("expires_at").and_then(Value::as_str).is_none()
8500 || !preview.get("impact").is_some_and(Value::is_object)
8501 {
8502 return Err(invalid_feed("bulk preview receipt is malformed"));
8503 }
8504 return Err(LinkError::BulkPreviewRequired { preview });
8505 }
8506 if preview_code != Some("bulk_preview_not_required") {
8507 return Err(invalid_feed("bulk preview requirement is inconsistent"));
8508 }
8509 body.as_object_mut()
8512 .expect("v2 commit request is an object")
8513 .remove("preview_only");
8514 response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8515 }
8516 let mut result = ensure_ok(response, "v2 sync push")?;
8517 if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
8518 if let Some(object) = result.as_object_mut() {
8519 object.insert(
8520 "sync_status".to_string(),
8521 Value::String("proposal_pending".to_string()),
8522 );
8523 }
8524 return Ok(result);
8525 }
8526 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
8527 let request_id = result
8528 .get("request_id")
8529 .and_then(Value::as_str)
8530 .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
8531 .to_string();
8532 let challenge = result
8533 .get("signing_challenge")
8534 .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
8535 let mut expected_candidate = remote.clone();
8536 let mut expected_candidate_assets = remote_assets.clone();
8537 apply_generated_v2_operations(
8538 &operations,
8539 &local_assets,
8540 &mut expected_candidate,
8541 &mut expected_candidate_assets,
8542 )?;
8543 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
8544 cfg,
8545 &head,
8546 &expected_candidate,
8547 &expected_candidate_assets,
8548 &mutation_id,
8549 &v2_signed_request_view(&body, &operations),
8550 challenge,
8551 )?;
8552 body["signing_challenge_id"] = Value::String(challenge_id);
8553 body["signature_base64url"] = Value::String(signature);
8554 candidate_hub_signer = Some(actor_signer);
8555 result = ensure_ok(
8556 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
8557 "v2 self-custody commit",
8558 )?;
8559 }
8560 let refreshed = v2_verified_head(cfg, requested_brain)?
8561 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
8562 if candidate_hub_signer
8563 .as_ref()
8564 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
8565 {
8566 return Err(invalid_feed(
8567 "self-custody actor signer differs from the committed hub pointer signer",
8568 ));
8569 }
8570 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
8571 if refreshed
8572 .pointer
8573 .as_ref()
8574 .map(|pointer| pointer.commit_hash.as_str())
8575 != accepted_hash
8576 {
8577 return Err(LinkError::RemoteAdvancedDuringSync);
8578 }
8579 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
8580 let rebased = result
8581 .get("rebased")
8582 .and_then(Value::as_bool)
8583 .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
8584 let (refreshed_files, refreshed_assets) = if rebased {
8585 (
8586 files_for_v2_view(
8587 &refreshed,
8588 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8589 ),
8590 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8591 )
8592 } else {
8593 let asset_changed = apply_generated_v2_operations(
8594 &operations,
8595 &local_assets,
8596 &mut remote,
8597 &mut remote_assets,
8598 )?;
8599 let assets = if asset_changed {
8600 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
8603 } else {
8604 remote_assets
8605 };
8606 (remote, assets)
8607 };
8608 let mut final_local = v2_local_files(store)?;
8609 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
8610 let final_assets = v2_local_asset_records(store)?;
8611 let local_dirty = final_local.riding != local_view.riding
8612 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
8613 final_local.policy.keeps_home(path)
8614 })
8615 || final_assets != local_assets
8616 || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
8617 let next = v2_baseline_from_head(
8618 cfg,
8619 &refreshed,
8620 refreshed_files,
8621 refreshed_assets,
8622 Some(&final_local),
8623 Some(&checkout_pseudonym),
8624 )?;
8625 let split_count = next.remote_copy_remains.len();
8626 accept_v2_head(cfg, &refreshed)?;
8627 if !local_dirty {
8628 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
8629 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
8630 }
8631 if let Some(object) = result.as_object_mut() {
8632 object.insert(
8633 "local_policy".to_string(),
8634 json!({ "remote_copy_remains": split_count }),
8635 );
8636 object.insert(
8637 "sync_status".to_string(),
8638 Value::String(if local_dirty {
8639 "remote_committed_local_dirty".to_string()
8640 } else {
8641 "synced".to_string()
8642 }),
8643 );
8644 }
8645 Ok(result)
8646}
8647
8648pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
8651 sync_push_incremental_with_policy(cfg, brain, store, false)
8652}
8653
8654pub fn sync_push_incremental_with_policy(
8657 cfg: &HubConfig,
8658 brain: &str,
8659 store: &Store,
8660 resume_local_policy: bool,
8661) -> LinkResult<Value> {
8662 sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
8663}
8664
8665pub fn sync_push_incremental_with_options(
8668 cfg: &HubConfig,
8669 brain: &str,
8670 store: &Store,
8671 resume_local_policy: bool,
8672 bulk_confirmation: Option<&V2BulkConfirmation>,
8673) -> LinkResult<Value> {
8674 sync_push_incremental_with_controls(
8675 cfg,
8676 brain,
8677 store,
8678 resume_local_policy,
8679 bulk_confirmation,
8680 &[],
8681 None,
8682 )
8683}
8684
8685pub fn sync_push_incremental_with_controls(
8687 cfg: &HubConfig,
8688 brain: &str,
8689 store: &Store,
8690 resume_local_policy: bool,
8691 bulk_confirmation: Option<&V2BulkConfirmation>,
8692 withdrawal_paths: &[String],
8693 withdrawal_reason: Option<&str>,
8694) -> LinkResult<Value> {
8695 require_safe_ref(brain)?;
8696 if let Some(head) = v2_verified_head(cfg, brain)? {
8697 return v2_sync_push(
8698 cfg,
8699 brain,
8700 store,
8701 head,
8702 V2SyncPushOptions {
8703 resume_local_policy,
8704 bulk_confirmation,
8705 resolution: None,
8706 pulled: None,
8707 withdrawal_paths,
8708 withdrawal_reason,
8709 },
8710 );
8711 }
8712 if !withdrawal_paths.is_empty() {
8713 return Err(LinkError::InvalidPack {
8714 message: "explicit withdrawal requires a link.md v2 brain".to_string(),
8715 });
8716 }
8717 legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
8718}
8719
8720pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
8724 require_safe_ref(brain)?;
8725 Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
8726}
8727
8728#[cfg(windows)]
8729fn legacy_sync_push_incremental(
8730 _cfg: &HubConfig,
8731 _brain: &str,
8732 _store: &Store,
8733 _resume_local_policy: bool,
8734 _bulk_confirmation: Option<&V2BulkConfirmation>,
8735) -> LinkResult<Value> {
8736 Err(LinkError::UnsupportedPlatform {
8737 operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
8738 })
8739}
8740
8741#[cfg(not(windows))]
8742fn legacy_sync_push_incremental(
8743 cfg: &HubConfig,
8744 brain: &str,
8745 store: &Store,
8746 resume_local_policy: bool,
8747 bulk_confirmation: Option<&V2BulkConfirmation>,
8748) -> LinkResult<Value> {
8749 if resume_local_policy || bulk_confirmation.is_some() {
8750 return Err(LinkError::InvalidPack {
8751 message: "v2 sync options require a link.md v2 brain".to_string(),
8752 });
8753 }
8754 let files = collect_push_files(store)?;
8755 sync_push(cfg, brain, &files)
8756}
8757
8758#[derive(Debug, Clone)]
8760pub enum V2ConflictChoice {
8761 KeepLocal,
8762 TakeRemote,
8763 From(PathBuf),
8764}
8765
8766fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
8767 if !crate::ulid::is_ulid(bundle) {
8768 return Err(LinkError::InvalidPack {
8769 message: "conflict bundle must be a lowercase ULID".to_string(),
8770 });
8771 }
8772 let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
8773 let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
8774 .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
8775 if plan.v != 2
8776 || plan.class != "content_resolution_required"
8777 || plan.bundle != bundle
8778 || !crate::ulid::is_ulid(&plan.brain)
8779 || plan.files.is_empty()
8780 || plan.files.len() > 100
8781 || plan.files.iter().any(|file| {
8782 crate::linkmd_v2::normalize_path(&file.path).is_err()
8783 || [&file.base, &file.local, &file.remote]
8784 .into_iter()
8785 .any(|coordinate| {
8786 coordinate
8787 .sha256
8788 .as_deref()
8789 .is_some_and(|hash| !is_sha256(hash))
8790 || coordinate.file.as_deref().is_some_and(|name| {
8791 name.starts_with('/')
8792 || name
8793 .split('/')
8794 .any(|part| part.is_empty() || part == "." || part == "..")
8795 })
8796 })
8797 })
8798 {
8799 return Err(invalid_feed("private conflict plan failed validation"));
8800 }
8801 Ok(plan)
8802}
8803
8804pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
8809 require_hardened_filesystem("private conflict maintenance")?;
8810 if all && !prune {
8811 return Err(LinkError::InvalidPack {
8812 message: "discarding all conflict bundles requires prune=true".to_string(),
8813 });
8814 }
8815 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8816 message: format!("conflict checkout is not a valid db.md store: {error}"),
8817 })?;
8818 let _transaction = store.transaction()?;
8819 let root = Path::new(".dbmd/conflicts");
8820 let names = match store.directory_names(root) {
8821 Ok(names) => names,
8822 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8823 Err(error) => return Err(error.into()),
8824 };
8825 let now = SystemTime::now()
8826 .duration_since(UNIX_EPOCH)
8827 .unwrap_or_default()
8828 .as_secs();
8829 let mut bundles = Vec::new();
8830 let mut pruned = 0_u64;
8831 for name in names {
8832 let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
8833 continue;
8834 };
8835 let plan_path = v2_conflict_relative(bundle, "plan.json");
8836 let plan_exists = store.regular_file_exists(&plan_path)?;
8837 let expired = if plan_exists {
8838 match load_v2_conflict_plan(&store, bundle) {
8839 Ok(plan) => plan.expires_unix < now,
8840 Err(error) if all => {
8841 let _ = error;
8842 true
8843 }
8844 Err(error) => return Err(error),
8845 }
8846 } else {
8847 true
8848 };
8849 if prune && (all || expired) {
8850 store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8851 pruned += 1;
8852 continue;
8853 }
8854 bundles.push(json!({
8855 "bundle": bundle,
8856 "complete": plan_exists,
8857 "expired": expired,
8858 }));
8859 }
8860 Ok(json!({
8861 "v": 2,
8862 "class": "private_conflict_state",
8863 "bundles": bundles.len(),
8864 "pruned": pruned,
8865 "items": bundles,
8866 }))
8867}
8868
8869pub fn sync_resolve_conflict(
8873 cfg: &HubConfig,
8874 checkout: &Path,
8875 bundle: &str,
8876 choice: V2ConflictChoice,
8877 bulk_confirmation: Option<&V2BulkConfirmation>,
8878) -> LinkResult<Value> {
8879 require_hardened_filesystem("conflict resolution")?;
8880 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8881 message: format!("conflict checkout is not a valid db.md store: {error}"),
8882 })?;
8883 let plan = load_v2_conflict_plan(&store, bundle)?;
8884 if plan.origin != normalized_origin(&cfg.hub)? {
8885 return Err(invalid_feed(
8886 "conflict bundle belongs to another hub origin",
8887 ));
8888 }
8889 let now = SystemTime::now()
8890 .duration_since(UNIX_EPOCH)
8891 .unwrap_or_default()
8892 .as_secs();
8893 if now > plan.expires_unix {
8894 return Err(LinkError::InvalidPack {
8895 message: "conflict bundle expired; rerun sync to obtain current coordinates"
8896 .to_string(),
8897 });
8898 }
8899 let head = v2_verified_head(cfg, &plan.brain)?
8900 .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8901 let pointer = head.pointer.as_ref();
8902 if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8903 || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8904 || pointer.and_then(|value| value.content_root.as_deref())
8905 != plan.remote_content_root.as_deref()
8906 || head.view_kind != plan.view_kind
8907 || head.view_revision != plan.view_revision
8908 {
8909 return Err(LinkError::RemoteAdvancedDuringSync);
8910 }
8911
8912 for file in &plan.files {
8914 let actual = match store.regular_file_exists(Path::new(&file.path))? {
8915 true => Some(content_sha256(&store.read_bounded(
8916 Path::new(&file.path),
8917 file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8918 )?)),
8919 false => None,
8920 };
8921 if actual.as_deref() != file.local.sha256.as_deref() {
8922 return Err(LinkError::InvalidPack {
8923 message: format!(
8924 "local conflict path `{}` changed after the bundle was created",
8925 file.path
8926 ),
8927 });
8928 }
8929 }
8930
8931 let from_source = match &choice {
8932 V2ConflictChoice::From(source) => Some(source.clone()),
8933 _ => None,
8934 };
8935 let result = match choice {
8936 V2ConflictChoice::TakeRemote => {
8937 if bulk_confirmation.is_some() {
8938 return Err(LinkError::InvalidPack {
8939 message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8940 });
8941 }
8942 let current_remote =
8946 files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8947 let _ = v2_take_remote_selection(&plan.files, ¤t_remote)?;
8948 let selected = plan
8949 .files
8950 .iter()
8951 .map(|file| file.path.clone())
8952 .collect::<std::collections::BTreeSet<_>>();
8953 serde_json::to_value(
8954 v2_sync_pull_with_resolution(
8955 cfg,
8956 &plan.brain,
8957 head,
8958 Some(checkout),
8959 Some(&selected),
8960 )?
8961 .report,
8962 )
8963 .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
8964 }
8965 V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
8966 if let Some(source) = from_source.as_ref() {
8967 if plan.files.len() != 1 {
8968 return Err(LinkError::InvalidPack {
8969 message: "--from requires a bundle with exactly one conflict".to_string(),
8970 });
8971 }
8972 let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
8973 if std::str::from_utf8(&candidate).is_err() {
8974 return Err(LinkError::NotUtf8 {
8975 path: source.display().to_string(),
8976 });
8977 }
8978 store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
8979 }
8980 let refreshed_store =
8981 Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8982 message: format!("resolved checkout is not a valid db.md store: {error}"),
8983 })?;
8984 let mut overrides = std::collections::BTreeMap::new();
8985 for file in &plan.files {
8986 let selected_local = match refreshed_store
8987 .regular_file_exists(Path::new(&file.path))?
8988 {
8989 true => Some(content_sha256(
8990 &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
8991 )),
8992 false => None,
8993 };
8994 overrides.insert(
8995 file.path.clone(),
8996 V2ResolutionOverride {
8997 expected_remote: file.remote.sha256.clone(),
8998 selected_local,
8999 },
9000 );
9001 }
9002 v2_sync_push(
9003 cfg,
9004 &plan.brain,
9005 &refreshed_store,
9006 head,
9007 V2SyncPushOptions {
9008 resume_local_policy: true,
9009 bulk_confirmation,
9010 resolution: Some(&overrides),
9011 pulled: None,
9012 withdrawal_paths: &[],
9013 withdrawal_reason: None,
9014 },
9015 )?
9016 }
9017 };
9018
9019 if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9020 let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9021 message: format!("resolved checkout is not a valid db.md store: {error}"),
9022 })?;
9023 installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9024 }
9025 Ok(json!({
9026 "v": 2,
9027 "class": "auto_converged",
9028 "bundle": bundle,
9029 "receipt": result,
9030 }))
9031}
9032
9033pub fn sync_converge(
9044 cfg: &HubConfig,
9045 brain: &str,
9046 checkout: &Path,
9047 resume_local_policy: bool,
9048) -> LinkResult<Value> {
9049 sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
9050}
9051
9052pub fn sync_converge_with_options(
9054 cfg: &HubConfig,
9055 brain: &str,
9056 checkout: &Path,
9057 resume_local_policy: bool,
9058 bulk_confirmation: Option<&V2BulkConfirmation>,
9059) -> LinkResult<Value> {
9060 sync_converge_with_controls(
9061 cfg,
9062 brain,
9063 checkout,
9064 resume_local_policy,
9065 bulk_confirmation,
9066 &[],
9067 None,
9068 )
9069}
9070
9071pub fn sync_converge_with_controls(
9073 cfg: &HubConfig,
9074 brain: &str,
9075 checkout: &Path,
9076 resume_local_policy: bool,
9077 bulk_confirmation: Option<&V2BulkConfirmation>,
9078 withdrawal_paths: &[String],
9079 withdrawal_reason: Option<&str>,
9080) -> LinkResult<Value> {
9081 require_hardened_filesystem("bidirectional sync")?;
9082 require_safe_ref(brain)?;
9083 let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
9084 message:
9085 "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
9086 .to_string(),
9087 })?;
9088 let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
9089 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9090 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9091 })?;
9092 let _transaction = store.transaction()?;
9093 let pulled_report = pulled.report.clone();
9094 let pulled_head = pulled.head.clone();
9095 let mut result = v2_sync_push(
9096 cfg,
9097 brain,
9098 &store,
9099 pulled_head,
9100 V2SyncPushOptions {
9101 resume_local_policy,
9102 bulk_confirmation,
9103 resolution: None,
9104 pulled: Some(pulled),
9105 withdrawal_paths,
9106 withdrawal_reason,
9107 },
9108 )?;
9109 if let Some(object) = result.as_object_mut() {
9110 object.insert("pulled_files".to_string(), json!(pulled_report.files));
9111 object.insert("checkout".to_string(), Value::String(pulled_report.dest));
9112 object.insert(
9113 "mode".to_string(),
9114 Value::String("bidirectional".to_string()),
9115 );
9116 }
9117 Ok(result)
9118}
9119
9120pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9126 require_hardened_filesystem("sync pull")?;
9127 require_safe_ref(brain)?;
9128 if let Some(head) = v2_verified_head(cfg, brain)? {
9129 return v2_sync_pull(cfg, brain, head, out);
9130 }
9131 legacy_sync_pull(cfg, brain, out)
9132}
9133
9134#[cfg(windows)]
9135fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
9136 Err(LinkError::UnsupportedPlatform {
9137 operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
9138 })
9139}
9140
9141#[cfg(not(windows))]
9142fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9143 let remote = verified_remote_head(cfg, brain, false)?;
9144 if !remote.head.verified {
9145 return Err(invalid_feed(
9146 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
9147 ));
9148 }
9149 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
9150 let path = format!(
9151 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
9152 remote.head.seq
9153 );
9154 let body = ensure_ok(
9155 request(cfg, "GET", &path, None, Auth::Required)?,
9156 "sync pull",
9157 )?;
9158 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
9159 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
9160 {
9161 return Err(invalid_feed(
9162 "export response is not bound to the verified snapshot token",
9163 ));
9164 }
9165
9166 let remote_slug = body
9167 .get("slug")
9168 .and_then(Value::as_str)
9169 .filter(|slug| is_safe_slug(slug));
9170 let slug = remote_slug
9171 .or_else(|| is_safe_slug(brain).then_some(brain))
9172 .unwrap_or("brain")
9173 .to_string();
9174 let brain_id = body
9175 .get("brain")
9176 .and_then(Value::as_str)
9177 .unwrap_or(&remote.head.brain)
9178 .to_string();
9179 if brain_id != remote.head.brain {
9180 return Err(invalid_feed(
9181 "export response names a different brain than the verified head",
9182 ));
9183 }
9184 let head_seq = remote.head.seq;
9185 let dest: PathBuf = match out {
9186 Some(p) => p.to_path_buf(),
9187 None => PathBuf::from(&slug),
9188 };
9189 let entries = if head_seq == 0 {
9190 let files = body
9191 .get("files")
9192 .and_then(Value::as_array)
9193 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
9194 if !files.is_empty() || body.get("url").is_some() {
9195 return Err(invalid_feed(
9196 "empty signed feed cannot authorize non-empty exported content",
9197 ));
9198 }
9199 Vec::new()
9200 } else {
9201 let signed_head = remote
9202 .head_entry
9203 .as_ref()
9204 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
9205 let expected = &signed_head.entry.pack_sha256;
9206 if !is_sha256(expected) {
9207 return Err(invalid_feed(
9208 "signed head carries an invalid snapshot pack digest",
9209 ));
9210 }
9211 if let Some(url) = body.get("url").and_then(Value::as_str) {
9212 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
9213 return Err(invalid_feed(
9214 "export pack digest does not match the signed head entry",
9215 ));
9216 }
9217 let bytes = get_presigned(cfg, url)?;
9218 let actual = format!("{:x}", Sha256::digest(&bytes));
9219 if actual != *expected {
9220 return Err(LinkError::InvalidPack {
9221 message: "downloaded pack does not match the signed snapshot digest"
9222 .to_string(),
9223 });
9224 }
9225 let entries = parse_store_pack(bytes)?;
9226 if signed_head.entry.kind == "push" {
9227 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9228 }
9229 entries
9230 } else {
9231 if signed_head.entry.kind != "push" {
9232 return Err(invalid_feed(
9233 "delta snapshots must export the exact signed pack",
9234 ));
9235 }
9236 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
9237 invalid_feed("verified snapshot export carried neither a pack nor files")
9238 })?;
9239 let mut entries = Vec::with_capacity(files.len());
9240 for file in files {
9241 let path = file
9242 .get("path")
9243 .and_then(Value::as_str)
9244 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
9245 let content = file
9246 .get("content")
9247 .and_then(Value::as_str)
9248 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
9249 entries.push((path.to_string(), content.as_bytes().to_vec()));
9250 }
9251 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9252 entries
9253 }
9254 };
9255
9256 let mut seen = std::collections::HashSet::new();
9258 for (path, _) in &entries {
9259 if !safe_store_rel_path(path) {
9260 return Err(LinkError::UnsafePath { path: path.clone() });
9261 }
9262 if !seen.insert(path) {
9263 return Err(LinkError::InvalidPack {
9264 message: format!("duplicate path `{path}`"),
9265 });
9266 }
9267 }
9268 let pulled: std::collections::BTreeSet<&str> =
9271 entries.iter().map(|(p, _)| p.as_str()).collect();
9272 let mut extra_local = Vec::new();
9273 if let Ok(store) = Store::open(&dest) {
9274 if let Ok(walked) = store.walk() {
9275 for rel in walked {
9276 let rel_str = rel.to_string_lossy().replace('\\', "/");
9277 if !pulled.contains(rel_str.as_str()) {
9278 extra_local.push(rel_str);
9279 }
9280 }
9281 }
9282 }
9283 #[cfg(unix)]
9284 install_pulled_snapshot(&dest, &entries)?;
9285
9286 Ok(PullReport {
9287 brain: brain_id,
9288 slug,
9289 head_seq,
9290 files: entries.len(),
9291 dest: dest.to_string_lossy().into_owned(),
9292 extra_local,
9293 sync_status: "synced".to_string(),
9294 })
9295}
9296
9297#[cfg(unix)]
9298fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
9299 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
9300 path: display.to_string(),
9301 })
9302}
9303
9304#[cfg(unix)]
9305fn open_dir_at(
9306 parent: std::os::fd::RawFd,
9307 name: &std::ffi::CStr,
9308 display: &str,
9309) -> LinkResult<std::fs::File> {
9310 use std::os::fd::FromRawFd as _;
9311 let fd = unsafe {
9312 libc::openat(
9313 parent,
9314 name.as_ptr(),
9315 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9316 )
9317 };
9318 if fd < 0 {
9319 return Err(LinkError::UnsafePath {
9320 path: display.to_string(),
9321 });
9322 }
9323 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
9324}
9325
9326#[cfg(unix)]
9330fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
9331 use std::os::fd::AsRawFd as _;
9332
9333 #[cfg(target_os = "macos")]
9337 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
9338 .into_iter()
9339 .find_map(|(alias, real)| {
9340 path.strip_prefix(alias)
9341 .ok()
9342 .map(|rest| Path::new(real).join(rest))
9343 })
9344 .unwrap_or_else(|| path.to_path_buf());
9345 #[cfg(not(target_os = "macos"))]
9346 let normalized = path.to_path_buf();
9347
9348 let start = if normalized.is_absolute() {
9349 std::fs::File::open("/")?
9350 } else {
9351 std::fs::File::open(".")?
9352 };
9353 let mut directory = start;
9354 for component in normalized.components() {
9355 use std::path::Component;
9356 let name = match component {
9357 Component::RootDir | Component::CurDir => continue,
9358 Component::Normal(name) => name,
9359 Component::ParentDir | Component::Prefix(_) => {
9360 return Err(LinkError::UnsafePath {
9361 path: path.display().to_string(),
9362 });
9363 }
9364 };
9365 use std::os::unix::ffi::OsStrExt as _;
9366 let name = c_name(name.as_bytes(), &path.display().to_string())?;
9367 if create {
9368 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9369 if made != 0 {
9370 let error = std::io::Error::last_os_error();
9371 if error.raw_os_error() != Some(libc::EEXIST) {
9372 return Err(error.into());
9373 }
9374 }
9375 }
9376 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
9377 }
9378 Ok(directory)
9379}
9380
9381#[cfg(unix)]
9382fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9383 open_dir_path_nofollow(path, true)
9384}
9385
9386#[cfg(unix)]
9387fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9388 open_dir_path_nofollow(path, false)
9389}
9390
9391#[cfg(unix)]
9392fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
9393 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9394 let result =
9395 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
9396 if result == 0 {
9397 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
9398 }
9399 let error = std::io::Error::last_os_error();
9400 if error.kind() == std::io::ErrorKind::NotFound {
9401 Ok(None)
9402 } else {
9403 Err(error.into())
9404 }
9405}
9406
9407#[cfg(unix)]
9408fn create_dir_exclusive_at(
9409 parent: std::os::fd::RawFd,
9410 name: &std::ffi::CStr,
9411 display: &str,
9412) -> LinkResult<std::fs::File> {
9413 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
9414 if made != 0 {
9415 return Err(LinkError::UnsafePath {
9416 path: display.to_string(),
9417 });
9418 }
9419 open_dir_at(parent, name, display)
9420}
9421
9422#[cfg(unix)]
9423fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
9424 use std::os::fd::AsRawFd as _;
9425
9426 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
9427 if duplicate < 0 {
9428 return Err(std::io::Error::last_os_error().into());
9429 }
9430 let stream = unsafe { libc::fdopendir(duplicate) };
9431 if stream.is_null() {
9432 let error = std::io::Error::last_os_error();
9433 unsafe {
9434 libc::close(duplicate);
9435 }
9436 return Err(error.into());
9437 }
9438 let mut names = Vec::new();
9439 loop {
9440 let entry = unsafe { libc::readdir(stream) };
9441 if entry.is_null() {
9442 break;
9443 }
9444 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
9445 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
9446 names.push(raw.to_owned());
9447 }
9448 }
9449 if unsafe { libc::closedir(stream) } != 0 {
9450 return Err(std::io::Error::last_os_error().into());
9451 }
9452 Ok(names)
9453}
9454
9455#[cfg(unix)]
9458fn remove_tree_at(
9459 parent: std::os::fd::RawFd,
9460 name: &std::ffi::CStr,
9461 display: &str,
9462) -> LinkResult<()> {
9463 use std::os::fd::AsRawFd as _;
9464
9465 match entry_is_dir_at(parent, name)? {
9466 None => return Ok(()),
9467 Some(false) => {
9468 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
9469 return Err(std::io::Error::last_os_error().into());
9470 }
9471 }
9472 Some(true) => {
9473 let directory = open_dir_at(parent, name, display)?;
9474 for child in directory_entry_names(&directory)? {
9475 let child_display =
9476 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
9477 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
9478 }
9479 drop(directory);
9480 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
9481 return Err(std::io::Error::last_os_error().into());
9482 }
9483 }
9484 }
9485 Ok(())
9486}
9487
9488#[cfg(unix)]
9492fn clone_tree_contents(
9493 source: &std::fs::File,
9494 destination: &std::fs::File,
9495 display: &str,
9496) -> LinkResult<()> {
9497 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9498
9499 for name in directory_entry_names(source)? {
9500 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9501 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9502 if unsafe {
9503 libc::fstatat(
9504 source.as_raw_fd(),
9505 name.as_ptr(),
9506 &mut stat,
9507 libc::AT_SYMLINK_NOFOLLOW,
9508 )
9509 } != 0
9510 {
9511 return Err(std::io::Error::last_os_error().into());
9512 }
9513 match stat.st_mode & libc::S_IFMT {
9514 libc::S_IFDIR => {
9515 if unsafe {
9516 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
9517 } != 0
9518 {
9519 return Err(std::io::Error::last_os_error().into());
9520 }
9521 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
9522 let destination_child =
9523 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
9524 clone_tree_contents(&source_child, &destination_child, &child_display)?;
9525 destination_child.sync_all()?;
9526 }
9527 libc::S_IFREG => {
9528 let source_fd = unsafe {
9529 libc::openat(
9530 source.as_raw_fd(),
9531 name.as_ptr(),
9532 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9533 )
9534 };
9535 if source_fd < 0 {
9536 return Err(std::io::Error::last_os_error().into());
9537 }
9538 let destination_fd = unsafe {
9539 libc::openat(
9540 destination.as_raw_fd(),
9541 name.as_ptr(),
9542 libc::O_WRONLY
9543 | libc::O_CREAT
9544 | libc::O_EXCL
9545 | libc::O_CLOEXEC
9546 | libc::O_NOFOLLOW,
9547 (stat.st_mode & 0o777) as libc::c_uint,
9548 )
9549 };
9550 if destination_fd < 0 {
9551 unsafe {
9552 libc::close(source_fd);
9553 }
9554 return Err(std::io::Error::last_os_error().into());
9555 }
9556 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
9557 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
9558 std::io::copy(&mut input, &mut output)?;
9559 output.sync_all()?;
9560 }
9561 libc::S_IFLNK => {
9562 let mut target = vec![0_u8; 4097];
9563 let length = unsafe {
9564 libc::readlinkat(
9565 source.as_raw_fd(),
9566 name.as_ptr(),
9567 target.as_mut_ptr().cast(),
9568 target.len(),
9569 )
9570 };
9571 if length < 0 || length as usize >= target.len() {
9572 return Err(LinkError::UnsafePath {
9573 path: child_display,
9574 });
9575 }
9576 target.truncate(length as usize);
9577 let target = c_name(&target, &child_display)?;
9578 if unsafe {
9579 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
9580 } != 0
9581 {
9582 return Err(std::io::Error::last_os_error().into());
9583 }
9584 }
9585 _ => {
9586 return Err(LinkError::UnsafePath {
9587 path: child_display,
9588 });
9589 }
9590 }
9591 }
9592 destination.sync_all()?;
9593 Ok(())
9594}
9595
9596#[cfg(target_os = "linux")]
9597fn install_stage_at(
9598 parent: std::os::fd::RawFd,
9599 stage: &std::ffi::CStr,
9600 dest: &std::ffi::CStr,
9601 dest_exists: bool,
9602) -> LinkResult<()> {
9603 let flags = if dest_exists {
9604 libc::RENAME_EXCHANGE
9605 } else {
9606 libc::RENAME_NOREPLACE
9607 };
9608 let result = unsafe {
9612 libc::syscall(
9613 libc::SYS_renameat2,
9614 parent,
9615 stage.as_ptr(),
9616 parent,
9617 dest.as_ptr(),
9618 flags,
9619 )
9620 };
9621 if result == 0 {
9622 Ok(())
9623 } else {
9624 Err(std::io::Error::last_os_error().into())
9625 }
9626}
9627
9628#[cfg(target_os = "macos")]
9629fn install_stage_at(
9630 parent: std::os::fd::RawFd,
9631 stage: &std::ffi::CStr,
9632 dest: &std::ffi::CStr,
9633 dest_exists: bool,
9634) -> LinkResult<()> {
9635 let flags = if dest_exists {
9636 libc::RENAME_SWAP
9637 } else {
9638 libc::RENAME_EXCL
9639 };
9640 let result =
9641 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
9642 if result == 0 {
9643 Ok(())
9644 } else {
9645 Err(std::io::Error::last_os_error().into())
9646 }
9647}
9648
9649#[cfg(unix)]
9650fn write_pull_entries_beneath_dir(
9651 root: &std::fs::File,
9652 entries: &[(String, Vec<u8>)],
9653) -> LinkResult<()> {
9654 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9655
9656 for (path, content) in entries {
9657 let components: Vec<&str> = path.split('/').collect();
9658 let (leaf, parents) = components
9659 .split_last()
9660 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9661 let mut directory = root.try_clone()?;
9662 for component in parents {
9663 let name = c_name(component.as_bytes(), path)?;
9664 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9665 if made != 0 {
9666 let error = std::io::Error::last_os_error();
9667 if error.raw_os_error() != Some(libc::EEXIST) {
9668 return Err(error.into());
9669 }
9670 }
9671 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9672 }
9673
9674 let leaf_name = c_name(leaf.as_bytes(), path)?;
9675 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9676 let inspected = unsafe {
9677 libc::fstatat(
9678 directory.as_raw_fd(),
9679 leaf_name.as_ptr(),
9680 &mut existing,
9681 libc::AT_SYMLINK_NOFOLLOW,
9682 )
9683 };
9684 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
9685 return Err(LinkError::UnsafePath { path: path.clone() });
9686 }
9687
9688 let nonce = std::time::SystemTime::now()
9689 .duration_since(std::time::UNIX_EPOCH)
9690 .unwrap_or_default()
9691 .as_nanos();
9692 let temp_name = format!(
9693 ".dbmd-pull-{}-{nonce}-{}",
9694 std::process::id(),
9695 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
9696 );
9697 let temp = c_name(temp_name.as_bytes(), path)?;
9698 let fd = unsafe {
9699 libc::openat(
9700 directory.as_raw_fd(),
9701 temp.as_ptr(),
9702 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9703 0o600,
9704 )
9705 };
9706 if fd < 0 {
9707 return Err(std::io::Error::last_os_error().into());
9708 }
9709 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9710 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
9711 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9712 return Err(error.into());
9713 }
9714 drop(file);
9715 let renamed = unsafe {
9716 libc::renameat(
9717 directory.as_raw_fd(),
9718 temp.as_ptr(),
9719 directory.as_raw_fd(),
9720 leaf_name.as_ptr(),
9721 )
9722 };
9723 if renamed != 0 {
9724 let error = std::io::Error::last_os_error();
9725 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9726 return Err(error.into());
9727 }
9728 directory.sync_all()?;
9729 }
9730 root.sync_all()?;
9731 Ok(())
9732}
9733
9734#[cfg(unix)]
9735fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9736 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9737
9738 let path = &entry.path;
9739 let components: Vec<&str> = path.split('/').collect();
9740 let (leaf, parents) = components
9741 .split_last()
9742 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9743 let mut directory = root.try_clone()?;
9744 for component in parents {
9745 let name = c_name(component.as_bytes(), path)?;
9746 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9747 if made != 0 {
9748 let error = std::io::Error::last_os_error();
9749 if error.raw_os_error() != Some(libc::EEXIST) {
9750 return Err(error.into());
9751 }
9752 }
9753 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9754 }
9755 let leaf_name = c_name(leaf.as_bytes(), path)?;
9756 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9757 if unsafe {
9758 libc::fstatat(
9759 directory.as_raw_fd(),
9760 leaf_name.as_ptr(),
9761 &mut existing,
9762 libc::AT_SYMLINK_NOFOLLOW,
9763 )
9764 } == 0
9765 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
9766 {
9767 return Err(LinkError::UnsafePath { path: path.clone() });
9768 }
9769 let nonce = SystemTime::now()
9770 .duration_since(UNIX_EPOCH)
9771 .unwrap_or_default()
9772 .as_nanos();
9773 let temp_name = format!(
9774 ".dbmd-pull-{}-{nonce}-{}",
9775 std::process::id(),
9776 content_sha256(path.as_bytes())
9777 );
9778 let temp = c_name(temp_name.as_bytes(), path)?;
9779 let fd = unsafe {
9780 libc::openat(
9781 directory.as_raw_fd(),
9782 temp.as_ptr(),
9783 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9784 0o600,
9785 )
9786 };
9787 if fd < 0 {
9788 return Err(std::io::Error::last_os_error().into());
9789 }
9790 let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
9791 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
9792 let mut digest = Sha256::new();
9793 let mut total = 0_u64;
9794 let mut buffer = [0_u8; 64 * 1024];
9795 let copied = (|| -> std::io::Result<()> {
9796 loop {
9797 let read = input.read(&mut buffer)?;
9798 if read == 0 {
9799 break;
9800 }
9801 total = total.saturating_add(read as u64);
9802 if total > entry.bytes {
9803 return Err(std::io::Error::new(
9804 std::io::ErrorKind::InvalidData,
9805 "staged sync source grew beyond its verified length",
9806 ));
9807 }
9808 digest.update(&buffer[..read]);
9809 output.write_all(&buffer[..read])?;
9810 }
9811 Ok(())
9812 })();
9813 if let Err(error) = copied {
9814 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9815 return Err(error.into());
9816 }
9817 drop(output);
9818 if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
9819 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9820 return Err(invalid_feed(
9821 "private staged sync source failed final integrity verification",
9822 ));
9823 }
9824 if unsafe {
9825 libc::renameat(
9826 directory.as_raw_fd(),
9827 temp.as_ptr(),
9828 directory.as_raw_fd(),
9829 leaf_name.as_ptr(),
9830 )
9831 } != 0
9832 {
9833 let error = std::io::Error::last_os_error();
9834 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9835 return Err(error.into());
9836 }
9837 Ok(())
9838}
9839
9840#[cfg(unix)]
9841fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9842 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9843
9844 let path = &entry.path;
9845 let components: Vec<&str> = path.split('/').collect();
9846 let (leaf, parents) = components
9847 .split_last()
9848 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9849 let mut directory = root.try_clone()?;
9850 for component in parents {
9851 directory = open_dir_at(
9852 directory.as_raw_fd(),
9853 &c_name(component.as_bytes(), path)?,
9854 path,
9855 )?;
9856 }
9857 let leaf = c_name(leaf.as_bytes(), path)?;
9858 let fd = unsafe {
9859 libc::openat(
9860 directory.as_raw_fd(),
9861 leaf.as_ptr(),
9862 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9863 )
9864 };
9865 if fd < 0 {
9866 return Err(std::io::Error::last_os_error().into());
9867 }
9868 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9869 if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
9870 return Err(invalid_feed(
9871 "private pull stage changed before its durability barrier",
9872 ));
9873 }
9874 file.sync_all()?;
9875 Ok(())
9876}
9877
9878#[cfg(unix)]
9879fn run_pull_source_workers(
9880 root: &std::fs::File,
9881 entries: &[V2StagedFile],
9882 operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
9883) -> LinkResult<()> {
9884 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9885
9886 let next = AtomicUsize::new(0);
9887 let failed = AtomicBool::new(false);
9888 let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
9889 let mut first_error = None;
9890 std::thread::scope(|scope| {
9891 let (sender, receiver) = std::sync::mpsc::channel();
9892 for _ in 0..worker_count {
9893 let sender = sender.clone();
9894 let next = &next;
9895 let failed = &failed;
9896 scope.spawn(move || {
9897 while !failed.load(Ordering::Acquire) {
9898 let index = next.fetch_add(1, Ordering::Relaxed);
9899 let Some(entry) = entries.get(index) else {
9900 break;
9901 };
9902 let result = operation(root, entry);
9903 if result.is_err() {
9904 failed.store(true, Ordering::Release);
9905 }
9906 if sender.send(result).is_err() {
9907 break;
9908 }
9909 }
9910 });
9911 }
9912 drop(sender);
9913 for result in receiver {
9914 if let Err(error) = result {
9915 if first_error.is_none() {
9916 first_error = Some(error);
9917 }
9918 }
9919 }
9920 });
9921 if let Some(error) = first_error {
9922 return Err(error);
9923 }
9924 if next.load(Ordering::Relaxed) < entries.len() {
9925 return Err(invalid_feed(
9926 "a bounded pull worker stopped before reporting every file",
9927 ));
9928 }
9929 Ok(())
9930}
9931
9932#[cfg(unix)]
9933fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9934 use std::os::fd::AsRawFd as _;
9935
9936 for name in directory_entry_names(root)? {
9937 if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9938 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9939 let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9940 sync_pull_directory_tree(&child, &child_display)?;
9941 }
9942 }
9943 root.sync_all()?;
9944 Ok(())
9945}
9946
9947#[cfg(unix)]
9948fn write_pull_sources_beneath_dir(
9949 root: &std::fs::File,
9950 entries: &[V2StagedFile],
9951) -> LinkResult<()> {
9952 run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
9959 run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
9960 sync_pull_directory_tree(root, "v2 pull stage")
9961}
9962
9963#[cfg(unix)]
9964fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
9965 use std::os::fd::AsRawFd as _;
9966 for path in paths {
9967 if !safe_store_rel_path(path) {
9968 return Err(LinkError::UnsafePath { path: path.clone() });
9969 }
9970 let components = path.split('/').collect::<Vec<_>>();
9971 let Some((leaf, parents)) = components.split_last() else {
9972 return Err(LinkError::UnsafePath { path: path.clone() });
9973 };
9974 let mut directory = root.try_clone()?;
9975 let mut missing = false;
9976 for component in parents {
9977 let name = c_name(component.as_bytes(), path)?;
9978 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
9979 None => {
9980 missing = true;
9981 break;
9982 }
9983 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
9984 Some(true) => {
9985 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9986 }
9987 }
9988 }
9989 if missing {
9990 continue;
9991 }
9992 let leaf = c_name(leaf.as_bytes(), path)?;
9993 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
9994 None => {}
9995 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
9996 Some(false) => {
9997 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
9998 return Err(std::io::Error::last_os_error().into());
9999 }
10000 directory.sync_all()?;
10001 }
10002 }
10003 }
10004 Ok(())
10005}
10006
10007#[cfg(unix)]
10008fn install_pulled_delta(
10009 dest: &Path,
10010 entries: &[(String, Vec<u8>)],
10011 deleted: &[String],
10012 rebuild_indexes: bool,
10013) -> LinkResult<()> {
10014 use ring::rand::SecureRandom as _;
10015 use std::os::fd::AsRawFd as _;
10016 use std::os::unix::ffi::OsStrExt as _;
10017
10018 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10019 let name = dest
10020 .file_name()
10021 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10022 .ok_or_else(|| LinkError::UnsafePath {
10023 path: dest.display().to_string(),
10024 })?;
10025 let parent_dir = open_or_create_dir_nofollow(parent)?;
10026 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10027 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10028 None => false,
10029 Some(true) => true,
10030 Some(false) => {
10031 return Err(LinkError::UnsafePath {
10032 path: dest.display().to_string(),
10033 });
10034 }
10035 };
10036
10037 let mut nonce = [0_u8; 16];
10038 ring::rand::SystemRandom::new()
10039 .fill(&mut nonce)
10040 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10041 let stage_label = format!(
10042 ".{}.dbmd-pull-stage-{}",
10043 name.to_string_lossy(),
10044 URL_SAFE_NO_PAD.encode(nonce)
10045 );
10046 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10047 let stage_dir = create_dir_exclusive_at(
10048 parent_dir.as_raw_fd(),
10049 &stage_name,
10050 &dest.display().to_string(),
10051 )?;
10052
10053 let prepared = (|| -> LinkResult<()> {
10054 if dest_exists {
10055 let live = open_dir_at(
10056 parent_dir.as_raw_fd(),
10057 &dest_name,
10058 &dest.display().to_string(),
10059 )?;
10060 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10061 }
10062 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10063 write_pull_entries_beneath_dir(&stage_dir, entries)?;
10064 if rebuild_indexes {
10065 let stage_store =
10066 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10067 .map_err(|error| LinkError::InvalidPack {
10068 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10069 })?;
10070 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10071 LinkError::InvalidPack {
10072 message: format!("could not materialize v2 local catalogs: {error}"),
10073 }
10074 })?;
10075 }
10076 stage_dir.sync_all()?;
10077 Ok(())
10078 })();
10079 if let Err(error) = prepared {
10080 let _ = remove_tree_at(
10081 parent_dir.as_raw_fd(),
10082 &stage_name,
10083 &dest.display().to_string(),
10084 );
10085 return Err(error);
10086 }
10087
10088 if let Err(error) =
10089 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10090 {
10091 let _ = remove_tree_at(
10092 parent_dir.as_raw_fd(),
10093 &stage_name,
10094 &dest.display().to_string(),
10095 );
10096 return Err(error);
10097 }
10098 parent_dir.sync_all()?;
10099 if dest_exists {
10100 let _ = remove_tree_at(
10104 parent_dir.as_raw_fd(),
10105 &stage_name,
10106 &dest.display().to_string(),
10107 );
10108 let _ = parent_dir.sync_all();
10109 }
10110 Ok(())
10111}
10112
10113#[cfg(unix)]
10114fn install_pulled_delta_sources(
10115 dest: &Path,
10116 entries: &[V2StagedFile],
10117 deleted: &[String],
10118 rebuild_indexes: bool,
10119 _previous: Option<&V2SyncBaseline>,
10120 _next: &V2VerifiedHead,
10121) -> LinkResult<()> {
10122 use ring::rand::SecureRandom as _;
10123 use std::os::fd::AsRawFd as _;
10124 use std::os::unix::ffi::OsStrExt as _;
10125
10126 if let Ok(store) = Store::open_strict(dest) {
10130 return install_established_v2_delta(
10131 store,
10132 entries,
10133 deleted,
10134 rebuild_indexes,
10135 _previous,
10136 _next,
10137 );
10138 }
10139
10140 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10141 let name = dest
10142 .file_name()
10143 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10144 .ok_or_else(|| LinkError::UnsafePath {
10145 path: dest.display().to_string(),
10146 })?;
10147 let parent_dir = open_or_create_dir_nofollow(parent)?;
10148 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10149 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10150 None => false,
10151 Some(true) => true,
10152 Some(false) => {
10153 return Err(LinkError::UnsafePath {
10154 path: dest.display().to_string(),
10155 })
10156 }
10157 };
10158 let mut nonce = [0_u8; 16];
10159 ring::rand::SystemRandom::new()
10160 .fill(&mut nonce)
10161 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10162 let stage_label = format!(
10163 ".{}.dbmd-pull-stage-{}",
10164 name.to_string_lossy(),
10165 URL_SAFE_NO_PAD.encode(nonce)
10166 );
10167 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10168 let stage_dir = create_dir_exclusive_at(
10169 parent_dir.as_raw_fd(),
10170 &stage_name,
10171 &dest.display().to_string(),
10172 )?;
10173 let prepared = (|| -> LinkResult<()> {
10174 if dest_exists {
10175 let live = open_dir_at(
10176 parent_dir.as_raw_fd(),
10177 &dest_name,
10178 &dest.display().to_string(),
10179 )?;
10180 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10181 }
10182 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10183 write_pull_sources_beneath_dir(&stage_dir, entries)?;
10184 if rebuild_indexes {
10185 let stage_store =
10186 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10187 .map_err(|error| LinkError::InvalidPack {
10188 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10189 })?;
10190 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10191 LinkError::InvalidPack {
10192 message: format!("could not materialize v2 local catalogs: {error}"),
10193 }
10194 })?;
10195 }
10196 stage_dir.sync_all()?;
10197 Ok(())
10198 })();
10199 if let Err(error) = prepared {
10200 let _ = remove_tree_at(
10201 parent_dir.as_raw_fd(),
10202 &stage_name,
10203 &dest.display().to_string(),
10204 );
10205 return Err(error);
10206 }
10207 if let Err(error) =
10208 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10209 {
10210 let _ = remove_tree_at(
10211 parent_dir.as_raw_fd(),
10212 &stage_name,
10213 &dest.display().to_string(),
10214 );
10215 return Err(error);
10216 }
10217 parent_dir.sync_all()?;
10218 if dest_exists {
10219 let _ = remove_tree_at(
10220 parent_dir.as_raw_fd(),
10221 &stage_name,
10222 &dest.display().to_string(),
10223 );
10224 let _ = parent_dir.sync_all();
10225 }
10226 Ok(())
10227}
10228
10229#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10230struct V2PullCoordinate {
10231 head_seq: Option<u64>,
10232 commit_hash: Option<String>,
10233 view_kind: Option<String>,
10234 view_revision: Option<String>,
10235}
10236
10237#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10238struct V2PullFileCoordinate {
10239 sha256: String,
10240 bytes: u64,
10241}
10242
10243#[derive(Debug, Clone, Deserialize, Serialize)]
10244struct V2PullJournalEntry {
10245 path: String,
10246 old: Option<V2PullFileCoordinate>,
10247 new: Option<V2PullFileCoordinate>,
10248 backup: Option<String>,
10249}
10250
10251#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10252#[serde(rename_all = "snake_case")]
10253enum V2PullPhase {
10254 Preparing,
10255 Ready,
10256}
10257
10258#[derive(Debug, Clone, Deserialize, Serialize)]
10259struct V2PullJournal {
10260 v: u8,
10261 phase: V2PullPhase,
10262 brain: String,
10263 previous: V2PullCoordinate,
10264 next: V2PullCoordinate,
10265 backup_dir: String,
10266 entries: Vec<V2PullJournalEntry>,
10267}
10268
10269const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
10270
10271fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
10272 V2PullCoordinate {
10273 head_seq: baseline.and_then(|value| value.head_seq),
10274 commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
10275 view_kind: baseline.and_then(|value| value.view_kind.clone()),
10276 view_revision: baseline.and_then(|value| value.view_revision.clone()),
10277 }
10278}
10279
10280fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
10281 V2PullCoordinate {
10282 head_seq: head.pointer.as_ref().map(|value| value.seq),
10283 commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
10284 view_kind: Some(head.view_kind.clone()),
10285 view_revision: Some(head.view_revision.clone()),
10286 }
10287}
10288
10289fn v2_pull_file_coordinate(
10290 store: &Store,
10291 path: &str,
10292 limit: u64,
10293) -> LinkResult<Option<V2PullFileCoordinate>> {
10294 let file = match store.open_regular(Path::new(path)) {
10295 Ok(file) => file,
10296 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10297 Err(error) => return Err(error.into()),
10298 };
10299 let bytes = file.metadata()?.len();
10300 if bytes > limit || bytes > MAX_STORE_BYTES {
10301 return Err(invalid_feed(
10302 "pull transaction file exceeds its declared bound",
10303 ));
10304 }
10305 Ok(Some(V2PullFileCoordinate {
10306 sha256: content_sha256_reader(file)?,
10307 bytes,
10308 }))
10309}
10310
10311fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
10312 let mut bytes = serde_json::to_vec_pretty(journal)
10313 .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
10314 bytes.push(b'\n');
10315 Ok(bytes)
10316}
10317
10318fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
10319 let backup_prefix = ".dbmd/pull-backup-";
10320 let suffix = journal
10321 .backup_dir
10322 .strip_prefix(backup_prefix)
10323 .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
10324 let mut paths = std::collections::BTreeSet::new();
10325 if journal.v != 1
10326 || !crate::ulid::is_ulid(&journal.brain)
10327 || !crate::ulid::is_ulid(suffix)
10328 || journal.entries.is_empty()
10329 || journal.entries.len() > MAX_PUSH_FILES + 4
10330 || journal.previous == journal.next
10331 {
10332 return Err(invalid_feed("v2 pull journal failed validation"));
10333 }
10334 for (index, entry) in journal.entries.iter().enumerate() {
10335 if !safe_store_rel_path(&entry.path)
10336 || entry.path == V2_PULL_JOURNAL
10337 || entry.path.starts_with(backup_prefix)
10338 || !paths.insert(entry.path.clone())
10339 || (entry.old.is_none() && entry.new.is_none())
10340 || entry
10341 .old
10342 .iter()
10343 .chain(entry.new.iter())
10344 .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
10345 || entry.backup.as_deref()
10346 != entry
10347 .old
10348 .as_ref()
10349 .map(|_| format!("{index:08x}"))
10350 .as_deref()
10351 {
10352 return Err(invalid_feed("v2 pull journal entry failed validation"));
10353 }
10354 }
10355 Ok(())
10356}
10357
10358fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
10359 #[cfg(unix)]
10360 {
10361 use std::os::unix::fs::PermissionsExt as _;
10362 match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
10363 Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
10364 return Err(invalid_feed(
10365 "v2 pull journal is accessible to group/other; set mode 0600",
10366 ));
10367 }
10368 Ok(_) => {}
10369 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10370 Err(error) => return Err(error.into()),
10371 }
10372 }
10373 let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
10374 Ok(bytes) => bytes,
10375 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10376 Err(error) => return Err(error.into()),
10377 };
10378 let journal: V2PullJournal =
10379 serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
10380 validate_v2_pull_journal(&journal)?;
10381 Ok(Some(journal))
10382}
10383
10384fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10385 match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
10389 Ok(()) => {}
10390 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
10391 Err(error) => return Err(error.into()),
10392 }
10393 match store.remove_private_tree(Path::new(&journal.backup_dir)) {
10394 Ok(()) => Ok(()),
10395 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
10396 Err(error) => Err(error.into()),
10397 }
10398}
10399
10400fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
10401 let names = match store.directory_names(Path::new(".dbmd")) {
10402 Ok(names) => names,
10403 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
10404 Err(error) => return Err(error.into()),
10405 };
10406 for name in names {
10407 let Some(name) = name.to_str() else {
10408 continue;
10409 };
10410 let Some(suffix) = name.strip_prefix("pull-backup-") else {
10411 continue;
10412 };
10413 if crate::ulid::is_ulid(suffix) {
10414 store.remove_private_tree(&Path::new(".dbmd").join(name))?;
10415 }
10416 }
10417 Ok(())
10418}
10419
10420fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10421 for entry in &journal.entries {
10423 let limit = entry
10424 .old
10425 .as_ref()
10426 .into_iter()
10427 .chain(entry.new.iter())
10428 .map(|value| value.bytes)
10429 .max()
10430 .unwrap_or(0);
10431 let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
10432 if current != entry.old && current != entry.new {
10433 return Err(LinkError::InvalidPack {
10434 message: format!(
10435 "cannot recover interrupted pull because `{}` changed afterward",
10436 entry.path
10437 ),
10438 });
10439 }
10440 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10441 let path = Path::new(&journal.backup_dir).join(backup);
10442 let file = store.open_regular(&path)?;
10443 if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
10444 return Err(invalid_feed("v2 pull recovery backup failed verification"));
10445 }
10446 }
10447 }
10448 for entry in journal.entries.iter().rev() {
10449 match (&entry.old, &entry.backup) {
10450 (Some(old), Some(backup)) => {
10451 let bytes =
10452 store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
10453 store.write_atomic(Path::new(&entry.path), &bytes)?;
10454 }
10455 (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
10456 store.remove_file(Path::new(&entry.path))?;
10457 }
10458 (None, None) => {}
10459 _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
10460 }
10461 }
10462 crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
10463 message: format!("could not rebuild catalogs after pull recovery: {error}"),
10464 })?;
10465 cleanup_v2_pull_journal(store, journal)
10466}
10467
10468fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
10469 let Ok(store) = Store::open_strict(dest) else {
10470 return Ok(());
10471 };
10472 if let Some(journal) = load_v2_pull_journal(&store)? {
10473 if journal.brain != brain {
10474 return Err(invalid_feed("v2 pull journal belongs to another brain"));
10475 }
10476 if journal.phase == V2PullPhase::Preparing {
10477 cleanup_v2_pull_journal(&store, &journal)?;
10478 } else {
10479 let baseline = load_v2_baseline(cfg, brain, dest)?;
10480 let current = v2_pull_baseline_coordinate(baseline.as_ref());
10481 if current == journal.next {
10482 cleanup_v2_pull_journal(&store, &journal)?;
10483 } else {
10484 if current != journal.previous {
10485 return Err(invalid_feed(
10486 "cannot recover interrupted pull because its baseline changed afterward",
10487 ));
10488 }
10489 rollback_v2_pull(&store, &journal)?;
10490 }
10491 }
10492 }
10493 prune_orphan_v2_pull_backups(&store)
10498}
10499
10500fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
10501 let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
10502 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
10503 })?;
10504 if let Some(journal) = load_v2_pull_journal(&store)? {
10505 cleanup_v2_pull_journal(&store, &journal)?;
10506 }
10507 Ok(())
10508}
10509
10510#[cfg(windows)]
10511fn install_windows_initial_sources(
10512 dest: &Path,
10513 entries: &[V2StagedFile],
10514 rebuild_indexes: bool,
10515) -> LinkResult<()> {
10516 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10517 let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
10518 path: dest.display().to_string(),
10519 })?;
10520 let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
10521 if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
10522 return Err(LinkError::UnsafePath {
10523 path: dest.display().to_string(),
10524 });
10525 }
10526 let stage_name = format!(
10527 ".{}.dbmd-pull-stage-{}",
10528 name.to_string_lossy(),
10529 crate::ulid::mint()
10530 );
10531 let stage_path = parent.join(&stage_name);
10532 let stage_capability =
10533 crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
10534 let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
10535 let prepared = (|| -> LinkResult<()> {
10536 for entry in entries {
10537 let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
10538 if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
10539 return Err(invalid_feed(
10540 "private staged sync source failed final integrity verification",
10541 ));
10542 }
10543 stage.write_atomic(Path::new(&entry.path), &bytes)?;
10544 }
10545 let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
10546 .map_err(|error| LinkError::InvalidPack {
10547 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10548 })?;
10549 if rebuild_indexes {
10550 crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
10551 message: format!("could not materialize v2 local catalogs: {error}"),
10552 })?;
10553 }
10554 Ok(())
10555 })();
10556 if let Err(error) = prepared {
10557 let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
10558 return Err(error);
10559 }
10560 crate::fsx::rename_directory_beneath(
10561 &parent_capability,
10562 Path::new(&stage_name),
10563 Path::new(name),
10564 )?;
10565 Ok(())
10566}
10567
10568fn install_established_v2_delta(
10569 store: Store,
10570 entries: &[V2StagedFile],
10571 deleted: &[String],
10572 rebuild_indexes: bool,
10573 previous: Option<&V2SyncBaseline>,
10574 next: &V2VerifiedHead,
10575) -> LinkResult<()> {
10576 if load_v2_pull_journal(&store)?.is_some() {
10577 return Err(invalid_feed(
10578 "an interrupted pull must be recovered before installing",
10579 ));
10580 }
10581 let mut sources = std::collections::BTreeMap::new();
10582 for entry in entries {
10583 if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
10584 return Err(invalid_feed("pull mutation repeats a path"));
10585 }
10586 }
10587 let mut paths = sources.keys().cloned().collect::<Vec<_>>();
10588 paths.extend(deleted.iter().cloned());
10589 paths.sort();
10590 paths.dedup();
10591 if paths.is_empty() {
10592 return Ok(());
10593 }
10594 let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
10595 let mut journal = V2PullJournal {
10596 v: 1,
10597 phase: V2PullPhase::Preparing,
10598 brain: next.brain_id.clone(),
10599 previous: v2_pull_baseline_coordinate(previous),
10600 next: v2_pull_head_coordinate(next),
10601 backup_dir: backup_dir.clone(),
10602 entries: Vec::with_capacity(paths.len()),
10603 };
10604 for path in &paths {
10605 let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
10606 let new = sources.get(path).map(|entry| V2PullFileCoordinate {
10607 sha256: entry.sha256.clone(),
10608 bytes: entry.bytes,
10609 });
10610 if old == new {
10611 continue;
10612 }
10613 let index = journal.entries.len();
10614 journal.entries.push(V2PullJournalEntry {
10615 path: path.clone(),
10616 backup: old.as_ref().map(|_| format!("{index:08x}")),
10617 old,
10618 new,
10619 });
10620 }
10621 if journal.entries.is_empty() {
10622 return Ok(());
10623 }
10624 let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
10625 entry
10626 .old
10627 .as_ref()
10628 .map_or(Some(total), |old| total.checked_add(old.bytes))
10629 });
10630 if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
10631 return Err(LinkError::InvalidPack {
10632 message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
10633 });
10634 }
10635 validate_v2_pull_journal(&journal)?;
10636 store.write_private_atomic_new(
10637 Path::new(V2_PULL_JOURNAL),
10638 &v2_pull_journal_bytes(&journal)?,
10639 )?;
10640 let prepared = (|| -> LinkResult<()> {
10641 store.create_private_dir_all(Path::new(&backup_dir))?;
10642 for entry in &journal.entries {
10643 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10644 let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
10645 if content_sha256(&bytes) != old.sha256 {
10646 return Err(invalid_feed("live pull source changed during backup"));
10647 }
10648 store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
10649 }
10650 }
10651 journal.phase = V2PullPhase::Ready;
10652 store.write_private_atomic(
10653 Path::new(V2_PULL_JOURNAL),
10654 &v2_pull_journal_bytes(&journal)?,
10655 )?;
10656 Ok(())
10657 })();
10658 if let Err(error) = prepared {
10659 let cleanup = cleanup_v2_pull_journal(&store, &journal);
10660 return match cleanup {
10661 Ok(()) => Err(error),
10662 Err(cleanup) => Err(LinkError::InvalidPack {
10663 message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
10664 }),
10665 };
10666 }
10667 let installed = (|| -> LinkResult<()> {
10668 for entry in &journal.entries {
10669 if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
10670 return Err(LinkError::InvalidPack {
10671 message: format!("local path `{}` changed during pull", entry.path),
10672 });
10673 }
10674 if let Some(source) = sources.get(&entry.path) {
10675 let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
10676 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
10677 return Err(invalid_feed(
10678 "private staged sync source failed final integrity verification",
10679 ));
10680 }
10681 store.write_atomic(Path::new(&entry.path), &bytes)?;
10682 } else if entry.old.is_some() {
10683 store.remove_file(Path::new(&entry.path))?;
10684 }
10685 }
10686 if rebuild_indexes {
10687 crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
10688 message: format!("could not materialize v2 local catalogs: {error}"),
10689 })?;
10690 }
10691 Ok(())
10692 })();
10693 if let Err(error) = installed {
10694 return match rollback_v2_pull(&store, &journal) {
10695 Ok(()) => Err(error),
10696 Err(rollback) => Err(LinkError::InvalidPack {
10697 message: format!("{error}; durable pull rollback also failed: {rollback}"),
10698 }),
10699 };
10700 }
10701 Ok(())
10702}
10703
10704#[cfg(windows)]
10705fn install_pulled_delta_sources(
10706 dest: &Path,
10707 entries: &[V2StagedFile],
10708 deleted: &[String],
10709 rebuild_indexes: bool,
10710 previous: Option<&V2SyncBaseline>,
10711 next: &V2VerifiedHead,
10712) -> LinkResult<()> {
10713 match Store::open_strict(dest) {
10714 Ok(store) => {
10715 install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
10716 }
10717 Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
10718 }
10719}
10720
10721#[cfg(not(any(unix, windows)))]
10722fn install_pulled_delta_sources(
10723 _dest: &Path,
10724 _entries: &[V2StagedFile],
10725 _deleted: &[String],
10726 _rebuild_indexes: bool,
10727 _previous: Option<&V2SyncBaseline>,
10728 _next: &V2VerifiedHead,
10729) -> LinkResult<()> {
10730 Err(LinkError::UnsupportedPlatform {
10731 operation: "atomic v2 pull install",
10732 })
10733}
10734
10735#[cfg(unix)]
10736fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
10737 install_pulled_delta(dest, entries, &[], false)
10738}
10739
10740#[cfg(not(windows))]
10741fn is_safe_slug(slug: &str) -> bool {
10742 !slug.is_empty()
10743 && slug.len() <= 63
10744 && !slug.starts_with('-')
10745 && !slug.ends_with('-')
10746 && slug
10747 .bytes()
10748 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
10749}
10750
10751fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
10752 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
10753}
10754
10755fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
10756 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
10757}
10758
10759fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
10760 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
10761}
10762
10763fn preflight_zip_central_directory(
10764 bytes: &[u8],
10765 offset: usize,
10766 size: usize,
10767 count: u64,
10768) -> LinkResult<()> {
10769 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
10770 let end = offset
10771 .checked_add(size)
10772 .filter(|end| *end <= bytes.len())
10773 .ok_or_else(|| LinkError::InvalidPack {
10774 message: "ZIP central directory is out of bounds".to_string(),
10775 })?;
10776 let mut cursor = offset;
10777 for _ in 0..count {
10778 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
10779 return Err(LinkError::InvalidPack {
10780 message: "ZIP central directory entry count is inconsistent".to_string(),
10781 });
10782 }
10783 if le_u16(bytes, cursor + 34) != Some(0) {
10784 return Err(LinkError::InvalidPack {
10785 message: "multi-disk ZIP archives are not supported".to_string(),
10786 });
10787 }
10788 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
10789 total.checked_add(le_u16(bytes, cursor + at)? as usize)
10790 });
10791 cursor = cursor
10792 .checked_add(46)
10793 .and_then(|fixed| fixed.checked_add(variable?))
10794 .filter(|cursor| *cursor <= end)
10795 .ok_or_else(|| LinkError::InvalidPack {
10796 message: "ZIP central directory entry is truncated".to_string(),
10797 })?;
10798 }
10799 if cursor != end {
10800 return Err(LinkError::InvalidPack {
10801 message: "ZIP central directory size is inconsistent".to_string(),
10802 });
10803 }
10804 Ok(())
10805}
10806
10807fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
10811 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
10812 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
10813 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
10814 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
10815 let eocd = bytes[search_start..]
10816 .windows(4)
10817 .rposition(|window| window == EOCD_SIG)
10818 .map(|offset| search_start + offset)
10819 .ok_or_else(|| LinkError::InvalidPack {
10820 message: "ZIP has no end-of-central-directory record".to_string(),
10821 })?;
10822 let invalid_end = || LinkError::InvalidPack {
10823 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
10824 };
10825 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
10826 if eocd
10827 .checked_add(22)
10828 .and_then(|end| end.checked_add(comment_len))
10829 != Some(bytes.len())
10830 {
10831 return Err(invalid_end());
10835 }
10836 let disk = le_u16(bytes, eocd + 4);
10837 let central_disk = le_u16(bytes, eocd + 6);
10838 if disk != Some(0) || central_disk != Some(0) {
10839 return Err(LinkError::InvalidPack {
10840 message: "multi-disk ZIP archives are not supported".to_string(),
10841 });
10842 }
10843 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
10844 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
10845 if entries_on_disk != ordinary {
10846 return Err(LinkError::InvalidPack {
10847 message: "multi-disk ZIP archives are not supported".to_string(),
10848 });
10849 }
10850 let zip64_locator = eocd
10851 .checked_sub(20)
10852 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
10853 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
10854 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
10855 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
10856 if central_offset
10857 .checked_add(central_size)
10858 .filter(|end| *end == eocd)
10859 .is_none()
10860 {
10861 return Err(invalid_end());
10862 }
10863 (ordinary as u64, central_offset, central_size)
10864 } else {
10865 let Some(locator) = zip64_locator else {
10866 return Err(invalid_end());
10867 };
10868 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
10869 return Err(LinkError::InvalidPack {
10870 message: "multi-disk ZIP64 archives are not supported".to_string(),
10871 });
10872 }
10873 let record = le_u64(bytes, locator + 8)
10874 .and_then(|offset| usize::try_from(offset).ok())
10875 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
10876 .ok_or_else(|| LinkError::InvalidPack {
10877 message: "ZIP64 archive has an invalid end record".to_string(),
10878 })?;
10879 let record_size = le_u64(bytes, record + 4)
10880 .and_then(|size| usize::try_from(size).ok())
10881 .filter(|size| *size >= 44)
10882 .ok_or_else(invalid_end)?;
10883 if record
10884 .checked_add(12)
10885 .and_then(|end| end.checked_add(record_size))
10886 != Some(locator)
10887 || le_u32(bytes, record + 16) != Some(0)
10888 || le_u32(bytes, record + 20) != Some(0)
10889 {
10890 return Err(invalid_end());
10891 }
10892 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
10893 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
10894 let central_size = le_u64(bytes, record + 40)
10895 .and_then(|size| usize::try_from(size).ok())
10896 .ok_or_else(invalid_end)?;
10897 let central_offset = le_u64(bytes, record + 48)
10898 .and_then(|offset| usize::try_from(offset).ok())
10899 .ok_or_else(invalid_end)?;
10900 if zip64_on_disk != zip64_total
10901 || central_offset
10902 .checked_add(central_size)
10903 .filter(|end| *end == record)
10904 .is_none()
10905 {
10906 return Err(invalid_end());
10907 }
10908 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10909 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10910 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10911 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10912 {
10913 return Err(invalid_end());
10914 }
10915 (zip64_total, central_offset, central_size)
10916 };
10917 if count == 0 || count > max_entries as u64 {
10918 return Err(LinkError::InvalidPack {
10919 message: format!("invalid file count {count}"),
10920 });
10921 }
10922 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10923 Ok(())
10924}
10925
10926fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10927 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10928 let mut archive =
10929 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10930 message: format!("ZIP parse failed: {err}"),
10931 })?;
10932 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10933 return Err(LinkError::InvalidPack {
10934 message: format!("invalid file count {}", archive.len()),
10935 });
10936 }
10937 let mut total = 0u64;
10938 let mut seen = std::collections::HashSet::new();
10939 let mut entries = Vec::with_capacity(archive.len());
10940 for index in 0..archive.len() {
10941 let mut file = archive
10942 .by_index(index)
10943 .map_err(|err| LinkError::InvalidPack {
10944 message: format!("ZIP entry failed: {err}"),
10945 })?;
10946 if file.is_dir() {
10947 continue;
10948 }
10949 let path = file.name().to_string();
10950 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10951 return Err(LinkError::UnsafePath { path });
10952 }
10953 if file
10954 .unix_mode()
10955 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
10956 {
10957 return Err(LinkError::InvalidPack {
10958 message: format!("non-file entry `{path}`"),
10959 });
10960 }
10961 if !seen.insert(path.clone()) {
10962 return Err(LinkError::InvalidPack {
10963 message: format!("duplicate path `{path}`"),
10964 });
10965 }
10966 let remaining = MAX_STORE_BYTES.saturating_sub(total);
10967 if file.size() > remaining {
10968 return Err(LinkError::InvalidPack {
10969 message: "expanded content exceeds the 512 MB limit".to_string(),
10970 });
10971 }
10972 let mut content = Vec::new();
10973 (&mut file)
10974 .take(remaining + 1)
10975 .read_to_end(&mut content)
10976 .map_err(|err| LinkError::InvalidPack {
10977 message: format!("could not decompress `{path}`: {err}"),
10978 })?;
10979 if content.len() as u64 > remaining {
10980 return Err(LinkError::InvalidPack {
10981 message: "expanded content exceeds the 512 MB limit".to_string(),
10982 });
10983 }
10984 if content.len() as u64 != file.size() {
10985 return Err(LinkError::InvalidPack {
10986 message: format!("length mismatch for `{path}`"),
10987 });
10988 }
10989 total += content.len() as u64;
10990 entries.push((path, content));
10991 }
10992 if entries.is_empty() {
10993 return Err(LinkError::InvalidPack {
10994 message: "pack contains no files".to_string(),
10995 });
10996 }
10997 Ok(entries)
10998}
10999
11000fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
11001 let mut expected = std::collections::BTreeMap::new();
11002 for file in signed {
11003 if !safe_store_rel_path(&file.path) {
11004 return Err(LinkError::UnsafePath {
11005 path: file.path.clone(),
11006 });
11007 }
11008 if !is_sha256(&file.sha256)
11009 || expected
11010 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11011 .is_some()
11012 {
11013 return Err(invalid_feed(
11014 "signed snapshot manifest contains an invalid or duplicate file",
11015 ));
11016 }
11017 }
11018 if expected.len() != entries.len() {
11019 return Err(invalid_feed(
11020 "downloaded pack file set differs from the signed snapshot manifest",
11021 ));
11022 }
11023 for (path, bytes) in entries {
11024 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
11025 return Err(invalid_feed(format!(
11026 "downloaded pack contains unsigned path `{path}`"
11027 )));
11028 };
11029 if *declared_bytes != bytes.len() as u64
11030 || *sha256 != format!("{:x}", Sha256::digest(bytes))
11031 {
11032 return Err(invalid_feed(format!(
11033 "downloaded file `{path}` differs from its signed manifest"
11034 )));
11035 }
11036 }
11037 Ok(())
11038}
11039
11040pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
11047 require_hardened_filesystem("sync push")?;
11048 preflight_push_ownership(store)?;
11049 let mut out: Vec<(String, String)> = Vec::new();
11050 let mut total = 0u64;
11051
11052 let mut read_text = |rel: &str| -> LinkResult<String> {
11053 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
11054 total = total
11055 .checked_add(bytes.len() as u64)
11056 .ok_or_else(|| LinkError::PushTooLarge {
11057 detail: "uncompressed byte count overflow".to_string(),
11058 })?;
11059 if total > MAX_STORE_BYTES {
11060 return Err(LinkError::PushTooLarge {
11061 detail: format!("{total} uncompressed bytes"),
11062 });
11063 }
11064 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
11065 path: rel.to_string(),
11066 })
11067 };
11068
11069 out.push(("DB.md".to_string(), read_text("DB.md")?));
11070 if store
11071 .regular_file_exists(Path::new("assets.jsonl"))
11072 .unwrap_or(false)
11073 {
11074 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
11075 }
11076
11077 for rel in store.walk()? {
11078 let rel_str = rel.to_string_lossy().replace('\\', "/");
11079 if !safe_store_rel_path(&rel_str) {
11080 return Err(LinkError::UnsafePath { path: rel_str });
11083 }
11084 let content = read_text(&rel_str)?;
11085 out.push((rel_str, content));
11086 }
11087
11088 out.sort_by(|a, b| a.0.cmp(&b.0));
11089 Ok(out)
11090}
11091
11092fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
11096 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
11097 return Err(LinkError::from(std::io::Error::new(
11098 std::io::ErrorKind::PermissionDenied,
11099 format!("cannot push: nested db.md store at {}", nested.display()),
11100 )));
11101 }
11102
11103 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
11104 return Err(LinkError::from(std::io::Error::new(
11105 std::io::ErrorKind::PermissionDenied,
11106 format!(
11107 "cannot push: {} is a symlink outside the store ownership model",
11108 symlink.display()
11109 ),
11110 )));
11111 }
11112 Ok(())
11113}
11114
11115pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
11121 require_safe_ref(brain)?;
11122 let remote = verified_remote_head(cfg, brain, false)?;
11123 if files.len() > MAX_PUSH_FILES {
11124 return Err(LinkError::PushTooLarge {
11125 detail: format!("{} files", files.len()),
11126 });
11127 }
11128 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
11129 if raw_total > MAX_STORE_BYTES {
11130 return Err(LinkError::PushTooLarge {
11131 detail: format!("{raw_total} uncompressed bytes"),
11132 });
11133 }
11134
11135 if cfg.brain_key.is_none() {
11139 let body = json!({
11140 "files": files
11141 .iter()
11142 .map(|(p, c)| json!({ "path": p, "content": c }))
11143 .collect::<Vec<_>>(),
11144 });
11145 if body.to_string().len() <= MAX_PUSH_BYTES {
11146 let path = format!("/api/hub/brains/{brain}/push");
11147 let pushed = ensure_ok(
11148 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11149 "sync push",
11150 )?;
11151 return Ok(pushed);
11152 }
11153 }
11154
11155 let pack = build_store_pack(files)?;
11156 if pack.len() as u64 > MAX_PACK_BYTES {
11157 return Err(LinkError::PushTooLarge {
11158 detail: format!("{} pack bytes", pack.len()),
11159 });
11160 }
11161 let sha256 = format!("{:x}", Sha256::digest(&pack));
11162 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
11163 if let Some(key) = &cfg.brain_key {
11164 if !remote.head.verified {
11165 return Err(invalid_feed(
11166 "self-custody push requires a fully verified, unscoped feed head",
11167 ));
11168 }
11169 let identity = remote
11170 .identity
11171 .as_ref()
11172 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
11173 let current_multikey = format!("ed25519:{}", identity.fingerprint);
11174 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
11175 return Err(invalid_feed(
11176 "configured brain key is not the verified current brain identity",
11177 ));
11178 }
11179 let next_seq = remote
11182 .head
11183 .seq
11184 .checked_add(1)
11185 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
11186 let mut manifest: Vec<WireFeedFile> = files
11187 .iter()
11188 .map(|(path, content)| WireFeedFile {
11189 path: path.clone(),
11190 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
11191 bytes: content.len() as u64,
11192 })
11193 .collect();
11194 manifest.sort_by(|a, b| a.path.cmp(&b.path));
11195 let ts = crate::now()
11196 .with_timezone(&chrono::Utc)
11197 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
11198 .to_string();
11199 let entry = self_custody_entry(
11200 key,
11201 next_seq,
11202 ts,
11203 &sha256,
11204 &manifest,
11205 remote.head.feed_hash.as_deref(),
11206 )?;
11207 meta["entry"] = Value::String(entry);
11208 }
11209 let presigned = ensure_ok(
11210 request(
11211 cfg,
11212 "POST",
11213 &format!("/api/hub/brains/{brain}/packs/presign"),
11214 Some(&meta),
11215 Auth::Required,
11216 )?,
11217 "prepare pack upload",
11218 )?;
11219 let url = presigned
11220 .get("url")
11221 .and_then(Value::as_str)
11222 .ok_or_else(|| LinkError::InvalidPack {
11223 message: "the hub returned no upload URL".to_string(),
11224 })?;
11225 put_presigned(
11226 cfg,
11227 url,
11228 presigned.get("headers").unwrap_or(&Value::Null),
11229 &pack,
11230 )?;
11231 let committed = ensure_ok(
11232 request(
11233 cfg,
11234 "POST",
11235 &format!("/api/hub/brains/{brain}/packs/commit"),
11236 Some(&meta),
11237 Auth::Required,
11238 )?,
11239 "commit pack",
11240 )?;
11241 Ok(committed)
11242}
11243
11244fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
11245 const LOCAL_HEADER: u32 = 0x0403_4b50;
11246 const CENTRAL_HEADER: u32 = 0x0201_4b50;
11247 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
11248 const VERSION_20: u16 = 20;
11249 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
11250 const UTF8_FLAG: u16 = 1 << 11;
11251 const STORED: u16 = 0;
11252 const DOS_TIME_MIDNIGHT: u16 = 0;
11253 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
11254 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
11255
11256 struct CentralEntry<'a> {
11257 name: &'a [u8],
11258 crc32: u32,
11259 size: u32,
11260 local_offset: u32,
11261 }
11262
11263 fn push_u16(out: &mut Vec<u8>, value: u16) {
11264 out.extend_from_slice(&value.to_le_bytes());
11265 }
11266
11267 fn push_u32(out: &mut Vec<u8>, value: u32) {
11268 out.extend_from_slice(&value.to_le_bytes());
11269 }
11270
11271 if files.is_empty() {
11272 return Err(LinkError::InvalidPack {
11273 message: "cannot create an empty snapshot pack".to_string(),
11274 });
11275 }
11276 if files.len() > u16::MAX as usize {
11277 return Err(LinkError::PushTooLarge {
11278 detail: format!(
11279 "{} files (canonical ZIP32 packs cap at {})",
11280 files.len(),
11281 u16::MAX
11282 ),
11283 });
11284 }
11285
11286 let mut sorted: Vec<_> = files.iter().collect();
11287 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
11288 let mut previous: Option<&str> = None;
11289 for (path, content) in &sorted {
11290 if !safe_store_rel_path(path) {
11291 return Err(LinkError::UnsafePath {
11292 path: (*path).clone(),
11293 });
11294 }
11295 if previous == Some(path.as_str()) {
11296 return Err(LinkError::InvalidPack {
11297 message: format!("duplicate path `{path}`"),
11298 });
11299 }
11300 previous = Some(path.as_str());
11301 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
11302 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11303 })?;
11304 }
11305
11306 let mut out = Vec::new();
11307 let mut central = Vec::with_capacity(sorted.len());
11308 for (path, content) in sorted {
11309 let name = path.as_bytes();
11310 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
11311 message: format!("ZIP entry name is too long: `{path}`"),
11312 })?;
11313 let bytes = content.as_bytes();
11314 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
11315 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11316 })?;
11317 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11318 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11319 })?;
11320 let crc32 = crc32fast::hash(bytes);
11321
11322 push_u32(&mut out, LOCAL_HEADER);
11325 push_u16(&mut out, VERSION_20);
11326 push_u16(&mut out, UTF8_FLAG);
11327 push_u16(&mut out, STORED);
11328 push_u16(&mut out, DOS_TIME_MIDNIGHT);
11329 push_u16(&mut out, DOS_DATE_1980_01_01);
11330 push_u32(&mut out, crc32);
11331 push_u32(&mut out, size);
11332 push_u32(&mut out, size);
11333 push_u16(&mut out, name_len);
11334 push_u16(&mut out, 0); out.extend_from_slice(name);
11336 out.extend_from_slice(bytes);
11337
11338 central.push(CentralEntry {
11339 name,
11340 crc32,
11341 size,
11342 local_offset,
11343 });
11344 }
11345
11346 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11347 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11348 })?;
11349 for entry in ¢ral {
11350 push_u32(&mut out, CENTRAL_HEADER);
11351 push_u16(&mut out, MADE_BY_UNIX_20);
11352 push_u16(&mut out, VERSION_20);
11353 push_u16(&mut out, UTF8_FLAG);
11354 push_u16(&mut out, STORED);
11355 push_u16(&mut out, DOS_TIME_MIDNIGHT);
11356 push_u16(&mut out, DOS_DATE_1980_01_01);
11357 push_u32(&mut out, entry.crc32);
11358 push_u32(&mut out, entry.size);
11359 push_u32(&mut out, entry.size);
11360 push_u16(&mut out, entry.name.len() as u16);
11361 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);
11366 push_u32(&mut out, entry.local_offset);
11367 out.extend_from_slice(entry.name);
11368 }
11369 let central_size = u32::try_from(out.len())
11370 .ok()
11371 .and_then(|end| end.checked_sub(central_offset))
11372 .ok_or_else(|| LinkError::PushTooLarge {
11373 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
11374 })?;
11375 let entry_count = central.len() as u16;
11376
11377 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
11378 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
11381 push_u16(&mut out, entry_count);
11382 push_u32(&mut out, central_size);
11383 push_u32(&mut out, central_offset);
11384 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
11387 return Err(LinkError::PushTooLarge {
11388 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
11389 });
11390 }
11391 Ok(out)
11392}
11393
11394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11400pub enum Capability {
11401 Read,
11403 Write,
11405}
11406
11407impl Capability {
11408 pub fn as_str(self) -> &'static str {
11410 match self {
11411 Capability::Read => "read",
11412 Capability::Write => "write",
11413 }
11414 }
11415}
11416
11417pub fn grant_issue(
11423 cfg: &HubConfig,
11424 brain: &str,
11425 grantee: &str,
11426 can: Capability,
11427 scope: Option<&str>,
11428 until: Option<&str>,
11429) -> LinkResult<Value> {
11430 require_safe_ref(brain)?;
11431 let is_key_grantee = URL_SAFE_NO_PAD
11436 .decode(grantee)
11437 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
11438 .unwrap_or(false);
11439 if let Some(head) = v2_verified_head(cfg, brain)? {
11440 if is_key_grantee {
11441 let scope = scope.unwrap_or("");
11442 let preset = match can {
11443 Capability::Read => "viewer",
11444 Capability::Write => "editor",
11445 };
11446 let entropy = format!(
11447 "{}\0{}\0{}\0{}\0{}\0{}\0{}",
11448 normalized_origin(&cfg.hub)?,
11449 head.brain_id,
11450 head.control_revision,
11451 grantee,
11452 preset,
11453 scope,
11454 until.unwrap_or("")
11455 );
11456 let mut body = json!({
11457 "context": "external",
11458 "expected_control_revision": head.control_revision,
11459 "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
11460 "preset": preset,
11461 "principal_kind": "key",
11462 "public_key": grantee,
11463 "scope": scope,
11464 "scope_kind": "prefix",
11465 });
11466 if let Some(value) = until {
11467 body["expires_at"] = json!(value);
11468 }
11469 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11470 let response = ensure_ok(
11471 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11472 "v2 grant issue",
11473 )?;
11474 let expected_fingerprint = identity_fingerprint(grantee)?;
11475 if response.get("v").and_then(Value::as_u64) != Some(2)
11476 || response
11477 .get("id")
11478 .and_then(Value::as_str)
11479 .is_none_or(|id| !crate::ulid::is_ulid(id))
11480 || response.get("principal_kind").and_then(Value::as_str) != Some("key")
11481 || response.get("principal_id").and_then(Value::as_str)
11482 != Some(expected_fingerprint.as_str())
11483 || response
11484 .get("control_revision")
11485 .and_then(Value::as_str)
11486 .is_none_or(|value| !is_sha256(value))
11487 {
11488 return Err(invalid_feed(
11489 "v2 grant issue response is not authority-bound",
11490 ));
11491 }
11492 return Ok(response);
11493 }
11494 let mut body = json!({ "email": grantee, "capability": can.as_str() });
11500 if let Some(value) = scope {
11501 body["scopePrefix"] = json!(value);
11502 }
11503 if let Some(value) = until {
11504 body["expiresAt"] = json!(value);
11505 }
11506 let path = format!("/api/hub/brains/{}/grants", head.brain_id);
11507 return ensure_ok(
11508 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11509 "account grant issue",
11510 );
11511 }
11512 let _ = verified_remote_head(cfg, brain, false)?;
11513 let mut body = if is_key_grantee {
11514 json!({ "keySpki": grantee, "capability": can.as_str() })
11515 } else {
11516 json!({ "email": grantee, "capability": can.as_str() })
11517 };
11518 if let Some(s) = scope {
11519 body["scopePrefix"] = json!(s);
11520 }
11521 if let Some(u) = until {
11522 body["expiresAt"] = json!(u);
11523 }
11524 let path = format!("/api/hub/brains/{brain}/grants");
11525 ensure_ok(
11526 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11527 "grant issue",
11528 )
11529}
11530
11531pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
11533 require_safe_ref(brain)?;
11534 if let Some(head) = v2_verified_head(cfg, brain)? {
11535 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11536 let response = ensure_ok(
11537 request(cfg, "GET", &path, None, Auth::Required)?,
11538 "v2 grant list",
11539 )?;
11540 if response.get("v").and_then(Value::as_u64) != Some(2)
11541 || response.get("control_revision").and_then(Value::as_str)
11542 != Some(head.control_revision.as_str())
11543 || !response.get("grants").is_some_and(Value::is_array)
11544 {
11545 return Err(invalid_feed(
11546 "v2 grant list is not bound to the verified authority",
11547 ));
11548 }
11549 return Ok(response);
11550 }
11551 let _ = verified_remote_head(cfg, brain, false)?;
11552 let path = format!("/api/hub/brains/{brain}/grants");
11553 ensure_ok(
11554 request(cfg, "GET", &path, None, Auth::Required)?,
11555 "grant list",
11556 )
11557}
11558
11559pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
11562 require_safe_ref(brain)?;
11563 require_safe_grant_id(grant_id)?;
11564 if let Some(head) = v2_verified_head(cfg, brain)? {
11565 let entropy = format!(
11566 "{}\0{}\0{}\0{}",
11567 normalized_origin(&cfg.hub)?,
11568 head.brain_id,
11569 head.control_revision,
11570 grant_id
11571 );
11572 let body = json!({
11573 "expected_control_revision": head.control_revision,
11574 "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
11575 });
11576 let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
11577 let response = ensure_ok(
11578 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11579 "v2 grant revoke",
11580 )?;
11581 if response.get("v").and_then(Value::as_u64) != Some(2)
11582 || response.get("id").and_then(Value::as_str) != Some(grant_id)
11583 || response.get("revoked").and_then(Value::as_bool) != Some(true)
11584 || response
11585 .get("control_revision")
11586 .and_then(Value::as_str)
11587 .is_none_or(|value| !is_sha256(value))
11588 {
11589 return Err(invalid_feed(
11590 "v2 grant revocation response is not authority-bound",
11591 ));
11592 }
11593 return Ok(response);
11594 }
11595 let _ = verified_remote_head(cfg, brain, false)?;
11596 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
11597 ensure_ok(
11598 request(cfg, "DELETE", &path, None, Auth::Required)?,
11599 "grant revoke",
11600 )
11601}
11602
11603#[derive(Debug)]
11608struct VerifiedV2Proposal {
11609 value: Value,
11610 changes: Value,
11611 blobs: Vec<(String, u64, String)>,
11612}
11613
11614fn require_proposal_id(id: &str) -> LinkResult<()> {
11615 if crate::ulid::is_ulid(id) {
11616 Ok(())
11617 } else {
11618 Err(invalid_feed("proposal id is not a lowercase ULID"))
11619 }
11620}
11621
11622fn verified_v2_proposal(
11623 cfg: &HubConfig,
11624 head: &V2VerifiedHead,
11625 proposal_id: &str,
11626) -> LinkResult<VerifiedV2Proposal> {
11627 require_proposal_id(proposal_id)?;
11628 if head.view_kind != "full" {
11629 return Err(invalid_feed(
11630 "proposal review requires a full readable view",
11631 ));
11632 }
11633 let path = format!(
11634 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11635 head.brain_id
11636 );
11637 let value = ensure_ok(
11638 request_capped(
11639 cfg,
11640 "GET",
11641 &path,
11642 None,
11643 Auth::Required,
11644 MAX_FEED_RESPONSE_BYTES,
11645 )?,
11646 "v2 proposal",
11647 )?;
11648 verify_v2_proposal_value(head, proposal_id, value)
11649}
11650
11651fn verify_v2_proposal_value(
11652 head: &V2VerifiedHead,
11653 proposal_id: &str,
11654 value: Value,
11655) -> LinkResult<VerifiedV2Proposal> {
11656 if value.get("v").and_then(Value::as_u64) != Some(2) {
11657 return Err(invalid_feed("proposal response has an invalid version"));
11658 }
11659 let proposal = value
11660 .get("proposal")
11661 .and_then(Value::as_object)
11662 .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
11663 if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
11664 return Err(invalid_feed("proposal response changed its id"));
11665 }
11666 let payload_hash = proposal
11667 .get("payload_sha256")
11668 .and_then(Value::as_str)
11669 .filter(|hash| is_sha256(hash))
11670 .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
11671 let clear_hash = proposal
11672 .get("clear_sha256")
11673 .and_then(Value::as_str)
11674 .filter(|hash| is_sha256(hash))
11675 .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
11676 let submission_hash = proposal
11677 .get("submission_claim_sha256")
11678 .and_then(Value::as_str)
11679 .filter(|hash| is_sha256(hash))
11680 .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
11681 let submission = STANDARD
11682 .decode(
11683 proposal
11684 .get("submission_claim_base64")
11685 .and_then(Value::as_str)
11686 .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
11687 )
11688 .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
11689 let submission_value: Value = serde_json::from_slice(&submission)
11690 .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
11691 if crate::linkmd_v2::canonical_bytes(&submission_value)
11692 .map_err(|error| invalid_feed(error.to_string()))?
11693 != submission
11694 || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
11695 .map_err(|error| invalid_feed(error.to_string()))?
11696 != submission_hash
11697 {
11698 return Err(invalid_feed(
11699 "proposal submission claim is not canonical or addressed",
11700 ));
11701 }
11702 let envelope = submission_value
11703 .as_object()
11704 .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
11705 let claim = envelope
11706 .get("claim")
11707 .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
11708 let claim_object = claim
11709 .as_object()
11710 .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
11711 let actor_root = claim_object
11712 .get("actor_root")
11713 .and_then(Value::as_object)
11714 .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
11715 let public_key = envelope
11716 .get("public_key")
11717 .and_then(Value::as_str)
11718 .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
11719 let fingerprint = envelope
11720 .get("fingerprint")
11721 .and_then(Value::as_str)
11722 .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
11723 let signature = envelope
11724 .get("sig")
11725 .and_then(Value::as_str)
11726 .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
11727 let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
11728 .map_err(|error| invalid_feed(error.to_string()))?;
11729 let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
11730 let signer = format!("{fingerprint}:{public_key}");
11731 let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
11732 let grants = actor_root.get("grants").and_then(Value::as_array);
11733 let grants_are_canonical = grants.is_some_and(|items| {
11734 let mut prior: Option<&str> = None;
11735 items.iter().all(|item| {
11736 let Some(grant) = item.as_str() else {
11737 return false;
11738 };
11739 if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
11740 return false;
11741 }
11742 prior = Some(grant);
11743 true
11744 })
11745 });
11746 let optional_actor_field = |name: &str| {
11747 actor_root.get(name).is_some_and(|value| {
11748 value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
11749 })
11750 };
11751 let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
11752 if claim_object.get("v").and_then(Value::as_u64) != Some(2)
11753 || format!("{:x}", Sha256::digest(&der)) != fingerprint
11754 || head
11755 .trust
11756 .hub_signer
11757 .as_ref()
11758 .is_some_and(|known| known != &signer)
11759 || !matches!(
11760 actor_class,
11761 Some(
11762 "user"
11763 | "owned_agent"
11764 | "foreign_key"
11765 | "curation"
11766 | "inbox"
11767 | "restore"
11768 | "migration"
11769 | "operator_recovery"
11770 )
11771 )
11772 || actor_root
11773 .get("principal")
11774 .and_then(Value::as_str)
11775 .is_none_or(|value| value.is_empty())
11776 || actor_root
11777 .get("credential")
11778 .and_then(Value::as_str)
11779 .is_none_or(|value| value.is_empty())
11780 || !optional_actor_field("organization")
11781 || !optional_actor_field("role")
11782 || !grants_are_canonical
11783 || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
11784 || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11785 || !claim_object
11786 .get("mutation_id")
11787 .and_then(Value::as_str)
11788 .is_some_and(|value| {
11789 !value.is_empty()
11790 && value.len() <= 128
11791 && value.chars().enumerate().all(|(index, char)| {
11792 char.is_ascii_alphanumeric()
11793 || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
11794 })
11795 })
11796 || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
11797 || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
11798 || !claim_object
11799 .get("control_revision")
11800 .and_then(Value::as_str)
11801 .is_some_and(is_sha256)
11802 || submitted_at.is_none_or(|value| {
11803 chrono::DateTime::parse_from_rfc3339(value).is_err()
11804 || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
11805 })
11806 || !proposal
11807 .get("state")
11808 .and_then(Value::as_str)
11809 .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
11810 || proposal
11811 .get("expires_at")
11812 .and_then(Value::as_str)
11813 .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
11814 || proposal
11815 .get("proposer")
11816 .and_then(Value::as_object)
11817 .and_then(|value| value.get("class"))
11818 .and_then(Value::as_str)
11819 != actor_class
11820 {
11821 return Err(invalid_feed(
11822 "proposal submission claim does not bind the verified proposal",
11823 ));
11824 }
11825 let changes_b64 = proposal
11826 .get("changes_base64")
11827 .and_then(Value::as_str)
11828 .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
11829 let changes_bytes = STANDARD
11830 .decode(changes_b64)
11831 .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
11832 let changes: Value = serde_json::from_slice(&changes_bytes)
11833 .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
11834 if crate::linkmd_v2::canonical_bytes(&changes)
11835 .map_err(|error| invalid_feed(error.to_string()))?
11836 != changes_bytes
11837 || changes.get("v").and_then(Value::as_u64) != Some(2)
11838 || !changes.get("operations").is_some_and(Value::is_array)
11839 {
11840 return Err(invalid_feed("proposal changeset is not canonical v2"));
11841 }
11842 let blob_values = proposal
11843 .get("blobs")
11844 .and_then(Value::as_array)
11845 .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
11846 let mut blobs = Vec::with_capacity(blob_values.len());
11847 let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
11848 let mut prior_hash: Option<String> = None;
11849 for item in blob_values {
11850 let hash = item
11851 .get("sha256")
11852 .and_then(Value::as_str)
11853 .filter(|hash| is_sha256(hash))
11854 .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
11855 let bytes = item
11856 .get("bytes")
11857 .and_then(Value::as_u64)
11858 .filter(|bytes| *bytes <= MAX_STORE_BYTES)
11859 .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
11860 if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
11861 return Err(invalid_feed(
11862 "proposal blob declarations are not unique and sorted",
11863 ));
11864 }
11865 prior_hash = Some(hash.to_string());
11866 let endpoint = item
11867 .get("endpoint")
11868 .and_then(Value::as_str)
11869 .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
11870 let expected_endpoint = format!(
11871 "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
11872 head.brain_id
11873 );
11874 if endpoint != expected_endpoint {
11875 return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
11876 }
11877 descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
11878 blobs.push((hash.to_string(), bytes, endpoint.to_string()));
11879 }
11880 let descriptor = json!({
11881 "base": proposal.get("base").cloned().unwrap_or(Value::Null),
11882 "blobs": descriptor_blobs,
11883 "changes_base64": changes_b64,
11884 "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
11885 "v": 2,
11886 });
11887 let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
11888 .map_err(|error| invalid_feed(error.to_string()))?;
11889 if content_sha256(&descriptor_bytes) != clear_hash {
11890 return Err(invalid_feed(
11891 "proposal clear payload differs from its signed submission claim",
11892 ));
11893 }
11894 Ok(VerifiedV2Proposal {
11895 value,
11896 changes,
11897 blobs,
11898 })
11899}
11900
11901pub fn proposal_list(
11902 cfg: &HubConfig,
11903 brain: &str,
11904 state: &str,
11905 after: Option<&str>,
11906 limit: usize,
11907) -> LinkResult<Value> {
11908 require_safe_ref(brain)?;
11909 if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11910 return Err(invalid_feed("proposal state is invalid"));
11911 }
11912 if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11913 return Err(invalid_feed("proposal cursor is invalid"));
11914 }
11915 let head = v2_verified_head(cfg, brain)?
11916 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11917 let path = format!(
11918 "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11919 head.brain_id,
11920 limit.clamp(1, 100),
11921 after.map_or_else(String::new, |value| format!("&after={value}"))
11922 );
11923 ensure_ok(
11924 request_capped(
11925 cfg,
11926 "GET",
11927 &path,
11928 None,
11929 Auth::Required,
11930 MAX_FEED_RESPONSE_BYTES,
11931 )?,
11932 "v2 proposal list",
11933 )
11934}
11935
11936pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11937 require_safe_ref(brain)?;
11938 let head = v2_verified_head(cfg, brain)?
11939 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11940 Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11941}
11942
11943pub fn proposal_reject(
11944 cfg: &HubConfig,
11945 brain: &str,
11946 proposal_id: &str,
11947 mutation_id: &str,
11948 reason: &str,
11949) -> LinkResult<Value> {
11950 require_safe_ref(brain)?;
11951 require_proposal_id(proposal_id)?;
11952 let head = v2_verified_head(cfg, brain)?
11953 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11954 let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
11955 let body = json!({
11956 "mutation_id": mutation_id,
11957 "control_revision": head.control_revision,
11958 "reason": reason,
11959 });
11960 let path = format!(
11961 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11962 head.brain_id
11963 );
11964 ensure_ok(
11965 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11966 "v2 proposal rejection",
11967 )
11968}
11969
11970pub fn proposal_accept_exact(
11971 cfg: &HubConfig,
11972 brain: &str,
11973 proposal_id: &str,
11974 mutation_id: &str,
11975 reason: &str,
11976) -> LinkResult<Value> {
11977 require_safe_ref(brain)?;
11978 require_proposal_id(proposal_id)?;
11979 let head = v2_verified_head(cfg, brain)?
11980 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11981 let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
11982 let operations = proposal
11983 .changes
11984 .get("operations")
11985 .and_then(Value::as_array)
11986 .cloned()
11987 .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
11988 if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
11989 return Err(invalid_feed("proposal operation count is invalid"));
11990 }
11991 let mut downloaded = std::collections::BTreeMap::new();
11992 for (hash, bytes, endpoint) in &proposal.blobs {
11993 let body = ensure_raw_ok(
11994 request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
11995 "v2 proposal blob",
11996 )?;
11997 if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
11998 return Err(invalid_feed("proposal blob does not match its declaration"));
11999 }
12000 downloaded.insert(hash.clone(), body);
12001 }
12002 let remote = files_for_v2_view(
12003 &head,
12004 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
12005 );
12006 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
12007 let mut expected_candidate = remote.clone();
12008 let mut expected_candidate_assets = remote_assets;
12009 for operation in &operations {
12010 let op = operation
12011 .get("op")
12012 .and_then(Value::as_str)
12013 .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
12014 match op {
12015 "put" | "restore" => {
12016 let path = operation
12017 .get("path")
12018 .and_then(Value::as_str)
12019 .ok_or_else(|| invalid_feed("proposal write has no path"))?;
12020 crate::linkmd_v2::normalize_path(path)
12021 .map_err(|error| invalid_feed(error.to_string()))?;
12022 let hash = operation
12023 .get("blob")
12024 .and_then(Value::as_str)
12025 .filter(|hash| is_sha256(hash))
12026 .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
12027 let bytes = operation
12028 .get("bytes")
12029 .and_then(Value::as_u64)
12030 .ok_or_else(|| invalid_feed("proposal write has no size"))?;
12031 expected_candidate.insert(
12032 path.to_string(),
12033 V2BaselineFile {
12034 sha256: hash.to_string(),
12035 bytes,
12036 proof: None,
12037 },
12038 );
12039 }
12040 "delete" | "withdraw_from_hosting" => {
12041 let path = operation
12042 .get("path")
12043 .and_then(Value::as_str)
12044 .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
12045 crate::linkmd_v2::normalize_path(path)
12046 .map_err(|error| invalid_feed(error.to_string()))?;
12047 expected_candidate.remove(path);
12048 }
12049 "rename" => {
12050 let from = operation
12051 .get("from")
12052 .and_then(Value::as_str)
12053 .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
12054 let to = operation
12055 .get("to")
12056 .and_then(Value::as_str)
12057 .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
12058 crate::linkmd_v2::normalize_path(from)
12059 .and_then(|_| crate::linkmd_v2::normalize_path(to))
12060 .map_err(|error| invalid_feed(error.to_string()))?;
12061 let hash = operation
12062 .get("blob")
12063 .and_then(Value::as_str)
12064 .filter(|hash| is_sha256(hash))
12065 .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
12066 let bytes = operation
12067 .get("bytes")
12068 .and_then(Value::as_u64)
12069 .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
12070 expected_candidate.remove(from);
12071 expected_candidate.insert(
12072 to.to_string(),
12073 V2BaselineFile {
12074 sha256: hash.to_string(),
12075 bytes,
12076 proof: None,
12077 },
12078 );
12079 }
12080 "asset_delete" => {
12081 let path = operation
12082 .get("path")
12083 .and_then(Value::as_str)
12084 .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
12085 expected_candidate_assets.remove(path);
12086 }
12087 "asset_withdraw" => {
12088 let path = operation
12089 .get("path")
12090 .and_then(Value::as_str)
12091 .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
12092 let asset = expected_candidate_assets
12093 .get_mut(path)
12094 .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
12095 asset.disposition = "withheld".to_string();
12096 asset.leaf_hash.clear();
12097 }
12098 "asset_put" | "asset_resume" => {
12099 let path = operation
12100 .get("path")
12101 .and_then(Value::as_str)
12102 .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
12103 let asset = operation
12104 .get("asset")
12105 .and_then(Value::as_object)
12106 .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
12107 let blob_sha256 = asset
12108 .get("blob_sha256")
12109 .and_then(Value::as_str)
12110 .filter(|hash| is_sha256(hash))
12111 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12112 let bytes = asset
12113 .get("bytes")
12114 .and_then(Value::as_u64)
12115 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12116 let media_type = asset
12117 .get("media_type")
12118 .and_then(Value::as_str)
12119 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12120 let wrappers = asset
12121 .get("wrappers")
12122 .and_then(Value::as_array)
12123 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12124 .iter()
12125 .map(|wrapper| {
12126 wrapper
12127 .as_str()
12128 .map(str::to_string)
12129 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12130 })
12131 .collect::<LinkResult<Vec<_>>>()?;
12132 let required = asset
12133 .get("required")
12134 .and_then(Value::as_bool)
12135 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12136 let disposition = asset
12137 .get("disposition")
12138 .and_then(Value::as_str)
12139 .filter(|value| matches!(*value, "hosted" | "withheld"))
12140 .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
12141 expected_candidate_assets.insert(
12142 path.to_string(),
12143 V2BaselineAsset {
12144 blob_sha256: blob_sha256.to_string(),
12145 bytes,
12146 media_type: media_type.to_string(),
12147 wrappers,
12148 required,
12149 disposition: disposition.to_string(),
12150 leaf_hash: String::new(),
12151 },
12152 );
12153 }
12154 _ => return Err(invalid_feed("proposal operation kind is unsupported")),
12155 }
12156 }
12157 let base = head.pointer.as_ref().map(|pointer| {
12158 json!({
12159 "seq": pointer.seq,
12160 "commit_hash": pointer.commit_hash,
12161 "content_root": pointer.content_root,
12162 "asset_root": pointer.asset_root,
12163 })
12164 });
12165 let mut body = json!({
12166 "mutation_id": mutation_id,
12167 "base": base,
12168 "rebase": "strict",
12169 "reason": reason,
12170 "operations": operations,
12171 "blobs": downloaded
12172 .iter()
12173 .map(|(sha256, bytes)| json!({
12174 "sha256": sha256,
12175 "bytes": bytes.len(),
12176 "content_base64": STANDARD.encode(bytes),
12177 }))
12178 .collect::<Vec<_>>(),
12179 "proposal_id": proposal_id,
12180 "proposal_mode": "exact",
12181 });
12182 let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
12183 total
12184 .checked_add(bytes.len())
12185 .ok_or_else(|| LinkError::PushTooLarge {
12186 detail: "proposal changed-byte total overflow".to_string(),
12187 })
12188 })?;
12189 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
12190 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12191 for operation in &operations {
12192 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
12193 return Err(invalid_feed("proposal upload operation has no kind"));
12194 };
12195 let hash = match kind {
12196 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
12197 "asset_put" | "asset_resume" => operation
12198 .get("asset")
12199 .and_then(|asset| asset.get("blob_sha256"))
12200 .and_then(Value::as_str),
12201 _ => None,
12202 };
12203 let Some(hash) = hash else { continue };
12204 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
12205 if kind == "rename" {
12206 for field in ["from", "to"] {
12207 coordinates.insert(
12208 operation
12209 .get(field)
12210 .and_then(Value::as_str)
12211 .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
12212 .to_string(),
12213 );
12214 }
12215 } else {
12216 let path = operation
12217 .get("path")
12218 .and_then(Value::as_str)
12219 .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
12220 coordinates.insert(if kind.starts_with("asset_") {
12221 format!("assets/{path}")
12222 } else {
12223 path.to_string()
12224 });
12225 }
12226 }
12227 let declarations = downloaded
12228 .iter()
12229 .map(|(sha256, bytes)| {
12230 json!({
12231 "sha256": sha256,
12232 "bytes": bytes.len(),
12233 "coordinates": coordinates_by_hash
12234 .get(sha256)
12235 .into_iter()
12236 .flatten()
12237 .collect::<Vec<_>>(),
12238 })
12239 })
12240 .collect::<Vec<_>>();
12241 let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
12242 for batch in batch_upload_declarations(declarations) {
12243 let reserved = reserve_upload_window(
12244 cfg,
12245 &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
12246 &json!({ "blobs": batch }),
12247 "prepare proposal blob transport",
12248 )?;
12249 let reserved_items = reserved
12250 .get("uploads")
12251 .and_then(Value::as_array)
12252 .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
12253 items.extend(reserved_items.iter().cloned());
12254 }
12255 if items.len() != downloaded.len() {
12256 return Err(invalid_feed("proposal upload reservation changed the set"));
12257 }
12258 let mut references = Vec::with_capacity(items.len());
12259 for item in items {
12260 let hash = item
12261 .get("sha256")
12262 .and_then(Value::as_str)
12263 .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
12264 let bytes = downloaded
12265 .get(hash)
12266 .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
12267 let reservation_id = item
12268 .get("reservation_id")
12269 .and_then(Value::as_str)
12270 .filter(|id| crate::ulid::is_ulid(id))
12271 .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
12272 let expected_coordinates = coordinates_by_hash
12273 .get(hash)
12274 .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
12275 let returned_coordinates = item
12276 .get("coordinates")
12277 .and_then(Value::as_array)
12278 .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
12279 if returned_coordinates.len() != expected_coordinates.len()
12280 || returned_coordinates
12281 .iter()
12282 .zip(expected_coordinates)
12283 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
12284 {
12285 return Err(invalid_feed(
12286 "proposal upload reservation changed its coordinates",
12287 ));
12288 }
12289 match item.get("status").and_then(Value::as_str) {
12290 Some("upload") => put_presigned(
12291 cfg,
12292 item.get("url")
12293 .and_then(Value::as_str)
12294 .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
12295 item.get("headers").unwrap_or(&Value::Null),
12296 bytes,
12297 )?,
12298 Some("already_present") => {}
12299 _ => return Err(invalid_feed("proposal upload status is invalid")),
12300 }
12301 references.push(json!({
12302 "sha256": hash,
12303 "bytes": bytes.len(),
12304 "reservation_id": reservation_id,
12305 }));
12306 }
12307 body["blobs"] = Value::Array(references);
12308 }
12309 stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
12313 let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
12314 let mut result = ensure_ok(
12315 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
12316 "exact proposal acceptance",
12317 )?;
12318 let mut candidate_hub_signer = None;
12319 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
12320 let request_id = result
12321 .get("request_id")
12322 .and_then(Value::as_str)
12323 .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
12324 .to_string();
12325 let challenge = result
12326 .get("signing_challenge")
12327 .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
12328 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
12329 cfg,
12330 &head,
12331 &expected_candidate,
12332 &expected_candidate_assets,
12333 mutation_id,
12334 &v2_signed_request_view(&body, &operations),
12335 challenge,
12336 )?;
12337 body["signing_challenge_id"] = Value::String(challenge_id);
12338 body["signature_base64url"] = Value::String(signature);
12339 candidate_hub_signer = Some(actor_signer);
12340 result = ensure_ok(
12341 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
12342 "signed exact proposal acceptance",
12343 )?;
12344 }
12345 let refreshed = v2_verified_head(cfg, brain)?
12346 .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
12347 if candidate_hub_signer
12348 .as_ref()
12349 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
12350 || refreshed
12351 .pointer
12352 .as_ref()
12353 .map(|pointer| pointer.commit_hash.as_str())
12354 != result.get("commit_hash").and_then(Value::as_str)
12355 || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12356 || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
12357 {
12358 return Err(LinkError::RemoteAdvancedDuringSync);
12359 }
12360 accept_v2_head(cfg, &refreshed)?;
12361 Ok(result)
12362}
12363
12364pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
12375 require_valid_handle(handle)?;
12376 if body.len() as u64 > MAX_PROPOSE_BYTES {
12377 return Err(LinkError::ProposeTooLarge {
12378 bytes: body.len() as u64,
12379 });
12380 }
12381 let payload = json!({ "app": app, "body": body });
12382 let (path, auth) = if crate::ulid::is_ulid(handle) {
12387 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
12388 } else {
12389 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
12390 };
12391 ensure_ok(
12392 request(cfg, "POST", &path, Some(&payload), auth)?,
12393 "propose",
12394 )
12395}
12396
12397#[derive(Debug, serde::Serialize)]
12403pub struct Head {
12404 pub brain: String,
12406 pub seq: u64,
12408 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
12410 pub updated_at: Option<String>,
12411 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
12413 pub feed_hash: Option<String>,
12414 pub verified: bool,
12417}
12418
12419struct BoundedVecVisitor<T, const MAX: usize> {
12420 label: &'static str,
12421 marker: std::marker::PhantomData<T>,
12422}
12423
12424impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
12425where
12426 T: Deserialize<'de>,
12427{
12428 type Value = Vec<T>;
12429
12430 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12431 write!(formatter, "at most {MAX} {}", self.label)
12432 }
12433
12434 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
12435 where
12436 A: serde::de::SeqAccess<'de>,
12437 {
12438 if sequence.size_hint().is_some_and(|size| size > MAX) {
12439 return Err(serde::de::Error::custom(format!(
12440 "{} exceeds the {MAX}-item limit",
12441 self.label
12442 )));
12443 }
12444 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
12445 while let Some(value) = sequence.next_element()? {
12446 if values.len() == MAX {
12447 return Err(serde::de::Error::custom(format!(
12448 "{} exceeds the {MAX}-item limit",
12449 self.label
12450 )));
12451 }
12452 values.push(value);
12453 }
12454 Ok(values)
12455 }
12456}
12457
12458fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
12459 deserializer: D,
12460 label: &'static str,
12461) -> Result<Vec<T>, D::Error>
12462where
12463 D: serde::Deserializer<'de>,
12464 T: Deserialize<'de>,
12465{
12466 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
12467 label,
12468 marker: std::marker::PhantomData,
12469 })
12470}
12471
12472fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
12473where
12474 D: serde::Deserializer<'de>,
12475{
12476 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
12477}
12478
12479fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12480where
12481 D: serde::Deserializer<'de>,
12482{
12483 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
12484}
12485
12486fn deserialize_previous_identities<'de, D>(
12487 deserializer: D,
12488) -> Result<Vec<PreviousIdentity>, D::Error>
12489where
12490 D: serde::Deserializer<'de>,
12491{
12492 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
12493 deserializer,
12494 "previous identities",
12495 )
12496}
12497
12498fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12499where
12500 D: serde::Deserializer<'de>,
12501{
12502 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
12503 deserializer,
12504 "rotation statements",
12505 )
12506}
12507
12508fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
12509where
12510 D: serde::Deserializer<'de>,
12511{
12512 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
12513}
12514
12515#[derive(Debug, Clone, Deserialize, Serialize)]
12516struct FeedFile {
12517 path: String,
12518 sha256: String,
12519 bytes: u64,
12520}
12521
12522#[cfg(test)]
12523#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12524enum V1DisclosureError {
12525 DuplicateFile,
12526 DuplicateRemoved,
12527 PushManifestMismatch,
12528 EditMissingChange,
12529 EditFalseFile,
12530 RemovedMismatch,
12531}
12532
12533#[cfg(test)]
12537fn verify_v1_manifest_disclosure(
12538 kind: &str,
12539 previous: &[FeedFile],
12540 resulting: &[FeedFile],
12541 files: &[FeedFile],
12542 removed: &[String],
12543) -> Result<(), V1DisclosureError> {
12544 fn as_map(
12545 files: &[FeedFile],
12546 ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
12547 let mut result = std::collections::BTreeMap::new();
12548 for file in files {
12549 if result
12550 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
12551 .is_some()
12552 {
12553 return Err(V1DisclosureError::DuplicateFile);
12554 }
12555 }
12556 Ok(result)
12557 }
12558 let previous = as_map(previous)?;
12559 let resulting = as_map(resulting)?;
12560 let disclosed = as_map(files)?;
12561 let removed_set: std::collections::BTreeSet<&str> =
12562 removed.iter().map(String::as_str).collect();
12563 if removed_set.len() != removed.len() {
12564 return Err(V1DisclosureError::DuplicateRemoved);
12565 }
12566 let expected_removed: std::collections::BTreeSet<&str> = previous
12567 .keys()
12568 .copied()
12569 .filter(|path| !resulting.contains_key(path))
12570 .collect();
12571 if removed_set != expected_removed {
12572 return Err(V1DisclosureError::RemovedMismatch);
12573 }
12574 if kind == "push" {
12575 return if disclosed == resulting {
12576 Ok(())
12577 } else {
12578 Err(V1DisclosureError::PushManifestMismatch)
12579 };
12580 }
12581 if kind != "edit" {
12582 return Err(V1DisclosureError::EditFalseFile);
12583 }
12584 if disclosed
12585 .iter()
12586 .any(|(path, value)| resulting.get(path) != Some(value))
12587 {
12588 return Err(V1DisclosureError::EditFalseFile);
12589 }
12590 for (path, value) in &resulting {
12591 if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
12592 return Err(V1DisclosureError::EditMissingChange);
12593 }
12594 }
12595 Ok(())
12596}
12597
12598#[derive(Debug, Clone, Deserialize, Serialize)]
12599struct FeedEntry {
12600 v: u8,
12601 seq: u64,
12602 ts: String,
12603 brain: String,
12604 public_key: String,
12605 kind: String,
12606 op: String,
12607 pack_sha256: String,
12608 #[serde(deserialize_with = "deserialize_feed_files")]
12609 files: Vec<FeedFile>,
12610 #[serde(deserialize_with = "deserialize_removed_paths")]
12611 removed: Vec<String>,
12612 prev_entry_hash: Option<String>,
12613 sig: String,
12614}
12615
12616#[derive(Serialize)]
12617struct UnsignedFeedEntry<'a> {
12618 v: u8,
12619 seq: u64,
12620 ts: &'a str,
12621 brain: &'a str,
12622 public_key: &'a str,
12623 kind: &'a str,
12624 op: &'a str,
12625 pack_sha256: &'a str,
12626 files: &'a [FeedFile],
12627 removed: &'a [String],
12628 prev_entry_hash: &'a Option<String>,
12629}
12630
12631#[derive(Debug, Clone, Deserialize, Serialize)]
12632struct FeedItem {
12633 hash: String,
12634 entry: FeedEntry,
12635}
12636
12637#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12638struct FeedIdentity {
12639 fingerprint: String,
12640 #[serde(rename = "publicKeySpki")]
12641 public_key_spki: String,
12642 #[serde(default, deserialize_with = "deserialize_previous_identities")]
12646 previous: Vec<PreviousIdentity>,
12647 #[serde(default, deserialize_with = "deserialize_rotations")]
12650 rotations: Vec<String>,
12651}
12652
12653#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12654struct PreviousIdentity {
12655 fingerprint: String,
12656 #[serde(rename = "publicKeySpki")]
12657 public_key_spki: String,
12658}
12659
12660#[derive(Debug, Deserialize)]
12661struct FeedResponse {
12662 #[serde(rename = "headSeq")]
12663 head_seq: u64,
12664 #[serde(rename = "feedHash")]
12665 feed_hash: Option<String>,
12666 identity: Option<FeedIdentity>,
12667 #[serde(deserialize_with = "deserialize_feed_items")]
12668 entries: Vec<FeedItem>,
12669 #[serde(rename = "scopeLimited")]
12670 scope_limited: bool,
12671}
12672
12673#[derive(Debug, Deserialize, Serialize)]
12674#[serde(deny_unknown_fields)]
12675struct RotationStatement {
12676 v: u8,
12677 op: String,
12678 brain: String,
12679 public_key: String,
12680 new_brain: String,
12681 new_public_key: String,
12682 prior_head_seq: u64,
12683 prior_feed_hash: Option<String>,
12684 ts: String,
12685 sig: String,
12686}
12687
12688#[derive(Debug, Clone, Deserialize, Serialize)]
12689struct TrustState {
12690 v: u8,
12691 origin: String,
12692 #[serde(default)]
12696 requested: String,
12697 brain: String,
12699 #[serde(default, skip_serializing_if = "Option::is_none")]
12702 home: Option<String>,
12703 anchor: String,
12704 current: String,
12705 #[serde(rename = "headSeq")]
12706 head_seq: u64,
12707 #[serde(rename = "feedHash")]
12708 feed_hash: Option<String>,
12709 #[serde(default)]
12713 rotations: Vec<String>,
12714 #[serde(default, skip_serializing_if = "Option::is_none")]
12717 hub_signer: Option<String>,
12718 #[serde(default, skip_serializing_if = "Option::is_none")]
12721 protocol_profile: Option<String>,
12722}
12723
12724fn accepted_as_v2(state: &TrustState) -> bool {
12725 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
12726}
12727
12728fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
12729 let directory = open_trust_dir(cfg)?;
12730 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
12731 return Ok(true);
12732 }
12733 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
12734 return Ok(false);
12735 };
12736 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
12737}
12738
12739#[derive(Debug, Clone, Deserialize, Serialize)]
12740struct AliasBinding {
12741 v: u8,
12742 origin: String,
12743 requested: String,
12744 brain: String,
12745 #[serde(default, skip_serializing_if = "Option::is_none")]
12746 home: Option<String>,
12747}
12748
12749struct VerifiedRemote {
12750 head: Head,
12751 identity: Option<FeedIdentity>,
12752 head_entry: Option<FeedItem>,
12753 entries: Vec<FeedItem>,
12755 anchor: Option<String>,
12756}
12757
12758fn invalid_feed(message: impl Into<String>) -> LinkError {
12759 LinkError::InvalidFeed {
12760 message: message.into(),
12761 }
12762}
12763
12764fn is_sha256(value: &str) -> bool {
12765 value.len() == 64
12766 && value
12767 .bytes()
12768 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
12769}
12770
12771fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
12772 let der = URL_SAFE_NO_PAD
12773 .decode(public_key_spki)
12774 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
12775 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
12776 return Err(invalid_feed(
12777 "identity public key is not a valid Ed25519 SPKI",
12778 ));
12779 }
12780 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
12781}
12782
12783fn verify_identity_chain(
12787 identity: &FeedIdentity,
12788 pinned: Option<&TrustState>,
12789) -> LinkResult<String> {
12790 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
12791 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
12792 {
12793 return Err(invalid_feed(
12794 "identity rotation history exceeds the client cap",
12795 ));
12796 }
12797 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
12798 return Err(invalid_feed(
12799 "current identity fingerprint does not match its public key",
12800 ));
12801 }
12802 for previous in &identity.previous {
12803 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
12804 return Err(invalid_feed(
12805 "previous identity fingerprint does not match its public key",
12806 ));
12807 }
12808 }
12809 if identity.rotations.len() != identity.previous.len() {
12810 return Err(invalid_feed(
12811 "identity history is missing an old-key-signed rotation statement",
12812 ));
12813 }
12814
12815 let mut chain: Vec<(&str, &str)> = identity
12819 .previous
12820 .iter()
12821 .rev()
12822 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
12823 .collect();
12824 chain.push((&identity.fingerprint, &identity.public_key_spki));
12825
12826 for (index, raw) in identity.rotations.iter().enumerate() {
12827 let statement: RotationStatement = serde_json::from_str(raw)
12828 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
12829 let (old_fingerprint, old_spki) = chain[index];
12830 let (new_fingerprint, new_spki) = chain[index + 1];
12831 if statement.v != 1
12832 || statement.op != "rotate"
12833 || statement.brain != format!("ed25519:{old_fingerprint}")
12834 || statement.public_key != old_spki
12835 || statement.new_brain != format!("ed25519:{new_fingerprint}")
12836 || statement.new_public_key != new_spki
12837 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
12838 || (statement.prior_head_seq > 0
12839 && statement
12840 .prior_feed_hash
12841 .as_deref()
12842 .is_none_or(|hash| !is_sha256(hash)))
12843 {
12844 return Err(invalid_feed(
12845 "rotation statement does not connect adjacent identities",
12846 ));
12847 }
12848 let unsigned = serde_json::to_string(&UnsignedRotation {
12849 v: statement.v,
12850 op: &statement.op,
12851 brain: &statement.brain,
12852 public_key: &statement.public_key,
12853 new_brain: &statement.new_brain,
12854 new_public_key: &statement.new_public_key,
12855 prior_head_seq: statement.prior_head_seq,
12856 prior_feed_hash: statement.prior_feed_hash.as_deref(),
12857 ts: statement.ts.clone(),
12858 })
12859 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
12860 let exact = format!(
12861 "{},\"sig\":\"{}\"}}",
12862 &unsigned[..unsigned.len() - 1],
12863 statement.sig
12864 );
12865 if exact != *raw {
12866 return Err(invalid_feed(
12867 "rotation statement is not in normative serialization",
12868 ));
12869 }
12870 let der = URL_SAFE_NO_PAD
12871 .decode(old_spki)
12872 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
12873 let signature = URL_SAFE_NO_PAD
12874 .decode(&statement.sig)
12875 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
12876 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
12877 .verify(unsigned.as_bytes(), &signature)
12878 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
12879 if index > 0 {
12880 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
12881 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
12882 if statement.prior_head_seq < prior.prior_head_seq {
12883 return Err(invalid_feed("rotation feed boundaries move backward"));
12884 }
12885 }
12886 }
12887
12888 let anchor = format!("ed25519:{}", chain[0].0);
12889 let current = format!("ed25519:{}", identity.fingerprint);
12890 if let Some(pin) = pinned {
12891 if pin.anchor != anchor {
12892 return Err(invalid_feed(
12893 "served identity chain does not descend from the pinned anchor",
12894 ));
12895 }
12896 if !chain
12897 .iter()
12898 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12899 {
12900 return Err(invalid_feed(
12901 "served identity chain forked away from the last pinned identity",
12902 ));
12903 }
12904 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
12905 return Err(invalid_feed("served identity discarded its rotation chain"));
12906 }
12907 if pin.v >= 2
12908 && (identity.rotations.len() < pin.rotations.len()
12909 || identity.rotations[..pin.rotations.len()] != pin.rotations)
12910 {
12911 return Err(invalid_feed(
12912 "served identity rewrote the locally accepted rotation history",
12913 ));
12914 }
12915 }
12916 Ok(anchor)
12917}
12918
12919fn verify_rotation_feed_boundaries(
12920 identity: &FeedIdentity,
12921 pinned: Option<&TrustState>,
12922 observed: &[FeedItem],
12923 advertised_seq: u64,
12924) -> LinkResult<()> {
12925 let mut chain: Vec<String> = identity
12926 .previous
12927 .iter()
12928 .rev()
12929 .map(|previous| format!("ed25519:{}", previous.fingerprint))
12930 .collect();
12931 chain.push(format!("ed25519:{}", identity.fingerprint));
12932 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
12933
12934 for (index, raw) in identity.rotations.iter().enumerate() {
12935 let rotation: RotationStatement = serde_json::from_str(raw)
12936 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12937 if rotation.prior_head_seq > advertised_seq {
12938 return Err(invalid_feed(
12939 "rotation claims a feed boundary beyond the advertised head",
12940 ));
12941 }
12942 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
12943 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
12944 return Err(invalid_feed(
12945 "newly disclosed rotation predates the local feed checkpoint",
12946 ));
12947 }
12948 }
12949 let actual = if rotation.prior_head_seq == 0 {
12950 None
12951 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
12952 pinned.and_then(|pin| pin.feed_hash.as_deref())
12953 } else {
12954 observed
12955 .iter()
12956 .find(|item| item.entry.seq == rotation.prior_head_seq)
12957 .map(|item| item.hash.as_str())
12958 };
12959 if let Some(actual) = actual {
12960 if rotation.prior_feed_hash.as_deref() != Some(actual) {
12961 return Err(invalid_feed(
12962 "rotation statement does not commit the verified feed boundary",
12963 ));
12964 }
12965 } else if rotation.prior_head_seq == 0 {
12966 } else if pinned.is_some_and(|pin| {
12969 pinned_index.is_some_and(|pin_index| index >= pin_index)
12970 || rotation.prior_head_seq >= pin.head_seq
12971 }) {
12972 return Err(invalid_feed(
12973 "rotation feed boundary was not present in the verified chain",
12974 ));
12975 }
12976 }
12977 Ok(())
12978}
12979
12980fn reject_retired_signer_after_checkpoint(
12985 identity: &FeedIdentity,
12986 pinned: Option<&TrustState>,
12987 item: &FeedItem,
12988) -> LinkResult<()> {
12989 let Some(pin) = pinned else {
12990 return Ok(());
12991 };
12992 if item.entry.seq <= pin.head_seq {
12993 return Ok(());
12994 }
12995 let mut chain: Vec<String> = identity
12996 .previous
12997 .iter()
12998 .rev()
12999 .map(|previous| format!("ed25519:{}", previous.fingerprint))
13000 .collect();
13001 chain.push(format!("ed25519:{}", identity.fingerprint));
13002 let pinned_index = chain
13003 .iter()
13004 .position(|key| key == &pin.current)
13005 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
13006 let signer_index = chain
13007 .iter()
13008 .position(|key| key == &item.entry.brain)
13009 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
13010 if signer_index < pinned_index {
13011 return Err(invalid_feed(
13012 "a retired identity attempted to sign after the local checkpoint",
13013 ));
13014 }
13015 Ok(())
13016}
13017
13018fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
13019 let origin = normalized_origin(&cfg.hub)?;
13020 let key = format!(
13021 "{:x}",
13022 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
13023 );
13024 Ok(format!("{key}.json"))
13025}
13026
13027fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
13028 let origin = normalized_origin(&cfg.hub)?;
13029 let key = format!(
13030 "{:x}",
13031 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
13032 );
13033 Ok(format!("alias-{key}.json"))
13034}
13035
13036#[cfg(any(unix, windows))]
13037struct TrustLock {
13038 _file: std::fs::File,
13039}
13040
13041#[cfg(unix)]
13042fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13043 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13044
13045 let lock_string = format!(".{state_name}.lock");
13046 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
13047 let fd = unsafe {
13048 libc::openat(
13049 directory.as_raw_fd(),
13050 lock_name.as_ptr(),
13051 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13052 0o600,
13053 )
13054 };
13055 if fd < 0 {
13056 return Err(std::io::Error::last_os_error().into());
13057 }
13058 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13059 if !file.metadata()?.is_file() {
13060 return Err(LinkError::UnsafePath { path: lock_string });
13061 }
13062 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
13063 return Err(std::io::Error::last_os_error().into());
13064 }
13065 Ok(TrustLock { _file: file })
13066}
13067
13068#[cfg(windows)]
13069fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13070 let lock_name = format!(".{state_name}.lock");
13071 let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
13072 Ok(TrustLock { _file: file })
13073}
13074
13075#[cfg(any(unix, windows))]
13076fn lock_trust_many(
13077 cfg: &HubConfig,
13078 directory: &std::fs::File,
13079 refs: &[&str],
13080) -> LinkResult<Vec<TrustLock>> {
13081 let mut names = refs
13082 .iter()
13083 .map(|reference| trust_file_name(cfg, reference))
13084 .collect::<LinkResult<Vec<_>>>()?;
13085 names.sort();
13086 names.dedup();
13087 names
13088 .iter()
13089 .map(|name| lock_trust_name(directory, name))
13090 .collect()
13091}
13092
13093#[cfg(not(any(unix, windows)))]
13094fn lock_trust_many(
13095 _cfg: &HubConfig,
13096 _directory: &TrustDirectory,
13097 _refs: &[&str],
13098) -> LinkResult<Vec<()>> {
13099 Err(LinkError::UnsupportedPlatform {
13100 operation: "verified link.md state",
13101 })
13102}
13103
13104#[cfg(any(unix, windows))]
13105type TrustDirectory = std::fs::File;
13106
13107#[cfg(not(any(unix, windows)))]
13108struct TrustDirectory;
13109
13110#[cfg(unix)]
13111fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13112 use std::os::fd::AsRawFd as _;
13113
13114 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
13115 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
13116 return Err(std::io::Error::last_os_error().into());
13117 }
13118 directory.sync_all()?;
13119 Ok(directory)
13120}
13121
13122#[cfg(windows)]
13123fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13124 let marker = cfg.state_dir.join("trust").join(".directory");
13125 crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
13126 Ok(crate::fsx::open_directory_nofollow(
13127 marker.parent().expect("trust marker has a parent"),
13128 )?)
13129}
13130
13131#[cfg(not(any(unix, windows)))]
13132fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13133 Err(LinkError::UnsupportedPlatform {
13134 operation: "verified link.md state",
13135 })
13136}
13137
13138#[cfg(unix)]
13139fn load_trust_in(
13140 cfg: &HubConfig,
13141 directory: &TrustDirectory,
13142 requested: &str,
13143) -> LinkResult<Option<TrustState>> {
13144 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13145
13146 let name_string = trust_file_name(cfg, requested)?;
13147 let name = c_name(name_string.as_bytes(), &name_string)?;
13148 let fd = unsafe {
13149 libc::openat(
13150 directory.as_raw_fd(),
13151 name.as_ptr(),
13152 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13153 )
13154 };
13155 if fd < 0 {
13156 let error = std::io::Error::last_os_error();
13157 if error.kind() == std::io::ErrorKind::NotFound {
13158 return Ok(None);
13159 }
13160 return Err(LinkError::UnsafePath { path: name_string });
13161 }
13162 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13163 if !file.metadata()?.is_file() {
13164 return Err(LinkError::UnsafePath { path: name_string });
13165 }
13166 let mut bytes = Vec::new();
13167 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
13168 if bytes.len() > 1024 * 1024 {
13169 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
13170 }
13171 let mut state: TrustState = serde_json::from_slice(&bytes)
13172 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13173 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13174 return Err(invalid_feed(
13175 "local identity/feed checkpoint does not match this hub and brain",
13176 ));
13177 }
13178 if state.v == 1 {
13179 if state.brain != requested {
13183 return Err(invalid_feed(
13184 "legacy checkpoint is not bound to the requested brain id",
13185 ));
13186 }
13187 state.requested = requested.to_string();
13188 } else if state.requested != requested {
13189 return Err(invalid_feed(
13190 "local identity/feed checkpoint is bound to a different requested ref",
13191 ));
13192 }
13193 Ok(Some(state))
13194}
13195
13196#[cfg(windows)]
13197fn load_trust_in(
13198 cfg: &HubConfig,
13199 directory: &TrustDirectory,
13200 requested: &str,
13201) -> LinkResult<Option<TrustState>> {
13202 let name = trust_file_name(cfg, requested)?;
13203 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13204 let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
13205 Ok(bytes) => bytes,
13206 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13207 Err(_) => return Err(LinkError::UnsafePath { path: name }),
13208 };
13209 let mut state: TrustState = serde_json::from_slice(&bytes)
13210 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13211 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13212 return Err(invalid_feed(
13213 "local identity/feed checkpoint does not match this hub and brain",
13214 ));
13215 }
13216 if state.v == 1 {
13217 if state.brain != requested {
13218 return Err(invalid_feed(
13219 "legacy checkpoint is not bound to the requested brain id",
13220 ));
13221 }
13222 state.requested = requested.to_string();
13223 } else if state.requested != requested {
13224 return Err(invalid_feed(
13225 "local identity/feed checkpoint is bound to a different requested ref",
13226 ));
13227 }
13228 Ok(Some(state))
13229}
13230
13231#[cfg(not(any(unix, windows)))]
13232fn load_trust_in(
13233 _cfg: &HubConfig,
13234 _directory: &TrustDirectory,
13235 _brain: &str,
13236) -> LinkResult<Option<TrustState>> {
13237 Err(LinkError::UnsupportedPlatform {
13238 operation: "verified link.md state",
13239 })
13240}
13241
13242#[cfg(all(test, any(unix, windows)))]
13243fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
13244 let directory = open_trust_dir(cfg)?;
13245 load_trust_in(cfg, &directory, requested)
13246}
13247
13248#[cfg(unix)]
13249fn save_trust_in(
13250 cfg: &HubConfig,
13251 directory: &TrustDirectory,
13252 state: &TrustState,
13253) -> LinkResult<()> {
13254 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13255
13256 let name_string = trust_file_name(cfg, &state.requested)?;
13257 let name = c_name(name_string.as_bytes(), &name_string)?;
13258 let mut bytes = serde_json::to_vec(state)
13259 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13260 bytes.push(b'\n');
13261
13262 let nonce = std::time::SystemTime::now()
13263 .duration_since(std::time::UNIX_EPOCH)
13264 .unwrap_or_default()
13265 .as_nanos();
13266 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13267 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13268 let fd = unsafe {
13269 libc::openat(
13270 directory.as_raw_fd(),
13271 temp.as_ptr(),
13272 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13273 0o600,
13274 )
13275 };
13276 if fd < 0 {
13277 return Err(std::io::Error::last_os_error().into());
13278 }
13279 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13280 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13281 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13282 return Err(error.into());
13283 }
13284 drop(file);
13285 if unsafe {
13286 libc::renameat(
13287 directory.as_raw_fd(),
13288 temp.as_ptr(),
13289 directory.as_raw_fd(),
13290 name.as_ptr(),
13291 )
13292 } != 0
13293 {
13294 let error = std::io::Error::last_os_error();
13295 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13296 return Err(error.into());
13297 }
13298 directory.sync_all()?;
13299 Ok(())
13300}
13301
13302#[cfg(windows)]
13303fn save_trust_in(
13304 cfg: &HubConfig,
13305 directory: &TrustDirectory,
13306 state: &TrustState,
13307) -> LinkResult<()> {
13308 let name = trust_file_name(cfg, &state.requested)?;
13309 let mut bytes = serde_json::to_vec(state)
13310 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13311 bytes.push(b'\n');
13312 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13313 Ok(())
13314}
13315
13316#[cfg(not(any(unix, windows)))]
13317fn save_trust_in(
13318 _cfg: &HubConfig,
13319 _directory: &TrustDirectory,
13320 _state: &TrustState,
13321) -> LinkResult<()> {
13322 Err(LinkError::UnsupportedPlatform {
13323 operation: "verified link.md state",
13324 })
13325}
13326
13327#[cfg(unix)]
13328fn load_alias_in(
13329 cfg: &HubConfig,
13330 directory: &TrustDirectory,
13331 requested: &str,
13332) -> LinkResult<Option<AliasBinding>> {
13333 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13334
13335 let name_string = alias_file_name(cfg, requested)?;
13336 let name = c_name(name_string.as_bytes(), &name_string)?;
13337 let fd = unsafe {
13338 libc::openat(
13339 directory.as_raw_fd(),
13340 name.as_ptr(),
13341 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13342 )
13343 };
13344 if fd < 0 {
13345 let error = std::io::Error::last_os_error();
13346 if error.kind() == std::io::ErrorKind::NotFound {
13347 return Ok(None);
13348 }
13349 return Err(LinkError::UnsafePath { path: name_string });
13350 }
13351 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13352 if !file.metadata()?.is_file() {
13353 return Err(LinkError::UnsafePath { path: name_string });
13354 }
13355 let mut bytes = Vec::new();
13356 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
13357 if bytes.len() > 64 * 1024 {
13358 return Err(invalid_feed("local alias binding is oversized"));
13359 }
13360 let alias: AliasBinding = serde_json::from_slice(&bytes)
13361 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13362 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13363 {
13364 return Err(invalid_feed(
13365 "local alias binding does not match this hub and requested ref",
13366 ));
13367 }
13368 Ok(Some(alias))
13369}
13370
13371#[cfg(windows)]
13372fn load_alias_in(
13373 cfg: &HubConfig,
13374 directory: &TrustDirectory,
13375 requested: &str,
13376) -> LinkResult<Option<AliasBinding>> {
13377 let name = alias_file_name(cfg, requested)?;
13378 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13379 let bytes = match reader.read(Path::new(&name), 64 * 1024) {
13380 Ok(bytes) => bytes,
13381 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13382 Err(_) => return Err(LinkError::UnsafePath { path: name }),
13383 };
13384 let alias: AliasBinding = serde_json::from_slice(&bytes)
13385 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13386 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13387 {
13388 return Err(invalid_feed(
13389 "local alias binding does not match this hub and requested ref",
13390 ));
13391 }
13392 Ok(Some(alias))
13393}
13394
13395#[cfg(not(any(unix, windows)))]
13396fn load_alias_in(
13397 _cfg: &HubConfig,
13398 _directory: &TrustDirectory,
13399 _requested: &str,
13400) -> LinkResult<Option<AliasBinding>> {
13401 Err(LinkError::UnsupportedPlatform {
13402 operation: "verified link.md state",
13403 })
13404}
13405
13406#[cfg(unix)]
13407fn save_alias_in(
13408 cfg: &HubConfig,
13409 directory: &TrustDirectory,
13410 alias: &AliasBinding,
13411) -> LinkResult<()> {
13412 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13413
13414 let name_string = alias_file_name(cfg, &alias.requested)?;
13415 let name = c_name(name_string.as_bytes(), &name_string)?;
13416 let mut bytes = serde_json::to_vec(alias)
13417 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13418 bytes.push(b'\n');
13419 let nonce = std::time::SystemTime::now()
13420 .duration_since(std::time::UNIX_EPOCH)
13421 .unwrap_or_default()
13422 .as_nanos();
13423 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13424 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13425 let fd = unsafe {
13426 libc::openat(
13427 directory.as_raw_fd(),
13428 temp.as_ptr(),
13429 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13430 0o600,
13431 )
13432 };
13433 if fd < 0 {
13434 return Err(std::io::Error::last_os_error().into());
13435 }
13436 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13437 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13438 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13439 return Err(error.into());
13440 }
13441 drop(file);
13442 if unsafe {
13443 libc::renameat(
13444 directory.as_raw_fd(),
13445 temp.as_ptr(),
13446 directory.as_raw_fd(),
13447 name.as_ptr(),
13448 )
13449 } != 0
13450 {
13451 let error = std::io::Error::last_os_error();
13452 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13453 return Err(error.into());
13454 }
13455 directory.sync_all()?;
13456 Ok(())
13457}
13458
13459#[cfg(windows)]
13460fn save_alias_in(
13461 cfg: &HubConfig,
13462 directory: &TrustDirectory,
13463 alias: &AliasBinding,
13464) -> LinkResult<()> {
13465 let name = alias_file_name(cfg, &alias.requested)?;
13466 let mut bytes = serde_json::to_vec(alias)
13467 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13468 bytes.push(b'\n');
13469 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13470 Ok(())
13471}
13472
13473#[cfg(not(any(unix, windows)))]
13474fn save_alias_in(
13475 _cfg: &HubConfig,
13476 _directory: &TrustDirectory,
13477 _alias: &AliasBinding,
13478) -> LinkResult<()> {
13479 Err(LinkError::UnsupportedPlatform {
13480 operation: "verified link.md state",
13481 })
13482}
13483
13484fn load_canonical_pin(
13489 cfg: &HubConfig,
13490 directory: &TrustDirectory,
13491 requested: &str,
13492 resolved_brain: &str,
13493) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
13494 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
13495 if requested == resolved_brain {
13496 return Ok((canonical, None));
13497 }
13498
13499 let mut alias = load_alias_in(cfg, directory, requested)?;
13500 if let Some(binding) = &alias {
13501 if binding.brain != resolved_brain {
13502 return Err(LinkError::AliasRebindRequired {
13503 alias: requested.to_string(),
13504 from: binding.brain.clone(),
13505 to: resolved_brain.to_string(),
13506 });
13507 }
13508 return Ok((canonical, alias));
13509 }
13510
13511 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
13515 if legacy.brain != resolved_brain {
13516 return Err(invalid_feed(
13517 "legacy alias checkpoint names a different canonical brain",
13518 ));
13519 }
13520 if let Some(existing) = &canonical {
13521 if existing.brain != legacy.brain
13522 || existing.anchor != legacy.anchor
13523 || existing.current != legacy.current
13524 || existing.head_seq != legacy.head_seq
13525 || existing.feed_hash != legacy.feed_hash
13526 || existing.rotations != legacy.rotations
13527 {
13528 return Err(invalid_feed(
13529 "legacy alias checkpoint conflicts with the canonical checkpoint",
13530 ));
13531 }
13532 } else {
13533 let mut promoted = legacy.clone();
13534 promoted.requested = resolved_brain.to_string();
13535 promoted.home = None;
13536 save_trust_in(cfg, directory, &promoted)?;
13537 canonical = Some(promoted);
13538 }
13539 alias = Some(AliasBinding {
13540 v: 1,
13541 origin: normalized_origin(&cfg.hub)?,
13542 requested: requested.to_string(),
13543 brain: resolved_brain.to_string(),
13544 home: legacy.home,
13545 });
13546 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
13547 }
13548 Ok((canonical, alias))
13549}
13550
13551pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
13556 require_hardened_filesystem("verified alias rebind")?;
13557 require_safe_ref(alias)?;
13558 require_safe_ref(from)?;
13559 require_safe_ref(to)?;
13560 if crate::ulid::is_ulid(alias)
13561 || !crate::ulid::is_ulid(from)
13562 || !crate::ulid::is_ulid(to)
13563 || from == to
13564 {
13565 return Err(LinkError::InvalidPack {
13566 message:
13567 "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
13568 .to_string(),
13569 });
13570 }
13571
13572 let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
13573 message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
13574 })?;
13575 accept_v2_head(cfg, &verified)?;
13576
13577 let alias_response = ensure_ok(
13578 request(
13579 cfg,
13580 "GET",
13581 &format!("/api/hub/brains/{alias}/v2/head"),
13582 None,
13583 Auth::Required,
13584 )?,
13585 "resolve alias for explicit rebind",
13586 )?;
13587 let resolved: V2HeadResponse = serde_json::from_value(alias_response)
13588 .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
13589 if resolved.v != 2 || resolved.brain_id != to {
13590 return Err(LinkError::RemoteAdvancedDuringSync);
13591 }
13592
13593 let directory = open_trust_dir(cfg)?;
13594 let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
13595 let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
13596 message: "the requested alias has no existing local binding to replace".to_string(),
13597 })?;
13598 if binding.brain != from {
13599 return Err(LinkError::AliasRebindRequired {
13600 alias: alias.to_string(),
13601 from: binding.brain,
13602 to: to.to_string(),
13603 });
13604 }
13605 save_alias_in(
13606 cfg,
13607 &directory,
13608 &AliasBinding {
13609 v: 1,
13610 origin: normalized_origin(&cfg.hub)?,
13611 requested: alias.to_string(),
13612 brain: to.to_string(),
13613 home: binding.home,
13614 },
13615 )?;
13616 Ok(json!({
13617 "v": 2,
13618 "alias": alias,
13619 "from": from,
13620 "to": to,
13621 "outcome": "alias_rebound",
13622 }))
13623}
13624
13625fn save_canonical_pin_and_alias(
13626 cfg: &HubConfig,
13627 directory: &TrustDirectory,
13628 requested: &str,
13629 resolved_brain: &str,
13630 mut state: TrustState,
13631 existing_alias: Option<&AliasBinding>,
13632) -> LinkResult<()> {
13633 state.requested = resolved_brain.to_string();
13634 state.brain = resolved_brain.to_string();
13635 state.home = None;
13636 save_trust_in(cfg, directory, &state)?;
13637 if requested != resolved_brain {
13638 save_alias_in(
13639 cfg,
13640 directory,
13641 &AliasBinding {
13642 v: 1,
13643 origin: normalized_origin(&cfg.hub)?,
13644 requested: requested.to_string(),
13645 brain: resolved_brain.to_string(),
13646 home: existing_alias.and_then(|alias| alias.home.clone()),
13647 },
13648 )?;
13649 }
13650 Ok(())
13651}
13652
13653fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
13654 const ED25519_SPKI_PREFIX: &[u8] = &[
13655 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13656 ];
13657 let entry = &item.entry;
13658 let public_der = URL_SAFE_NO_PAD
13659 .decode(&entry.public_key)
13660 .map_err(|_| invalid_feed("public key is not base64url"))?;
13661 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
13662 || !public_der.starts_with(ED25519_SPKI_PREFIX)
13663 {
13664 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
13665 }
13666 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
13667 if entry.brain != format!("ed25519:{fingerprint}") {
13668 return Err(invalid_feed(
13669 "brain fingerprint does not match its public key",
13670 ));
13671 }
13672 let _ = verify_identity_chain(identity, None)?;
13674 let mut chain: Vec<(&str, &str)> = identity
13675 .previous
13676 .iter()
13677 .rev()
13678 .map(|previous| {
13679 (
13680 previous.fingerprint.as_str(),
13681 previous.public_key_spki.as_str(),
13682 )
13683 })
13684 .collect();
13685 chain.push((&identity.fingerprint, &identity.public_key_spki));
13686 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
13687 *known_fingerprint == fingerprint && *spki == entry.public_key
13688 });
13689 let Some(signer_index) = signer_index else {
13690 return Err(invalid_feed(
13691 "entry signer is not this brain's identity (current or rotated-from)",
13692 ));
13693 };
13694 let lower_boundary = if signer_index == 0 {
13695 None
13696 } else {
13697 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
13698 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13699 Some(prior.prior_head_seq)
13700 };
13701 let upper_boundary = if signer_index == identity.rotations.len() {
13702 None
13703 } else {
13704 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
13705 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13706 Some(next.prior_head_seq)
13707 };
13708 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
13709 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
13710 {
13711 return Err(invalid_feed(
13712 "entry signer is outside its authenticated rotation epoch",
13713 ));
13714 }
13715 let unsigned = UnsignedFeedEntry {
13716 v: entry.v,
13717 seq: entry.seq,
13718 ts: &entry.ts,
13719 brain: &entry.brain,
13720 public_key: &entry.public_key,
13721 kind: &entry.kind,
13722 op: &entry.op,
13723 pack_sha256: &entry.pack_sha256,
13724 files: &entry.files,
13725 removed: &entry.removed,
13726 prev_entry_hash: &entry.prev_entry_hash,
13727 };
13728 let message =
13729 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
13730 let signature = URL_SAFE_NO_PAD
13731 .decode(&entry.sig)
13732 .map_err(|_| invalid_feed("signature is not base64url"))?;
13733 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
13734 .verify(&message, &signature)
13735 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
13736
13737 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
13738 exact.push(b'\n');
13739 let actual_hash = format!("{:x}", Sha256::digest(&exact));
13740 if actual_hash != item.hash {
13741 return Err(invalid_feed("entry SHA-256 does not match"));
13742 }
13743 Ok(())
13744}
13745
13746#[derive(Serialize)]
13752struct UnsignedRotation<'a> {
13753 v: u8,
13754 op: &'a str,
13755 brain: &'a str,
13756 public_key: &'a str,
13757 new_brain: &'a str,
13758 new_public_key: &'a str,
13759 prior_head_seq: u64,
13760 prior_feed_hash: Option<&'a str>,
13761 ts: String,
13762}
13763
13764#[derive(Debug, Deserialize, Serialize)]
13769#[serde(deny_unknown_fields)]
13770struct RotationJournal {
13771 v: u8,
13772 origin: String,
13773 brain: String,
13774 old_brain: String,
13775 new_brain: String,
13776 prior_head_seq: u64,
13777 prior_feed_hash: Option<String>,
13778 statement: String,
13779}
13780
13781fn rotation_journal_path(key_path: &Path) -> PathBuf {
13782 let mut path = key_path.as_os_str().to_os_string();
13783 path.push(".rotation.json");
13784 PathBuf::from(path)
13785}
13786
13787fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
13788 #[cfg(unix)]
13789 let file = {
13790 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13791 use std::os::unix::ffi::OsStrExt as _;
13792 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13793 .map_err(|error| {
13794 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
13795 })?;
13796 let leaf_name = path
13797 .file_name()
13798 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
13799 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
13800 let fd = unsafe {
13801 libc::openat(
13802 parent.as_raw_fd(),
13803 leaf.as_ptr(),
13804 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13805 )
13806 };
13807 if fd < 0 {
13808 return Err(bad_agent_key(
13809 "the rotation journal must be an existing regular file without symlink ancestors",
13810 ));
13811 }
13812 unsafe { std::fs::File::from_raw_fd(fd) }
13813 };
13814 #[cfg(not(unix))]
13815 let file = std::fs::File::open(path)
13816 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
13817 let metadata = file
13818 .metadata()
13819 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
13820 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
13821 return Err(bad_agent_key(
13822 "the rotation journal must be a bounded regular file",
13823 ));
13824 }
13825 #[cfg(unix)]
13826 {
13827 use std::os::unix::fs::PermissionsExt as _;
13828 if metadata.permissions().mode() & 0o077 != 0 {
13829 return Err(bad_agent_key(
13830 "the rotation journal is accessible to group/other; set mode 0600",
13831 ));
13832 }
13833 }
13834 serde_json::from_reader(file)
13835 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
13836}
13837
13838fn remove_rotation_journal(path: &Path) {
13839 #[cfg(unix)]
13840 {
13841 use std::os::fd::AsRawFd as _;
13842 use std::os::unix::ffi::OsStrExt as _;
13843 let Ok(parent) =
13844 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13845 else {
13846 return;
13847 };
13848 let Some(leaf_name) = path.file_name() else {
13849 return;
13850 };
13851 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
13852 return;
13853 };
13854 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
13855 let _ = parent.sync_all();
13856 }
13857 }
13858 #[cfg(not(unix))]
13859 {
13860 let _ = std::fs::remove_file(path);
13861 }
13862}
13863
13864fn validate_rotation_journal(
13865 journal: &RotationJournal,
13866 cfg: &HubConfig,
13867 canonical_brain: &str,
13868 old_key: &AgentSigningKey,
13869 new_key: &AgentSigningKey,
13870 head: &Head,
13871) -> LinkResult<()> {
13872 if journal.v != 1
13873 || journal.origin != normalized_origin(&cfg.hub)?
13874 || journal.brain != canonical_brain
13875 || journal.old_brain != old_key.multikey
13876 || journal.new_brain != new_key.multikey
13877 || journal.prior_head_seq != head.seq
13878 || journal.prior_feed_hash != head.feed_hash
13879 {
13880 return Err(invalid_feed(
13881 "rotation journal does not match the verified key and feed boundary",
13882 ));
13883 }
13884 let statement: RotationStatement = serde_json::from_str(&journal.statement)
13885 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
13886 if statement.prior_head_seq != journal.prior_head_seq
13887 || statement.prior_feed_hash != journal.prior_feed_hash
13888 || statement.brain != old_key.multikey
13889 || statement.public_key != old_key.public_key_spki
13890 || statement.new_brain != new_key.multikey
13891 || statement.new_public_key != new_key.public_key_spki
13892 {
13893 return Err(invalid_feed(
13894 "rotation journal statement does not match its durable intent",
13895 ));
13896 }
13897 let identity = FeedIdentity {
13898 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13899 public_key_spki: new_key.public_key_spki.clone(),
13900 previous: vec![PreviousIdentity {
13901 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
13902 public_key_spki: old_key.public_key_spki.clone(),
13903 }],
13904 rotations: vec![journal.statement.clone()],
13905 };
13906 verify_identity_chain(&identity, None)?;
13907 Ok(())
13908}
13909
13910#[derive(Debug, Serialize)]
13912pub struct RotationReport {
13913 pub brain: String,
13915 pub multikey: String,
13917 #[serde(rename = "keyFile")]
13919 pub key_file: String,
13920 pub previous: Vec<String>,
13922}
13923
13924pub fn rotate_brain_key(
13930 cfg: &HubConfig,
13931 brain: &str,
13932 old_key: &AgentSigningKey,
13933 out: &Path,
13934) -> LinkResult<RotationReport> {
13935 require_hardened_filesystem("key rotation")?;
13936 require_safe_ref(brain)?;
13937 let new_key = if out.exists() {
13941 load_signing_key(out)?
13942 } else {
13943 let rng = ring::rand::SystemRandom::new();
13944 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
13945 .map_err(|_| bad_agent_key("key generation failed"))?;
13946 let pair = agent_keypair(pkcs8.as_ref())?;
13947 let (public_key_spki, multikey) = public_identity_for(&pair);
13948 write_secret_new(
13949 out,
13950 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
13951 )?;
13952 AgentSigningKey {
13953 pkcs8: pkcs8.as_ref().to_vec(),
13954 multikey,
13955 public_key_spki,
13956 }
13957 };
13958 let new_spki = new_key.public_key_spki.clone();
13959 let new_multikey = new_key.multikey.clone();
13960 let journal_path = rotation_journal_path(out);
13961 let before_v2 = v2_verified_head(cfg, brain)?;
13962 let (canonical_brain, served_identity, observed_head, v2_profile) =
13963 if let Some(head) = before_v2 {
13964 let observed = Head {
13965 brain: head.brain_id.clone(),
13966 seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13967 updated_at: head
13968 .pointer
13969 .as_ref()
13970 .map(|pointer| pointer.signed_at.clone()),
13971 feed_hash: head
13972 .pointer
13973 .as_ref()
13974 .map(|pointer| pointer.feed_hash.clone()),
13975 verified: true,
13976 };
13977 let identity = v2_identity(&head.identity);
13978 let canonical = head.brain_id.clone();
13979 accept_v2_head(cfg, &head)?;
13980 (canonical, identity, observed, true)
13981 } else {
13982 let remote = verified_remote_head(cfg, brain, false)?;
13983 let identity = remote
13984 .identity
13985 .clone()
13986 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
13987 (remote.head.brain.clone(), identity, remote.head, false)
13988 };
13989 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
13990 let already_rotated = served_multikey == new_multikey;
13991 if already_rotated && !journal_path.exists() {
13996 remove_rotation_journal(&journal_path);
13997 return Ok(RotationReport {
13998 brain: brain.to_string(),
13999 multikey: new_multikey,
14000 key_file: out.display().to_string(),
14001 previous: served_identity
14002 .previous
14003 .iter()
14004 .map(|identity| format!("ed25519:{}", identity.fingerprint))
14005 .collect(),
14006 });
14007 }
14008 if !already_rotated && served_multikey != old_key.multikey {
14009 return Err(invalid_feed(
14010 "the supplied old key is not the brain's verified current identity",
14011 ));
14012 }
14013
14014 let journal = if journal_path.exists() {
14015 read_rotation_journal(&journal_path)?
14016 } else {
14017 let ts = crate::now()
14018 .with_timezone(&chrono::Utc)
14019 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
14020 .to_string();
14021 let unsigned = serde_json::to_string(&UnsignedRotation {
14022 v: 1,
14023 op: "rotate",
14024 brain: &old_key.multikey,
14025 public_key: &old_key.public_key_spki,
14026 new_brain: &new_multikey,
14027 new_public_key: &new_spki,
14028 prior_head_seq: observed_head.seq,
14029 prior_feed_hash: observed_head.feed_hash.as_deref(),
14030 ts,
14031 })
14032 .expect("serialize rotation");
14033 let old_pair = agent_keypair(&old_key.pkcs8)?;
14034 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14035 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
14036 let journal = RotationJournal {
14037 v: 1,
14038 origin: normalized_origin(&cfg.hub)?,
14039 brain: canonical_brain.clone(),
14040 old_brain: old_key.multikey.clone(),
14041 new_brain: new_multikey.clone(),
14042 prior_head_seq: observed_head.seq,
14043 prior_feed_hash: observed_head.feed_hash.clone(),
14044 statement,
14045 };
14046 let mut exact = serde_json::to_vec(&journal)
14047 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
14048 exact.push(b'\n');
14049 if write_secret_new(&journal_path, &exact).is_err() {
14050 read_rotation_journal(&journal_path)?
14053 } else {
14054 journal
14055 }
14056 };
14057 validate_rotation_journal(
14058 &journal,
14059 cfg,
14060 &canonical_brain,
14061 old_key,
14062 &new_key,
14063 &observed_head,
14064 )?;
14065
14066 let body = json!({ "statement": journal.statement });
14067 let path = format!("/api/hub/brains/{brain}/rotate");
14068 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
14069 let attempted_failure = match attempted {
14070 Ok(response) if (200..300).contains(&response.status) => None,
14071 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
14072 Err(error) => Some(error),
14073 };
14074
14075 let identity = if v2_profile {
14079 match v2_verified_head(cfg, brain) {
14080 Ok(Some(after)) => {
14081 let identity = v2_identity(&after.identity);
14082 accept_v2_head(cfg, &after)?;
14083 identity
14084 }
14085 Ok(None) => {
14086 return Err(attempted_failure.unwrap_or_else(|| {
14087 invalid_feed("rotated v2 brain no longer serves a v2 head")
14088 }));
14089 }
14090 Err(error) => return Err(attempted_failure.unwrap_or(error)),
14091 }
14092 } else {
14093 match verified_remote_head(cfg, brain, false) {
14094 Ok(after) => after
14095 .identity
14096 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
14097 Err(error) => return Err(attempted_failure.unwrap_or(error)),
14098 }
14099 };
14100 if format!("ed25519:{}", identity.fingerprint) != new_multikey
14101 || identity.public_key_spki != new_spki
14102 {
14103 return Err(attempted_failure.unwrap_or_else(|| {
14104 invalid_feed("hub acknowledged rotation without committing the verified new identity")
14105 }));
14106 }
14107 if v2_profile {
14108 if let Some(error) = attempted_failure {
14109 return Err(error);
14114 }
14115 }
14116 let previous = identity
14117 .previous
14118 .iter()
14119 .map(|prior| format!("ed25519:{}", prior.fingerprint))
14120 .collect();
14121 remove_rotation_journal(&journal_path);
14122
14123 Ok(RotationReport {
14124 brain: brain.to_string(),
14125 multikey: new_multikey,
14126 key_file: out.display().to_string(),
14127 previous,
14128 })
14129}
14130
14131#[derive(Debug, Serialize)]
14137pub struct MirrorReport {
14138 pub brain: String,
14140 #[serde(rename = "headSeq")]
14142 pub head_seq: u64,
14143 #[serde(rename = "feedHash")]
14145 pub feed_hash: Option<String>,
14146 pub entries: u64,
14148 pub pinned: String,
14150 pub files: usize,
14152}
14153
14154pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
14156
14157#[derive(Debug)]
14159pub struct VerifiedMirrorMaterial {
14160 pub brain: String,
14161 pub head_seq: u64,
14162 pub feed_hash: Option<String>,
14163 pub identity: serde_json::Value,
14164 pub entries: Vec<(u64, String, String)>,
14166 pub pack_sha256: Option<String>,
14167}
14168
14169#[derive(Deserialize)]
14170#[serde(deny_unknown_fields)]
14171struct StoredMirrorHead {
14172 brain: String,
14173 #[serde(rename = "headSeq")]
14174 head_seq: u64,
14175 #[serde(rename = "feedHash")]
14176 feed_hash: Option<String>,
14177}
14178
14179pub fn verify_mirror_material(
14182 head_bytes: &[u8],
14183 identity_bytes: &[u8],
14184 feed_bytes: &[Vec<u8>],
14185 snapshot_pack: Option<&[u8]>,
14186 expected_anchor: &str,
14187) -> LinkResult<VerifiedMirrorMaterial> {
14188 let snapshot_hash = snapshot_pack
14189 .filter(|pack| !pack.is_empty())
14190 .map(content_sha256);
14191 verify_mirror_material_with_pack_hash(
14192 head_bytes,
14193 identity_bytes,
14194 feed_bytes,
14195 snapshot_hash.as_deref(),
14196 expected_anchor,
14197 )
14198}
14199
14200pub fn verify_mirror_material_with_pack_hash(
14204 head_bytes: &[u8],
14205 identity_bytes: &[u8],
14206 feed_bytes: &[Vec<u8>],
14207 snapshot_pack_sha256: Option<&str>,
14208 expected_anchor: &str,
14209) -> LinkResult<VerifiedMirrorMaterial> {
14210 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
14211 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
14212 require_safe_ref(&head.brain)?;
14213 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
14214 return Err(invalid_feed(
14215 "stored mirror feed count does not match its bounded head sequence",
14216 ));
14217 }
14218 let aggregate = feed_bytes
14219 .iter()
14220 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
14221 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
14222 if aggregate > MAX_FEED_REPLAY_BYTES {
14223 return Err(invalid_feed(
14224 "stored mirror feed metadata exceeds the aggregate limit",
14225 ));
14226 }
14227 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
14228 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
14229 let anchor = verify_identity_chain(&identity, None)?;
14230 if anchor != expected_anchor {
14231 return Err(invalid_feed(
14232 "stored mirror identity does not descend from the explicitly trusted anchor",
14233 ));
14234 }
14235
14236 let mut entries = Vec::with_capacity(feed_bytes.len());
14237 let mut items = Vec::with_capacity(feed_bytes.len());
14238 let mut previous_hash = None;
14239 let mut pack_sha256 = None;
14240 for (index, bytes) in feed_bytes.iter().enumerate() {
14241 let exact = bytes
14242 .strip_suffix(b"\n")
14243 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
14244 if exact.ends_with(b"\n") {
14245 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
14246 }
14247 let entry: FeedEntry = serde_json::from_slice(exact)
14248 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
14249 let expected_seq = index as u64 + 1;
14250 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
14251 return Err(invalid_feed(
14252 "stored mirror feed is not contiguous and hash-chained",
14253 ));
14254 }
14255 let canonical = serde_json::to_vec(&entry)
14256 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
14257 if canonical != exact {
14258 return Err(invalid_feed(
14259 "stored feed entry is not in normative serialization",
14260 ));
14261 }
14262 let hash = content_sha256(bytes);
14263 let item = FeedItem {
14264 hash: hash.clone(),
14265 entry,
14266 };
14267 verify_feed_item(&item, &identity)?;
14268 previous_hash = Some(hash.clone());
14269 if expected_seq == head.head_seq {
14270 pack_sha256 = Some(item.entry.pack_sha256.clone());
14271 }
14272 entries.push((
14273 expected_seq,
14274 std::str::from_utf8(exact)
14275 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
14276 .to_string(),
14277 hash,
14278 ));
14279 items.push(item);
14280 }
14281 if previous_hash != head.feed_hash {
14282 return Err(invalid_feed(
14283 "stored mirror feed does not converge on its advertised head",
14284 ));
14285 }
14286 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
14287 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
14288 (0, None, None) => {}
14289 (_, Some(actual), Some(expected)) if actual == expected => {}
14290 _ => {
14291 return Err(LinkError::InvalidPack {
14292 message: "stored snapshot pack does not match the signed head digest".to_string(),
14293 });
14294 }
14295 }
14296 let identity_value = serde_json::to_value(&identity)
14297 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
14298 Ok(VerifiedMirrorMaterial {
14299 brain: head.brain,
14300 head_seq: head.head_seq,
14301 feed_hash: head.feed_hash,
14302 identity: identity_value,
14303 entries,
14304 pack_sha256,
14305 })
14306}
14307
14308pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
14311 format!(
14312 "{:x}",
14313 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
14314 )
14315}
14316
14317pub fn content_sha256(bytes: &[u8]) -> String {
14320 format!("{:x}", Sha256::digest(bytes))
14321}
14322
14323pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
14325 let mut digest = Sha256::new();
14326 let mut buffer = [0u8; 64 * 1024];
14327 loop {
14328 let read = reader.read(&mut buffer)?;
14329 if read == 0 {
14330 break;
14331 }
14332 digest.update(&buffer[..read]);
14333 }
14334 Ok(format!("{:x}", digest.finalize()))
14335}
14336
14337#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
14345pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
14346 require_hardened_filesystem("mirror")?;
14347 require_safe_ref(brain)?;
14348 #[cfg(windows)]
14349 {
14350 let _ = (cfg, dest);
14351 return Err(LinkError::UnsupportedPlatform {
14352 operation: "atomic whole-mirror replacement on Windows",
14353 });
14354 }
14355 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
14356 let name = dest
14357 .file_name()
14358 .and_then(|name| name.to_str())
14359 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
14360 .ok_or_else(|| LinkError::UnsafePath {
14361 path: dest.display().to_string(),
14362 })?;
14363 #[cfg(unix)]
14364 let parent_dir = open_or_create_dir_nofollow(parent)?;
14365 #[cfg(unix)]
14366 use std::os::fd::AsRawFd as _;
14367 #[cfg(unix)]
14368 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
14369 #[cfg(unix)]
14370 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
14371 None => false,
14372 Some(true) => true,
14373 Some(false) => {
14374 return Err(LinkError::UnsafePath {
14375 path: dest.display().to_string(),
14376 });
14377 }
14378 };
14379
14380 #[cfg(unix)]
14383 let legacy_backup_name = c_name(
14384 format!(".{name}.dbmd-backup").as_bytes(),
14385 &dest.display().to_string(),
14386 )?;
14387 #[cfg(unix)]
14388 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
14389 return Err(LinkError::UnsafePath {
14390 path: parent
14391 .join(format!(".{name}.dbmd-backup"))
14392 .display()
14393 .to_string(),
14394 });
14395 }
14396
14397 let nonce = std::time::SystemTime::now()
14398 .duration_since(std::time::UNIX_EPOCH)
14399 .unwrap_or_default()
14400 .as_nanos();
14401 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
14402 #[cfg(unix)]
14403 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
14404 #[cfg(unix)]
14405 let stage_dir = create_dir_exclusive_at(
14406 parent_dir.as_raw_fd(),
14407 &stage_name,
14408 &dest.display().to_string(),
14409 )?;
14410
14411 let assembled = (|| -> LinkResult<MirrorReport> {
14412 let remote = verified_remote_head(cfg, brain, true)?;
14413 let brain_id = remote.head.brain.clone();
14414 let identity = remote
14415 .identity
14416 .as_ref()
14417 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
14418 let anchor = remote
14419 .anchor
14420 .clone()
14421 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
14422 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
14423 let snapshot_entries = parse_store_pack(pack.clone())?;
14424 let snapshot_count = snapshot_entries.len();
14425 let mut staged_entries = snapshot_entries;
14426 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
14427 for item in &remote.entries {
14428 let mut exact = serde_json::to_vec(&item.entry)
14429 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
14430 exact.push(b'\n');
14431 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
14432 return Err(invalid_feed(
14433 "serialized mirror entry differs from its verified hash",
14434 ));
14435 }
14436 staged_entries.push((
14437 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
14438 exact,
14439 ));
14440 }
14441 let mut identity_bytes = serde_json::to_vec(identity)
14442 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
14443 identity_bytes.push(b'\n');
14444 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
14445 let mut head_bytes = serde_json::to_vec(&json!({
14446 "brain": brain_id,
14447 "headSeq": remote.head.seq,
14448 "feedHash": remote.head.feed_hash,
14449 }))
14450 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
14451 head_bytes.push(b'\n');
14452 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
14453 staged_entries.push((
14454 CONFIG_REL_PATH.to_string(),
14455 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
14456 ));
14457 #[cfg(unix)]
14458 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
14459
14460 Ok(MirrorReport {
14461 brain: brain_id,
14462 head_seq: remote.head.seq,
14463 feed_hash: remote.head.feed_hash,
14464 entries: remote.entries.len() as u64,
14465 pinned: anchor,
14466 files: snapshot_count,
14467 })
14468 })();
14469
14470 let report = match assembled {
14471 Ok(report) => report,
14472 Err(error) => {
14473 #[cfg(unix)]
14474 let _ = remove_tree_at(
14475 parent_dir.as_raw_fd(),
14476 &stage_name,
14477 &dest.display().to_string(),
14478 );
14479 return Err(error);
14480 }
14481 };
14482
14483 #[cfg(unix)]
14484 if let Err(error) =
14485 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
14486 {
14487 let _ = remove_tree_at(
14488 parent_dir.as_raw_fd(),
14489 &stage_name,
14490 &dest.display().to_string(),
14491 );
14492 return Err(error);
14493 }
14494 #[cfg(unix)]
14497 if dest_exists {
14498 remove_tree_at(
14499 parent_dir.as_raw_fd(),
14500 &stage_name,
14501 &dest.display().to_string(),
14502 )?;
14503 }
14504 #[cfg(unix)]
14505 parent_dir.sync_all()?;
14506 Ok(report)
14507}
14508
14509fn verified_remote_head(
14510 cfg: &HubConfig,
14511 brain: &str,
14512 require_full_chain: bool,
14513) -> LinkResult<VerifiedRemote> {
14514 require_hardened_filesystem("verified link.md state")?;
14515 require_safe_ref(brain)?;
14516 let trust_directory = open_trust_dir(cfg)?;
14520 let path = format!("/api/hub/brains/{brain}");
14521 let body = ensure_ok(
14522 request(cfg, "GET", &path, None, Auth::Required)?,
14523 "subscribe",
14524 )?;
14525 let resolved_brain = body
14526 .get("id")
14527 .and_then(Value::as_str)
14528 .filter(|id| crate::ulid::is_ulid(id))
14529 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
14530 .to_string();
14531 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
14532 return Err(invalid_feed(
14533 "brain card id differs from the explicitly requested brain id",
14534 ));
14535 }
14536 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
14541 let (pinned, alias_binding) =
14542 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
14543 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
14544 let advertised_hash = body
14545 .get("feedHash")
14546 .and_then(Value::as_str)
14547 .map(str::to_string);
14548 let updated_at = body
14549 .get("updatedAt")
14550 .and_then(Value::as_str)
14551 .map(str::to_string);
14552 if let Some(pin) = &pinned {
14553 if seq < pin.head_seq {
14554 return Err(invalid_feed(format!(
14555 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
14556 pin.head_seq
14557 )));
14558 }
14559 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
14560 return Err(invalid_feed(
14561 "feed equivocation: the checkpoint sequence now has a different hash",
14562 ));
14563 }
14564 }
14565 if seq == 0 {
14566 if advertised_hash.is_some() {
14567 return Err(invalid_feed("an empty feed advertised a head hash"));
14568 }
14569 let identity: FeedIdentity = serde_json::from_value(
14570 body.get("identity")
14571 .cloned()
14572 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
14573 )
14574 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
14575 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
14576 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
14581 save_canonical_pin_and_alias(
14582 cfg,
14583 &trust_directory,
14584 brain,
14585 &resolved_brain,
14586 TrustState {
14587 v: 2,
14588 origin: normalized_origin(&cfg.hub)?,
14589 requested: resolved_brain.clone(),
14590 brain: resolved_brain.clone(),
14591 home: None,
14592 anchor: anchor.clone(),
14593 current: format!("ed25519:{}", identity.fingerprint),
14594 head_seq: 0,
14595 feed_hash: None,
14596 rotations: identity.rotations.clone(),
14597 hub_signer: None,
14598 protocol_profile: None,
14599 },
14600 alias_binding.as_ref(),
14601 )?;
14602 return Ok(VerifiedRemote {
14603 head: Head {
14604 brain: resolved_brain,
14605 seq,
14606 updated_at,
14607 feed_hash: None,
14608 verified: true,
14609 },
14610 identity: Some(identity),
14611 head_entry: None,
14612 entries: Vec::new(),
14613 anchor: Some(anchor),
14614 });
14615 }
14616 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
14617 return Err(invalid_feed(
14618 "non-empty feed did not advertise a valid SHA-256 head",
14619 ));
14620 }
14621
14622 let replay_head_only = !require_full_chain
14626 && pinned
14627 .as_ref()
14628 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
14629 let mut after = if replay_head_only {
14630 seq - 1
14631 } else if require_full_chain || pinned.is_none() {
14632 0
14633 } else {
14634 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
14635 };
14636 let mut expected_seq = after + 1;
14637 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
14638 None
14639 } else {
14640 pinned
14641 .as_ref()
14642 .and_then(|checkpoint| checkpoint.feed_hash.clone())
14643 };
14644 let mut identity: Option<FeedIdentity> = None;
14645 let mut anchor: Option<String> = None;
14646 let mut head_entry: Option<FeedItem> = None;
14647 let mut all_entries = Vec::new();
14648 let mut observed_entries = Vec::new();
14649 let replay_count = seq
14650 .checked_sub(after)
14651 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
14652 if replay_count > MAX_FEED_REPLAY_ENTRIES {
14653 return Err(invalid_feed(format!(
14654 "feed replay requires {replay_count} entries, over the client cap"
14655 )));
14656 }
14657 let mut replay_bytes = 0u64;
14658
14659 loop {
14660 let feed_bytes = ensure_raw_ok(
14661 request_raw(
14662 cfg,
14663 "GET",
14664 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
14665 None,
14666 Auth::Required,
14667 MAX_FEED_RESPONSE_BYTES,
14668 )?,
14669 "subscribe feed",
14670 )?;
14671 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
14672 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
14673 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
14674 return Err(invalid_feed("brain card and feed head disagree"));
14675 }
14676 if feed.entries.len() > FEED_PAGE_LIMIT {
14677 return Err(invalid_feed("feed page exceeds the requested entry limit"));
14678 }
14679 if feed.scope_limited {
14680 if require_full_chain {
14681 return Err(invalid_feed(
14682 "path-scoped grants cannot verify a full snapshot chain",
14683 ));
14684 }
14685 return Ok(VerifiedRemote {
14686 head: Head {
14687 brain: resolved_brain,
14688 seq,
14689 updated_at,
14690 feed_hash: advertised_hash,
14691 verified: false,
14692 },
14693 identity: None,
14694 head_entry: None,
14695 entries: Vec::new(),
14696 anchor: None,
14697 });
14698 }
14699 let page_identity = feed
14700 .identity
14701 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14702 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
14703 if identity
14704 .as_ref()
14705 .is_some_and(|existing| existing != &page_identity)
14706 {
14707 return Err(invalid_feed("identity changed while reading the feed"));
14708 }
14709 if anchor
14710 .as_ref()
14711 .is_some_and(|existing| existing != &page_anchor)
14712 {
14713 return Err(invalid_feed(
14714 "identity anchor changed while reading the feed",
14715 ));
14716 }
14717 identity = Some(page_identity.clone());
14718 if anchor.is_none() {
14719 anchor = Some(page_anchor);
14720 }
14721 if feed.entries.is_empty() {
14722 return Err(invalid_feed("feed page was empty before the signed head"));
14723 }
14724
14725 for item in feed.entries {
14726 if item.entry.seq != expected_seq {
14727 return Err(invalid_feed(format!(
14728 "expected entry {expected_seq}, feed served {}",
14729 item.entry.seq
14730 )));
14731 }
14732 if item.entry.seq > seq {
14733 return Err(invalid_feed("feed advanced past the card snapshot"));
14734 }
14735 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
14736 return Err(invalid_feed(format!(
14737 "entry {} does not chain to the local checkpoint",
14738 item.entry.seq
14739 )));
14740 }
14741 verify_feed_item(&item, &page_identity)?;
14742 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
14743 replay_bytes = replay_bytes.saturating_add(
14744 serde_json::to_vec(&item)
14745 .map_err(|_| invalid_feed("could not size feed entry"))?
14746 .len() as u64,
14747 );
14748 if replay_bytes > MAX_FEED_REPLAY_BYTES {
14749 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
14750 }
14751 previous_hash = Some(item.hash.clone());
14752 after = item.entry.seq;
14753 expected_seq = expected_seq
14754 .checked_add(1)
14755 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
14756 if require_full_chain {
14757 all_entries.push(item.clone());
14758 }
14759 observed_entries.push(item.clone());
14760 head_entry = Some(item);
14761 }
14762 if after == seq {
14763 break;
14764 }
14765 }
14766
14767 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
14768 return Err(invalid_feed(
14769 "verified chain does not converge on the advertised head",
14770 ));
14771 }
14772 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14773 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
14774 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
14775 save_canonical_pin_and_alias(
14776 cfg,
14777 &trust_directory,
14778 brain,
14779 &resolved_brain,
14780 TrustState {
14781 v: 2,
14782 origin: normalized_origin(&cfg.hub)?,
14783 requested: resolved_brain.clone(),
14784 brain: resolved_brain.clone(),
14785 home: None,
14786 anchor: anchor.clone(),
14787 current: format!("ed25519:{}", identity.fingerprint),
14788 head_seq: seq,
14789 feed_hash: advertised_hash.clone(),
14790 rotations: identity.rotations.clone(),
14791 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
14792 protocol_profile: pinned
14793 .as_ref()
14794 .and_then(|state| state.protocol_profile.clone()),
14795 },
14796 alias_binding.as_ref(),
14797 )?;
14798 Ok(VerifiedRemote {
14799 head: Head {
14800 brain: resolved_brain,
14801 seq,
14802 updated_at,
14803 feed_hash: advertised_hash,
14804 verified: true,
14805 },
14806 identity: Some(identity),
14807 head_entry,
14808 entries: all_entries,
14809 anchor: Some(anchor),
14810 })
14811}
14812
14813pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
14818 if let Some(verified) = v2_verified_head(cfg, brain)? {
14819 let observation = Head {
14820 brain: verified.brain_id.clone(),
14821 seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14822 updated_at: verified
14823 .pointer
14824 .as_ref()
14825 .map(|pointer| pointer.signed_at.clone()),
14826 feed_hash: verified
14827 .pointer
14828 .as_ref()
14829 .map(|pointer| pointer.feed_hash.clone()),
14830 verified: true,
14831 };
14832 accept_v2_head(cfg, &verified)?;
14833 return Ok(observation);
14834 }
14835 Ok(verified_remote_head(cfg, brain, false)?.head)
14836}
14837
14838#[cfg(test)]
14839mod tests {
14840 use super::*;
14841
14842 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
14843
14844 fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
14845 json!({
14846 "sha256": "a".repeat(64),
14847 "bytes": 10,
14848 "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
14849 })
14850 }
14851
14852 #[test]
14853 fn upload_reservations_batch_by_count_and_by_size() {
14854 let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
14858 let batches = batch_upload_declarations(declarations.clone());
14859
14860 assert!(batches.len() > 1, "5,000 blobs must not ride one request");
14861 for batch in &batches {
14862 assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
14863 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14864 .expect("batch serializes")
14865 .len();
14866 assert!(
14867 bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
14868 "batch body {bytes} exceeds the reservation budget"
14869 );
14870 }
14871 let flattened: Vec<Value> = batches.into_iter().flatten().collect();
14872 assert_eq!(
14873 flattened, declarations,
14874 "batching must preserve the set and order"
14875 );
14876 }
14877
14878 #[test]
14879 fn only_load_shaped_hub_answers_are_worth_asking_again() {
14880 for status in [408, 429, 500, 502, 503, 504] {
14885 assert!(is_retryable_hub_status(status), "{status} is load-shaped");
14886 }
14887 for status in [400, 401, 403, 404, 409, 413, 422] {
14888 assert!(
14889 !is_retryable_hub_status(status),
14890 "{status} states something about the request"
14891 );
14892 }
14893 let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
14895 assert!(total >= 60_000, "backoff totals only {total}ms");
14896 }
14897
14898 #[test]
14899 fn a_batch_shares_a_connection_only_within_one_authority() {
14900 let cfg = HubConfig {
14905 hub: "https://www.sevrahq.com".to_string(),
14906 key: Some("k".to_string()),
14907 agent_key: None,
14908 brain_key: None,
14909 state_dir: PathBuf::from("."),
14910 store_selected: false,
14911 };
14912 assert!(shared_staging_agent(&cfg, &[]).is_none());
14913 assert!(
14914 shared_staging_agent(
14915 &cfg,
14916 &[
14917 "https://one.example.com/a?sig=1",
14918 "https://two.example.com/b?sig=2",
14919 ]
14920 )
14921 .is_none(),
14922 "two authorities must not share a pinned pool"
14923 );
14924 assert!(
14925 shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
14926 "an unsafe object-store URL must not produce an agent"
14927 );
14928 assert!(
14929 shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
14930 "credentials in the URL must not produce an agent"
14931 );
14932 }
14933
14934 #[test]
14935 fn a_staged_change_states_only_operations_and_blobs() {
14936 let operations = vec![json!({
14940 "op": "put",
14941 "path": "records/a.md",
14942 "blob": "a".repeat(64),
14943 "bytes": 3,
14944 })];
14945 let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
14946 let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
14947 let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
14948 let keys: Vec<&str> = parsed
14949 .as_object()
14950 .expect("manifest is an object")
14951 .keys()
14952 .map(String::as_str)
14953 .collect();
14954 assert_eq!(keys, ["blobs", "operations"]);
14955 assert_eq!(parsed["operations"], Value::Array(operations));
14956 assert_eq!(parsed["blobs"], blobs);
14957 }
14958
14959 #[test]
14960 fn a_staged_push_signs_the_change_not_the_transport() {
14961 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14966 let staged = json!({
14967 "mutation_id": "dbmd-1",
14968 "rebase": "strict",
14969 "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
14970 });
14971 let view = v2_signed_request_view(&staged, &operations);
14972 assert_eq!(view["operations"], Value::Array(operations.clone()));
14973 assert!(view.get("staged_change").is_none());
14974 assert_eq!(view["mutation_id"], staged["mutation_id"]);
14975
14976 let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
14977 assert_eq!(v2_signed_request_view(&inline, &[]), inline);
14978 }
14979
14980 #[test]
14981 fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
14982 let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
14983 let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
14984 .expect_err("an oversized change must not be staged");
14985 assert!(
14986 matches!(error, LinkError::PushTooLarge { .. }),
14987 "expected a size refusal, got {error:?}"
14988 );
14989 }
14990
14991 #[test]
14992 fn a_push_that_fits_the_request_is_left_inline() {
14993 let cfg = HubConfig {
14997 hub: "http://127.0.0.1:9".to_string(),
14998 key: Some("k".to_string()),
14999 agent_key: None,
15000 brain_key: None,
15001 state_dir: PathBuf::from("."),
15002 store_selected: false,
15003 };
15004 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15005 let mut body = json!({
15006 "mutation_id": "dbmd-1",
15007 "operations": operations,
15008 "blobs": [],
15009 });
15010 stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
15011 assert!(body.get("staged_change").is_none());
15012 assert_eq!(body["operations"], Value::Array(operations));
15013 }
15014
15015 #[test]
15016 fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
15017 let declarations: Vec<Value> = (0..2_000)
15021 .map(|index| {
15022 json!({
15023 "sha256": "a".repeat(64),
15024 "bytes": 10,
15025 "coordinates": (0..24)
15026 .map(|slot| format!(
15027 "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
15028 ))
15029 .collect::<Vec<_>>(),
15030 })
15031 })
15032 .collect();
15033 let batches = batch_upload_declarations(declarations);
15034 assert!(
15035 batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
15036 "wide coordinate sets must bound the batch by size"
15037 );
15038 for batch in &batches {
15039 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15040 .expect("batch serializes")
15041 .len();
15042 assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
15043 }
15044 }
15045
15046 #[test]
15047 fn a_small_push_still_rides_exactly_one_request() {
15048 let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
15049 assert_eq!(batch_upload_declarations(declarations).len(), 1);
15050 assert!(batch_upload_declarations(Vec::new()).is_empty());
15051 }
15052
15053 #[test]
15054 fn exact_source_move_becomes_one_provenance_preserving_rename() {
15055 let hash = "a".repeat(64);
15056 let operations = vec![
15057 json!({
15058 "op": "put",
15059 "path": "sources/curated/item.md",
15060 "expected": { "kind": "absent" },
15061 "blob": hash,
15062 "bytes": 19,
15063 }),
15064 json!({
15065 "op": "delete",
15066 "path": "sources/inbox/item.md",
15067 "expected": { "kind": "blob", "hash": hash },
15068 }),
15069 ];
15070
15071 assert_eq!(
15072 infer_exact_source_promotions(operations),
15073 vec![json!({
15074 "op": "rename",
15075 "from": "sources/inbox/item.md",
15076 "to": "sources/curated/item.md",
15077 "expected_from": { "kind": "blob", "hash": hash },
15078 "expected_to": { "kind": "absent" },
15079 "blob": hash,
15080 "bytes": 19,
15081 })]
15082 );
15083 }
15084
15085 #[test]
15086 fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
15087 let hash = "b".repeat(64);
15088 let operations = vec![
15089 json!({
15090 "op": "delete",
15091 "path": "sources/inbox/a.md",
15092 "expected": { "kind": "blob", "hash": hash },
15093 }),
15094 json!({
15095 "op": "delete",
15096 "path": "sources/inbox/b.md",
15097 "expected": { "kind": "blob", "hash": hash },
15098 }),
15099 json!({
15100 "op": "put",
15101 "path": "sources/curated/item.md",
15102 "expected": { "kind": "absent" },
15103 "blob": hash,
15104 "bytes": 19,
15105 }),
15106 ];
15107
15108 assert_eq!(
15109 infer_exact_source_promotions(operations.clone()),
15110 operations,
15111 "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
15112 );
15113 }
15114
15115 #[test]
15116 fn accepted_source_promotion_advances_the_local_baseline_exactly() {
15117 let hash = "c".repeat(64);
15118 let mut candidate = std::collections::BTreeMap::from([(
15119 "sources/inbox/item.md".to_string(),
15120 V2BaselineFile {
15121 sha256: hash.clone(),
15122 bytes: 19,
15123 proof: None,
15124 },
15125 )]);
15126 let mut candidate_assets = std::collections::BTreeMap::new();
15127 let operations = vec![
15128 json!({
15129 "op": "rename",
15130 "from": "sources/inbox/item.md",
15131 "to": "sources/curated/item.md",
15132 "expected_from": { "kind": "blob", "hash": hash },
15133 "expected_to": { "kind": "absent" },
15134 "blob": hash,
15135 "bytes": 19,
15136 }),
15137 json!({
15138 "op": "put",
15139 "path": "records/rsvps/item.md",
15140 "expected": { "kind": "absent" },
15141 "blob": "d".repeat(64),
15142 "bytes": 23,
15143 }),
15144 ];
15145
15146 assert!(!apply_generated_v2_operations(
15147 &operations,
15148 &std::collections::BTreeMap::new(),
15149 &mut candidate,
15150 &mut candidate_assets,
15151 )
15152 .unwrap());
15153 assert!(!candidate.contains_key("sources/inbox/item.md"));
15154 assert_eq!(
15155 candidate
15156 .get("sources/curated/item.md")
15157 .map(|file| (&file.sha256, file.bytes)),
15158 Some((&hash, 19))
15159 );
15160 assert_eq!(
15161 candidate
15162 .get("records/rsvps/item.md")
15163 .map(|file| (file.sha256.as_str(), file.bytes)),
15164 Some((
15165 "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
15166 23
15167 ))
15168 );
15169 }
15170
15171 fn merge_fixture(
15172 base: Option<&str>,
15173 remote: Option<&str>,
15174 local: Option<&str>,
15175 keep_local: bool,
15176 ) -> V2PulledMerge<String> {
15177 let map = |value: Option<&str>| {
15178 value
15179 .map(|value| [("records/a.md".to_string(), value.to_string())])
15180 .into_iter()
15181 .flatten()
15182 .collect::<std::collections::BTreeMap<_, _>>()
15183 };
15184 merge_v2_pulled_records(
15185 &map(base),
15186 &map(remote),
15187 &map(local),
15188 |value, _| value.clone(),
15189 |value, _| value.clone(),
15190 |_| keep_local,
15191 )
15192 }
15193
15194 #[test]
15195 fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
15196 let path = "records/a.md".to_string();
15197
15198 let local_add = merge_fixture(None, None, Some("local"), false);
15199 assert_eq!(
15200 local_add.records.get(&path).map(String::as_str),
15201 Some("local")
15202 );
15203 assert!(local_add.accept_remote.is_empty());
15204 assert!(local_add.conflicts.is_empty());
15205
15206 let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
15207 assert_eq!(
15208 local_edit.records.get(&path).map(String::as_str),
15209 Some("local")
15210 );
15211 assert!(local_edit.accept_remote.is_empty());
15212 assert!(local_edit.conflicts.is_empty());
15213
15214 let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
15215 assert!(!local_delete.records.contains_key(&path));
15216 assert!(local_delete.accept_remote.is_empty());
15217 assert!(local_delete.conflicts.is_empty());
15218
15219 let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
15220 assert_eq!(
15221 remote_edit.records.get(&path).map(String::as_str),
15222 Some("remote")
15223 );
15224 assert!(remote_edit.accept_remote.contains(&path));
15225 assert!(remote_edit.conflicts.is_empty());
15226
15227 let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
15228 assert!(!remote_delete.records.contains_key(&path));
15229 assert!(remote_delete.accept_remote.contains(&path));
15230 assert!(remote_delete.conflicts.is_empty());
15231
15232 let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
15233 assert_eq!(
15234 same_edit.records.get(&path).map(String::as_str),
15235 Some("same")
15236 );
15237 assert!(same_edit.accept_remote.contains(&path));
15238 assert!(same_edit.conflicts.is_empty());
15239
15240 let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
15241 assert_eq!(conflict.conflicts, vec![path.clone()]);
15242 assert_eq!(
15243 conflict.records.get(&path).map(String::as_str),
15244 Some("local")
15245 );
15246 assert!(conflict.accept_remote.is_empty());
15247
15248 let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
15249 assert_eq!(
15250 kept_home.records.get(&path).map(String::as_str),
15251 Some("local")
15252 );
15253 assert!(kept_home.accept_remote.is_empty());
15254 assert!(kept_home.conflicts.is_empty());
15255 }
15256
15257 #[test]
15258 fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
15259 let path = "sources/report.pdf";
15260 let record = crate::AssetRecord {
15261 path: path.to_string(),
15262 sha256: "a".repeat(64),
15263 bytes: 42,
15264 media_type: "application/pdf".to_string(),
15265 wrappers: vec!["gzip".to_string()],
15266 required: true,
15267 };
15268 let mut remote = V2BaselineAsset {
15269 blob_sha256: record.sha256.clone(),
15270 bytes: record.bytes,
15271 media_type: record.media_type.clone(),
15272 wrappers: record.wrappers.clone(),
15273 required: record.required,
15274 disposition: "withheld".to_string(),
15275 leaf_hash: "b".repeat(64),
15276 };
15277
15278 assert!(v2_asset_resumes_hosting(
15279 Some(&remote),
15280 path,
15281 &record,
15282 "hosted"
15283 ));
15284 assert!(!v2_asset_resumes_hosting(
15285 Some(&remote),
15286 path,
15287 &record,
15288 "withheld"
15289 ));
15290
15291 remote.disposition = "hosted".to_string();
15292 assert!(!v2_asset_resumes_hosting(
15293 Some(&remote),
15294 path,
15295 &record,
15296 "hosted"
15297 ));
15298
15299 remote.disposition = "withheld".to_string();
15300 remote.blob_sha256 = "c".repeat(64);
15301 assert!(!v2_asset_resumes_hosting(
15302 Some(&remote),
15303 path,
15304 &record,
15305 "hosted"
15306 ));
15307 assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
15308 }
15309
15310 #[test]
15311 fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
15312 let path = "records/team/alpha.md".to_string();
15313 let deleted_path = "records/team/deleted.md".to_string();
15314 let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
15315 sha256,
15316 bytes,
15317 file: None,
15318 };
15319 let files = vec![
15320 V2ConflictFile {
15321 path: path.clone(),
15322 base: coordinate(None, None),
15323 local: coordinate(Some("b".repeat(64)), Some(7)),
15324 remote: coordinate(Some("a".repeat(64)), Some(5)),
15325 },
15326 V2ConflictFile {
15327 path: deleted_path.clone(),
15328 base: coordinate(Some("c".repeat(64)), Some(9)),
15329 local: coordinate(Some("d".repeat(64)), Some(11)),
15330 remote: coordinate(None, None),
15331 },
15332 ];
15333 let proven = V2BaselineFile {
15334 sha256: "a".repeat(64),
15335 bytes: 5,
15336 proof: None,
15337 };
15338 let current = [(path.clone(), proven.clone())]
15339 .into_iter()
15340 .collect::<std::collections::BTreeMap<_, _>>();
15341
15342 let (selected, deleted) = v2_take_remote_selection(&files, ¤t).unwrap();
15343 assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
15344 assert_eq!(deleted, vec![deleted_path.clone()]);
15345
15346 let changed = [(
15347 path.clone(),
15348 V2BaselineFile {
15349 sha256: "e".repeat(64),
15350 bytes: 5,
15351 proof: None,
15352 },
15353 )]
15354 .into_iter()
15355 .collect::<std::collections::BTreeMap<_, _>>();
15356 assert!(v2_take_remote_selection(&files, &changed).is_err());
15357
15358 let resurrected = [
15359 (path, proven),
15360 (
15361 deleted_path,
15362 V2BaselineFile {
15363 sha256: "f".repeat(64),
15364 bytes: 13,
15365 proof: None,
15366 },
15367 ),
15368 ]
15369 .into_iter()
15370 .collect::<std::collections::BTreeMap<_, _>>();
15371 assert!(v2_take_remote_selection(&files, &resurrected).is_err());
15372 }
15373
15374 #[cfg(target_os = "linux")]
15375 #[test]
15376 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
15377 use std::os::fd::AsRawFd as _;
15378
15379 let sandbox = tempfile::TempDir::new().unwrap();
15380 let parent = std::fs::File::open(sandbox.path()).unwrap();
15381 let stage = std::ffi::CString::new("stage").unwrap();
15382 let destination = std::ffi::CString::new("brain").unwrap();
15383
15384 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15385 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
15386 install_stage_at(
15387 parent.as_raw_fd(),
15388 stage.as_c_str(),
15389 destination.as_c_str(),
15390 false,
15391 )
15392 .unwrap();
15393 assert!(!sandbox.path().join("stage").exists());
15394 assert_eq!(
15395 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15396 b"created"
15397 );
15398
15399 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15400 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
15401 install_stage_at(
15402 parent.as_raw_fd(),
15403 stage.as_c_str(),
15404 destination.as_c_str(),
15405 true,
15406 )
15407 .unwrap();
15408 assert_eq!(
15409 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15410 b"replacement"
15411 );
15412 assert_eq!(
15413 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
15414 b"created",
15415 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
15416 );
15417 }
15418
15419 struct SignedRemoteFixture {
15420 card: String,
15421 feed: String,
15422 key: AgentSigningKey,
15423 identity: FeedIdentity,
15424 }
15425
15426 fn signed_remote_fixture() -> SignedRemoteFixture {
15427 let rng = ring::rand::SystemRandom::new();
15428 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15429 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15430 let (public_key, multikey) = public_identity_for(&pair);
15431 let identity = FeedIdentity {
15432 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15433 public_key_spki: public_key.clone(),
15434 previous: Vec::new(),
15435 rotations: Vec::new(),
15436 };
15437 let mut entry = FeedEntry {
15438 v: 1,
15439 seq: 1,
15440 ts: "2026-07-30T12:00:00.000Z".to_string(),
15441 brain: multikey.clone(),
15442 public_key: public_key.clone(),
15443 kind: "push".to_string(),
15444 op: "snapshot".to_string(),
15445 pack_sha256: "a".repeat(64),
15446 files: Vec::new(),
15447 removed: Vec::new(),
15448 prev_entry_hash: None,
15449 sig: String::new(),
15450 };
15451 let unsigned = UnsignedFeedEntry {
15452 v: entry.v,
15453 seq: entry.seq,
15454 ts: &entry.ts,
15455 brain: &entry.brain,
15456 public_key: &entry.public_key,
15457 kind: &entry.kind,
15458 op: &entry.op,
15459 pack_sha256: &entry.pack_sha256,
15460 files: &entry.files,
15461 removed: &entry.removed,
15462 prev_entry_hash: &entry.prev_entry_hash,
15463 };
15464 entry.sig =
15465 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15466 let mut exact = serde_json::to_vec(&entry).unwrap();
15467 exact.push(b'\n');
15468 let hash = content_sha256(&exact);
15469 let card = json!({
15470 "id": TEST_BRAIN_ID,
15471 "headSeq": 1,
15472 "feedHash": hash,
15473 "identity": identity.clone(),
15474 })
15475 .to_string();
15476 let feed = json!({
15477 "headSeq": 1,
15478 "feedHash": hash,
15479 "identity": identity.clone(),
15480 "entries": [{"hash": hash, "entry": entry}],
15481 "scopeLimited": false,
15482 })
15483 .to_string();
15484 SignedRemoteFixture {
15485 card,
15486 feed,
15487 key: AgentSigningKey {
15488 pkcs8: pkcs8.as_ref().to_vec(),
15489 multikey,
15490 public_key_spki: public_key,
15491 },
15492 identity,
15493 }
15494 }
15495
15496 #[test]
15497 fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
15498 let file = |path: &str, byte: char| FeedFile {
15499 path: path.to_string(),
15500 sha256: byte.to_string().repeat(64),
15501 bytes: 1,
15502 };
15503 let a0 = file("records/a.md", 'a');
15504 let a1 = file("records/a.md", 'b');
15505 let stable = file("records/stable.md", 'c');
15506 let added = file("records/added.md", 'd');
15507 let removed_file = file("records/removed.md", 'e');
15508 let previous = vec![a0, stable.clone(), removed_file.clone()];
15509 let resulting = vec![a1.clone(), stable.clone(), added.clone()];
15510 let removed = vec![removed_file.path.clone()];
15511
15512 assert_eq!(
15513 verify_v1_manifest_disclosure(
15514 "edit",
15515 &previous,
15516 &resulting,
15517 &[a1.clone(), added.clone()],
15518 &removed,
15519 ),
15520 Ok(())
15521 );
15522 assert_eq!(
15523 verify_v1_manifest_disclosure(
15524 "edit",
15525 &previous,
15526 &resulting,
15527 &[stable.clone(), added.clone(), a1.clone()],
15528 &removed,
15529 ),
15530 Ok(())
15531 );
15532 assert_eq!(
15533 verify_v1_manifest_disclosure(
15534 "edit",
15535 &previous,
15536 &resulting,
15537 std::slice::from_ref(&added),
15538 &removed,
15539 ),
15540 Err(V1DisclosureError::EditMissingChange)
15541 );
15542 assert_eq!(
15543 verify_v1_manifest_disclosure(
15544 "edit",
15545 &previous,
15546 &resulting,
15547 &[file("records/a.md", 'f'), added.clone()],
15548 &removed,
15549 ),
15550 Err(V1DisclosureError::EditFalseFile)
15551 );
15552 assert_eq!(
15553 verify_v1_manifest_disclosure(
15554 "edit",
15555 &previous,
15556 &resulting,
15557 &[a1.clone(), added.clone()],
15558 &[],
15559 ),
15560 Err(V1DisclosureError::RemovedMismatch)
15561 );
15562 assert_eq!(
15563 verify_v1_manifest_disclosure(
15564 "push",
15565 &previous,
15566 &resulting,
15567 &[added.clone(), stable, a1],
15568 &removed,
15569 ),
15570 Ok(())
15571 );
15572 assert_eq!(
15573 verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
15574 Err(V1DisclosureError::PushManifestMismatch)
15575 );
15576 }
15577
15578 #[test]
15579 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
15580 let fixture = signed_remote_fixture();
15581 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
15582 let item = feed["entries"][0].to_string();
15583 let oversized_page = format!(
15584 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
15585 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
15586 .collect::<Vec<_>>()
15587 .join(",")
15588 );
15589 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
15590
15591 let oversized_identity = format!(
15592 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
15593 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
15594 .collect::<Vec<_>>()
15595 .join(",")
15596 );
15597 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
15598
15599 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
15600 let oversized_entry = format!(
15601 "{{\"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\"}}",
15602 "a".repeat(64),
15603 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
15604 .collect::<Vec<_>>()
15605 .join(",")
15606 );
15607 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
15608 }
15609
15610 #[test]
15611 fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
15612 let id = "01arz3ndektsv4rrffq69g5fav";
15613 let digest = "a".repeat(64);
15614 assert_eq!(
15615 V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
15616 V2BulkConfirmation {
15617 id: id.to_string(),
15618 digest,
15619 }
15620 );
15621 for invalid in [
15622 "",
15623 "01arz3ndektsv4rrffq69g5fav",
15624 "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15625 "01arz3ndektsv4rrffq69g5fav:ABCDEF",
15626 "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15627 ] {
15628 assert!(matches!(
15629 V2BulkConfirmation::parse(invalid),
15630 Err(LinkError::InvalidPack { .. })
15631 ));
15632 }
15633 }
15634
15635 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
15636 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15637 use std::net::TcpListener;
15638
15639 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15640 let url = format!("http://{}", listener.local_addr().unwrap());
15641 let handle = std::thread::spawn(move || {
15642 for (status, body) in responses {
15643 let (stream, _) = listener.accept().unwrap();
15644 let mut reader = BufReader::new(stream);
15645 let mut line = String::new();
15646 reader.read_line(&mut line).unwrap();
15647 let mut content_length = 0usize;
15648 loop {
15649 line.clear();
15650 reader.read_line(&mut line).unwrap();
15651 if line == "\r\n" || line == "\n" || line.is_empty() {
15652 break;
15653 }
15654 if let Some((name, value)) = line.split_once(':') {
15655 if name.eq_ignore_ascii_case("content-length") {
15656 content_length = value.trim().parse().unwrap();
15657 }
15658 }
15659 }
15660 let mut request_body = vec![0_u8; content_length];
15661 reader.read_exact(&mut request_body).unwrap();
15662 let response = format!(
15663 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15664 body.len()
15665 );
15666 reader.get_mut().write_all(response.as_bytes()).unwrap();
15667 }
15668 });
15669 (url, handle)
15670 }
15671
15672 fn routed_json_hub(
15673 requests: usize,
15674 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
15675 ) -> (String, std::thread::JoinHandle<()>) {
15676 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15677 use std::net::TcpListener;
15678
15679 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15680 let url = format!("http://{}", listener.local_addr().unwrap());
15681 let handle = std::thread::spawn(move || {
15682 for _ in 0..requests {
15683 let (stream, _) = listener.accept().unwrap();
15684 let mut reader = BufReader::new(stream);
15685 let mut line = String::new();
15686 reader.read_line(&mut line).unwrap();
15687 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
15688 let mut content_length = 0usize;
15689 loop {
15690 line.clear();
15691 reader.read_line(&mut line).unwrap();
15692 if line == "\r\n" || line == "\n" || line.is_empty() {
15693 break;
15694 }
15695 if let Some((name, value)) = line.split_once(':') {
15696 if name.eq_ignore_ascii_case("content-length") {
15697 content_length = value.trim().parse().unwrap();
15698 }
15699 }
15700 }
15701 let mut request_body = vec![0_u8; content_length];
15702 reader.read_exact(&mut request_body).unwrap();
15703 let (status, body) = respond(&path);
15704 let response = format!(
15705 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15706 body.len()
15707 );
15708 reader.get_mut().write_all(response.as_bytes()).unwrap();
15709 }
15710 });
15711 (url, handle)
15712 }
15713
15714 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
15715 HubConfig {
15716 hub,
15717 key: Some("test-key".to_string()),
15718 agent_key: None,
15719 brain_key: None,
15720 state_dir,
15721 store_selected: false,
15722 }
15723 }
15724
15725 #[cfg(any(unix, windows))]
15726 #[test]
15727 fn an_expired_asset_capability_is_refreshed_without_losing_cached_progress() {
15728 use std::sync::{Arc, Mutex};
15729
15730 let bytes = b"immutable asset bytes".to_vec();
15731 let sha256 = content_sha256(&bytes);
15732 let commit_hash = "c".repeat(64);
15733 let base_url = Arc::new(Mutex::new(String::new()));
15734 let server_base = Arc::clone(&base_url);
15735 let object_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15736 let server_attempt = Arc::clone(&object_attempt);
15737 let response_bytes = bytes.clone();
15738 let response_sha = sha256.clone();
15739 let response_commit = commit_hash.clone();
15740 let (hub, server) = routed_json_hub(4, move |path| {
15741 if path.contains("/v2/assets/downloads") {
15742 let attempt = server_attempt.load(std::sync::atomic::Ordering::SeqCst) + 1;
15743 let url = format!("{}/asset/{attempt}", server_base.lock().unwrap());
15744 return (
15745 200,
15746 json!({
15747 "v": 2,
15748 "commit": response_commit,
15749 "downloads": [{
15750 "path": "assets/proof.bin",
15751 "sha256": response_sha,
15752 "bytes": response_bytes.len(),
15753 "url": url,
15754 "method": "GET"
15755 }]
15756 })
15757 .to_string(),
15758 );
15759 }
15760 let attempt = server_attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15761 if attempt == 0 {
15762 (403, "{}".to_string())
15763 } else {
15764 (200, String::from_utf8(response_bytes.clone()).unwrap())
15765 }
15766 });
15767 *base_url.lock().unwrap() = hub.clone();
15768
15769 let temp = tempfile::tempdir().unwrap();
15770 let cache = temp.path().join("cache");
15771 std::fs::create_dir(&cache).unwrap();
15772 let cfg = test_hub_config(hub, temp.path().to_path_buf());
15773 let pointer = V2PointerBody {
15774 v: 2,
15775 brain: TEST_BRAIN_ID.to_string(),
15776 seq: 1,
15777 commit_hash,
15778 feed_hash: "f".repeat(64),
15779 content_root: Some("a".repeat(64)),
15780 asset_root: Some("b".repeat(64)),
15781 materializer: "m".repeat(64),
15782 signer_epoch: 1,
15783 control_revision: "d".repeat(64),
15784 backup_preparation: "ready".to_string(),
15785 prior_pointer_hash: None,
15786 signed_at: "2026-08-23T00:00:00Z".to_string(),
15787 };
15788 let path = "assets/proof.bin".to_string();
15789 let asset = V2BaselineAsset {
15790 blob_sha256: sha256.clone(),
15791 bytes: bytes.len() as u64,
15792 media_type: "application/octet-stream".to_string(),
15793 wrappers: Vec::new(),
15794 required: true,
15795 disposition: "hosted".to_string(),
15796 leaf_hash: "e".repeat(64),
15797 };
15798
15799 let staged = stage_v2_asset_download_window(
15800 &cfg,
15801 TEST_BRAIN_ID,
15802 &pointer,
15803 &cache,
15804 &[(&path, &asset)],
15805 )
15806 .expect("a fresh authority-checked capability recovers an expired one");
15807 assert_eq!(staged.len(), 1);
15808 assert_eq!(staged[0].path, path);
15809 assert_eq!(std::fs::read(cache.join(sha256)).unwrap(), bytes);
15810 assert_eq!(object_attempt.load(std::sync::atomic::Ordering::SeqCst), 2);
15811 server.join().unwrap();
15812 }
15813
15814 #[cfg(any(unix, windows))]
15815 #[test]
15816 fn asset_capability_windows_cannot_exceed_the_worker_bound() {
15817 let temp = tempfile::tempdir().unwrap();
15818 let cfg = test_hub_config("http://127.0.0.1:1".to_string(), temp.path().to_path_buf());
15819 let pointer = V2PointerBody {
15820 v: 2,
15821 brain: TEST_BRAIN_ID.to_string(),
15822 seq: 1,
15823 commit_hash: "c".repeat(64),
15824 feed_hash: "f".repeat(64),
15825 content_root: Some("a".repeat(64)),
15826 asset_root: Some("b".repeat(64)),
15827 materializer: "m".repeat(64),
15828 signer_epoch: 1,
15829 control_revision: "d".repeat(64),
15830 backup_preparation: "ready".to_string(),
15831 prior_pointer_hash: None,
15832 signed_at: "2026-08-23T00:00:00Z".to_string(),
15833 };
15834 let paths = (0..=V2_DOWNLOAD_CAPABILITY_FILES)
15835 .map(|index| format!("assets/{index}.bin"))
15836 .collect::<Vec<_>>();
15837 let assets = paths
15838 .iter()
15839 .map(|_| V2BaselineAsset {
15840 blob_sha256: "a".repeat(64),
15841 bytes: 1,
15842 media_type: "application/octet-stream".to_string(),
15843 wrappers: Vec::new(),
15844 required: true,
15845 disposition: "hosted".to_string(),
15846 leaf_hash: "b".repeat(64),
15847 })
15848 .collect::<Vec<_>>();
15849 let pending = paths.iter().zip(&assets).collect::<Vec<_>>();
15850
15851 let error =
15852 stage_v2_asset_download_window(&cfg, TEST_BRAIN_ID, &pointer, temp.path(), &pending)
15853 .expect_err("an oversized window must fail before any network request");
15854 assert!(matches!(error, LinkError::InvalidFeed { .. }));
15855 }
15856
15857 #[test]
15858 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
15859 use ring::signature::KeyPair as _;
15860
15861 let rng = ring::rand::SystemRandom::new();
15862 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15863 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15864 let (spki, multikey) = public_identity_for(&pair);
15865 let key = AgentSigningKey {
15866 pkcs8: pkcs8.as_ref().to_vec(),
15867 multikey,
15868 public_key_spki: spki,
15869 };
15870 let header = linkmd_sig_header(
15871 &key,
15872 "https://hub-a.example",
15873 "post",
15874 "/api/hub/brains/brain/push?mode=exact",
15875 Some("{\"ok\":true}"),
15876 )
15877 .unwrap();
15878 assert!(header.starts_with("LinkMD-Sig v2,"));
15879 let ts = header
15880 .split(",ts=")
15881 .nth(1)
15882 .unwrap()
15883 .split(',')
15884 .next()
15885 .unwrap();
15886 let signature = URL_SAFE_NO_PAD
15887 .decode(header.rsplit(",sig=").next().unwrap())
15888 .unwrap();
15889 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
15890 let accepted = format!(
15891 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15892 );
15893 let replayed = format!(
15894 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15895 );
15896 let public = pair.public_key().as_ref();
15897 assert!(UnparsedPublicKey::new(&ED25519, public)
15898 .verify(accepted.as_bytes(), &signature)
15899 .is_ok());
15900 assert!(
15901 UnparsedPublicKey::new(&ED25519, public)
15902 .verify(replayed.as_bytes(), &signature)
15903 .is_err(),
15904 "a proof captured at hub A must not authenticate at hub B"
15905 );
15906 }
15907
15908 #[test]
15909 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
15910 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15911 let card = json!({
15912 "id": other,
15913 "headSeq": 0,
15914 "identity": signed_remote_fixture().identity,
15915 })
15916 .to_string();
15917 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15918 let state = tempfile::tempdir().unwrap();
15919 let cfg = test_hub_config(hub, state.path().to_path_buf());
15920 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15921 assert!(
15922 error.contains("differs from the explicitly requested"),
15923 "{error}"
15924 );
15925 server.join().unwrap();
15926 }
15927
15928 #[test]
15929 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
15930 let first = signed_remote_fixture().identity;
15931 let second = signed_remote_fixture().identity;
15932 let card = |identity: FeedIdentity| {
15933 json!({
15934 "id": TEST_BRAIN_ID,
15935 "headSeq": 0,
15936 "identity": identity,
15937 })
15938 .to_string()
15939 };
15940 let (hub, server) = scripted_json_hub(vec![
15941 (404, "{}".to_string()),
15942 (200, card(first)),
15943 (404, "{}".to_string()),
15944 (200, card(second)),
15945 ]);
15946 let state = tempfile::tempdir().unwrap();
15947 let cfg = test_hub_config(hub, state.path().to_path_buf());
15948 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15949 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15950 assert!(
15951 error.contains("pinned anchor") || error.contains("forked away"),
15952 "{error}"
15953 );
15954 server.join().unwrap();
15955 }
15956
15957 #[test]
15958 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
15959 let old = signed_remote_fixture();
15960 let new = signed_remote_fixture();
15961 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
15962 let unsigned = serde_json::to_string(&UnsignedRotation {
15963 v: 1,
15964 op: "rotate",
15965 brain: &old.key.multikey,
15966 public_key: &old.key.public_key_spki,
15967 new_brain: &new.key.multikey,
15968 new_public_key: &new.key.public_key_spki,
15969 prior_head_seq: 1,
15970 prior_feed_hash: Some(&"a".repeat(64)),
15971 ts: "2026-07-30T12:00:00.000Z".to_string(),
15972 })
15973 .unwrap();
15974 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
15975 let rotation = format!(
15976 "{},\"sig\":\"{}\"}}",
15977 &unsigned[..unsigned.len() - 1],
15978 signature
15979 );
15980 let identity = FeedIdentity {
15981 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
15982 public_key_spki: new.key.public_key_spki,
15983 previous: vec![PreviousIdentity {
15984 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
15985 public_key_spki: old.key.public_key_spki,
15986 }],
15987 rotations: vec![rotation],
15988 };
15989 let card = json!({
15990 "id": TEST_BRAIN_ID,
15991 "headSeq": 0,
15992 "feedHash": null,
15993 "identity": identity,
15994 })
15995 .to_string();
15996 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15997 let state = tempfile::tempdir().unwrap();
15998 let cfg = test_hub_config(hub, state.path().to_path_buf());
15999 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16000 assert!(
16001 error.contains("rotation claims a feed boundary beyond the advertised head"),
16002 "{error}"
16003 );
16004 assert!(
16005 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
16006 "an inconsistent empty-head identity must not become the TOFU checkpoint"
16007 );
16008 server.join().unwrap();
16009 }
16010
16011 #[test]
16012 fn trust_checkpoint_rejects_a_later_fork() {
16013 let fixture = signed_remote_fixture();
16014 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
16015 fork["feedHash"] = Value::String("b".repeat(64));
16016 let (hub, server) = scripted_json_hub(vec![
16017 (404, "{}".to_string()),
16018 (200, fixture.card),
16019 (200, fixture.feed),
16020 (404, "{}".to_string()),
16021 (200, fork.to_string()),
16022 ]);
16023 let state = tempfile::tempdir().unwrap();
16024 let cfg = test_hub_config(hub, state.path().to_path_buf());
16025 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16026 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
16027 server.join().unwrap();
16028 }
16029
16030 #[test]
16031 fn alias_and_canonical_id_share_one_identity_checkpoint() {
16032 let trusted = signed_remote_fixture();
16033 let attacker = signed_remote_fixture();
16034 let (hub, server) = scripted_json_hub(vec![
16035 (404, "{}".to_string()),
16036 (200, trusted.card),
16037 (200, trusted.feed),
16038 (404, "{}".to_string()),
16039 (200, attacker.card),
16040 ]);
16041 let state = tempfile::tempdir().unwrap();
16042 let cfg = test_hub_config(hub, state.path().to_path_buf());
16043 assert!(head(&cfg, "trusted-slug").unwrap().verified);
16044 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16045 assert!(
16046 error.contains("equivocation")
16047 || error.contains("pinned")
16048 || error.contains("identity"),
16049 "{error}"
16050 );
16051 server.join().unwrap();
16052 }
16053
16054 #[test]
16055 fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
16056 let state = tempfile::tempdir().unwrap();
16057 let cfg = test_hub_config(
16058 "https://hub.example".to_string(),
16059 state.path().to_path_buf(),
16060 );
16061 let directory = open_trust_dir(&cfg).unwrap();
16062 let old = TEST_BRAIN_ID;
16063 let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16064 save_alias_in(
16065 &cfg,
16066 &directory,
16067 &AliasBinding {
16068 v: 1,
16069 origin: normalized_origin(&cfg.hub).unwrap(),
16070 requested: "company-brain".to_string(),
16071 brain: old.to_string(),
16072 home: Some("company-brain".to_string()),
16073 },
16074 )
16075 .unwrap();
16076
16077 let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
16078 assert!(matches!(
16079 error,
16080 LinkError::AliasRebindRequired {
16081 alias,
16082 from,
16083 to
16084 } if alias == "company-brain" && from == old && to == new
16085 ));
16086 let unchanged = load_alias_in(&cfg, &directory, "company-brain")
16087 .unwrap()
16088 .unwrap();
16089 assert_eq!(unchanged.brain, old);
16090 assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
16091 }
16092
16093 #[test]
16094 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
16095 let alpha = signed_remote_fixture();
16096 let beta = signed_remote_fixture();
16097 let alpha_card = alpha.card.clone();
16098 let alpha_feed = alpha.feed.clone();
16099 let beta_card = beta.card.clone();
16100 let beta_feed = beta.feed.clone();
16101 let (hub, server) = routed_json_hub(5, move |path| {
16102 if path.ends_with("/v2/head") {
16103 (404, "{}".to_string())
16104 } else if path.contains("/alpha/feed?") {
16105 (200, alpha_feed.clone())
16106 } else if path.contains("/beta/feed?") {
16107 (200, beta_feed.clone())
16108 } else if path.ends_with("/alpha") {
16109 (200, alpha_card.clone())
16110 } else if path.ends_with("/beta") {
16111 (200, beta_card.clone())
16112 } else {
16113 (500, r#"{"error":"unexpected path"}"#.to_string())
16114 }
16115 });
16116 let state = tempfile::tempdir().unwrap();
16117 let cfg = test_hub_config(hub, state.path().to_path_buf());
16118 let alpha_cfg = cfg.clone();
16119 let beta_cfg = cfg;
16120 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
16121 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
16122 let results = [first.join().unwrap(), second.join().unwrap()];
16123 assert_eq!(
16124 results.iter().filter(|result| result.is_ok()).count(),
16125 1,
16126 "only one alias identity may establish canonical TOFU: {results:?}"
16127 );
16128 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
16129 server.join().unwrap();
16130 }
16131
16132 #[cfg(unix)]
16133 #[test]
16134 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
16135 use std::os::unix::fs::symlink;
16136
16137 let fixture = signed_remote_fixture();
16138 let card = json!({
16139 "id": TEST_BRAIN_ID,
16140 "headSeq": 0,
16141 "feedHash": Value::Null,
16142 "identity": fixture.identity,
16143 })
16144 .to_string();
16145 let work = tempfile::tempdir().unwrap();
16146 let outside = tempfile::tempdir().unwrap();
16147 let state = work.path().join("state");
16148 let moved = work.path().join("state-held");
16149 let swap_state = state.clone();
16150 let swap_moved = moved.clone();
16151 let outside_path = outside.path().to_path_buf();
16152 let (hub, server) = routed_json_hub(1, move |_| {
16153 std::fs::rename(&swap_state, &swap_moved).unwrap();
16155 symlink(&outside_path, &swap_state).unwrap();
16156 (200, card.clone())
16157 });
16158 let cfg = test_hub_config(hub, state);
16159
16160 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
16161 assert_eq!(verified.head.seq, 0);
16162 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
16163 assert!(std::fs::read_dir(moved.join("trust"))
16164 .unwrap()
16165 .flatten()
16166 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
16167 server.join().unwrap();
16168 }
16169
16170 #[test]
16171 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
16172 let remote = signed_remote_fixture();
16173 let unrelated = signed_remote_fixture().key;
16174 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
16175 let state = tempfile::tempdir().unwrap();
16176 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
16177 cfg.brain_key = Some(unrelated);
16178 let error = sync_push(
16179 &cfg,
16180 TEST_BRAIN_ID,
16181 &[("DB.md".to_string(), "signed local content".to_string())],
16182 )
16183 .unwrap_err()
16184 .to_string();
16185 assert!(
16186 error.contains("not the verified current brain identity"),
16187 "{error}"
16188 );
16189 server.join().unwrap();
16190 }
16191
16192 #[test]
16193 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
16194 let remote = signed_remote_fixture();
16195 let new = signed_remote_fixture().key;
16196 let state = tempfile::tempdir().unwrap();
16197 let new_file = state.path().join("new.key");
16198 std::fs::write(
16199 &new_file,
16200 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
16201 )
16202 .unwrap();
16203 #[cfg(unix)]
16204 {
16205 use std::os::unix::fs::PermissionsExt as _;
16206 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
16207 }
16208 let forged = json!({
16209 "brain": TEST_BRAIN_ID,
16210 "identity": {
16211 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
16212 "publicKeySpki": new.public_key_spki,
16213 }
16214 })
16215 .to_string();
16216 let (hub, server) = scripted_json_hub(vec![
16217 (404, "{}".to_string()),
16218 (200, remote.card.clone()),
16219 (200, remote.feed.clone()),
16220 (200, forged),
16221 (200, remote.card),
16222 (200, remote.feed),
16223 ]);
16224 let cfg = test_hub_config(hub, state.path().to_path_buf());
16225 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
16226 .unwrap_err()
16227 .to_string();
16228 assert!(
16229 error.contains("without committing the verified new identity"),
16230 "{error}"
16231 );
16232 server.join().unwrap();
16233 }
16234
16235 #[test]
16236 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
16237 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16238 let raw = format!(
16239 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16240 );
16241 let pack = build_store_pack(&[
16242 (
16243 "DB.md".to_string(),
16244 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
16245 ),
16246 ("records/clients/truth.md".to_string(), raw.clone()),
16247 ])
16248 .unwrap();
16249 let by_id = resolve_from_verified_pack(
16250 "01j5qc3v9k4ym8rwbn2tqe6f7d",
16251 &AddressTarget::Id(record_id.to_string()),
16252 pack.clone(),
16253 )
16254 .unwrap();
16255 assert_eq!(by_id["document"]["summary"], "Signed truth");
16256 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
16257 assert_eq!(
16258 by_id["document"]["contentSha"],
16259 content_sha256(raw.as_bytes())
16260 );
16261
16262 let by_path = resolve_from_verified_pack(
16263 "01j5qc3v9k4ym8rwbn2tqe6f7d",
16264 &AddressTarget::Path("records/clients/truth.md".to_string()),
16265 pack,
16266 )
16267 .unwrap();
16268 assert_eq!(by_path["document"]["id"], record_id);
16269 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
16270
16271 let wrong_id = resolve_from_verified_record_bytes(
16272 TEST_BRAIN_ID,
16273 &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
16274 "records/clients/truth.md".to_string(),
16275 raw.as_bytes().to_vec(),
16276 )
16277 .unwrap_err()
16278 .to_string();
16279 assert!(wrong_id.contains("id differs"), "{wrong_id}");
16280
16281 let wrong_path = resolve_from_verified_record_bytes(
16282 TEST_BRAIN_ID,
16283 &AddressTarget::Path("records/clients/other.md".to_string()),
16284 "records/clients/truth.md".to_string(),
16285 raw.into_bytes(),
16286 )
16287 .unwrap_err()
16288 .to_string();
16289 assert!(wrong_path.contains("path differs"), "{wrong_path}");
16290 }
16291
16292 #[test]
16293 fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
16294 let path = "records/clients/truth.md";
16295 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16296 let raw = format!(
16297 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16298 );
16299 let sha256 = content_sha256(raw.as_bytes());
16300 let mut nonce = 0_u128;
16301 let tree = crate::linkmd_v2::build_content_tree(
16302 &[crate::linkmd_v2::ContentFile {
16303 path: path.to_string(),
16304 blob_hash: sha256.clone(),
16305 bytes: raw.len() as u64,
16306 }],
16307 None,
16308 &mut || {
16309 nonce += 1;
16310 format!("{nonce:032x}")
16311 },
16312 )
16313 .unwrap();
16314 let root = tree.root.clone().unwrap();
16315 let mut directory_root = root.clone();
16316 let mut proof = Vec::new();
16317 for component in path.split('/') {
16318 let inclusion =
16319 crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
16320 let child = match &inclusion {
16321 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
16322 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
16323 panic!("fixture path must have an inclusion proof")
16324 }
16325 };
16326 proof.push(json!({
16327 "directory_root": directory_root,
16328 "component": component,
16329 "proof": inclusion,
16330 }));
16331 directory_root = child;
16332 }
16333 let commit_hash = "c".repeat(64);
16334 let pointer = V2PointerBody {
16335 v: 2,
16336 brain: TEST_BRAIN_ID.to_string(),
16337 seq: 1,
16338 commit_hash: commit_hash.clone(),
16339 feed_hash: "f".repeat(64),
16340 content_root: Some(root.clone()),
16341 asset_root: None,
16342 materializer: "dbmd-projection-v1".to_string(),
16343 signer_epoch: 1,
16344 control_revision: "d".repeat(64),
16345 backup_preparation: "e".repeat(64),
16346 prior_pointer_hash: None,
16347 signed_at: "2026-08-21T12:00:00.000Z".to_string(),
16348 };
16349 let manifest = json!({
16350 "v": 2,
16351 "commit": commit_hash,
16352 "content_root": root,
16353 "files": [{
16354 "path": path,
16355 "sha256": sha256,
16356 "bytes": raw.len(),
16357 "proof": proof,
16358 }],
16359 "next_cursor": Value::Null,
16360 })
16361 .to_string();
16362
16363 let path_manifest = manifest.clone();
16364 let (hub, server) = routed_json_hub(1, move |request| {
16365 assert_eq!(
16366 request,
16367 format!(
16368 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
16369 "c".repeat(64)
16370 )
16371 );
16372 (200, path_manifest.clone())
16373 });
16374 let state = tempfile::tempdir().unwrap();
16375 let cfg = test_hub_config(hub, state.path().to_path_buf());
16376 let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
16377 .unwrap()
16378 .unwrap();
16379 assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
16380 assert!(by_path.proof.is_some());
16381 server.join().unwrap();
16382
16383 let (hub, server) = routed_json_hub(1, move |request| {
16384 assert_eq!(
16385 request,
16386 format!(
16387 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
16388 "c".repeat(64)
16389 )
16390 );
16391 (404, r#"{"error":"File not found"}"#.to_string())
16392 });
16393 let state = tempfile::tempdir().unwrap();
16394 let cfg = test_hub_config(hub, state.path().to_path_buf());
16395 assert!(
16396 v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
16397 .unwrap()
16398 .is_none()
16399 );
16400 server.join().unwrap();
16401
16402 let id_manifest = manifest;
16403 let (hub, server) = routed_json_hub(1, move |request| {
16404 assert_eq!(
16405 request,
16406 format!(
16407 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
16408 "c".repeat(64)
16409 )
16410 );
16411 (200, id_manifest.clone())
16412 });
16413 let state = tempfile::tempdir().unwrap();
16414 let cfg = test_hub_config(hub, state.path().to_path_buf());
16415 let (located_path, by_id) =
16416 v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
16417 assert_eq!(located_path, path);
16418 assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
16419 server.join().unwrap();
16420 }
16421
16422 #[test]
16423 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
16424 let unsorted = vec![
16425 ("records/a.md".to_string(), "alpha\n".to_string()),
16426 ("DB.md".to_string(), "# db\n".to_string()),
16427 ];
16428 let sorted = vec![
16429 ("DB.md".to_string(), "# db\n".to_string()),
16430 ("records/a.md".to_string(), "alpha\n".to_string()),
16431 ];
16432 let pack = build_store_pack(&unsorted).unwrap();
16433
16434 assert_eq!(pack.len(), 219);
16439 assert_eq!(
16440 content_sha256(&pack),
16441 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
16442 );
16443 assert_eq!(pack, build_store_pack(&sorted).unwrap());
16444 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
16445 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
16446 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
16447
16448 assert_eq!(
16449 parse_store_pack(pack).unwrap(),
16450 vec![
16451 ("DB.md".to_string(), b"# db\n".to_vec()),
16452 ("records/a.md".to_string(), b"alpha\n".to_vec()),
16453 ]
16454 );
16455 }
16456
16457 #[test]
16458 fn canonical_store_pack_validates_every_path_before_writing() {
16459 let duplicate = vec![
16460 ("DB.md".to_string(), "first".to_string()),
16461 ("DB.md".to_string(), "second".to_string()),
16462 ];
16463 assert!(build_store_pack(&duplicate)
16464 .unwrap_err()
16465 .to_string()
16466 .contains("duplicate path"));
16467 assert!(matches!(
16468 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
16469 Err(LinkError::UnsafePath { .. })
16470 ));
16471 }
16472
16473 #[test]
16474 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
16475 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16476 let mut bytes = vec![0_u8];
16479 let zip64_offset = bytes.len() as u64;
16480 bytes.extend_from_slice(b"PK\x06\x06");
16481 bytes.extend_from_slice(&44_u64.to_le_bytes());
16482 bytes.extend_from_slice(&[0_u8; 12]);
16483 bytes.extend_from_slice(&COUNT.to_le_bytes());
16484 bytes.extend_from_slice(&COUNT.to_le_bytes());
16485 bytes.extend_from_slice(&1_u64.to_le_bytes());
16486 bytes.extend_from_slice(&0_u64.to_le_bytes());
16487 bytes.extend_from_slice(b"PK\x06\x07");
16488 bytes.extend_from_slice(&0_u32.to_le_bytes());
16489 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16490 bytes.extend_from_slice(&1_u32.to_le_bytes());
16491 bytes.extend_from_slice(b"PK\x05\x06");
16492 bytes.extend_from_slice(&0_u16.to_le_bytes());
16493 bytes.extend_from_slice(&0_u16.to_le_bytes());
16494 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16495 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16496 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16497 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16498 bytes.extend_from_slice(&0_u16.to_le_bytes());
16499
16500 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16501 .unwrap_err()
16502 .to_string();
16503 assert!(error.contains("invalid file count"), "{error}");
16504 }
16505
16506 #[test]
16507 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
16508 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16509 let mut bytes = vec![0_u8];
16510 let zip64_offset = bytes.len() as u64;
16511 bytes.extend_from_slice(b"PK\x06\x06");
16512 bytes.extend_from_slice(&44_u64.to_le_bytes());
16513 bytes.extend_from_slice(&[0_u8; 12]);
16514 bytes.extend_from_slice(&COUNT.to_le_bytes());
16515 bytes.extend_from_slice(&COUNT.to_le_bytes());
16516 bytes.extend_from_slice(&1_u64.to_le_bytes());
16517 bytes.extend_from_slice(&0_u64.to_le_bytes());
16518 bytes.extend_from_slice(b"PK\x06\x07");
16519 bytes.extend_from_slice(&0_u32.to_le_bytes());
16520 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16521 bytes.extend_from_slice(&1_u32.to_le_bytes());
16522 bytes.extend_from_slice(b"PK\x05\x06");
16523 bytes.extend_from_slice(&0_u16.to_le_bytes());
16524 bytes.extend_from_slice(&0_u16.to_le_bytes());
16525 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16526 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16527 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16528 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16529 bytes.extend_from_slice(&0_u16.to_le_bytes());
16530 let fake_eocd = bytes.len() as u32;
16534 bytes.extend_from_slice(b"PK\x05\x06");
16535 bytes.extend_from_slice(&0_u16.to_le_bytes());
16536 bytes.extend_from_slice(&0_u16.to_le_bytes());
16537 bytes.extend_from_slice(&1_u16.to_le_bytes());
16538 bytes.extend_from_slice(&1_u16.to_le_bytes());
16539 bytes.extend_from_slice(&0_u32.to_le_bytes());
16540 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
16541 bytes.extend_from_slice(&0_u16.to_le_bytes());
16542
16543 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16544 .unwrap_err()
16545 .to_string();
16546 assert!(error.contains("central directory"), "{error}");
16547 }
16548
16549 #[test]
16550 fn strict_http_status_handling_rejects_redirects_without_panicking() {
16551 let error = ensure_ok(
16552 HubResponse {
16553 status: 302,
16554 body: Some(json!({"redirect": "/elsewhere"})),
16555 },
16556 "mutation",
16557 )
16558 .unwrap_err();
16559 assert!(matches!(error, LinkError::Http { status: 302, .. }));
16560
16561 let error = ensure_raw_ok(
16562 RawHubResponse {
16563 status: 302,
16564 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
16565 },
16566 "feed",
16567 )
16568 .unwrap_err();
16569 assert!(matches!(error, LinkError::Http { status: 302, .. }));
16570 }
16571
16572 #[cfg(unix)]
16573 #[test]
16574 fn collect_push_files_refuses_external_symlink_and_nested_store() {
16575 use std::os::unix::fs::symlink;
16576
16577 let root = tempfile::tempdir().unwrap();
16578 std::fs::write(
16579 root.path().join("DB.md"),
16580 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
16581 )
16582 .unwrap();
16583 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
16584
16585 let external = tempfile::tempdir().unwrap();
16586 let secret = external.path().join("secret.md");
16587 std::fs::write(&secret, "TOP SECRET").unwrap();
16588 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
16589
16590 let store = Store::open_strict(root.path()).unwrap();
16591 let err = collect_push_files(&store).unwrap_err().to_string();
16592 assert!(err.contains("cannot push"), "{err}");
16593 assert!(
16594 !err.contains("TOP SECRET"),
16595 "external bytes must never leak"
16596 );
16597
16598 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
16599 let nested = root.path().join("records/nested");
16600 std::fs::create_dir_all(&nested).unwrap();
16601 std::fs::write(
16602 nested.join("DB.md"),
16603 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
16604 )
16605 .unwrap();
16606 let err = collect_push_files(&store).unwrap_err().to_string();
16607 assert!(err.contains("nested db.md store"), "{err}");
16608 }
16609
16610 #[cfg(unix)]
16611 #[test]
16612 fn remote_push_uses_opened_root_after_path_replacement() {
16613 use std::os::unix::fs::symlink;
16614
16615 let sandbox = tempfile::tempdir().unwrap();
16616 let root = sandbox.path().join("store");
16617 std::fs::create_dir_all(root.join("records/notes")).unwrap();
16618 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16619 std::fs::write(
16620 root.join("records/notes/owned.md"),
16621 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
16622 )
16623 .unwrap();
16624 let store = Store::open_strict(&root).unwrap();
16625 let detached = sandbox.path().join("detached");
16626 std::fs::rename(&root, &detached).unwrap();
16627
16628 let replacement = sandbox.path().join("replacement");
16629 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
16630 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16631 std::fs::write(
16632 replacement.join("records/notes/secret.md"),
16633 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
16634 )
16635 .unwrap();
16636 symlink(&replacement, &root).unwrap();
16637
16638 let files = collect_push_files(&store).unwrap();
16639 let wire_text = files
16640 .iter()
16641 .map(|(path, content)| format!("{path}\n{content}"))
16642 .collect::<Vec<_>>()
16643 .join("\n");
16644 assert!(wire_text.contains("owned upload"));
16645 assert!(!wire_text.contains("replacement sentinel"));
16646 assert!(!wire_text.contains("records/notes/secret.md"));
16647
16648 let remote = signed_remote_fixture();
16649 let (hub, server) = scripted_json_hub(vec![
16650 (200, remote.card),
16651 (200, remote.feed),
16652 (200, json!({"ok": true}).to_string()),
16653 ]);
16654 let state = tempfile::tempdir().unwrap();
16655 let cfg = test_hub_config(hub, state.path().to_path_buf());
16656 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
16657 assert_eq!(pushed, json!({"ok": true}));
16658 server.join().unwrap();
16659 }
16660
16661 #[test]
16662 fn signed_feed_item_verifies_identity_hash_and_signature() {
16663 use ring::rand::SystemRandom;
16664 use ring::signature::{Ed25519KeyPair, KeyPair};
16665
16666 const PREFIX: &[u8] = &[
16667 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
16668 ];
16669 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
16670 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16671 let mut spki = PREFIX.to_vec();
16672 spki.extend_from_slice(pair.public_key().as_ref());
16673 let public_key = URL_SAFE_NO_PAD.encode(&spki);
16674 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
16675 let mut entry = FeedEntry {
16676 v: 1,
16677 seq: 1,
16678 ts: "2026-07-14T00:00:00.000Z".to_string(),
16679 brain: format!("ed25519:{fingerprint}"),
16680 public_key: public_key.clone(),
16681 kind: "push".to_string(),
16682 op: "snapshot".to_string(),
16683 pack_sha256: "a".repeat(64),
16684 files: vec![FeedFile {
16685 path: "DB.md".to_string(),
16686 sha256: "b".repeat(64),
16687 bytes: 3,
16688 }],
16689 removed: vec![],
16690 prev_entry_hash: None,
16691 sig: String::new(),
16692 };
16693 let unsigned = UnsignedFeedEntry {
16694 v: entry.v,
16695 seq: entry.seq,
16696 ts: &entry.ts,
16697 brain: &entry.brain,
16698 public_key: &entry.public_key,
16699 kind: &entry.kind,
16700 op: &entry.op,
16701 pack_sha256: &entry.pack_sha256,
16702 files: &entry.files,
16703 removed: &entry.removed,
16704 prev_entry_hash: &entry.prev_entry_hash,
16705 };
16706 entry.sig =
16707 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16708 let mut exact = serde_json::to_vec(&entry).unwrap();
16709 exact.push(b'\n');
16710 let item = FeedItem {
16711 hash: format!("{:x}", Sha256::digest(&exact)),
16712 entry,
16713 };
16714 let identity = FeedIdentity {
16715 fingerprint,
16716 public_key_spki: public_key,
16717 previous: Vec::new(),
16718 rotations: Vec::new(),
16719 };
16720 assert!(verify_feed_item(&item, &identity).is_ok());
16721 let mut tampered = item;
16722 tampered.entry.pack_sha256 = "c".repeat(64);
16723 assert!(verify_feed_item(&tampered, &identity).is_err());
16724 }
16725
16726 #[test]
16727 fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
16728 let rng = ring::rand::SystemRandom::new();
16729 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16730 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16731 let (spki, multikey) = public_identity_for(&pair);
16732 let identity = V2HeadIdentity {
16733 custody: "self".to_string(),
16734 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16735 public_key_spki: spki.clone(),
16736 previous: Vec::new(),
16737 rotations: Vec::new(),
16738 };
16739 let unsigned = json!({
16740 "actor_ref": "a".repeat(64),
16741 "asset_root": Value::Null,
16742 "brain": multikey,
16743 "changes_sha256": "b".repeat(64),
16744 "control_revision": "c".repeat(64),
16745 "materializer": "dbmd-projection-v1",
16746 "op": "changeset",
16747 "parent_asset_root": Value::Null,
16748 "parent_commit": Value::Null,
16749 "parent_root": Value::Null,
16750 "prev_entry_hash": Value::Null,
16751 "public_key": spki,
16752 "seq": 1,
16753 "signer_epoch": 1,
16754 "state_root": "d".repeat(64),
16755 "ts": "2026-08-19T12:00:00.000Z",
16756 "v": 2,
16757 "v1_bridge": {
16758 "feed_hash": "e".repeat(64),
16759 "head_seq": 7,
16760 "pack_sha256": "f".repeat(64),
16761 },
16762 });
16763 let sign_value = |value: Value| {
16764 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16765 let mut object = value.as_object().unwrap().clone();
16766 object.insert(
16767 "sig".to_string(),
16768 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16769 );
16770 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16771 };
16772 assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
16773
16774 let mut extra = unsigned.clone();
16775 extra
16776 .as_object_mut()
16777 .unwrap()
16778 .insert("future".to_string(), Value::Bool(true));
16779 assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
16780
16781 let mut missing = unsigned.clone();
16782 missing.as_object_mut().unwrap().remove("v1_bridge");
16783 assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
16784
16785 let mut invalid_bridge = unsigned;
16786 invalid_bridge.as_object_mut().unwrap().insert(
16787 "v1_bridge".to_string(),
16788 json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
16789 );
16790 assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
16791 }
16792
16793 #[test]
16794 fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
16795 let vector: Value = serde_json::from_str(include_str!(
16796 "../tests/vectors/linkmd-v2-commit-bridge.json"
16797 ))
16798 .unwrap();
16799 let identity_value = vector.get("identity").unwrap();
16800 let identity = V2HeadIdentity {
16801 custody: "self".to_string(),
16802 fingerprint: identity_value
16803 .get("fingerprint")
16804 .and_then(Value::as_str)
16805 .unwrap()
16806 .to_string(),
16807 public_key_spki: identity_value
16808 .get("public_key_spki")
16809 .and_then(Value::as_str)
16810 .unwrap()
16811 .to_string(),
16812 previous: Vec::new(),
16813 rotations: Vec::new(),
16814 };
16815 let private = URL_SAFE_NO_PAD
16816 .decode(
16817 identity_value
16818 .get("private_key_pkcs8")
16819 .and_then(Value::as_str)
16820 .unwrap(),
16821 )
16822 .unwrap();
16823 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
16824 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
16825 .unwrap();
16826 let base = vector.get("body").unwrap().as_object().unwrap();
16827
16828 for item in vector.get("valid").unwrap().as_array().unwrap() {
16829 let mut body = base.clone();
16830 body.insert(
16831 "v1_bridge".to_string(),
16832 item.get("v1_bridge").unwrap().clone(),
16833 );
16834 body.insert(
16835 "sig".to_string(),
16836 item.get("signature_base64url").unwrap().clone(),
16837 );
16838 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16839 assert!(verified_v2_commit_object(&signed, &identity).is_ok());
16840 assert_eq!(
16841 crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
16842 item.get("commit_hash").and_then(Value::as_str).unwrap()
16843 );
16844 assert_eq!(
16845 format!("{:x}", Sha256::digest(&signed)),
16846 item.get("feed_hash").and_then(Value::as_str).unwrap()
16847 );
16848 }
16849
16850 for item in vector.get("invalid").unwrap().as_array().unwrap() {
16851 let mut body = base.clone();
16852 if let Some(remove) = item.get("remove").and_then(Value::as_array) {
16853 for field in remove {
16854 body.remove(field.as_str().unwrap());
16855 }
16856 }
16857 if let Some(set) = item.get("set").and_then(Value::as_object) {
16858 for (field, value) in set {
16859 body.insert(field.clone(), value.clone());
16860 }
16861 }
16862 let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
16863 body.insert(
16864 "sig".to_string(),
16865 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16866 );
16867 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16868 assert!(
16869 verified_v2_commit_object(&signed, &identity).is_err(),
16870 "accepted invalid shared vector {}",
16871 item.get("reason").and_then(Value::as_str).unwrap()
16872 );
16873 }
16874 }
16875
16876 #[test]
16877 fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
16878 let vector: Value = serde_json::from_str(include_str!(
16879 "../tests/vectors/linkmd-v2-changeset-withheld.json"
16880 ))
16881 .unwrap();
16882 assert_eq!(
16883 vector.get("profile").and_then(Value::as_str),
16884 Some("link.md-v2-changeset-withheld")
16885 );
16886 let canonical =
16887 crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
16888 let expected = STANDARD
16889 .decode(
16890 vector
16891 .get("canonical_base64")
16892 .and_then(Value::as_str)
16893 .unwrap(),
16894 )
16895 .unwrap();
16896 assert_eq!(canonical, expected);
16897 assert_eq!(
16898 crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
16899 vector.get("domain_hash").and_then(Value::as_str).unwrap()
16900 );
16901 }
16902
16903 #[test]
16904 fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
16905 let remote = signed_remote_fixture();
16906 let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
16907 let legacy_item = legacy.entries.first().unwrap();
16908 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
16909 let body = json!({
16910 "actor_ref": "a".repeat(64),
16911 "asset_root": Value::Null,
16912 "brain": remote.key.multikey,
16913 "changes_sha256": "b".repeat(64),
16914 "control_revision": "c".repeat(64),
16915 "materializer": "dbmd-projection-v1",
16916 "op": "changeset",
16917 "parent_asset_root": Value::Null,
16918 "parent_commit": Value::Null,
16919 "parent_root": Value::Null,
16920 "prev_entry_hash": Value::Null,
16921 "public_key": remote.key.public_key_spki,
16922 "seq": 1,
16923 "signer_epoch": 1,
16924 "state_root": "d".repeat(64),
16925 "ts": "2026-08-19T12:00:00.000Z",
16926 "v": 2,
16927 "v1_bridge": {
16928 "feed_hash": legacy_item.hash,
16929 "head_seq": legacy_item.entry.seq,
16930 "pack_sha256": legacy_item.entry.pack_sha256,
16931 },
16932 });
16933 let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
16934 let mut signed = body.as_object().unwrap().clone();
16935 signed.insert(
16936 "sig".to_string(),
16937 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16938 );
16939 let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
16940 let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
16941 let feed_hash = content_sha256(&raw);
16942 let pointer = V2PointerBody {
16943 v: 2,
16944 brain: TEST_BRAIN_ID.to_string(),
16945 seq: 1,
16946 commit_hash: commit_hash.clone(),
16947 feed_hash: feed_hash.clone(),
16948 content_root: Some("d".repeat(64)),
16949 asset_root: None,
16950 materializer: "dbmd-projection-v1".to_string(),
16951 signer_epoch: 1,
16952 control_revision: "c".repeat(64),
16953 backup_preparation: "e".repeat(64),
16954 prior_pointer_hash: None,
16955 signed_at: "2026-08-19T12:00:00.000Z".to_string(),
16956 };
16957 let v2_page = json!({
16958 "v": 2,
16959 "head_seq": 1,
16960 "head_commit_hash": commit_hash,
16961 "head_feed_hash": feed_hash,
16962 "entries": [{
16963 "seq": 1,
16964 "commit_hash": pointer.commit_hash,
16965 "feed_hash": pointer.feed_hash,
16966 "bytes_base64": STANDARD.encode(&raw),
16967 }],
16968 "next_after": 1,
16969 "complete": true,
16970 })
16971 .to_string();
16972 let identity = V2HeadIdentity {
16973 custody: "self".to_string(),
16974 fingerprint: remote.identity.fingerprint.clone(),
16975 public_key_spki: remote.identity.public_key_spki.clone(),
16976 previous: Vec::new(),
16977 rotations: Vec::new(),
16978 };
16979 let checkpoint = TrustState {
16980 v: 2,
16981 origin: "unused".to_string(),
16982 requested: TEST_BRAIN_ID.to_string(),
16983 brain: TEST_BRAIN_ID.to_string(),
16984 home: None,
16985 anchor: remote.key.multikey.clone(),
16986 current: remote.key.multikey,
16987 head_seq: legacy_item.entry.seq,
16988 feed_hash: Some(legacy_item.hash.clone()),
16989 rotations: Vec::new(),
16990 hub_signer: None,
16991 protocol_profile: None,
16992 };
16993 let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
16994 let state = tempfile::tempdir().unwrap();
16995 let cfg = test_hub_config(hub, state.path().to_path_buf());
16996 verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
16997 server.join().unwrap();
16998
16999 let mut wrong = checkpoint;
17000 wrong.feed_hash = Some("0".repeat(64));
17001 let (hub, server) = scripted_json_hub(vec![(
17002 200,
17003 json!({
17004 "v": 2,
17005 "head_seq": 1,
17006 "head_commit_hash": pointer.commit_hash,
17007 "head_feed_hash": pointer.feed_hash,
17008 "entries": [{
17009 "seq": 1,
17010 "commit_hash": pointer.commit_hash,
17011 "feed_hash": pointer.feed_hash,
17012 "bytes_base64": STANDARD.encode(&raw),
17013 }],
17014 "next_after": 1,
17015 "complete": true,
17016 })
17017 .to_string(),
17018 )]);
17019 let state = tempfile::tempdir().unwrap();
17020 let cfg = test_hub_config(hub, state.path().to_path_buf());
17021 assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
17022 server.join().unwrap();
17023 }
17024
17025 #[test]
17026 fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
17027 let rng = ring::rand::SystemRandom::new();
17028 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17029 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17030 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17031 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17032 let (old_spki, old_multikey) = public_identity_for(&old);
17033 let (new_spki, new_multikey) = public_identity_for(&new);
17034 let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
17035 v: 1,
17036 op: "rotate",
17037 brain: &old_multikey,
17038 public_key: &old_spki,
17039 new_brain: &new_multikey,
17040 new_public_key: &new_spki,
17041 prior_head_seq: 1,
17042 prior_feed_hash: Some(&"9".repeat(64)),
17043 ts: "2026-08-19T12:01:00.000Z".to_string(),
17044 })
17045 .unwrap();
17046 let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
17047 let rotation = format!(
17048 "{},\"sig\":\"{}\"}}",
17049 &rotation_unsigned[..rotation_unsigned.len() - 1],
17050 rotation_sig
17051 );
17052 let identity = V2HeadIdentity {
17053 custody: "self".to_string(),
17054 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17055 public_key_spki: new_spki.clone(),
17056 previous: vec![V2PreviousIdentity {
17057 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17058 public_key_spki: old_spki.clone(),
17059 }],
17060 rotations: vec![rotation],
17061 };
17062 let commit = |seq: u64,
17063 epoch: u64,
17064 multikey: &str,
17065 spki: &str,
17066 pair: &ring::signature::Ed25519KeyPair| {
17067 let value = json!({
17068 "actor_ref": "a".repeat(64),
17069 "asset_root": Value::Null,
17070 "brain": multikey,
17071 "changes_sha256": "b".repeat(64),
17072 "control_revision": "c".repeat(64),
17073 "materializer": "dbmd-projection-v1",
17074 "op": "changeset",
17075 "parent_asset_root": Value::Null,
17076 "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
17077 "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
17078 "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
17079 "public_key": spki,
17080 "seq": seq,
17081 "signer_epoch": epoch,
17082 "state_root": "1".repeat(64),
17083 "ts": "2026-08-19T12:00:00.000Z",
17084 "v": 2,
17085 "v1_bridge": Value::Null,
17086 });
17087 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
17088 let mut object = value.as_object().unwrap().clone();
17089 object.insert(
17090 "sig".to_string(),
17091 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17092 );
17093 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
17094 };
17095
17096 assert!(verified_v2_commit_object(
17097 &commit(1, 1, &old_multikey, &old_spki, &old),
17098 &identity,
17099 )
17100 .is_ok());
17101 assert!(verified_v2_commit_object(
17102 &commit(2, 2, &new_multikey, &new_spki, &new),
17103 &identity,
17104 )
17105 .is_ok());
17106 assert!(verified_v2_commit_object(
17107 &commit(2, 1, &old_multikey, &old_spki, &old),
17108 &identity,
17109 )
17110 .is_err());
17111 assert!(verified_v2_commit_object(
17112 &commit(1, 2, &new_multikey, &new_spki, &new),
17113 &identity,
17114 )
17115 .is_err());
17116 }
17117
17118 #[test]
17119 fn a_self_custody_entry_verifies_like_any_hub_entry() {
17120 let rng = ring::rand::SystemRandom::new();
17121 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17122 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17123 let (spki, multikey) = public_identity_for(&pair);
17124 let key = AgentSigningKey {
17125 pkcs8: pkcs8.as_ref().to_vec(),
17126 multikey: multikey.clone(),
17127 public_key_spki: spki.clone(),
17128 };
17129 let files = vec![WireFeedFile {
17130 path: "DB.md".to_string(),
17131 sha256: "a".repeat(64),
17132 bytes: 3,
17133 }];
17134 let raw = self_custody_entry(
17135 &key,
17136 1,
17137 "2026-07-23T12:00:00.000Z".to_string(),
17138 &"c".repeat(64),
17139 &files,
17140 None,
17141 )
17142 .unwrap();
17143 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
17147 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
17148 let item = FeedItem { hash, entry };
17149 let identity = FeedIdentity {
17150 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
17151 public_key_spki: spki,
17152 previous: Vec::new(),
17153 rotations: Vec::new(),
17154 };
17155 assert!(verify_feed_item(&item, &identity).is_ok());
17156 }
17157
17158 #[test]
17159 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
17160 let rng = ring::rand::SystemRandom::new();
17161 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17162 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17163 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17164 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17165 let (old_spki, old_multikey) = public_identity_for(&old);
17166 let (new_spki, new_multikey) = public_identity_for(&new);
17167 let unsigned = serde_json::to_string(&UnsignedRotation {
17168 v: 1,
17169 op: "rotate",
17170 brain: &old_multikey,
17171 public_key: &old_spki,
17172 new_brain: &new_multikey,
17173 new_public_key: &new_spki,
17174 prior_head_seq: 1,
17175 prior_feed_hash: Some(&"a".repeat(64)),
17176 ts: "2026-07-30T12:00:00.000Z".to_string(),
17177 })
17178 .unwrap();
17179 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
17180 let rotation = format!(
17181 "{},\"sig\":\"{}\"}}",
17182 &unsigned[..unsigned.len() - 1],
17183 signature
17184 );
17185 let identity = FeedIdentity {
17186 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17187 public_key_spki: new_spki,
17188 previous: vec![PreviousIdentity {
17189 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17190 public_key_spki: old_spki,
17191 }],
17192 rotations: vec![rotation],
17193 };
17194 let pin = TrustState {
17195 v: 2,
17196 origin: "https://hub.example".to_string(),
17197 requested: "brain".to_string(),
17198 brain: "brain".to_string(),
17199 home: None,
17200 anchor: old_multikey.clone(),
17201 current: old_multikey.clone(),
17202 head_seq: 1,
17203 feed_hash: Some("a".repeat(64)),
17204 rotations: Vec::new(),
17205 hub_signer: None,
17206 protocol_profile: None,
17207 };
17208 assert_eq!(
17209 verify_identity_chain(&identity, Some(&pin)).unwrap(),
17210 old_multikey
17211 );
17212 let mut accepted = pin.clone();
17213 accepted.current = new_multikey.clone();
17214 accepted.rotations = identity.rotations.clone();
17215 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
17216 v: 1,
17217 op: "rotate",
17218 brain: &old_multikey,
17219 public_key: &identity.previous[0].public_key_spki,
17220 new_brain: &new_multikey,
17221 new_public_key: &identity.public_key_spki,
17222 prior_head_seq: 1,
17223 prior_feed_hash: Some(&"a".repeat(64)),
17224 ts: "2026-07-30T12:00:01.000Z".to_string(),
17225 })
17226 .unwrap();
17227 let alternate_signature =
17228 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
17229 let mut rewritten = identity.clone();
17230 rewritten.rotations[0] = format!(
17231 "{},\"sig\":\"{}\"}}",
17232 &alternate_unsigned[..alternate_unsigned.len() - 1],
17233 alternate_signature
17234 );
17235 assert!(
17236 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
17237 "an alternate valid statement must not rewrite accepted history"
17238 );
17239
17240 let mut stale_entry = FeedEntry {
17241 v: 1,
17242 seq: 2,
17243 ts: "2026-07-30T12:01:00.000Z".to_string(),
17244 brain: pin.current.clone(),
17245 public_key: identity.previous[0].public_key_spki.clone(),
17246 kind: "push".to_string(),
17247 op: "snapshot".to_string(),
17248 pack_sha256: "b".repeat(64),
17249 files: Vec::new(),
17250 removed: Vec::new(),
17251 prev_entry_hash: pin.feed_hash.clone(),
17252 sig: String::new(),
17253 };
17254 let stale_unsigned = UnsignedFeedEntry {
17255 v: stale_entry.v,
17256 seq: stale_entry.seq,
17257 ts: &stale_entry.ts,
17258 brain: &stale_entry.brain,
17259 public_key: &stale_entry.public_key,
17260 kind: &stale_entry.kind,
17261 op: &stale_entry.op,
17262 pack_sha256: &stale_entry.pack_sha256,
17263 files: &stale_entry.files,
17264 removed: &stale_entry.removed,
17265 prev_entry_hash: &stale_entry.prev_entry_hash,
17266 };
17267 stale_entry.sig = URL_SAFE_NO_PAD.encode(
17268 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
17269 .as_ref(),
17270 );
17271 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
17272 stale_exact.push(b'\n');
17273 let stale_item = FeedItem {
17274 hash: content_sha256(&stale_exact),
17275 entry: stale_entry,
17276 };
17277 assert!(
17278 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
17279 .is_err(),
17280 "a key retired before the checkpoint must never append after it"
17281 );
17282 assert!(
17283 verify_feed_item(&stale_item, &identity).is_err(),
17284 "an old key must never append after its signed rotation boundary"
17285 );
17286
17287 let mut missing = identity.clone();
17288 missing.rotations.clear();
17289 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
17290
17291 let mut tampered = identity;
17292 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
17293 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
17294 }
17295
17296 #[cfg(unix)]
17297 #[test]
17298 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
17299 use std::os::unix::fs::symlink;
17300
17301 let dir = tempfile::tempdir().unwrap();
17302 let target = dir.path().join("valuable.txt");
17303 let planted = dir.path().join("agent.key");
17304 std::fs::write(&target, "do not overwrite").unwrap();
17305 symlink(&target, &planted).unwrap();
17306
17307 assert!(matches!(
17308 generate_agent_key(&planted),
17309 Err(LinkError::BadAgentKey { .. })
17310 ));
17311 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
17312 }
17313
17314 #[cfg(unix)]
17315 #[test]
17316 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
17317 use std::os::unix::fs::symlink;
17318
17319 let root = tempfile::tempdir().unwrap();
17320 let outside = tempfile::tempdir().unwrap();
17321 symlink(outside.path(), root.path().join("redirect")).unwrap();
17322
17323 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
17324 assert!(!outside.path().join("agent.key").exists());
17325 }
17326
17327 #[test]
17330 fn address_bare_brain_with_and_without_sigil() {
17331 for raw in ["@acme-ops", "acme-ops"] {
17332 let a = Address::parse(raw).expect(raw);
17333 assert_eq!(a.brain, "acme-ops");
17334 assert_eq!(a.target, None);
17335 }
17336 }
17337
17338 #[test]
17339 fn address_ulid_target_parses_as_id() {
17340 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
17341 assert_eq!(a.brain, "acme");
17342 assert_eq!(
17343 a.target,
17344 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
17345 );
17346 }
17347
17348 #[test]
17349 fn address_md_path_target_parses_as_path() {
17350 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
17351 assert_eq!(
17352 a.target,
17353 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
17354 );
17355 }
17356
17357 #[test]
17358 fn address_rejects_malformed_forms() {
17359 for raw in [
17360 "",
17361 "@",
17362 "@/x",
17363 "@acme/",
17364 "@acme/../etc/passwd",
17365 "@acme/records/.hidden.md",
17366 "@ACME", "@acme/notes/x.txt", "@a b", ] {
17370 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
17371 }
17372 }
17373
17374 #[test]
17377 fn safe_paths_accept_store_shapes_and_reject_escapes() {
17378 for ok in [
17379 "DB.md",
17380 "assets.jsonl",
17381 "records/clients/lumio.md",
17382 "sources/emails/2026/07/x.md",
17383 ] {
17384 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
17385 }
17386 for bad in [
17387 "",
17388 "/etc/passwd",
17389 "../up.md",
17390 "records/../../up.md",
17391 "records//x.md",
17392 ".dbmd/config",
17393 "records/.hidden/x.md",
17394 "records/a b.md",
17395 "records\\win.md",
17396 ] {
17397 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
17398 }
17399 }
17400
17401 #[cfg(unix)]
17402 #[test]
17403 fn opened_destination_capability_survives_an_ancestor_path_swap() {
17404 use std::os::unix::fs::symlink;
17405
17406 let work = tempfile::tempdir().unwrap();
17407 let outside = tempfile::tempdir().unwrap();
17408 let original = work.path().join("destination");
17409 let moved = work.path().join("destination-moved");
17410 let directory = open_or_create_dir_nofollow(&original).unwrap();
17411
17412 std::fs::rename(&original, &moved).unwrap();
17413 symlink(outside.path(), &original).unwrap();
17414 write_pull_entries_beneath_dir(
17415 &directory,
17416 &[("records/note.md".to_string(), b"held inode".to_vec())],
17417 )
17418 .unwrap();
17419
17420 assert_eq!(
17421 std::fs::read(moved.join("records/note.md")).unwrap(),
17422 b"held inode"
17423 );
17424 assert!(!outside.path().join("records/note.md").exists());
17425 }
17426
17427 #[test]
17431 fn hub_config_flag_beats_file_and_requires_some_source() {
17432 let dir = tempfile::tempdir().unwrap();
17433 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
17434 std::fs::write(
17435 dir.path().join(CONFIG_REL_PATH),
17436 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
17437 )
17438 .unwrap();
17439
17440 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
17441 assert_eq!(from_flag.hub, "https://flag.example.com");
17442
17443 let from_file = hub_config(None, dir.path()).unwrap();
17444 assert_eq!(from_file.hub, "https://file.example.com");
17445
17446 let none = hub_config(None, tempfile::tempdir().unwrap().path());
17447 assert!(matches!(none, Err(LinkError::NoHub)));
17448 }
17449
17450 #[test]
17451 fn https_guard_allows_loopback_only_for_plain_http() {
17452 assert!(assert_safe_hub("https://hub.example.com").is_ok());
17453 assert!(assert_safe_hub("http://localhost:3000").is_ok());
17454 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
17455 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
17456 assert!(matches!(
17457 assert_safe_hub("http://hub.example.com"),
17458 Err(LinkError::UnsafeHub { .. })
17459 ));
17460 assert!(matches!(
17461 assert_safe_hub("hub.example.com"),
17462 Err(LinkError::UnsafeHub { .. })
17463 ));
17464 assert!(matches!(
17465 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
17466 Err(LinkError::UnsafeHub { .. })
17467 ));
17468 assert!(matches!(
17469 assert_safe_hub("https://hub.example.com@attacker.example"),
17470 Err(LinkError::UnsafeHub { .. })
17471 ));
17472 assert!(matches!(
17473 assert_safe_hub("https://hub.example.com/base"),
17474 Err(LinkError::UnsafeHub { .. })
17475 ));
17476 }
17477
17478 #[test]
17479 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
17480 for blocked in [
17481 "127.0.0.1",
17482 "10.0.0.1",
17483 "100.64.0.1",
17484 "169.254.169.254",
17485 "172.16.0.1",
17486 "192.168.0.1",
17487 "192.88.99.1",
17488 "198.18.0.1",
17489 "203.0.113.1",
17490 "::1",
17491 "fe80::1",
17492 "fd00::1",
17493 "2001:db8::1",
17494 "2001:1::1",
17495 "2002:7f00:1::",
17496 "3fff::1",
17497 ] {
17498 assert!(
17499 !is_public_registry_ip(blocked.parse().unwrap()),
17500 "must block {blocked}"
17501 );
17502 }
17503 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
17504 assert!(is_public_registry_ip(
17505 "2606:4700:4700::1111".parse().unwrap()
17506 ));
17507 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
17508 }
17509
17510 #[test]
17511 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
17512 use ureq::Resolver as _;
17513
17514 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
17515 let resolver = PinnedRegistryResolver {
17516 netloc: "home.example:443".to_string(),
17517 addresses: vec![pinned],
17518 };
17519 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
17520 assert!(resolver.resolve("127.0.0.1:443").is_err());
17521 assert_eq!(
17522 resolver.resolve("home.example:443").unwrap(),
17523 vec![pinned],
17524 "subsequent connects reuse the validated answer instead of DNS"
17525 );
17526 }
17527
17528 #[test]
17529 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
17530 let cfg = HubConfig {
17531 hub: "https://hub.example".to_string(),
17532 key: None,
17533 agent_key: None,
17534 brain_key: None,
17535 state_dir: tempfile::tempdir().unwrap().keep(),
17536 store_selected: false,
17537 };
17538 assert!(
17539 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
17540 "a production hub must not turn its presigned URL into an SSRF primitive"
17541 );
17542
17543 let store_selected = HubConfig {
17544 hub: "https://127.0.0.1".to_string(),
17545 store_selected: true,
17546 ..cfg
17547 };
17548 assert!(
17549 hub_agent(&store_selected).is_err(),
17550 "bytes in a cloned store must not select a private-network hub"
17551 );
17552 }
17553
17554 #[test]
17555 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
17556 assert_eq!(
17557 one_past_bounded_limit(MAX_PACK_BYTES),
17558 Some(MAX_PACK_BYTES + 1),
17559 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
17560 );
17561 assert_eq!(
17562 presigned_download_read_limit(),
17563 MAX_PACK_BYTES + 1,
17564 "the presigned reader is capped by the client constant, not a hub response"
17565 );
17566 assert_eq!(
17567 one_past_bounded_limit(u64::MAX),
17568 None,
17569 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
17570 );
17571 }
17572
17573 #[test]
17574 fn https_guard_matches_the_scheme_case_insensitively() {
17575 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
17578 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
17579 assert!(matches!(
17581 assert_safe_hub("HTTP://hub.example.com"),
17582 Err(LinkError::UnsafeHub { .. })
17583 ));
17584 }
17585
17586 #[test]
17587 fn clean_key_refuses_paste_artifacts_without_echoing() {
17588 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
17589 for bad in ["vc account", "vc\naccount", "ключ", ""] {
17590 let err = clean_key(bad).unwrap_err();
17591 assert!(matches!(err, LinkError::BadKey));
17592 assert!(
17593 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
17594 "error must not echo the key"
17595 );
17596 }
17597 }
17598
17599 fn dead_hub() -> HubConfig {
17605 HubConfig {
17606 hub: "http://127.0.0.1:9".to_string(),
17607 key: Some("k".to_string()),
17608 agent_key: None,
17609 brain_key: None,
17610 state_dir: PathBuf::from("."),
17611 store_selected: false,
17612 }
17613 }
17614
17615 #[test]
17616 fn request_retries_a_connection_failure_before_sending() {
17617 use std::io::{Read as _, Write as _};
17618 use std::net::TcpListener;
17619 use std::thread;
17620 use std::time::Duration;
17621
17622 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
17623 let address = probe.local_addr().unwrap();
17624 drop(probe);
17625 let server = thread::spawn(move || {
17626 thread::sleep(Duration::from_millis(40));
17627 let listener = TcpListener::bind(address).unwrap();
17628 let (mut stream, _) = listener.accept().unwrap();
17629 let mut request_bytes = [0_u8; 1024];
17630 let _ = stream.read(&mut request_bytes).unwrap();
17631 stream
17632 .write_all(
17633 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17634 )
17635 .unwrap();
17636 });
17637 let cfg = HubConfig {
17638 hub: format!("http://{address}"),
17639 key: None,
17640 agent_key: None,
17641 brain_key: None,
17642 state_dir: tempfile::tempdir().unwrap().keep(),
17643 store_selected: false,
17644 };
17645
17646 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
17647 assert_eq!(response.status, 200);
17648 assert_eq!(response.body, Some(json!({ "ok": true })));
17649 server.join().unwrap();
17650 }
17651
17652 #[test]
17653 fn a_commit_goes_back_for_a_receipt_it_lost() {
17654 use std::io::{Read as _, Write as _};
17655 use std::net::TcpListener;
17656 use std::thread;
17657
17658 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17664 let address = listener.local_addr().unwrap();
17665 let server = thread::spawn(move || {
17666 let (mut first, _) = listener.accept().unwrap();
17668 let mut bytes = [0_u8; 4096];
17669 let _ = first.read(&mut bytes).unwrap();
17670 first
17671 .write_all(
17672 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
17673 )
17674 .unwrap();
17675 drop(first);
17676 let (mut second, _) = listener.accept().unwrap();
17678 let _ = second.read(&mut bytes).unwrap();
17679 second
17680 .write_all(
17681 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\"}",
17682 )
17683 .unwrap();
17684 });
17685 let cfg = HubConfig {
17686 hub: format!("http://{address}"),
17687 key: Some("k".to_string()),
17688 agent_key: None,
17689 brain_key: None,
17690 state_dir: tempfile::tempdir().unwrap().keep(),
17691 store_selected: false,
17692 };
17693
17694 let response = request_patient(
17695 &cfg,
17696 "POST",
17697 "/api/hub/brains/b/v2/commits",
17698 Some(&json!({ "mutation_id": "dbmd-1" })),
17699 Auth::Required,
17700 )
17701 .expect("the receipt is collected on the second ask");
17702 assert_eq!(response.status, 200);
17703 assert_eq!(
17704 response
17705 .body
17706 .as_ref()
17707 .and_then(|value| value.get("outcome"))
17708 .and_then(Value::as_str),
17709 Some("converged"),
17710 "an already-applied mutation answers with its receipt"
17711 );
17712 server.join().unwrap();
17713 }
17714
17715 #[test]
17716 fn a_mutation_body_that_dies_mid_stream_is_a_transport_failure() {
17717 use std::io::{Read as _, Write as _};
17718 use std::net::TcpListener;
17719 use std::thread;
17720
17721 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17727 let address = listener.local_addr().unwrap();
17728 let server = thread::spawn(move || {
17729 let (mut stream, _) = listener.accept().unwrap();
17730 let mut request_bytes = [0_u8; 1024];
17731 let _ = stream.read(&mut request_bytes).unwrap();
17732 stream
17734 .write_all(
17735 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17736 )
17737 .unwrap();
17738 });
17739 let cfg = HubConfig {
17740 hub: format!("http://{address}"),
17741 key: None,
17742 agent_key: None,
17743 brain_key: None,
17744 state_dir: tempfile::tempdir().unwrap().keep(),
17745 store_selected: false,
17746 };
17747
17748 let error = request(&cfg, "POST", "/truncated", None, Auth::None)
17749 .expect_err("a truncated body must not read as success");
17750 match error {
17751 LinkError::Transport { hub, .. } => {
17752 assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
17753 }
17754 other => panic!("expected a transport failure, got {other:?}"),
17755 }
17756 server.join().unwrap();
17757 }
17758
17759 #[test]
17760 fn a_safe_get_retries_when_its_body_dies_mid_stream() {
17761 use std::io::{Read as _, Write as _};
17762 use std::net::{TcpListener, TcpStream};
17763 use std::thread;
17764
17765 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17766 let address = listener.local_addr().unwrap();
17767 let server = thread::spawn(move || {
17768 let read_request = |stream: &mut TcpStream| {
17769 let mut request = Vec::new();
17770 let mut bytes = [0_u8; 1024];
17771 loop {
17772 let read = stream.read(&mut bytes).unwrap();
17773 if read == 0 {
17774 break;
17775 }
17776 request.extend_from_slice(&bytes[..read]);
17777 let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17778 else {
17779 continue;
17780 };
17781 let headers = String::from_utf8_lossy(&request[..header_end]);
17782 let content_length = headers
17783 .lines()
17784 .find_map(|line| {
17785 let (name, value) = line.split_once(':')?;
17786 name.eq_ignore_ascii_case("content-length")
17787 .then(|| value.trim().parse::<usize>().ok())
17788 .flatten()
17789 })
17790 .unwrap_or(0);
17791 if request.len() >= header_end + 4 + content_length {
17792 break;
17793 }
17794 }
17795 };
17796 let (mut first, _) = listener.accept().unwrap();
17797 read_request(&mut first);
17798 first
17799 .write_all(
17800 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17801 )
17802 .unwrap();
17803 drop(first);
17804
17805 let (mut second, _) = listener.accept().unwrap();
17806 read_request(&mut second);
17807 second
17808 .write_all(
17809 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17810 )
17811 .unwrap();
17812 });
17813 let cfg = HubConfig {
17814 hub: format!("http://{address}"),
17815 key: None,
17816 agent_key: None,
17817 brain_key: None,
17818 state_dir: tempfile::tempdir().unwrap().keep(),
17819 store_selected: false,
17820 };
17821
17822 let response = request(&cfg, "GET", "/retry-body", None, Auth::None)
17823 .expect("a safe read retries the interrupted body");
17824 assert_eq!(response.status, 200);
17825 assert_eq!(response.body, Some(json!({ "ok": true })));
17826 server.join().unwrap();
17827 }
17828
17829 #[test]
17830 fn an_explicit_read_only_post_retries_when_its_body_dies_mid_stream() {
17831 use std::io::{Read as _, Write as _};
17832 use std::net::{TcpListener, TcpStream};
17833 use std::thread;
17834
17835 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17836 let address = listener.local_addr().unwrap();
17837 let server = thread::spawn(move || {
17838 let read_request = |stream: &mut TcpStream| {
17839 let mut request = Vec::new();
17840 let mut bytes = [0_u8; 1024];
17841 loop {
17842 let read = stream.read(&mut bytes).unwrap();
17843 if read == 0 {
17844 break;
17845 }
17846 request.extend_from_slice(&bytes[..read]);
17847 let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17848 else {
17849 continue;
17850 };
17851 let headers = String::from_utf8_lossy(&request[..header_end]);
17852 let content_length = headers
17853 .lines()
17854 .find_map(|line| {
17855 let (name, value) = line.split_once(':')?;
17856 name.eq_ignore_ascii_case("content-length")
17857 .then(|| value.trim().parse::<usize>().ok())
17858 .flatten()
17859 })
17860 .unwrap_or(0);
17861 if request.len() >= header_end + 4 + content_length {
17862 break;
17863 }
17864 }
17865 };
17866 let (mut first, _) = listener.accept().unwrap();
17867 read_request(&mut first);
17868 first
17869 .write_all(
17870 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17871 )
17872 .unwrap();
17873 drop(first);
17874
17875 let (mut second, _) = listener.accept().unwrap();
17876 read_request(&mut second);
17877 second
17878 .write_all(
17879 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17880 )
17881 .unwrap();
17882 });
17883 let cfg = HubConfig {
17884 hub: format!("http://{address}"),
17885 key: None,
17886 agent_key: None,
17887 brain_key: None,
17888 state_dir: tempfile::tempdir().unwrap().keep(),
17889 store_selected: false,
17890 };
17891
17892 let response = request_raw_retryable_read(
17893 &cfg,
17894 "POST",
17895 "/v2/stream",
17896 Some(&json!({ "files": ["proof"] })),
17897 Auth::None,
17898 1_024,
17899 )
17900 .expect("an explicitly safe POST retries the interrupted body");
17901 assert_eq!(response.status, 200);
17902 assert_eq!(
17903 serde_json::from_slice::<Value>(&response.body).unwrap(),
17904 json!({ "ok": true })
17905 );
17906 server.join().unwrap();
17907 }
17908
17909 #[test]
17910 fn object_store_transport_errors_never_render_presigned_urls() {
17911 use std::net::TcpListener;
17912
17913 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17914 let address = listener.local_addr().unwrap();
17915 drop(listener);
17916 let signature = "do-not-render-this-presigned-signature";
17917 let raw =
17918 format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
17919 let error = ureq::get(&raw)
17920 .timeout(std::time::Duration::from_millis(250))
17921 .call()
17922 .expect_err("the closed local port must fail");
17923 let ureq::Error::Transport(transport) = error else {
17924 panic!("expected a transport failure");
17925 };
17926
17927 let rendered = object_store_transport_error(transport).to_string();
17928 assert!(rendered.contains("the object store"));
17929 assert!(rendered.contains("network error"));
17930 assert!(!rendered.contains(&raw));
17931 assert!(!rendered.contains(signature));
17932 assert!(!rendered.contains("X-Amz-"));
17933 }
17934
17935 #[test]
17936 fn endpoint_cap_refuses_a_body_before_json_parsing() {
17937 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
17938 let cfg = HubConfig {
17939 hub,
17940 key: None,
17941 agent_key: None,
17942 brain_key: None,
17943 state_dir: tempfile::tempdir().unwrap().keep(),
17944 store_selected: false,
17945 };
17946
17947 assert!(matches!(
17948 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
17949 Err(LinkError::ResponseTooLarge { .. })
17950 ));
17951 server.join().unwrap();
17952 }
17953
17954 #[test]
17955 fn overall_deadline_stops_a_dribbled_response_body() {
17956 use std::io::{Read as _, Write as _};
17957 use std::net::TcpListener;
17958 use std::time::{Duration, Instant};
17959
17960 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17961 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
17962 let server = std::thread::spawn(move || {
17963 let (mut stream, _) = listener.accept().unwrap();
17964 let mut request = [0_u8; 1024];
17965 let _ = stream.read(&mut request);
17966 stream
17967 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
17968 .unwrap();
17969 for byte in [b'x'; 32] {
17970 if stream.write_all(&[byte]).is_err() {
17971 break;
17972 }
17973 std::thread::sleep(Duration::from_millis(40));
17974 }
17975 });
17976 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17977 let started = Instant::now();
17978 let response = http.get(&url).call().unwrap();
17979 let mut body = Vec::new();
17980 let error = response
17981 .into_reader()
17982 .read_to_end(&mut body)
17983 .expect_err("per-read progress must not reset the overall deadline");
17984 assert!(
17985 started.elapsed() < Duration::from_millis(700),
17986 "dribbled body exceeded the wall-clock budget: {error}"
17987 );
17988 server.join().unwrap();
17989 }
17990
17991 #[test]
17992 fn overall_deadline_stops_a_stalled_upload() {
17993 use std::net::TcpListener;
17994 use std::time::{Duration, Instant};
17995
17996 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17997 let url = format!("http://{}/upload", listener.local_addr().unwrap());
17998 let server = std::thread::spawn(move || {
17999 let (_stream, _) = listener.accept().unwrap();
18000 std::thread::sleep(Duration::from_millis(600));
18003 });
18004 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
18005 let body = vec![0x5a; 32 * 1024 * 1024];
18006 let started = Instant::now();
18007 let error = http
18008 .put(&url)
18009 .send_bytes(&body)
18010 .expect_err("stalled request-body writes must time out");
18011 assert!(
18012 started.elapsed() < Duration::from_millis(700),
18013 "stalled upload exceeded the wall-clock budget: {error}"
18014 );
18015 server.join().unwrap();
18016 }
18017
18018 #[test]
18019 fn presigned_source_retries_share_one_upload_deadline() {
18020 use std::net::TcpListener;
18021 use std::time::{Duration, Instant};
18022
18023 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18024 let address = listener.local_addr().unwrap();
18025 let signature = "do-not-render-this-stalled-upload-signature";
18026 let url = format!(
18027 "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
18028 );
18029 let server = std::thread::spawn(move || {
18030 let (_stream, _) = listener.accept().unwrap();
18031 std::thread::sleep(Duration::from_millis(600));
18035 });
18036
18037 let directory = tempfile::tempdir().unwrap();
18038 std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
18039 std::fs::create_dir(directory.path().join("records")).unwrap();
18040 let relative = "records/stalled.bin";
18041 let bytes = vec![0x5a; 32 * 1024 * 1024];
18042 std::fs::write(directory.path().join(relative), &bytes).unwrap();
18043 let store = Store::open_strict(directory.path()).unwrap();
18044 let cfg = HubConfig {
18045 hub: format!("http://{address}"),
18046 key: None,
18047 agent_key: None,
18048 brain_key: None,
18049 state_dir: tempfile::tempdir().unwrap().keep(),
18050 store_selected: false,
18051 };
18052 let source = V2UploadSource {
18053 path: relative.to_string(),
18054 bytes: bytes.len() as u64,
18055 };
18056
18057 let started = Instant::now();
18058 let error = put_presigned_source_with_budget(
18059 &cfg,
18060 &url,
18061 &json!({ "content-length": source.bytes.to_string() }),
18062 &store,
18063 &source,
18064 None,
18065 Duration::from_millis(150),
18066 )
18067 .expect_err("a black-holed upload must leave at its shared deadline");
18068 assert!(
18069 started.elapsed() < Duration::from_millis(700),
18070 "presigned retries exceeded their shared budget: {error}"
18071 );
18072 let rendered = error.to_string();
18073 assert!(rendered.contains("the object store"));
18074 assert!(!rendered.contains(&url));
18075 assert!(!rendered.contains(signature));
18076 server.join().unwrap();
18077 }
18078
18079 #[test]
18080 fn verb_entry_gates_accept_the_hub_ref_shapes() {
18081 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
18082 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
18083 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
18084 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
18085 }
18086 }
18087
18088 #[test]
18089 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
18090 let cfg = dead_hub();
18091 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
18092 assert!(
18093 matches!(
18094 sync_pull(&cfg, bad, None),
18095 Err(LinkError::BadAddress { .. })
18096 ),
18097 "sync_pull must refuse {bad:?}"
18098 );
18099 assert!(
18100 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
18101 "sync_push must refuse {bad:?}"
18102 );
18103 assert!(
18104 matches!(
18105 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
18106 Err(LinkError::BadAddress { .. })
18107 ),
18108 "grant_issue must refuse {bad:?}"
18109 );
18110 assert!(
18111 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
18112 "grant_list must refuse {bad:?}"
18113 );
18114 assert!(
18115 matches!(
18116 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
18117 Err(LinkError::BadAddress { .. })
18118 ),
18119 "grant_revoke must refuse brain {bad:?}"
18120 );
18121 assert!(
18122 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
18123 "head must refuse {bad:?}"
18124 );
18125 }
18126 }
18127
18128 #[test]
18129 fn grant_revoke_refuses_url_reshaping_grant_ids() {
18130 let cfg = dead_hub();
18131 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
18132 assert!(
18133 matches!(
18134 grant_revoke(&cfg, "acme", bad),
18135 Err(LinkError::BadGrantId { .. })
18136 ),
18137 "grant_revoke must refuse grant id {bad:?}"
18138 );
18139 }
18140 }
18141
18142 #[test]
18143 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
18144 let cfg = dead_hub();
18145 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
18146 assert!(
18147 matches!(
18148 propose(&cfg, bad, "intake", "hi"),
18149 Err(LinkError::BadAddress { .. })
18150 ),
18151 "propose must refuse handle {bad:?}"
18152 );
18153 }
18154 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
18155 assert!(matches!(
18156 propose(&cfg, "acme-site", "intake", &oversize),
18157 Err(LinkError::ProposeTooLarge { .. })
18158 ));
18159 assert!(matches!(
18162 propose(&cfg, "acme-site", "intake", "hi"),
18163 Err(LinkError::Transport { .. })
18164 ));
18165 }
18166
18167 #[test]
18168 fn resolve_refuses_a_hand_built_unsafe_address() {
18169 let cfg = dead_hub();
18170 for brain in ["../up", "a/b", "a?x", "a#f"] {
18171 let addr = Address {
18172 brain: brain.to_string(),
18173 target: None,
18174 };
18175 assert!(
18176 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18177 "resolve must refuse brain {brain:?}"
18178 );
18179 }
18180 for target in [
18181 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
18182 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
18184 AddressTarget::Path("records/x.md#frag".to_string()),
18185 ] {
18186 let addr = Address {
18187 brain: "acme".to_string(),
18188 target: Some(target.clone()),
18189 };
18190 assert!(
18191 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18192 "resolve must refuse target {target:?}"
18193 );
18194 }
18195 }
18196
18197 #[test]
18198 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
18199 let mut local = std::collections::BTreeMap::new();
18200 local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
18201 local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
18202 let mut remote = std::collections::BTreeMap::new();
18203 remote.insert(
18204 "records/a.md".to_string(),
18205 V2BaselineFile {
18206 sha256: "c".repeat(64),
18207 bytes: 1,
18208 proof: None,
18209 },
18210 );
18211 remote.insert(
18212 "records/b.md".to_string(),
18213 V2BaselineFile {
18214 sha256: "b".repeat(64),
18215 bytes: 1,
18216 proof: None,
18217 },
18218 );
18219 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18220 }
18221
18222 #[test]
18223 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
18224 let local = std::collections::BTreeMap::new();
18225 let mut remote = std::collections::BTreeMap::new();
18226 remote.insert(
18227 "private/local.md".to_string(),
18228 V2BaselineFile {
18229 sha256: "d".repeat(64),
18230 bytes: 1,
18231 proof: None,
18232 },
18233 );
18234 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
18235 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18236 }
18237
18238 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
18239 V2VerifiedHead {
18240 requested: TEST_BRAIN_ID.to_string(),
18241 brain_id: TEST_BRAIN_ID.to_string(),
18242 view_kind: "scoped".to_string(),
18243 view_revision: revision.to_string(),
18244 control_revision: revision.to_string(),
18245 identity: V2HeadIdentity {
18246 custody: "hub".to_string(),
18247 fingerprint: "test".to_string(),
18248 public_key_spki: "test".to_string(),
18249 previous: Vec::new(),
18250 rotations: Vec::new(),
18251 },
18252 pointer: None,
18253 trust: TrustState {
18254 v: 2,
18255 origin: "https://hub.example".to_string(),
18256 requested: TEST_BRAIN_ID.to_string(),
18257 brain: TEST_BRAIN_ID.to_string(),
18258 home: None,
18259 anchor: "ed25519:test".to_string(),
18260 current: "ed25519:test".to_string(),
18261 head_seq: 0,
18262 feed_hash: None,
18263 rotations: Vec::new(),
18264 hub_signer: None,
18265 protocol_profile: Some("link-v2".to_string()),
18266 },
18267 alias: None,
18268 }
18269 }
18270
18271 #[test]
18272 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
18273 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
18274 assert!(accepted_as_v2(&trust));
18275
18276 trust.protocol_profile = None;
18277 trust.hub_signer = Some("ed25519:hub".to_string());
18278 assert!(accepted_as_v2(&trust));
18279
18280 trust.hub_signer = None;
18281 assert!(!accepted_as_v2(&trust));
18282 }
18283
18284 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
18285 V2SyncBaseline {
18286 v: 2,
18287 origin: "https://hub.example".to_string(),
18288 brain: TEST_BRAIN_ID.to_string(),
18289 checkout_id: Some("c".repeat(64)),
18290 head_seq: Some(0),
18291 commit_hash: None,
18292 content_root: None,
18293 asset_root: None,
18294 assets: std::collections::BTreeMap::new(),
18295 view_kind: Some("scoped".to_string()),
18296 view_revision: Some(revision.to_string()),
18297 control_revision: Some(revision.to_string()),
18298 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
18299 files: std::collections::BTreeMap::new(),
18300 local_policy_digest: None,
18301 local_eligibility: std::collections::BTreeMap::new(),
18302 remote_copy_remains: std::collections::BTreeMap::new(),
18303 }
18304 }
18305
18306 #[test]
18307 fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
18308 let cfg = test_hub_config(
18309 "https://hub.example".to_string(),
18310 tempfile::tempdir().unwrap().keep(),
18311 );
18312 let mut baseline = scoped_test_baseline(&"a".repeat(64));
18313 baseline.assets.insert(
18314 "assets/archive.bin".to_string(),
18315 V2BaselineAsset {
18316 blob_sha256: "b".repeat(64),
18317 bytes: MAX_STORE_BYTES + 1,
18318 media_type: "application/octet-stream".to_string(),
18319 wrappers: vec!["records/archive.md".to_string()],
18320 required: true,
18321 disposition: "hosted".to_string(),
18322 leaf_hash: "c".repeat(64),
18323 },
18324 );
18325
18326 let accepted = serde_json::to_vec(&baseline).unwrap();
18327 assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
18328
18329 baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
18330 let refused = serde_json::to_vec(&baseline).unwrap();
18331 assert!(matches!(
18332 parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
18333 Err(LinkError::InvalidFeed { .. })
18334 ));
18335 }
18336
18337 #[test]
18338 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
18339 let directory = tempfile::tempdir().unwrap();
18340 std::fs::write(
18341 directory.path().join("DB.md"),
18342 scoped_projection_bytes(TEST_BRAIN_ID),
18343 )
18344 .unwrap();
18345 let store = Store::open_strict(directory.path()).unwrap();
18346 let head = scoped_test_head(&"a".repeat(64));
18347 let baseline = scoped_test_baseline(&"a".repeat(64));
18348 let mut view = v2_local_files(&store).unwrap();
18349 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
18350 assert!(!view.riding.contains_key("DB.md"));
18351 assert!(!view.eligibility.contains_key("DB.md"));
18352 }
18353
18354 #[test]
18355 fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
18356 let directory = tempfile::tempdir().unwrap();
18357 std::fs::write(
18358 directory.path().join("DB.md"),
18359 scoped_projection_bytes(TEST_BRAIN_ID),
18360 )
18361 .unwrap();
18362 let store = Store::open_strict(directory.path()).unwrap();
18363 let head = scoped_test_head(&"a".repeat(64));
18364 let baseline = scoped_test_baseline(&"a".repeat(64));
18365
18366 let mut carried = v2_local_files(&store).unwrap();
18367 remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
18368 let handed_off =
18369 local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
18370 assert!(!handed_off.riding.contains_key("DB.md"));
18371
18372 let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
18373 assert!(!freshly_scanned.riding.contains_key("DB.md"));
18374
18375 std::fs::write(
18376 directory.path().join("DB.md"),
18377 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18378 )
18379 .unwrap();
18380 let tampered = Store::open_strict(directory.path()).unwrap();
18381 assert!(matches!(
18382 local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
18383 Err(LinkError::ScopedProjectionModified)
18384 ));
18385 }
18386
18387 #[test]
18388 fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
18389 let directory = tempfile::tempdir().unwrap();
18390 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18391 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18392 std::fs::write(
18393 directory.path().join("DB.md"),
18394 b"---\nname: Kept home test\n---\n",
18395 )
18396 .unwrap();
18397 std::fs::write(
18398 directory.path().join("records/notes/a.md"),
18399 b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
18400 )
18401 .unwrap();
18402 std::fs::write(
18403 directory.path().join("sources/private/secret.md"),
18404 b"---\ntype: note\n---\nlocal only\n",
18405 )
18406 .unwrap();
18407 std::fs::write(
18408 directory.path().join("sources/private/unlinked.md"),
18409 b"---\ntype: note\n---\nnot disclosed\n",
18410 )
18411 .unwrap();
18412 std::fs::write(
18413 directory.path().join(".sevralocal"),
18414 b"sources/private/**\n",
18415 )
18416 .unwrap();
18417
18418 let store = Store::open_strict(directory.path()).unwrap();
18419 let view = v2_local_files(&store).unwrap();
18420 assert!(!view.riding.contains_key("sources/private/secret.md"));
18421 assert_eq!(
18422 view.withheld_links,
18423 vec![V2WithheldLink {
18424 source: "records/notes/a.md".to_string(),
18425 target: "sources/private/secret.md".to_string(),
18426 }]
18427 );
18428 }
18429
18430 #[test]
18431 fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
18432 let directory = tempfile::tempdir().unwrap();
18437 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18438 std::fs::write(
18439 directory.path().join("DB.md"),
18440 b"---\nname: Restored export\n---\n",
18441 )
18442 .unwrap();
18443 std::fs::write(
18444 directory.path().join("records/notes/a.md"),
18445 b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
18446 )
18447 .unwrap();
18448 std::fs::write(
18449 directory.path().join(".sevralocal"),
18450 b"sources/private/**\n",
18451 )
18452 .unwrap();
18453
18454 let store = Store::open_strict(directory.path()).unwrap();
18455 let view = v2_local_files(&store).unwrap();
18456 assert_eq!(
18457 view.withheld_links,
18458 vec![V2WithheldLink {
18459 source: "records/notes/a.md".to_string(),
18460 target: "sources/private/absent.md".to_string(),
18461 }]
18462 );
18463 std::fs::write(
18465 directory.path().join("records/notes/b.md"),
18466 b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
18467 )
18468 .unwrap();
18469 let store = Store::open_strict(directory.path()).unwrap();
18470 let view = v2_local_files(&store).unwrap();
18471 assert!(
18472 !view
18473 .withheld_links
18474 .iter()
18475 .any(|link| link.target == "records/notes/nowhere.md"),
18476 "an unclaimed dangling target must not be declared withheld"
18477 );
18478 }
18479
18480 #[test]
18481 fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
18482 let directory = tempfile::tempdir().unwrap();
18483 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18484 std::fs::write(
18485 directory.path().join("DB.md"),
18486 b"---\nname: Withdrawal test\n---\n",
18487 )
18488 .unwrap();
18489 let source = b"---\ntype: note\n---\nlocal evidence\n";
18490 std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
18491 std::fs::write(
18492 directory.path().join(".sevralocal"),
18493 b"sources/private/**\n",
18494 )
18495 .unwrap();
18496 let store = Store::open_strict(directory.path()).unwrap();
18497 let view = v2_local_files(&store).unwrap();
18498 let mut remote = std::collections::BTreeMap::new();
18499 remote.insert(
18500 "sources/private/evidence.md".to_string(),
18501 V2BaselineFile {
18502 sha256: content_sha256(source),
18503 bytes: source.len() as u64,
18504 proof: None,
18505 },
18506 );
18507 assert_eq!(
18508 v2_content_withdrawal_operation(
18509 &store,
18510 &view,
18511 &remote,
18512 "sources/private/evidence.md",
18513 "approved retention change",
18514 )
18515 .unwrap(),
18516 json!({
18517 "op": "withdraw_from_hosting",
18518 "path": "sources/private/evidence.md",
18519 "expected": { "kind": "blob", "hash": content_sha256(source) },
18520 "reason": "approved retention change",
18521 })
18522 );
18523
18524 std::fs::write(
18525 directory.path().join("sources/private/evidence.md"),
18526 b"changed after review",
18527 )
18528 .unwrap();
18529 assert!(matches!(
18530 v2_content_withdrawal_operation(
18531 &store,
18532 &view,
18533 &remote,
18534 "sources/private/evidence.md",
18535 "approved retention change",
18536 ),
18537 Err(LinkError::InvalidPack { .. })
18538 ));
18539 }
18540
18541 #[test]
18542 fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
18543 let directory = tempfile::tempdir().unwrap();
18544 std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
18545 std::fs::write(
18546 directory.path().join("DB.md"),
18547 b"---\nname: Asset withdrawal test\n---\n",
18548 )
18549 .unwrap();
18550 let bytes = b"private binary";
18551 std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
18552 std::fs::write(
18553 directory.path().join(".sevralocal"),
18554 b"sources/files/private.pdf\n",
18555 )
18556 .unwrap();
18557 let store = Store::open_strict(directory.path()).unwrap();
18558 let view = v2_local_files(&store).unwrap();
18559 let local = crate::AssetRecord {
18560 path: "sources/files/private.pdf".to_string(),
18561 sha256: content_sha256(bytes),
18562 bytes: bytes.len() as u64,
18563 media_type: "application/pdf".to_string(),
18564 wrappers: vec!["sources/files/private.md".to_string()],
18565 required: true,
18566 };
18567 let current = V2BaselineAsset {
18568 blob_sha256: local.sha256.clone(),
18569 bytes: local.bytes,
18570 media_type: local.media_type.clone(),
18571 wrappers: local.wrappers.clone(),
18572 required: local.required,
18573 disposition: "hosted".to_string(),
18574 leaf_hash: "d".repeat(64),
18575 };
18576 assert_eq!(
18577 v2_asset_withdrawal_operation(
18578 &store,
18579 &view,
18580 &local.path,
18581 &local,
18582 ¤t,
18583 "approved retention change",
18584 )
18585 .unwrap(),
18586 json!({
18587 "op": "asset_withdraw",
18588 "path": local.path,
18589 "expected": { "kind": "asset", "hash": "d".repeat(64) },
18590 "reason": "approved retention change",
18591 })
18592 );
18593
18594 let mut mismatched = current.clone();
18595 mismatched.required = false;
18596 assert!(matches!(
18597 v2_asset_withdrawal_operation(
18598 &store,
18599 &view,
18600 &local.path,
18601 &local,
18602 &mismatched,
18603 "approved retention change",
18604 ),
18605 Err(LinkError::InvalidPack { .. })
18606 ));
18607 }
18608
18609 #[test]
18610 fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
18611 let first = v2_checkout_id(None).unwrap();
18612 assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
18613 assert_ne!(first, v2_checkout_id(None).unwrap());
18614 assert!(is_sha256(&first));
18615 }
18616
18617 #[test]
18618 fn scoped_projection_edit_and_scope_transition_fail_closed() {
18619 let directory = tempfile::tempdir().unwrap();
18620 std::fs::write(
18621 directory.path().join("DB.md"),
18622 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
18623 )
18624 .unwrap();
18625 let store = Store::open_strict(directory.path()).unwrap();
18626 let head = scoped_test_head(&"a".repeat(64));
18627 let baseline = scoped_test_baseline(&"a".repeat(64));
18628 let mut view = v2_local_files(&store).unwrap();
18629 assert!(matches!(
18630 remove_scoped_projection(&head, Some(&baseline), &mut view),
18631 Err(LinkError::ScopedProjectionModified)
18632 ));
18633
18634 let changed = scoped_test_head(&"b".repeat(64));
18635 assert!(matches!(
18636 ensure_v2_view_compatible(&changed, Some(&baseline)),
18637 Err(LinkError::ScopedViewChanged)
18638 ));
18639
18640 let mut same_view_new_control = head.clone();
18641 same_view_new_control.control_revision = "c".repeat(64);
18642 assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
18643 assert!(!same_v2_head(&head, &same_view_new_control));
18644 }
18645
18646 #[test]
18647 fn verified_baseline_reuse_requires_exact_content_assets_view_and_authority() {
18648 let mut head = scoped_test_head(&"a".repeat(64));
18649 head.control_revision = "b".repeat(64);
18650 head.pointer = Some(V2PointerBody {
18651 v: 2,
18652 brain: TEST_BRAIN_ID.to_string(),
18653 seq: 7,
18654 commit_hash: "c".repeat(64),
18655 feed_hash: "d".repeat(64),
18656 content_root: Some("e".repeat(64)),
18657 asset_root: Some("f".repeat(64)),
18658 materializer: "dbmd-projection-v1".to_string(),
18659 signer_epoch: 1,
18660 control_revision: head.control_revision.clone(),
18661 backup_preparation: "0".repeat(64),
18662 prior_pointer_hash: Some("1".repeat(64)),
18663 signed_at: "2026-08-24T00:00:00.000Z".to_string(),
18664 });
18665 let mut baseline = scoped_test_baseline(&head.view_revision);
18666 baseline.head_seq = Some(7);
18667 baseline.commit_hash = Some("c".repeat(64));
18668 baseline.content_root = Some("e".repeat(64));
18669 baseline.asset_root = Some("f".repeat(64));
18670 baseline.control_revision = Some(head.control_revision.clone());
18671 assert!(v2_baseline_matches_head(&head, &baseline));
18672
18673 let mut changed = baseline.clone();
18674 changed.head_seq = Some(8);
18675 assert!(!v2_baseline_matches_head(&head, &changed));
18676 let mut changed = baseline.clone();
18677 changed.commit_hash = Some("2".repeat(64));
18678 assert!(!v2_baseline_matches_head(&head, &changed));
18679 let mut changed = baseline.clone();
18680 changed.content_root = Some("3".repeat(64));
18681 assert!(!v2_baseline_matches_head(&head, &changed));
18682 let mut changed = baseline.clone();
18683 changed.asset_root = Some("4".repeat(64));
18684 assert!(!v2_baseline_matches_head(&head, &changed));
18685 let mut changed = baseline.clone();
18686 changed.view_revision = Some("5".repeat(64));
18687 assert!(!v2_baseline_matches_head(&head, &changed));
18688 let mut changed = baseline.clone();
18689 changed.control_revision = Some("6".repeat(64));
18690 assert!(!v2_baseline_matches_head(&head, &changed));
18691
18692 let mut changed_head = head.clone();
18693 changed_head.view_kind = "full".to_string();
18694 assert!(!v2_baseline_matches_head(&changed_head, &baseline));
18695 }
18696
18697 #[test]
18698 fn legacy_baseline_without_authority_revision_refreshes_before_reuse() {
18699 let sandbox = tempfile::tempdir().unwrap();
18700 let cfg = test_hub_config(
18701 "https://hub.example".to_string(),
18702 sandbox.path().to_path_buf(),
18703 );
18704 let head = scoped_test_head(&"a".repeat(64));
18705 let baseline = scoped_test_baseline(&head.view_revision);
18706 let mut encoded = serde_json::to_value(&baseline).unwrap();
18707 encoded.as_object_mut().unwrap().remove("control_revision");
18708 let parsed =
18709 parse_v2_baseline(&cfg, TEST_BRAIN_ID, &serde_json::to_vec(&encoded).unwrap()).unwrap();
18710 assert!(parsed.control_revision.is_none());
18711 assert!(!v2_baseline_matches_head(&head, &parsed));
18712 }
18713
18714 #[test]
18715 fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
18716 let scoped = scoped_test_head(&"a".repeat(64));
18717 let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
18718 assert!(matches!(
18719 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
18720 Err(LinkError::ScopedProjectionModified)
18721 ));
18722
18723 let mut full = scoped.clone();
18724 full.view_kind = "full".to_string();
18725 let mut full_baseline = scoped_baseline.clone();
18726 full_baseline.view_kind = Some("full".to_string());
18727 full_baseline.projection_sha256 = None;
18728 assert!(matches!(
18729 ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
18730 Err(LinkError::InvalidPack { .. })
18731 ));
18732
18733 assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
18734 assert!(
18735 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
18736 );
18737 }
18738
18739 #[test]
18740 fn scoped_view_metadata_is_explicitly_non_authoritative() {
18741 let head = scoped_test_head(&"a".repeat(64));
18742 let value: Value =
18743 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
18744 assert_eq!(value["kind"], "link.md-scoped-view");
18745 assert_eq!(value["authoritative"], false);
18746 assert_eq!(value["visible_files"], 7);
18747 assert_eq!(value["brain"], TEST_BRAIN_ID);
18748 }
18749
18750 #[test]
18751 fn local_scoped_marker_requires_the_exact_generated_projection() {
18752 let directory = tempfile::tempdir().unwrap();
18753 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
18754 std::fs::write(
18755 directory.path().join("DB.md"),
18756 scoped_projection_bytes(TEST_BRAIN_ID),
18757 )
18758 .unwrap();
18759 let head = scoped_test_head(&"a".repeat(64));
18760 std::fs::write(
18761 directory.path().join(".dbmd/view.json"),
18762 scoped_view_metadata(&head, 0).unwrap(),
18763 )
18764 .unwrap();
18765 let store = Store::open_strict(directory.path()).unwrap();
18766 assert!(has_verified_local_scoped_view(&store));
18767
18768 std::fs::write(
18769 directory.path().join("DB.md"),
18770 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18771 )
18772 .unwrap();
18773 let altered = Store::open_strict(directory.path()).unwrap();
18774 assert!(!has_verified_local_scoped_view(&altered));
18775 }
18776
18777 fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
18778 use ring::signature::KeyPair as _;
18779
18780 let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
18781 let rng = ring::rand::SystemRandom::new();
18782 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18783 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18784 let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
18785 let public_key = URL_SAFE_NO_PAD.encode(&public_der);
18786 let fingerprint = format!("{:x}", Sha256::digest(&public_der));
18787 let blob = b"new";
18788 let blob_hash = content_sha256(blob);
18789 let changes = json!({
18790 "mutation_id": "sync:proposal-fixture",
18791 "operations": [{
18792 "blob": blob_hash,
18793 "bytes": blob.len(),
18794 "expected": null,
18795 "op": "put",
18796 "path": "records/new.md",
18797 }],
18798 "reason": "fixture",
18799 "v": 2,
18800 });
18801 let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
18802 let changes_base64 = STANDARD.encode(&changes_bytes);
18803 let descriptor = json!({
18804 "base": null,
18805 "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
18806 "changes_base64": changes_base64,
18807 "rebase": "strict",
18808 "v": 2,
18809 });
18810 let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
18811 let payload_hash = "b".repeat(64);
18812 let submitted_at = "2026-08-19T12:00:00.000Z";
18813 let claim = json!({
18814 "actor_root": {
18815 "actor_class": "foreign_key",
18816 "credential": "ed25519:fixture",
18817 "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
18818 "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
18819 "principal": "key:fixture",
18820 "role": null,
18821 },
18822 "brain": TEST_BRAIN_ID,
18823 "clear_sha256": clear_hash,
18824 "control_revision": "c".repeat(64),
18825 "mutation_id": "sync:proposal-fixture",
18826 "payload_sha256": payload_hash,
18827 "proposal_id": proposal_id,
18828 "submitted_at": submitted_at,
18829 "v": 2,
18830 });
18831 let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
18832 let envelope = json!({
18833 "claim": claim,
18834 "fingerprint": fingerprint,
18835 "public_key": public_key,
18836 "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
18837 });
18838 let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18839 let submission_hash =
18840 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
18841 let mut head = scoped_test_head(&"c".repeat(64));
18842 head.view_kind = "full".to_string();
18843 head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
18844 let value = json!({
18845 "proposal": {
18846 "base": null,
18847 "blobs": [{
18848 "bytes": blob.len(),
18849 "endpoint": format!(
18850 "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
18851 ),
18852 "sha256": blob_hash,
18853 }],
18854 "changes_base64": changes_base64,
18855 "clear_sha256": clear_hash,
18856 "expires_at": "2026-08-26T12:00:00.000Z",
18857 "id": proposal_id,
18858 "payload_sha256": payload_hash,
18859 "proposer": { "class": "foreign_key" },
18860 "rebase": "strict",
18861 "state": "pending",
18862 "submission_claim_base64": STANDARD.encode(envelope_bytes),
18863 "submission_claim_sha256": submission_hash,
18864 "submitted_at": submitted_at,
18865 },
18866 "v": 2,
18867 });
18868 (head, proposal_id, value)
18869 }
18870
18871 #[test]
18872 fn v2_proposal_verifier_accepts_exact_signed_payload() {
18873 let (head, proposal_id, value) = signed_proposal_fixture();
18874 let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
18875 assert_eq!(verified.blobs.len(), 1);
18876 assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
18877 }
18878
18879 #[test]
18880 fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
18881 let (head, proposal_id, value) = signed_proposal_fixture();
18882
18883 let mut changed = value.clone();
18884 changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
18885 assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
18886
18887 let mut redirected = value.clone();
18888 redirected["proposal"]["blobs"][0]["endpoint"] =
18889 Value::String("https://attacker.example/blob".to_string());
18890 assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
18891
18892 let mut forged = value;
18893 let encoded = forged["proposal"]["submission_claim_base64"]
18894 .as_str()
18895 .unwrap();
18896 let mut envelope: Value =
18897 serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
18898 envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
18899 let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18900 forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
18901 forged["proposal"]["submission_claim_sha256"] = Value::String(
18902 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
18903 );
18904 assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
18905 }
18906
18907 #[cfg(unix)]
18908 #[test]
18909 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
18910 let sandbox = tempfile::tempdir().unwrap();
18911 let destination = sandbox.path().join("brain");
18912 let entries = vec![
18913 (
18914 "DB.md".to_string(),
18915 scoped_projection_bytes(TEST_BRAIN_ID),
18916 ),
18917 (
18918 "records/contacts/a.md".to_string(),
18919 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
18920 .to_vec(),
18921 ),
18922 ];
18923 install_pulled_delta(&destination, &entries, &[], true).unwrap();
18924 assert!(destination.join("index.md").is_file());
18925 assert!(destination.join("records/index.md").is_file());
18926 assert!(destination.join("records/contacts/index.md").is_file());
18927 assert!(destination.join("records/contacts/index.jsonl").is_file());
18928 }
18929
18930 #[cfg(unix)]
18931 #[test]
18932 fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
18933 let sandbox = tempfile::tempdir().unwrap();
18934 let destination = sandbox.path().join("brain");
18935 let cache = sandbox.path().join("cache");
18936 std::fs::create_dir(&cache).unwrap();
18937 let db = scoped_projection_bytes(TEST_BRAIN_ID);
18938 let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
18939 let db_source = cache.join("db");
18940 let shared_source = cache.join("shared");
18941 crate::fsx::write_atomic(&db_source, &db).unwrap();
18942 crate::fsx::write_atomic(&shared_source, shared).unwrap();
18943 let mut entries = vec![V2StagedFile {
18944 path: "DB.md".to_string(),
18945 source: db_source,
18946 sha256: content_sha256(&db),
18947 bytes: db.len() as u64,
18948 }];
18949 for index in 0..512 {
18950 entries.push(V2StagedFile {
18951 path: format!("records/items/{index:05}.md"),
18952 source: shared_source.clone(),
18953 sha256: content_sha256(shared),
18954 bytes: shared.len() as u64,
18955 });
18956 }
18957 install_pulled_delta_sources(
18958 &destination,
18959 &entries,
18960 &[],
18961 false,
18962 None,
18963 &scoped_test_head(&"c".repeat(64)),
18964 )
18965 .unwrap();
18966 assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
18967 for index in 0..512 {
18968 assert_eq!(
18969 std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
18970 shared
18971 );
18972 }
18973 assert!(
18974 std::fs::read_dir(sandbox.path())
18975 .unwrap()
18976 .all(|entry| !entry
18977 .unwrap()
18978 .file_name()
18979 .to_string_lossy()
18980 .contains("pull-stage")),
18981 "the private stage must be atomically installed or removed"
18982 );
18983 }
18984
18985 #[cfg(unix)]
18986 #[test]
18987 fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
18988 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
18989
18990 let sandbox = tempfile::tempdir().unwrap();
18991 let root = sandbox.path().join("brain");
18992 std::fs::create_dir_all(root.join("records/items")).unwrap();
18993 let db = scoped_projection_bytes(TEST_BRAIN_ID);
18994 let old = b"---\ntype: note\n---\n\nold\n";
18995 let new = b"---\ntype: note\n---\n\nnew\n";
18996 let removed = b"---\ntype: note\n---\n\nremove me\n";
18997 std::fs::write(root.join("DB.md"), &db).unwrap();
18998 std::fs::write(root.join("records/items/change.md"), old).unwrap();
18999 std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
19000 for index in 0..512 {
19001 std::fs::write(
19002 root.join(format!("records/items/untouched-{index:04}.md")),
19003 old,
19004 )
19005 .unwrap();
19006 }
19007 let untouched = root.join("records/items/untouched-0256.md");
19008 let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
19009 let source = sandbox.path().join("changed-source");
19010 crate::fsx::write_atomic(&source, new).unwrap();
19011 let same_source = sandbox.path().join("unchanged-source");
19012 crate::fsx::write_atomic(&same_source, old).unwrap();
19013 let same_entry = V2StagedFile {
19014 path: "records/items/change.md".to_string(),
19015 source: same_source,
19016 sha256: content_sha256(old),
19017 bytes: old.len() as u64,
19018 };
19019 let entry = V2StagedFile {
19020 path: "records/items/change.md".to_string(),
19021 source,
19022 sha256: content_sha256(new),
19023 bytes: new.len() as u64,
19024 };
19025 let head = scoped_test_head(&"c".repeat(64));
19026
19027 install_established_v2_delta(
19031 Store::open_strict(&root).unwrap(),
19032 &[same_entry],
19033 &["records/items/already-absent.md".to_string()],
19034 true,
19035 None,
19036 &head,
19037 )
19038 .unwrap();
19039 assert_eq!(
19040 std::fs::metadata(&untouched).unwrap().ino(),
19041 untouched_inode
19042 );
19043 assert!(!root.join(V2_PULL_JOURNAL).exists());
19044
19045 install_established_v2_delta(
19046 Store::open_strict(&root).unwrap(),
19047 &[entry],
19048 &["records/items/delete.md".to_string()],
19049 false,
19050 None,
19051 &head,
19052 )
19053 .unwrap();
19054 assert_eq!(
19055 std::fs::read(root.join("records/items/change.md")).unwrap(),
19056 new
19057 );
19058 assert!(!root.join("records/items/delete.md").exists());
19059 assert_eq!(
19060 std::fs::metadata(&untouched).unwrap().ino(),
19061 untouched_inode
19062 );
19063 assert!(root.join(V2_PULL_JOURNAL).is_file());
19064 assert_eq!(
19065 std::fs::metadata(root.join(V2_PULL_JOURNAL))
19066 .unwrap()
19067 .permissions()
19068 .mode()
19069 & 0o777,
19070 0o600
19071 );
19072 let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
19073 .unwrap()
19074 .unwrap();
19075 assert_eq!(
19076 std::fs::metadata(root.join(&journal.backup_dir))
19077 .unwrap()
19078 .permissions()
19079 .mode()
19080 & 0o777,
19081 0o700
19082 );
19083 for entry in &journal.entries {
19084 if let Some(backup) = &entry.backup {
19085 assert_eq!(
19086 std::fs::metadata(root.join(&journal.backup_dir).join(backup))
19087 .unwrap()
19088 .permissions()
19089 .mode()
19090 & 0o777,
19091 0o600
19092 );
19093 }
19094 }
19095
19096 let cfg = test_hub_config(
19097 "https://example.test".to_string(),
19098 sandbox.path().join("state"),
19099 );
19100 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19101 assert_eq!(
19102 std::fs::read(root.join("records/items/change.md")).unwrap(),
19103 old
19104 );
19105 assert_eq!(
19106 std::fs::read(root.join("records/items/delete.md")).unwrap(),
19107 removed
19108 );
19109 assert_eq!(
19110 std::fs::metadata(&untouched).unwrap().ino(),
19111 untouched_inode
19112 );
19113 assert!(!root.join(V2_PULL_JOURNAL).exists());
19114 }
19115
19116 #[test]
19117 fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
19118 let body = b"bounded bytes";
19119 let path = "records/example.md".to_string();
19120 let file = V2BaselineFile {
19121 sha256: content_sha256(body),
19122 bytes: body.len() as u64,
19123 proof: None,
19124 };
19125 let header = serde_json::to_vec(&json!({
19126 "bytes": body.len(),
19127 "path": path,
19128 "sha256": file.sha256,
19129 "v": 2,
19130 }))
19131 .unwrap();
19132 let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
19133 stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
19134 stream.extend_from_slice(&header);
19135 stream.extend_from_slice(body);
19136 stream.extend_from_slice(&0_u32.to_be_bytes());
19137 let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
19138 assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
19139
19140 let mut tampered = stream.clone();
19141 let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
19142 tampered[body_offset] ^= 1;
19143 assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
19144
19145 let mut trailing = stream;
19146 trailing.push(0);
19147 assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
19148 }
19149
19150 #[test]
19151 fn first_checkout_resolution_does_not_recreate_the_same_conflict() {
19152 let path = "records/value.md".to_string();
19153 let mut local = std::collections::BTreeMap::new();
19154 local.insert(path.clone(), (content_sha256(b"local"), 5));
19155 let mut remote = std::collections::BTreeMap::new();
19156 remote.insert(
19157 path.clone(),
19158 V2BaselineFile {
19159 sha256: content_sha256(b"remote"),
19160 bytes: 6,
19161 proof: None,
19162 },
19163 );
19164
19165 assert_eq!(
19166 v2_initial_content_conflicts(&local, &remote, false),
19167 vec![path]
19168 );
19169 assert!(v2_initial_content_conflicts(&local, &remote, true).is_empty());
19170
19171 let mut resolution = std::collections::BTreeMap::new();
19172 resolution.insert(
19173 "records/value.md".to_string(),
19174 V2ResolutionOverride {
19175 expected_remote: Some(content_sha256(b"remote")),
19176 selected_local: Some(content_sha256(b"local")),
19177 },
19178 );
19179 assert!(v2_resolution_allows_path(
19180 Some(&resolution),
19181 "records/value.md",
19182 true
19183 ));
19184 assert!(v2_resolution_allows_path(
19185 Some(&resolution),
19186 "records/new-target.md",
19187 false
19188 ));
19189 assert!(!v2_resolution_allows_path(
19190 Some(&resolution),
19191 "records/unreviewed-remote.md",
19192 true
19193 ));
19194 }
19195
19196 #[test]
19197 fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
19198 let sandbox = tempfile::TempDir::new().unwrap();
19199 let root = sandbox.path().join("brain");
19200 std::fs::create_dir_all(&root).unwrap();
19201 std::fs::write(
19202 root.join("DB.md"),
19203 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19204 )
19205 .unwrap();
19206 let store = Store::open_strict(&root).unwrap();
19207 let incomplete = crate::ulid::mint();
19208 store
19209 .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
19210 .unwrap();
19211 let expired = crate::ulid::mint();
19212 store
19213 .create_dir_all(&v2_conflict_relative(&expired, "files"))
19214 .unwrap();
19215 let plan = V2ConflictPlan {
19216 v: 2,
19217 class: "content_resolution_required".to_string(),
19218 bundle: expired.clone(),
19219 brain: TEST_BRAIN_ID.to_string(),
19220 origin: "https://example.test".to_string(),
19221 created_unix: 0,
19222 expires_unix: 0,
19223 base_seq: None,
19224 base_commit: None,
19225 remote_seq: 0,
19226 remote_commit: None,
19227 remote_content_root: None,
19228 view_kind: "full".to_string(),
19229 view_revision: "a".repeat(64),
19230 files: vec![V2ConflictFile {
19231 path: "records/value.md".to_string(),
19232 base: V2ConflictCoordinate {
19233 sha256: None,
19234 bytes: None,
19235 file: None,
19236 },
19237 local: V2ConflictCoordinate {
19238 sha256: None,
19239 bytes: None,
19240 file: None,
19241 },
19242 remote: V2ConflictCoordinate {
19243 sha256: None,
19244 bytes: None,
19245 file: None,
19246 },
19247 }],
19248 };
19249 let mut bytes = serde_json::to_vec(&plan).unwrap();
19250 bytes.push(b'\n');
19251 store
19252 .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
19253 .unwrap();
19254
19255 let listed = sync_conflicts(&root, false, false).unwrap();
19256 assert_eq!(listed["bundles"], 2);
19257 assert_eq!(listed["pruned"], 0);
19258 let pruned = sync_conflicts(&root, true, false).unwrap();
19259 assert_eq!(pruned["bundles"], 0);
19260 assert_eq!(pruned["pruned"], 2);
19261 assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
19262 assert!(!root.join(".dbmd/conflicts").join(expired).exists());
19263 }
19264
19265 #[test]
19266 fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
19267 let sandbox = tempfile::TempDir::new().unwrap();
19268 let root = sandbox.path().join("brain");
19269 std::fs::create_dir_all(&root).unwrap();
19270 std::fs::write(
19271 root.join("DB.md"),
19272 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19273 )
19274 .unwrap();
19275 let store = Store::open_strict(&root).unwrap();
19276 let bundle = crate::ulid::mint();
19277 store
19278 .create_dir_all(&v2_conflict_relative(&bundle, "files"))
19279 .unwrap();
19280 store
19281 .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
19282 .unwrap();
19283
19284 assert!(sync_conflicts(&root, true, false).is_err());
19285 assert!(sync_conflicts(&root, false, true).is_err());
19286 let pruned = sync_conflicts(&root, true, true).unwrap();
19287 assert_eq!(pruned["pruned"], 1);
19288 assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
19289 }
19290
19291 #[test]
19292 fn ready_pull_journal_rolls_back_exact_preimages() {
19293 let sandbox = tempfile::TempDir::new().unwrap();
19294 let root = sandbox.path().join("brain");
19295 std::fs::create_dir_all(root.join("records")).unwrap();
19296 std::fs::write(
19297 root.join("DB.md"),
19298 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19299 )
19300 .unwrap();
19301 let path = "records/value.md";
19302 let old = b"---\ntype: note\n---\n\nold\n";
19303 let new = b"---\ntype: note\n---\n\nnew\n";
19304 std::fs::write(root.join(path), old).unwrap();
19305 let store = Store::open_strict(&root).unwrap();
19306 let bundle = crate::ulid::mint();
19307 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19308 store
19309 .create_private_dir_all(Path::new(&backup_dir))
19310 .unwrap();
19311 store
19312 .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
19313 .unwrap();
19314 let journal = V2PullJournal {
19315 v: 1,
19316 phase: V2PullPhase::Ready,
19317 brain: TEST_BRAIN_ID.to_string(),
19318 previous: V2PullCoordinate {
19319 head_seq: None,
19320 commit_hash: None,
19321 view_kind: None,
19322 view_revision: None,
19323 },
19324 next: V2PullCoordinate {
19325 head_seq: Some(2),
19326 commit_hash: Some("c".repeat(64)),
19327 view_kind: Some("full".to_string()),
19328 view_revision: Some("d".repeat(64)),
19329 },
19330 backup_dir: backup_dir.clone(),
19331 entries: vec![V2PullJournalEntry {
19332 path: path.to_string(),
19333 old: Some(V2PullFileCoordinate {
19334 sha256: content_sha256(old),
19335 bytes: old.len() as u64,
19336 }),
19337 new: Some(V2PullFileCoordinate {
19338 sha256: content_sha256(new),
19339 bytes: new.len() as u64,
19340 }),
19341 backup: Some("00000000".to_string()),
19342 }],
19343 };
19344 validate_v2_pull_journal(&journal).unwrap();
19345 store
19346 .write_private_atomic_new(
19347 Path::new(V2_PULL_JOURNAL),
19348 &v2_pull_journal_bytes(&journal).unwrap(),
19349 )
19350 .unwrap();
19351 store.write_atomic(Path::new(path), new).unwrap();
19352
19353 let cfg = test_hub_config(
19354 "https://example.test".to_string(),
19355 sandbox.path().join("state"),
19356 );
19357 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19358 assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
19359 assert!(!root.join(V2_PULL_JOURNAL).exists());
19360 assert!(!root.join(backup_dir).exists());
19361 }
19362
19363 #[test]
19364 fn preparing_pull_journal_discards_only_private_staging() {
19365 let sandbox = tempfile::TempDir::new().unwrap();
19366 let root = sandbox.path().join("brain");
19367 std::fs::create_dir_all(root.join(".dbmd")).unwrap();
19368 std::fs::write(
19369 root.join("DB.md"),
19370 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19371 )
19372 .unwrap();
19373 let store = Store::open_strict(&root).unwrap();
19374 let bundle = crate::ulid::mint();
19375 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19376 store
19377 .create_private_dir_all(Path::new(&backup_dir))
19378 .unwrap();
19379 let journal = V2PullJournal {
19380 v: 1,
19381 phase: V2PullPhase::Preparing,
19382 brain: TEST_BRAIN_ID.to_string(),
19383 previous: V2PullCoordinate {
19384 head_seq: None,
19385 commit_hash: None,
19386 view_kind: None,
19387 view_revision: None,
19388 },
19389 next: V2PullCoordinate {
19390 head_seq: Some(1),
19391 commit_hash: Some("a".repeat(64)),
19392 view_kind: Some("full".to_string()),
19393 view_revision: Some("b".repeat(64)),
19394 },
19395 backup_dir: backup_dir.clone(),
19396 entries: vec![V2PullJournalEntry {
19397 path: "records/new.md".to_string(),
19398 old: None,
19399 new: Some(V2PullFileCoordinate {
19400 sha256: "c".repeat(64),
19401 bytes: 1,
19402 }),
19403 backup: None,
19404 }],
19405 };
19406 store
19407 .write_private_atomic_new(
19408 Path::new(V2_PULL_JOURNAL),
19409 &v2_pull_journal_bytes(&journal).unwrap(),
19410 )
19411 .unwrap();
19412 let cfg = test_hub_config(
19413 "https://example.test".to_string(),
19414 sandbox.path().join("state"),
19415 );
19416
19417 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19418
19419 assert!(root.join("DB.md").is_file());
19420 assert!(!root.join(V2_PULL_JOURNAL).exists());
19421 assert!(!root.join(backup_dir).exists());
19422 }
19423
19424 #[test]
19425 fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
19426 let sandbox = tempfile::TempDir::new().unwrap();
19427 let root = sandbox.path().join("brain");
19428 std::fs::create_dir_all(root.join("records")).unwrap();
19429 std::fs::write(
19430 root.join("DB.md"),
19431 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19432 )
19433 .unwrap();
19434 let new = b"---\ntype: note\n---\n\nnew\n";
19435 std::fs::write(root.join("records/value.md"), new).unwrap();
19436 let store = Store::open_strict(&root).unwrap();
19437 let bundle = crate::ulid::mint();
19438 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19439 store
19440 .create_private_dir_all(Path::new(&backup_dir))
19441 .unwrap();
19442 let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
19443 store.create_private_dir_all(Path::new(&orphan)).unwrap();
19444 let next = V2PullCoordinate {
19445 head_seq: Some(2),
19446 commit_hash: Some("c".repeat(64)),
19447 view_kind: Some("full".to_string()),
19448 view_revision: Some("d".repeat(64)),
19449 };
19450 let journal = V2PullJournal {
19451 v: 1,
19452 phase: V2PullPhase::Ready,
19453 brain: TEST_BRAIN_ID.to_string(),
19454 previous: V2PullCoordinate {
19455 head_seq: Some(1),
19456 commit_hash: Some("a".repeat(64)),
19457 view_kind: Some("full".to_string()),
19458 view_revision: Some("b".repeat(64)),
19459 },
19460 next: next.clone(),
19461 backup_dir: backup_dir.clone(),
19462 entries: vec![V2PullJournalEntry {
19463 path: "records/value.md".to_string(),
19464 old: Some(V2PullFileCoordinate {
19465 sha256: "e".repeat(64),
19466 bytes: new.len() as u64,
19467 }),
19468 new: Some(V2PullFileCoordinate {
19469 sha256: content_sha256(new),
19470 bytes: new.len() as u64,
19471 }),
19472 backup: Some("00000000".to_string()),
19473 }],
19474 };
19475 store
19476 .write_private_atomic_new(
19477 Path::new(V2_PULL_JOURNAL),
19478 &v2_pull_journal_bytes(&journal).unwrap(),
19479 )
19480 .unwrap();
19481 let cfg = test_hub_config(
19482 "https://example.test".to_string(),
19483 sandbox.path().join("state"),
19484 );
19485 save_v2_baseline(
19486 &cfg,
19487 TEST_BRAIN_ID,
19488 &root,
19489 &V2SyncBaseline {
19490 v: 2,
19491 origin: "https://example.test".to_string(),
19492 brain: TEST_BRAIN_ID.to_string(),
19493 checkout_id: Some("c".repeat(64)),
19494 head_seq: next.head_seq,
19495 commit_hash: next.commit_hash.clone(),
19496 content_root: Some("f".repeat(64)),
19497 asset_root: None,
19498 assets: Default::default(),
19499 view_kind: next.view_kind.clone(),
19500 view_revision: next.view_revision.clone(),
19501 control_revision: Some("d".repeat(64)),
19502 projection_sha256: None,
19503 files: Default::default(),
19504 local_policy_digest: None,
19505 local_eligibility: Default::default(),
19506 remote_copy_remains: Default::default(),
19507 },
19508 )
19509 .unwrap();
19510
19511 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19512
19513 assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
19514 assert!(!root.join(V2_PULL_JOURNAL).exists());
19515 assert!(!root.join(backup_dir).exists());
19516 assert!(!root.join(orphan).exists());
19517 }
19518}