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
7340#[derive(Debug, Clone)]
7341struct V2ResolutionOverride {
7342 expected_remote: Option<String>,
7343 selected_local: Option<String>,
7344}
7345
7346#[derive(Debug, Clone)]
7347struct V2UploadSource {
7348 path: String,
7349 bytes: u64,
7350}
7351
7352struct V2SyncPushOptions<'a> {
7353 resume_local_policy: bool,
7354 bulk_confirmation: Option<&'a V2BulkConfirmation>,
7355 resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7356 pulled: Option<V2PulledSnapshot>,
7357 withdrawal_paths: &'a [String],
7358 withdrawal_reason: Option<&'a str>,
7359}
7360
7361fn verify_v2_upload_source(
7362 store: &Store,
7363 path: &str,
7364 sha256: &str,
7365 expected_bytes: u64,
7366) -> LinkResult<()> {
7367 let file = store.open_regular(Path::new(path))?;
7368 if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7369 return Err(LinkError::InvalidPack {
7370 message: format!("local path `{path}` changed during sync planning"),
7371 });
7372 }
7373 Ok(())
7374}
7375
7376struct V2PendingUpload<'a> {
7379 url: String,
7380 headers: Value,
7381 sha256: String,
7382 source: &'a V2UploadSource,
7383}
7384
7385const V2_UPLOAD_CONCURRENCY: usize = 16;
7392
7393fn upload_v2_batch_concurrently(
7397 cfg: &HubConfig,
7398 store: &Store,
7399 pending: &[V2PendingUpload<'_>],
7400) -> LinkResult<()> {
7401 if pending.is_empty() {
7402 return Ok(());
7403 }
7404 let urls = pending
7405 .iter()
7406 .map(|task| task.url.as_str())
7407 .collect::<Vec<_>>();
7408 let shared = shared_staging_agent(cfg, &urls);
7409 if pending.len() == 1 {
7410 let task = &pending[0];
7411 put_presigned_source(
7412 cfg,
7413 &task.url,
7414 &task.headers,
7415 store,
7416 task.source,
7417 shared.as_ref(),
7418 )?;
7419 return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7420 }
7421 let next = std::sync::atomic::AtomicUsize::new(0);
7422 let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7423 let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7424 std::thread::scope(|scope| {
7425 for _ in 0..workers {
7426 scope.spawn(|| loop {
7427 if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7428 return;
7429 }
7430 let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7431 let Some(task) = pending.get(index) else {
7432 return;
7433 };
7434 let outcome = put_presigned_source(
7435 cfg,
7436 &task.url,
7437 &task.headers,
7438 store,
7439 task.source,
7440 shared.as_ref(),
7441 )
7442 .and_then(|()| {
7443 verify_v2_upload_source(
7444 store,
7445 &task.source.path,
7446 &task.sha256,
7447 task.source.bytes,
7448 )
7449 });
7450 if let Err(error) = outcome {
7451 if let Ok(mut guard) = failure.lock() {
7452 guard.get_or_insert(error);
7453 }
7454 return;
7455 }
7456 });
7457 }
7458 });
7459 match failure.into_inner() {
7460 Ok(Some(error)) => Err(error),
7461 Ok(None) => Ok(()),
7462 Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7463 }
7464}
7465
7466fn put_presigned_source(
7467 cfg: &HubConfig,
7468 raw: &str,
7469 headers: &Value,
7470 store: &Store,
7471 source: &V2UploadSource,
7472 shared: Option<&ureq::Agent>,
7473) -> LinkResult<()> {
7474 put_presigned_source_with_budget(
7475 cfg,
7476 raw,
7477 headers,
7478 store,
7479 source,
7480 shared,
7481 std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
7482 )
7483}
7484
7485fn put_presigned_source_with_budget(
7486 cfg: &HubConfig,
7487 raw: &str,
7488 headers: &Value,
7489 store: &Store,
7490 source: &V2UploadSource,
7491 shared: Option<&ureq::Agent>,
7492 total_budget: std::time::Duration,
7493) -> LinkResult<()> {
7494 let owned = match shared {
7497 Some(_) => {
7498 checked_presigned_url(cfg, raw)?;
7499 None
7500 }
7501 None => Some(presigned_agent(cfg, raw)?),
7502 };
7503 let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7504 let deadline = std::time::Instant::now()
7505 .checked_add(total_budget)
7506 .ok_or_else(upload_deadline_error)?;
7507 let mut attempt = 0;
7508 let result = loop {
7509 let file = store.open_regular(Path::new(&source.path))?;
7510 if file.metadata()?.len() != source.bytes {
7511 return Err(LinkError::InvalidPack {
7512 message: format!("local path `{}` changed before upload", source.path),
7513 });
7514 }
7515 let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
7520 let mut has_content_length = false;
7521 if let Some(map) = headers.as_object() {
7522 for (name, value) in map {
7523 if let Some(value) = value.as_str() {
7524 has_content_length |= name.eq_ignore_ascii_case("content-length");
7525 req = req.set(name, value);
7526 }
7527 }
7528 }
7529 if !has_content_length {
7530 req = req.set("Content-Length", &source.bytes.to_string());
7531 }
7532 match req.send(file) {
7533 Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
7539 attempt += 1;
7540 }
7541 Err(ureq::Error::Status(status, _))
7547 if status != 412
7548 && is_retryable_upload_status(status)
7549 && wait_for_upload_retry(deadline, attempt) =>
7550 {
7551 attempt += 1;
7552 }
7553 result => break result,
7554 }
7555 };
7556 match result {
7557 Ok(response) if (200..300).contains(&response.status()) => {
7558 drain_presigned_response(response);
7559 Ok(())
7560 }
7561 Ok(response) => {
7562 let status = response.status();
7567 let detail = response
7568 .into_string()
7569 .ok()
7570 .map(|body| body.chars().take(400).collect::<String>())
7571 .filter(|body| !body.trim().is_empty());
7572 Err(LinkError::Http {
7573 what: "v2 changed-byte upload",
7574 status,
7575 message: match detail {
7576 Some(body) => format!(
7577 "object store rejected the upload of `{}`: {}",
7578 source.path,
7579 body.replace('\n', " ")
7580 ),
7581 None => format!("object store rejected the upload of `{}`", source.path),
7582 },
7583 code: None,
7584 details: None,
7585 })
7586 }
7587 Err(error) => match error {
7588 ureq::Error::Status(412, _) => Ok(()),
7589 ureq::Error::Status(_, response) => {
7590 let status = response.status();
7591 let detail = response
7592 .into_string()
7593 .ok()
7594 .map(|body| body.chars().take(400).collect::<String>())
7595 .filter(|body| !body.trim().is_empty());
7596 Err(LinkError::Http {
7597 what: "v2 changed-byte upload",
7598 status,
7599 message: match detail {
7600 Some(body) => format!(
7601 "object store rejected the upload of `{}`: {}",
7602 source.path,
7603 body.replace('\n', " ")
7604 ),
7605 None => {
7606 format!("object store rejected the upload of `{}`", source.path)
7607 }
7608 },
7609 code: None,
7610 details: None,
7611 })
7612 }
7613 ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
7614 },
7615 }
7616}
7617
7618fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
7622 if body.get("operations").is_some() {
7623 return body.clone();
7624 }
7625 let mut value = body.clone();
7626 if let Some(map) = value.as_object_mut() {
7627 map.remove("staged_change");
7628 map.insert("operations".to_string(), Value::Array(operations.to_vec()));
7629 }
7630 value
7631}
7632
7633fn reserve_upload_window(
7637 cfg: &HubConfig,
7638 path: &str,
7639 body: &Value,
7640 what: &'static str,
7641) -> LinkResult<Value> {
7642 let mut attempt = 0;
7643 loop {
7644 let pause = |attempt: usize| {
7645 std::thread::sleep(std::time::Duration::from_millis(
7646 RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
7647 ));
7648 };
7649 match request(cfg, "POST", path, Some(body), Auth::Required) {
7650 Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
7655 pause(attempt);
7656 attempt += 1;
7657 }
7658 Err(error) => return Err(error),
7659 Ok(response) => {
7660 if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
7661 pause(attempt);
7662 attempt += 1;
7663 continue;
7664 }
7665 return ensure_ok(response, what);
7666 }
7667 }
7668 }
7669}
7670
7671fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
7675 let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
7676 .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
7677 if bytes.len() > MAX_STAGED_CHANGE_BYTES {
7678 return Err(LinkError::PushTooLarge {
7679 detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
7680 });
7681 }
7682 Ok(bytes)
7683}
7684
7685fn stage_v2_change(
7695 cfg: &HubConfig,
7696 requested_brain: &str,
7697 operations: &[Value],
7698 blobs: Value,
7699) -> LinkResult<Value> {
7700 let bytes = v2_change_manifest(operations, blobs)?;
7701 let sha256 = content_sha256(&bytes);
7702 let reserved = reserve_upload_window(
7703 cfg,
7704 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7705 &json!({
7706 "blobs": [{
7707 "sha256": sha256,
7708 "bytes": bytes.len(),
7709 "kind": "staged_change",
7710 }],
7711 }),
7712 "stage the v2 change",
7713 )?;
7714 let items = reserved
7715 .get("uploads")
7716 .and_then(Value::as_array)
7717 .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
7718 let [item] = items.as_slice() else {
7719 return Err(invalid_feed(
7720 "v2 change staging response changed the requested set",
7721 ));
7722 };
7723 let reservation_id = item
7724 .get("reservation_id")
7725 .and_then(Value::as_str)
7726 .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
7727 if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
7728 || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
7729 || !crate::ulid::is_ulid(reservation_id)
7730 {
7731 return Err(invalid_feed("v2 change staging item is inconsistent"));
7732 }
7733 match item.get("status").and_then(Value::as_str) {
7734 Some("upload") => put_presigned(
7735 cfg,
7736 item.get("url")
7737 .and_then(Value::as_str)
7738 .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
7739 item.get("headers").unwrap_or(&Value::Null),
7740 &bytes,
7741 )?,
7742 Some("already_present") => {}
7743 _ => return Err(invalid_feed("v2 change staging has an unknown status")),
7744 }
7745 Ok(json!({
7746 "sha256": sha256,
7747 "bytes": bytes.len(),
7748 "reservation_id": reservation_id,
7749 }))
7750}
7751
7752fn stage_oversized_v2_change(
7756 cfg: &HubConfig,
7757 requested_brain: &str,
7758 operations: &[Value],
7759 body: &mut Value,
7760) -> LinkResult<()> {
7761 if body.to_string().len() <= MAX_PUSH_BYTES {
7762 return Ok(());
7763 }
7764 let staged = stage_v2_change(
7765 cfg,
7766 requested_brain,
7767 operations,
7768 body.get("blobs")
7769 .cloned()
7770 .unwrap_or(Value::Array(Vec::new())),
7771 )?;
7772 let map = body
7773 .as_object_mut()
7774 .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
7775 map.remove("operations");
7776 map.remove("blobs");
7777 map.insert("staged_change".to_string(), staged);
7778 Ok(())
7779}
7780
7781fn v2_sync_push(
7782 cfg: &HubConfig,
7783 requested_brain: &str,
7784 store: &Store,
7785 head: V2VerifiedHead,
7786 options: V2SyncPushOptions<'_>,
7787) -> LinkResult<Value> {
7788 let V2SyncPushOptions {
7789 resume_local_policy,
7790 bulk_confirmation,
7791 resolution,
7792 pulled,
7793 withdrawal_paths,
7794 withdrawal_reason,
7795 } = options;
7796 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
7797 let head = v2_verified_head(cfg, requested_brain)?
7798 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7799 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
7800 ensure_v2_view_compatible(&head, baseline.as_ref())?;
7801 let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
7802 match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
7803 Some(snapshot) => (
7804 snapshot.files,
7805 snapshot.assets,
7806 Some(snapshot.local),
7807 Some(snapshot.local_assets),
7808 ),
7809 None => match baseline
7810 .as_ref()
7811 .filter(|state| v2_baseline_matches_head(&head, state))
7812 {
7813 Some(state) => (state.files.clone(), state.assets.clone(), None, None),
7814 None => (
7815 files_for_v2_view(
7816 &head,
7817 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7818 ),
7819 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7820 None,
7821 None,
7822 ),
7823 },
7824 };
7825 if head.view_kind == "scoped" && baseline.is_none() {
7826 return Err(LinkError::ScopedViewChanged);
7827 }
7828 let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
7829 let local = &local_view.riding;
7830 let local_assets = match carried_local_assets {
7831 Some(assets) => assets,
7832 None => v2_local_asset_records(store)?,
7833 };
7834 if withdrawal_paths.len() > MAX_PUSH_FILES {
7835 return Err(LinkError::PushTooLarge {
7836 detail: "too many explicit withdrawal paths".to_string(),
7837 });
7838 }
7839 let withdrawal_reason = if withdrawal_paths.is_empty() {
7840 None
7841 } else {
7842 let reason = withdrawal_reason
7843 .map(str::trim)
7844 .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
7845 .ok_or_else(|| LinkError::InvalidPack {
7846 message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
7847 })?;
7848 Some(reason)
7849 };
7850 let mut withdrawals = withdrawal_paths
7851 .iter()
7852 .map(|path| {
7853 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
7854 path: error.to_string(),
7855 })
7856 })
7857 .collect::<LinkResult<Vec<_>>>()?;
7858 withdrawals.sort();
7859 withdrawals.dedup();
7860 if withdrawals.len() != withdrawal_paths.len() {
7861 return Err(LinkError::InvalidPack {
7862 message: "explicit withdrawal paths must be unique".to_string(),
7863 });
7864 }
7865 let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
7866 let mut consumed_withdrawals = BTreeSet::new();
7867 if let Some(previous) = baseline.as_ref() {
7868 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
7869 && !resume_local_policy
7870 {
7871 let mut newly_eligible = previous
7872 .local_eligibility
7873 .iter()
7874 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7875 .map(|(path, _)| path.clone())
7876 .collect::<Vec<_>>();
7877 if !newly_eligible.is_empty() {
7878 newly_eligible.truncate(100);
7879 return Err(LinkError::LocalPolicyTransition {
7880 paths: newly_eligible,
7881 });
7882 }
7883 }
7884 }
7885 let base = match baseline.as_ref() {
7886 Some(state) => &state.files,
7887 None if remote.is_empty() => &remote,
7888 None => {
7889 let mut conflicts = remote
7890 .iter()
7891 .filter(|(path, file)| {
7892 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7893 })
7894 .map(|(path, _)| path.clone())
7895 .collect::<Vec<_>>();
7896 if !conflicts.is_empty() {
7897 conflicts.truncate(100);
7898 let (bundle, paths) =
7899 create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7900 return Err(LinkError::ConflictBundle { bundle, paths });
7901 }
7902 &remote
7903 }
7904 };
7905 let all_paths = base
7906 .keys()
7907 .chain(remote.keys())
7908 .chain(local.keys())
7909 .cloned()
7910 .collect::<std::collections::BTreeSet<_>>();
7911 let mut conflicts = Vec::new();
7912 let mut operations = Vec::new();
7913 let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7914 for path in all_paths {
7915 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7916 let remote_file = remote.get(&path);
7917 let remote_hash = remote_file.map(|file| file.sha256.as_str());
7918 let local_file = local.get(&path);
7919 let local_hash = local_file.map(|file| file.0.as_str());
7920 if local_hash == base_hash {
7921 continue;
7922 }
7923 if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
7924 continue;
7925 }
7926 if local_view.policy.keeps_home(&path) {
7927 continue;
7930 }
7931 if remote_hash != base_hash && local_hash != remote_hash {
7932 let explicitly_resolved = resolution
7933 .and_then(|allowed| allowed.get(&path))
7934 .is_some_and(|selected| {
7935 selected.expected_remote.as_deref() == remote_hash
7936 && selected.selected_local.as_deref() == local_hash
7937 });
7938 if !explicitly_resolved {
7939 conflicts.push(path);
7940 continue;
7941 }
7942 }
7943 match local_file {
7944 Some((sha256, byte_count)) => {
7945 verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7946 operations.push(json!({
7947 "op": "put",
7948 "path": path,
7949 "expected": v2_expected(remote_file),
7950 "blob": sha256,
7951 "bytes": byte_count,
7952 }));
7953 upload_sources
7954 .entry(sha256.clone())
7955 .or_insert_with(|| V2UploadSource {
7956 path: path.clone(),
7957 bytes: *byte_count,
7958 });
7959 }
7960 None => {
7961 let Some(current) = remote_file else {
7962 continue;
7963 };
7964 operations.push(json!({
7965 "op": "delete",
7966 "path": path,
7967 "expected": { "kind": "blob", "hash": current.sha256 },
7968 }));
7969 }
7970 }
7971 }
7972 operations = infer_exact_source_promotions(operations);
7973 for path in &withdrawals {
7974 if local_assets.contains_key(path) {
7975 continue;
7976 }
7977 operations.push(v2_content_withdrawal_operation(
7978 store,
7979 &local_view,
7980 &remote,
7981 path,
7982 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7983 )?);
7984 consumed_withdrawals.insert(path.clone());
7985 }
7986 if !conflicts.is_empty() {
7987 conflicts.truncate(100);
7988 let (bundle, paths) = create_v2_conflict_bundle(
7989 cfg,
7990 store,
7991 &head,
7992 baseline.as_ref(),
7993 local,
7994 &remote,
7995 &conflicts,
7996 )?;
7997 return Err(LinkError::ConflictBundle { bundle, paths });
7998 }
7999 let base_assets = match baseline.as_ref() {
8000 Some(state) => &state.assets,
8001 None if remote_assets.is_empty() => &remote_assets,
8002 None => {
8003 let mismatched = remote_assets.iter().any(|(path, remote)| {
8004 local_assets.get(path) != Some(&v2_asset_record(remote, path))
8005 }) || local_assets.len() != remote_assets.len();
8006 if mismatched {
8007 return Err(LinkError::Conflict {
8008 paths: vec!["assets.jsonl".to_string()],
8009 });
8010 }
8011 &remote_assets
8012 }
8013 };
8014 let asset_paths = base_assets
8015 .keys()
8016 .chain(remote_assets.keys())
8017 .chain(local_assets.keys())
8018 .cloned()
8019 .collect::<std::collections::BTreeSet<_>>();
8020 let mut asset_policy_transitions = Vec::new();
8021 for path in asset_paths {
8022 let base_record = base_assets
8023 .get(&path)
8024 .map(|asset| v2_asset_record(asset, &path));
8025 let remote = remote_assets.get(&path);
8026 let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
8027 let local_record = local_assets.get(&path);
8028 if withdrawal_set.contains(&path) {
8029 let record = local_record.ok_or_else(|| LinkError::InvalidPack {
8030 message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
8031 })?;
8032 let current = remote.ok_or_else(|| LinkError::InvalidPack {
8033 message: format!(
8034 "asset withdrawal path `{path}` has no readable hosted coordinate"
8035 ),
8036 })?;
8037 operations.push(v2_asset_withdrawal_operation(
8038 store,
8039 &local_view,
8040 &path,
8041 record,
8042 current,
8043 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8044 )?);
8045 consumed_withdrawals.insert(path.clone());
8046 continue;
8047 }
8048 let mut raw_present = false;
8049 let mut disposition = "withheld";
8050 let mut resumes_hosting = false;
8051 if let Some(record) = local_record {
8052 crate::linkmd_v2::normalize_path(&record.path)
8053 .map_err(|error| invalid_feed(error.to_string()))?;
8054 let kept_home = local_view.policy.keeps_home(&path);
8055 raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
8056 disposition = if kept_home || !raw_present {
8057 "withheld"
8058 } else {
8059 "hosted"
8060 };
8061 if !raw_present && record.required && !kept_home {
8062 return Err(LinkError::InvalidPack {
8063 message: format!("required asset {path} is missing"),
8064 });
8065 }
8066 resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
8067 }
8068 if local_record == base_record.as_ref() && !resumes_hosting {
8069 continue;
8070 }
8071 if remote_record != base_record && local_record != remote_record.as_ref() {
8072 conflicts.push(path);
8073 continue;
8074 }
8075 let Some(record) = local_record else {
8076 if let Some(remote) = remote {
8077 operations.push(json!({
8078 "op": "asset_delete",
8079 "path": path,
8080 "expected": v2_asset_expected(Some(remote)),
8081 }));
8082 }
8083 continue;
8084 };
8085 let raw = if raw_present {
8086 verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
8087 Some(())
8088 } else {
8089 None
8090 };
8091 let op = if resumes_hosting {
8092 if !resume_local_policy {
8093 asset_policy_transitions.push(path);
8094 continue;
8095 }
8096 "asset_resume"
8097 } else {
8098 "asset_put"
8099 };
8100 operations.push(json!({
8101 "op": op,
8102 "path": path,
8103 "expected": v2_asset_expected(remote),
8104 "asset": v2_asset_value(record, disposition),
8105 }));
8106 if disposition == "hosted" {
8107 raw.expect("hosted asset was checked present");
8108 upload_sources
8109 .entry(record.sha256.clone())
8110 .or_insert_with(|| V2UploadSource {
8111 path: path.clone(),
8112 bytes: record.bytes,
8113 });
8114 }
8115 }
8116 if consumed_withdrawals != withdrawal_set {
8117 let missing = withdrawal_set
8118 .difference(&consumed_withdrawals)
8119 .next()
8120 .expect("different withdrawal sets have one member");
8121 return Err(LinkError::InvalidPack {
8122 message: format!(
8123 "withdrawal path `{missing}` is not a readable content or asset coordinate"
8124 ),
8125 });
8126 }
8127 if !conflicts.is_empty() {
8128 conflicts.truncate(100);
8129 return Err(LinkError::Conflict { paths: conflicts });
8130 }
8131 if !asset_policy_transitions.is_empty() {
8132 asset_policy_transitions.truncate(100);
8133 return Err(LinkError::LocalPolicyTransition {
8134 paths: asset_policy_transitions,
8135 });
8136 }
8137 let touched_sources = operations
8138 .iter()
8139 .filter_map(
8140 |operation| match operation.get("op").and_then(Value::as_str) {
8141 Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
8142 Some("rename") => operation.get("to").and_then(Value::as_str),
8143 _ => None,
8144 },
8145 )
8146 .collect::<std::collections::BTreeSet<_>>();
8147 let withheld_links = local_view
8148 .withheld_links
8149 .iter()
8150 .filter(|link| touched_sources.contains(link.source.as_str()))
8151 .collect::<Vec<_>>();
8152 let checkout_pseudonym = v2_checkout_id(
8153 baseline
8154 .as_ref()
8155 .and_then(|current| current.checkout_id.as_deref()),
8156 )?;
8157 let checkout_id = if withheld_links.is_empty() {
8158 None
8159 } else {
8160 Some(checkout_pseudonym.clone())
8161 };
8162 if operations.is_empty() {
8163 let final_head = v2_verified_head(cfg, requested_brain)?
8164 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
8165 if !same_v2_head(&head, &final_head) {
8166 return Err(LinkError::RemoteAdvancedDuringSync);
8167 }
8168 let mut final_local = v2_local_files(store)?;
8169 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8170 let final_assets = v2_local_asset_records(store)?;
8171 let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
8172 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
8173 final_local.policy.keeps_home(path)
8174 }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
8175 let next = v2_baseline_from_head(
8176 cfg,
8177 &head,
8178 remote,
8179 remote_assets,
8180 Some(&final_local),
8181 Some(&checkout_pseudonym),
8182 )?;
8183 let split_count = next.remote_copy_remains.len();
8184 accept_v2_head(cfg, &final_head)?;
8185 if !local_changed && !remote_ahead {
8186 refresh_scoped_view_marker(store, &head, next.files.len())?;
8187 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8188 }
8189 return Ok(json!({
8190 "v": 2,
8191 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
8192 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
8193 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8194 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8195 "local_policy": {
8196 "remote_copy_remains": split_count,
8197 },
8198 }));
8199 }
8200 let includes_contract = operations
8201 .iter()
8202 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
8203 let rebase = if head.pointer.is_none() || includes_contract {
8204 "strict"
8205 } else {
8206 "disjoint"
8207 };
8208 let base_value = head.pointer.as_ref().map(|pointer| {
8209 json!({
8210 "seq": pointer.seq,
8211 "commit_hash": pointer.commit_hash,
8212 "content_root": pointer.content_root,
8213 "asset_root": pointer.asset_root,
8214 })
8215 });
8216 let entropy = format!(
8220 "{}\0{}\0{}\0{}\0{}\0{}",
8221 normalized_origin(&cfg.hub)?,
8222 head.brain_id,
8223 serde_json::to_string(&base_value).unwrap_or_default(),
8224 serde_json::to_string(&operations).unwrap_or_default(),
8225 serde_json::to_string(&withheld_links).unwrap_or_default(),
8226 checkout_id.as_deref().unwrap_or("")
8227 );
8228 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
8229 let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
8230 total
8231 .checked_add(source.bytes)
8232 .ok_or_else(|| LinkError::PushTooLarge {
8233 detail: "v2 changed-byte total overflow".to_string(),
8234 })
8235 })?;
8236 let inline = changed_bytes <= 3 * 1024 * 1024;
8237 let inline_blobs = if inline {
8238 upload_sources
8239 .iter()
8240 .map(|(sha256, source)| {
8241 let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
8242 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
8243 return Err(LinkError::InvalidPack {
8244 message: format!("local path `{}` changed before upload", source.path),
8245 });
8246 }
8247 Ok(json!({
8248 "sha256": sha256,
8249 "bytes": source.bytes,
8250 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
8251 }))
8252 })
8253 .collect::<LinkResult<Vec<_>>>()?
8254 } else {
8255 Vec::new()
8256 };
8257 let mut body = json!({
8258 "mutation_id": mutation_id,
8259 "base": base_value,
8260 "rebase": rebase,
8261 "reason": "dbmd sync",
8262 "operations": operations,
8263 "blobs": inline_blobs,
8264 });
8265 if !withheld_links.is_empty() {
8266 body["withheld_links"] = serde_json::to_value(&withheld_links)
8267 .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
8268 body["checkout_id"] =
8269 Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
8270 }
8271 if let Some(confirmation) = bulk_confirmation {
8272 if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
8273 return Err(LinkError::InvalidPack {
8274 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
8275 .to_string(),
8276 });
8277 }
8278 body["rebase"] = Value::String("strict".to_string());
8282 body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8283 body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8284 }
8285 if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8286 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8287 for operation in &operations {
8288 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8289 return Err(invalid_feed("v2 upload operation has no kind"));
8290 };
8291 let hash = match kind {
8292 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
8293 "asset_put" | "asset_resume" => operation
8294 .get("asset")
8295 .and_then(|asset| asset.get("blob_sha256"))
8296 .and_then(Value::as_str),
8297 _ => None,
8298 };
8299 let Some(hash) = hash else { continue };
8300 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8301 if kind == "rename" {
8302 for field in ["from", "to"] {
8303 coordinates.insert(
8304 operation
8305 .get(field)
8306 .and_then(Value::as_str)
8307 .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8308 .to_string(),
8309 );
8310 }
8311 } else {
8312 let path = operation
8313 .get("path")
8314 .and_then(Value::as_str)
8315 .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8316 coordinates.insert(if kind.starts_with("asset_") {
8317 format!("assets/{path}")
8318 } else {
8319 path.to_string()
8320 });
8321 }
8322 }
8323 let declarations = upload_sources
8324 .iter()
8325 .map(|(sha256, source)| {
8326 json!({
8327 "sha256": sha256,
8328 "bytes": source.bytes,
8329 "coordinates": coordinates_by_hash
8330 .get(sha256)
8331 .into_iter()
8332 .flatten()
8333 .collect::<Vec<_>>(),
8334 })
8335 })
8336 .collect::<Vec<_>>();
8337 let mut references = Vec::with_capacity(upload_sources.len());
8338 let mut seen = std::collections::BTreeSet::new();
8339 let mut reserved_count = 0usize;
8340 let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8341 for batch in batch_upload_declarations(declarations) {
8345 let batch_len = batch.len();
8346 let reserved = reserve_upload_window(
8347 cfg,
8348 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8349 &json!({ "blobs": batch }),
8350 "prepare v2 changed-byte uploads",
8351 )?;
8352 let items = reserved
8353 .get("uploads")
8354 .and_then(Value::as_array)
8355 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8356 if items.len() != batch_len {
8357 return Err(invalid_feed(
8358 "v2 upload reservation response changed the requested set",
8359 ));
8360 }
8361 reserved_count += items.len();
8362 for item in items {
8363 let sha256 = item
8364 .get("sha256")
8365 .and_then(Value::as_str)
8366 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8367 let source = upload_sources
8368 .get(sha256)
8369 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8370 let declared_bytes = item
8371 .get("bytes")
8372 .and_then(Value::as_u64)
8373 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8374 let reservation_id = item
8375 .get("reservation_id")
8376 .and_then(Value::as_str)
8377 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8378 let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8379 invalid_feed("v2 upload reservation has no coordinate binding")
8380 })?;
8381 let returned_coordinates = item
8382 .get("coordinates")
8383 .and_then(Value::as_array)
8384 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8385 if declared_bytes != source.bytes
8386 || !crate::ulid::is_ulid(reservation_id)
8387 || !seen.insert(sha256.to_string())
8388 || returned_coordinates.len() != expected_coordinates.len()
8389 || returned_coordinates
8390 .iter()
8391 .zip(expected_coordinates)
8392 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8393 {
8394 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8395 }
8396 match item.get("status").and_then(Value::as_str) {
8397 Some("upload") => {
8398 let url = item
8399 .get("url")
8400 .and_then(Value::as_str)
8401 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8402 pending_uploads.push(V2PendingUpload {
8403 url: url.to_string(),
8404 headers: item.get("headers").cloned().unwrap_or(Value::Null),
8405 sha256: sha256.to_string(),
8406 source,
8407 });
8408 }
8409 Some("already_present") => {}
8410 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
8411 }
8412 references.push(json!({
8413 "sha256": sha256,
8414 "bytes": source.bytes,
8415 "reservation_id": reservation_id,
8416 }));
8417 }
8418 upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
8424 pending_uploads.clear();
8425 }
8426 if reserved_count != upload_sources.len() {
8427 return Err(invalid_feed(
8428 "v2 upload reservation response changed the requested set",
8429 ));
8430 }
8431 body["blobs"] = Value::Array(references);
8432 }
8433 stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
8434 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
8435 let mut candidate_hub_signer: Option<String> = None;
8436 let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8437 let bulk_preview_required = !(200..300).contains(&response.status)
8438 && response.body.as_ref().is_some_and(|value| {
8439 value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
8440 || value
8441 .get("details")
8442 .and_then(|details| details.get("code"))
8443 .and_then(Value::as_str)
8444 == Some("bulk_preview_required")
8445 });
8446 if bulk_preview_required && bulk_confirmation.is_none() {
8447 body["rebase"] = Value::String("strict".to_string());
8448 body["preview_only"] = Value::Bool(true);
8449 let preview = ensure_ok(
8450 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
8451 "v2 bulk preview",
8452 )?;
8453 let preview_code = preview.get("code").and_then(Value::as_str);
8454 let required = preview.get("required").and_then(Value::as_bool);
8455 if preview.get("v").and_then(Value::as_u64) != Some(2)
8456 || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
8457 || !matches!(
8458 preview_code,
8459 Some("bulk_preview_created" | "bulk_preview_not_required")
8460 )
8461 || required.is_none()
8462 {
8463 return Err(invalid_feed(
8464 "bulk preview response is not bound to the requested mutation",
8465 ));
8466 }
8467 if required == Some(true) {
8468 let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
8469 let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
8470 if preview_code != Some("bulk_preview_created")
8471 || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
8472 || preview_digest.is_none_or(|value| !is_sha256(value))
8473 || preview.get("expires_at").and_then(Value::as_str).is_none()
8474 || !preview.get("impact").is_some_and(Value::is_object)
8475 {
8476 return Err(invalid_feed("bulk preview receipt is malformed"));
8477 }
8478 return Err(LinkError::BulkPreviewRequired { preview });
8479 }
8480 if preview_code != Some("bulk_preview_not_required") {
8481 return Err(invalid_feed("bulk preview requirement is inconsistent"));
8482 }
8483 body.as_object_mut()
8486 .expect("v2 commit request is an object")
8487 .remove("preview_only");
8488 response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8489 }
8490 let mut result = ensure_ok(response, "v2 sync push")?;
8491 if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
8492 if let Some(object) = result.as_object_mut() {
8493 object.insert(
8494 "sync_status".to_string(),
8495 Value::String("proposal_pending".to_string()),
8496 );
8497 }
8498 return Ok(result);
8499 }
8500 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
8501 let request_id = result
8502 .get("request_id")
8503 .and_then(Value::as_str)
8504 .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
8505 .to_string();
8506 let challenge = result
8507 .get("signing_challenge")
8508 .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
8509 let mut expected_candidate = remote.clone();
8510 let mut expected_candidate_assets = remote_assets.clone();
8511 apply_generated_v2_operations(
8512 &operations,
8513 &local_assets,
8514 &mut expected_candidate,
8515 &mut expected_candidate_assets,
8516 )?;
8517 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
8518 cfg,
8519 &head,
8520 &expected_candidate,
8521 &expected_candidate_assets,
8522 &mutation_id,
8523 &v2_signed_request_view(&body, &operations),
8524 challenge,
8525 )?;
8526 body["signing_challenge_id"] = Value::String(challenge_id);
8527 body["signature_base64url"] = Value::String(signature);
8528 candidate_hub_signer = Some(actor_signer);
8529 result = ensure_ok(
8530 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
8531 "v2 self-custody commit",
8532 )?;
8533 }
8534 let refreshed = v2_verified_head(cfg, requested_brain)?
8535 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
8536 if candidate_hub_signer
8537 .as_ref()
8538 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
8539 {
8540 return Err(invalid_feed(
8541 "self-custody actor signer differs from the committed hub pointer signer",
8542 ));
8543 }
8544 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
8545 if refreshed
8546 .pointer
8547 .as_ref()
8548 .map(|pointer| pointer.commit_hash.as_str())
8549 != accepted_hash
8550 {
8551 return Err(LinkError::RemoteAdvancedDuringSync);
8552 }
8553 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
8554 let rebased = result
8555 .get("rebased")
8556 .and_then(Value::as_bool)
8557 .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
8558 let (refreshed_files, refreshed_assets) = if rebased {
8559 (
8560 files_for_v2_view(
8561 &refreshed,
8562 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8563 ),
8564 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8565 )
8566 } else {
8567 let asset_changed = apply_generated_v2_operations(
8568 &operations,
8569 &local_assets,
8570 &mut remote,
8571 &mut remote_assets,
8572 )?;
8573 let assets = if asset_changed {
8574 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
8577 } else {
8578 remote_assets
8579 };
8580 (remote, assets)
8581 };
8582 let mut final_local = v2_local_files(store)?;
8583 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
8584 let final_assets = v2_local_asset_records(store)?;
8585 let local_dirty = final_local.riding != local_view.riding
8586 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
8587 final_local.policy.keeps_home(path)
8588 })
8589 || final_assets != local_assets
8590 || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
8591 let next = v2_baseline_from_head(
8592 cfg,
8593 &refreshed,
8594 refreshed_files,
8595 refreshed_assets,
8596 Some(&final_local),
8597 Some(&checkout_pseudonym),
8598 )?;
8599 let split_count = next.remote_copy_remains.len();
8600 accept_v2_head(cfg, &refreshed)?;
8601 if !local_dirty {
8602 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
8603 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
8604 }
8605 if let Some(object) = result.as_object_mut() {
8606 object.insert(
8607 "local_policy".to_string(),
8608 json!({ "remote_copy_remains": split_count }),
8609 );
8610 object.insert(
8611 "sync_status".to_string(),
8612 Value::String(if local_dirty {
8613 "remote_committed_local_dirty".to_string()
8614 } else {
8615 "synced".to_string()
8616 }),
8617 );
8618 }
8619 Ok(result)
8620}
8621
8622pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
8625 sync_push_incremental_with_policy(cfg, brain, store, false)
8626}
8627
8628pub fn sync_push_incremental_with_policy(
8631 cfg: &HubConfig,
8632 brain: &str,
8633 store: &Store,
8634 resume_local_policy: bool,
8635) -> LinkResult<Value> {
8636 sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
8637}
8638
8639pub fn sync_push_incremental_with_options(
8642 cfg: &HubConfig,
8643 brain: &str,
8644 store: &Store,
8645 resume_local_policy: bool,
8646 bulk_confirmation: Option<&V2BulkConfirmation>,
8647) -> LinkResult<Value> {
8648 sync_push_incremental_with_controls(
8649 cfg,
8650 brain,
8651 store,
8652 resume_local_policy,
8653 bulk_confirmation,
8654 &[],
8655 None,
8656 )
8657}
8658
8659pub fn sync_push_incremental_with_controls(
8661 cfg: &HubConfig,
8662 brain: &str,
8663 store: &Store,
8664 resume_local_policy: bool,
8665 bulk_confirmation: Option<&V2BulkConfirmation>,
8666 withdrawal_paths: &[String],
8667 withdrawal_reason: Option<&str>,
8668) -> LinkResult<Value> {
8669 require_safe_ref(brain)?;
8670 if let Some(head) = v2_verified_head(cfg, brain)? {
8671 return v2_sync_push(
8672 cfg,
8673 brain,
8674 store,
8675 head,
8676 V2SyncPushOptions {
8677 resume_local_policy,
8678 bulk_confirmation,
8679 resolution: None,
8680 pulled: None,
8681 withdrawal_paths,
8682 withdrawal_reason,
8683 },
8684 );
8685 }
8686 if !withdrawal_paths.is_empty() {
8687 return Err(LinkError::InvalidPack {
8688 message: "explicit withdrawal requires a link.md v2 brain".to_string(),
8689 });
8690 }
8691 legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
8692}
8693
8694pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
8698 require_safe_ref(brain)?;
8699 Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
8700}
8701
8702#[cfg(windows)]
8703fn legacy_sync_push_incremental(
8704 _cfg: &HubConfig,
8705 _brain: &str,
8706 _store: &Store,
8707 _resume_local_policy: bool,
8708 _bulk_confirmation: Option<&V2BulkConfirmation>,
8709) -> LinkResult<Value> {
8710 Err(LinkError::UnsupportedPlatform {
8711 operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
8712 })
8713}
8714
8715#[cfg(not(windows))]
8716fn legacy_sync_push_incremental(
8717 cfg: &HubConfig,
8718 brain: &str,
8719 store: &Store,
8720 resume_local_policy: bool,
8721 bulk_confirmation: Option<&V2BulkConfirmation>,
8722) -> LinkResult<Value> {
8723 if resume_local_policy || bulk_confirmation.is_some() {
8724 return Err(LinkError::InvalidPack {
8725 message: "v2 sync options require a link.md v2 brain".to_string(),
8726 });
8727 }
8728 let files = collect_push_files(store)?;
8729 sync_push(cfg, brain, &files)
8730}
8731
8732#[derive(Debug, Clone)]
8734pub enum V2ConflictChoice {
8735 KeepLocal,
8736 TakeRemote,
8737 From(PathBuf),
8738}
8739
8740fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
8741 if !crate::ulid::is_ulid(bundle) {
8742 return Err(LinkError::InvalidPack {
8743 message: "conflict bundle must be a lowercase ULID".to_string(),
8744 });
8745 }
8746 let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
8747 let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
8748 .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
8749 if plan.v != 2
8750 || plan.class != "content_resolution_required"
8751 || plan.bundle != bundle
8752 || !crate::ulid::is_ulid(&plan.brain)
8753 || plan.files.is_empty()
8754 || plan.files.len() > 100
8755 || plan.files.iter().any(|file| {
8756 crate::linkmd_v2::normalize_path(&file.path).is_err()
8757 || [&file.base, &file.local, &file.remote]
8758 .into_iter()
8759 .any(|coordinate| {
8760 coordinate
8761 .sha256
8762 .as_deref()
8763 .is_some_and(|hash| !is_sha256(hash))
8764 || coordinate.file.as_deref().is_some_and(|name| {
8765 name.starts_with('/')
8766 || name
8767 .split('/')
8768 .any(|part| part.is_empty() || part == "." || part == "..")
8769 })
8770 })
8771 })
8772 {
8773 return Err(invalid_feed("private conflict plan failed validation"));
8774 }
8775 Ok(plan)
8776}
8777
8778pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
8783 require_hardened_filesystem("private conflict maintenance")?;
8784 if all && !prune {
8785 return Err(LinkError::InvalidPack {
8786 message: "discarding all conflict bundles requires prune=true".to_string(),
8787 });
8788 }
8789 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8790 message: format!("conflict checkout is not a valid db.md store: {error}"),
8791 })?;
8792 let _transaction = store.transaction()?;
8793 let root = Path::new(".dbmd/conflicts");
8794 let names = match store.directory_names(root) {
8795 Ok(names) => names,
8796 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8797 Err(error) => return Err(error.into()),
8798 };
8799 let now = SystemTime::now()
8800 .duration_since(UNIX_EPOCH)
8801 .unwrap_or_default()
8802 .as_secs();
8803 let mut bundles = Vec::new();
8804 let mut pruned = 0_u64;
8805 for name in names {
8806 let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
8807 continue;
8808 };
8809 let plan_path = v2_conflict_relative(bundle, "plan.json");
8810 let plan_exists = store.regular_file_exists(&plan_path)?;
8811 let expired = if plan_exists {
8812 match load_v2_conflict_plan(&store, bundle) {
8813 Ok(plan) => plan.expires_unix < now,
8814 Err(error) if all => {
8815 let _ = error;
8816 true
8817 }
8818 Err(error) => return Err(error),
8819 }
8820 } else {
8821 true
8822 };
8823 if prune && (all || expired) {
8824 store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8825 pruned += 1;
8826 continue;
8827 }
8828 bundles.push(json!({
8829 "bundle": bundle,
8830 "complete": plan_exists,
8831 "expired": expired,
8832 }));
8833 }
8834 Ok(json!({
8835 "v": 2,
8836 "class": "private_conflict_state",
8837 "bundles": bundles.len(),
8838 "pruned": pruned,
8839 "items": bundles,
8840 }))
8841}
8842
8843pub fn sync_resolve_conflict(
8847 cfg: &HubConfig,
8848 checkout: &Path,
8849 bundle: &str,
8850 choice: V2ConflictChoice,
8851 bulk_confirmation: Option<&V2BulkConfirmation>,
8852) -> LinkResult<Value> {
8853 require_hardened_filesystem("conflict resolution")?;
8854 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8855 message: format!("conflict checkout is not a valid db.md store: {error}"),
8856 })?;
8857 let plan = load_v2_conflict_plan(&store, bundle)?;
8858 if plan.origin != normalized_origin(&cfg.hub)? {
8859 return Err(invalid_feed(
8860 "conflict bundle belongs to another hub origin",
8861 ));
8862 }
8863 let now = SystemTime::now()
8864 .duration_since(UNIX_EPOCH)
8865 .unwrap_or_default()
8866 .as_secs();
8867 if now > plan.expires_unix {
8868 return Err(LinkError::InvalidPack {
8869 message: "conflict bundle expired; rerun sync to obtain current coordinates"
8870 .to_string(),
8871 });
8872 }
8873 let head = v2_verified_head(cfg, &plan.brain)?
8874 .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8875 let pointer = head.pointer.as_ref();
8876 if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8877 || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8878 || pointer.and_then(|value| value.content_root.as_deref())
8879 != plan.remote_content_root.as_deref()
8880 || head.view_kind != plan.view_kind
8881 || head.view_revision != plan.view_revision
8882 {
8883 return Err(LinkError::RemoteAdvancedDuringSync);
8884 }
8885
8886 for file in &plan.files {
8888 let actual = match store.regular_file_exists(Path::new(&file.path))? {
8889 true => Some(content_sha256(&store.read_bounded(
8890 Path::new(&file.path),
8891 file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8892 )?)),
8893 false => None,
8894 };
8895 if actual.as_deref() != file.local.sha256.as_deref() {
8896 return Err(LinkError::InvalidPack {
8897 message: format!(
8898 "local conflict path `{}` changed after the bundle was created",
8899 file.path
8900 ),
8901 });
8902 }
8903 }
8904
8905 let from_source = match &choice {
8906 V2ConflictChoice::From(source) => Some(source.clone()),
8907 _ => None,
8908 };
8909 let result = match choice {
8910 V2ConflictChoice::TakeRemote => {
8911 if bulk_confirmation.is_some() {
8912 return Err(LinkError::InvalidPack {
8913 message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8914 });
8915 }
8916 let current_remote =
8920 files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8921 let _ = v2_take_remote_selection(&plan.files, ¤t_remote)?;
8922 let selected = plan
8923 .files
8924 .iter()
8925 .map(|file| file.path.clone())
8926 .collect::<std::collections::BTreeSet<_>>();
8927 serde_json::to_value(
8928 v2_sync_pull_with_resolution(
8929 cfg,
8930 &plan.brain,
8931 head,
8932 Some(checkout),
8933 Some(&selected),
8934 )?
8935 .report,
8936 )
8937 .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
8938 }
8939 V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
8940 if let Some(source) = from_source.as_ref() {
8941 if plan.files.len() != 1 {
8942 return Err(LinkError::InvalidPack {
8943 message: "--from requires a bundle with exactly one conflict".to_string(),
8944 });
8945 }
8946 let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
8947 if std::str::from_utf8(&candidate).is_err() {
8948 return Err(LinkError::NotUtf8 {
8949 path: source.display().to_string(),
8950 });
8951 }
8952 store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
8953 }
8954 let refreshed_store =
8955 Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8956 message: format!("resolved checkout is not a valid db.md store: {error}"),
8957 })?;
8958 let mut overrides = std::collections::BTreeMap::new();
8959 for file in &plan.files {
8960 let selected_local = match refreshed_store
8961 .regular_file_exists(Path::new(&file.path))?
8962 {
8963 true => Some(content_sha256(
8964 &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
8965 )),
8966 false => None,
8967 };
8968 overrides.insert(
8969 file.path.clone(),
8970 V2ResolutionOverride {
8971 expected_remote: file.remote.sha256.clone(),
8972 selected_local,
8973 },
8974 );
8975 }
8976 v2_sync_push(
8977 cfg,
8978 &plan.brain,
8979 &refreshed_store,
8980 head,
8981 V2SyncPushOptions {
8982 resume_local_policy: true,
8983 bulk_confirmation,
8984 resolution: Some(&overrides),
8985 pulled: None,
8986 withdrawal_paths: &[],
8987 withdrawal_reason: None,
8988 },
8989 )?
8990 }
8991 };
8992
8993 if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
8994 let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8995 message: format!("resolved checkout is not a valid db.md store: {error}"),
8996 })?;
8997 installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8998 }
8999 Ok(json!({
9000 "v": 2,
9001 "class": "auto_converged",
9002 "bundle": bundle,
9003 "receipt": result,
9004 }))
9005}
9006
9007pub fn sync_converge(
9018 cfg: &HubConfig,
9019 brain: &str,
9020 checkout: &Path,
9021 resume_local_policy: bool,
9022) -> LinkResult<Value> {
9023 sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
9024}
9025
9026pub fn sync_converge_with_options(
9028 cfg: &HubConfig,
9029 brain: &str,
9030 checkout: &Path,
9031 resume_local_policy: bool,
9032 bulk_confirmation: Option<&V2BulkConfirmation>,
9033) -> LinkResult<Value> {
9034 sync_converge_with_controls(
9035 cfg,
9036 brain,
9037 checkout,
9038 resume_local_policy,
9039 bulk_confirmation,
9040 &[],
9041 None,
9042 )
9043}
9044
9045pub fn sync_converge_with_controls(
9047 cfg: &HubConfig,
9048 brain: &str,
9049 checkout: &Path,
9050 resume_local_policy: bool,
9051 bulk_confirmation: Option<&V2BulkConfirmation>,
9052 withdrawal_paths: &[String],
9053 withdrawal_reason: Option<&str>,
9054) -> LinkResult<Value> {
9055 require_hardened_filesystem("bidirectional sync")?;
9056 require_safe_ref(brain)?;
9057 let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
9058 message:
9059 "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
9060 .to_string(),
9061 })?;
9062 let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
9063 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9064 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9065 })?;
9066 let _transaction = store.transaction()?;
9067 let pulled_report = pulled.report.clone();
9068 let pulled_head = pulled.head.clone();
9069 let mut result = v2_sync_push(
9070 cfg,
9071 brain,
9072 &store,
9073 pulled_head,
9074 V2SyncPushOptions {
9075 resume_local_policy,
9076 bulk_confirmation,
9077 resolution: None,
9078 pulled: Some(pulled),
9079 withdrawal_paths,
9080 withdrawal_reason,
9081 },
9082 )?;
9083 if let Some(object) = result.as_object_mut() {
9084 object.insert("pulled_files".to_string(), json!(pulled_report.files));
9085 object.insert("checkout".to_string(), Value::String(pulled_report.dest));
9086 object.insert(
9087 "mode".to_string(),
9088 Value::String("bidirectional".to_string()),
9089 );
9090 }
9091 Ok(result)
9092}
9093
9094pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9100 require_hardened_filesystem("sync pull")?;
9101 require_safe_ref(brain)?;
9102 if let Some(head) = v2_verified_head(cfg, brain)? {
9103 return v2_sync_pull(cfg, brain, head, out);
9104 }
9105 legacy_sync_pull(cfg, brain, out)
9106}
9107
9108#[cfg(windows)]
9109fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
9110 Err(LinkError::UnsupportedPlatform {
9111 operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
9112 })
9113}
9114
9115#[cfg(not(windows))]
9116fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9117 let remote = verified_remote_head(cfg, brain, false)?;
9118 if !remote.head.verified {
9119 return Err(invalid_feed(
9120 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
9121 ));
9122 }
9123 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
9124 let path = format!(
9125 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
9126 remote.head.seq
9127 );
9128 let body = ensure_ok(
9129 request(cfg, "GET", &path, None, Auth::Required)?,
9130 "sync pull",
9131 )?;
9132 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
9133 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
9134 {
9135 return Err(invalid_feed(
9136 "export response is not bound to the verified snapshot token",
9137 ));
9138 }
9139
9140 let remote_slug = body
9141 .get("slug")
9142 .and_then(Value::as_str)
9143 .filter(|slug| is_safe_slug(slug));
9144 let slug = remote_slug
9145 .or_else(|| is_safe_slug(brain).then_some(brain))
9146 .unwrap_or("brain")
9147 .to_string();
9148 let brain_id = body
9149 .get("brain")
9150 .and_then(Value::as_str)
9151 .unwrap_or(&remote.head.brain)
9152 .to_string();
9153 if brain_id != remote.head.brain {
9154 return Err(invalid_feed(
9155 "export response names a different brain than the verified head",
9156 ));
9157 }
9158 let head_seq = remote.head.seq;
9159 let dest: PathBuf = match out {
9160 Some(p) => p.to_path_buf(),
9161 None => PathBuf::from(&slug),
9162 };
9163 let entries = if head_seq == 0 {
9164 let files = body
9165 .get("files")
9166 .and_then(Value::as_array)
9167 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
9168 if !files.is_empty() || body.get("url").is_some() {
9169 return Err(invalid_feed(
9170 "empty signed feed cannot authorize non-empty exported content",
9171 ));
9172 }
9173 Vec::new()
9174 } else {
9175 let signed_head = remote
9176 .head_entry
9177 .as_ref()
9178 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
9179 let expected = &signed_head.entry.pack_sha256;
9180 if !is_sha256(expected) {
9181 return Err(invalid_feed(
9182 "signed head carries an invalid snapshot pack digest",
9183 ));
9184 }
9185 if let Some(url) = body.get("url").and_then(Value::as_str) {
9186 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
9187 return Err(invalid_feed(
9188 "export pack digest does not match the signed head entry",
9189 ));
9190 }
9191 let bytes = get_presigned(cfg, url)?;
9192 let actual = format!("{:x}", Sha256::digest(&bytes));
9193 if actual != *expected {
9194 return Err(LinkError::InvalidPack {
9195 message: "downloaded pack does not match the signed snapshot digest"
9196 .to_string(),
9197 });
9198 }
9199 let entries = parse_store_pack(bytes)?;
9200 if signed_head.entry.kind == "push" {
9201 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9202 }
9203 entries
9204 } else {
9205 if signed_head.entry.kind != "push" {
9206 return Err(invalid_feed(
9207 "delta snapshots must export the exact signed pack",
9208 ));
9209 }
9210 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
9211 invalid_feed("verified snapshot export carried neither a pack nor files")
9212 })?;
9213 let mut entries = Vec::with_capacity(files.len());
9214 for file in files {
9215 let path = file
9216 .get("path")
9217 .and_then(Value::as_str)
9218 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
9219 let content = file
9220 .get("content")
9221 .and_then(Value::as_str)
9222 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
9223 entries.push((path.to_string(), content.as_bytes().to_vec()));
9224 }
9225 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9226 entries
9227 }
9228 };
9229
9230 let mut seen = std::collections::HashSet::new();
9232 for (path, _) in &entries {
9233 if !safe_store_rel_path(path) {
9234 return Err(LinkError::UnsafePath { path: path.clone() });
9235 }
9236 if !seen.insert(path) {
9237 return Err(LinkError::InvalidPack {
9238 message: format!("duplicate path `{path}`"),
9239 });
9240 }
9241 }
9242 let pulled: std::collections::BTreeSet<&str> =
9245 entries.iter().map(|(p, _)| p.as_str()).collect();
9246 let mut extra_local = Vec::new();
9247 if let Ok(store) = Store::open(&dest) {
9248 if let Ok(walked) = store.walk() {
9249 for rel in walked {
9250 let rel_str = rel.to_string_lossy().replace('\\', "/");
9251 if !pulled.contains(rel_str.as_str()) {
9252 extra_local.push(rel_str);
9253 }
9254 }
9255 }
9256 }
9257 #[cfg(unix)]
9258 install_pulled_snapshot(&dest, &entries)?;
9259
9260 Ok(PullReport {
9261 brain: brain_id,
9262 slug,
9263 head_seq,
9264 files: entries.len(),
9265 dest: dest.to_string_lossy().into_owned(),
9266 extra_local,
9267 sync_status: "synced".to_string(),
9268 })
9269}
9270
9271#[cfg(unix)]
9272fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
9273 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
9274 path: display.to_string(),
9275 })
9276}
9277
9278#[cfg(unix)]
9279fn open_dir_at(
9280 parent: std::os::fd::RawFd,
9281 name: &std::ffi::CStr,
9282 display: &str,
9283) -> LinkResult<std::fs::File> {
9284 use std::os::fd::FromRawFd as _;
9285 let fd = unsafe {
9286 libc::openat(
9287 parent,
9288 name.as_ptr(),
9289 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9290 )
9291 };
9292 if fd < 0 {
9293 return Err(LinkError::UnsafePath {
9294 path: display.to_string(),
9295 });
9296 }
9297 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
9298}
9299
9300#[cfg(unix)]
9304fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
9305 use std::os::fd::AsRawFd as _;
9306
9307 #[cfg(target_os = "macos")]
9311 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
9312 .into_iter()
9313 .find_map(|(alias, real)| {
9314 path.strip_prefix(alias)
9315 .ok()
9316 .map(|rest| Path::new(real).join(rest))
9317 })
9318 .unwrap_or_else(|| path.to_path_buf());
9319 #[cfg(not(target_os = "macos"))]
9320 let normalized = path.to_path_buf();
9321
9322 let start = if normalized.is_absolute() {
9323 std::fs::File::open("/")?
9324 } else {
9325 std::fs::File::open(".")?
9326 };
9327 let mut directory = start;
9328 for component in normalized.components() {
9329 use std::path::Component;
9330 let name = match component {
9331 Component::RootDir | Component::CurDir => continue,
9332 Component::Normal(name) => name,
9333 Component::ParentDir | Component::Prefix(_) => {
9334 return Err(LinkError::UnsafePath {
9335 path: path.display().to_string(),
9336 });
9337 }
9338 };
9339 use std::os::unix::ffi::OsStrExt as _;
9340 let name = c_name(name.as_bytes(), &path.display().to_string())?;
9341 if create {
9342 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9343 if made != 0 {
9344 let error = std::io::Error::last_os_error();
9345 if error.raw_os_error() != Some(libc::EEXIST) {
9346 return Err(error.into());
9347 }
9348 }
9349 }
9350 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
9351 }
9352 Ok(directory)
9353}
9354
9355#[cfg(unix)]
9356fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9357 open_dir_path_nofollow(path, true)
9358}
9359
9360#[cfg(unix)]
9361fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9362 open_dir_path_nofollow(path, false)
9363}
9364
9365#[cfg(unix)]
9366fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
9367 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9368 let result =
9369 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
9370 if result == 0 {
9371 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
9372 }
9373 let error = std::io::Error::last_os_error();
9374 if error.kind() == std::io::ErrorKind::NotFound {
9375 Ok(None)
9376 } else {
9377 Err(error.into())
9378 }
9379}
9380
9381#[cfg(unix)]
9382fn create_dir_exclusive_at(
9383 parent: std::os::fd::RawFd,
9384 name: &std::ffi::CStr,
9385 display: &str,
9386) -> LinkResult<std::fs::File> {
9387 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
9388 if made != 0 {
9389 return Err(LinkError::UnsafePath {
9390 path: display.to_string(),
9391 });
9392 }
9393 open_dir_at(parent, name, display)
9394}
9395
9396#[cfg(unix)]
9397fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
9398 use std::os::fd::AsRawFd as _;
9399
9400 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
9401 if duplicate < 0 {
9402 return Err(std::io::Error::last_os_error().into());
9403 }
9404 let stream = unsafe { libc::fdopendir(duplicate) };
9405 if stream.is_null() {
9406 let error = std::io::Error::last_os_error();
9407 unsafe {
9408 libc::close(duplicate);
9409 }
9410 return Err(error.into());
9411 }
9412 let mut names = Vec::new();
9413 loop {
9414 let entry = unsafe { libc::readdir(stream) };
9415 if entry.is_null() {
9416 break;
9417 }
9418 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
9419 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
9420 names.push(raw.to_owned());
9421 }
9422 }
9423 if unsafe { libc::closedir(stream) } != 0 {
9424 return Err(std::io::Error::last_os_error().into());
9425 }
9426 Ok(names)
9427}
9428
9429#[cfg(unix)]
9432fn remove_tree_at(
9433 parent: std::os::fd::RawFd,
9434 name: &std::ffi::CStr,
9435 display: &str,
9436) -> LinkResult<()> {
9437 use std::os::fd::AsRawFd as _;
9438
9439 match entry_is_dir_at(parent, name)? {
9440 None => return Ok(()),
9441 Some(false) => {
9442 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
9443 return Err(std::io::Error::last_os_error().into());
9444 }
9445 }
9446 Some(true) => {
9447 let directory = open_dir_at(parent, name, display)?;
9448 for child in directory_entry_names(&directory)? {
9449 let child_display =
9450 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
9451 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
9452 }
9453 drop(directory);
9454 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
9455 return Err(std::io::Error::last_os_error().into());
9456 }
9457 }
9458 }
9459 Ok(())
9460}
9461
9462#[cfg(unix)]
9466fn clone_tree_contents(
9467 source: &std::fs::File,
9468 destination: &std::fs::File,
9469 display: &str,
9470) -> LinkResult<()> {
9471 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9472
9473 for name in directory_entry_names(source)? {
9474 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9475 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9476 if unsafe {
9477 libc::fstatat(
9478 source.as_raw_fd(),
9479 name.as_ptr(),
9480 &mut stat,
9481 libc::AT_SYMLINK_NOFOLLOW,
9482 )
9483 } != 0
9484 {
9485 return Err(std::io::Error::last_os_error().into());
9486 }
9487 match stat.st_mode & libc::S_IFMT {
9488 libc::S_IFDIR => {
9489 if unsafe {
9490 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
9491 } != 0
9492 {
9493 return Err(std::io::Error::last_os_error().into());
9494 }
9495 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
9496 let destination_child =
9497 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
9498 clone_tree_contents(&source_child, &destination_child, &child_display)?;
9499 destination_child.sync_all()?;
9500 }
9501 libc::S_IFREG => {
9502 let source_fd = unsafe {
9503 libc::openat(
9504 source.as_raw_fd(),
9505 name.as_ptr(),
9506 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9507 )
9508 };
9509 if source_fd < 0 {
9510 return Err(std::io::Error::last_os_error().into());
9511 }
9512 let destination_fd = unsafe {
9513 libc::openat(
9514 destination.as_raw_fd(),
9515 name.as_ptr(),
9516 libc::O_WRONLY
9517 | libc::O_CREAT
9518 | libc::O_EXCL
9519 | libc::O_CLOEXEC
9520 | libc::O_NOFOLLOW,
9521 (stat.st_mode & 0o777) as libc::c_uint,
9522 )
9523 };
9524 if destination_fd < 0 {
9525 unsafe {
9526 libc::close(source_fd);
9527 }
9528 return Err(std::io::Error::last_os_error().into());
9529 }
9530 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
9531 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
9532 std::io::copy(&mut input, &mut output)?;
9533 output.sync_all()?;
9534 }
9535 libc::S_IFLNK => {
9536 let mut target = vec![0_u8; 4097];
9537 let length = unsafe {
9538 libc::readlinkat(
9539 source.as_raw_fd(),
9540 name.as_ptr(),
9541 target.as_mut_ptr().cast(),
9542 target.len(),
9543 )
9544 };
9545 if length < 0 || length as usize >= target.len() {
9546 return Err(LinkError::UnsafePath {
9547 path: child_display,
9548 });
9549 }
9550 target.truncate(length as usize);
9551 let target = c_name(&target, &child_display)?;
9552 if unsafe {
9553 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
9554 } != 0
9555 {
9556 return Err(std::io::Error::last_os_error().into());
9557 }
9558 }
9559 _ => {
9560 return Err(LinkError::UnsafePath {
9561 path: child_display,
9562 });
9563 }
9564 }
9565 }
9566 destination.sync_all()?;
9567 Ok(())
9568}
9569
9570#[cfg(target_os = "linux")]
9571fn install_stage_at(
9572 parent: std::os::fd::RawFd,
9573 stage: &std::ffi::CStr,
9574 dest: &std::ffi::CStr,
9575 dest_exists: bool,
9576) -> LinkResult<()> {
9577 let flags = if dest_exists {
9578 libc::RENAME_EXCHANGE
9579 } else {
9580 libc::RENAME_NOREPLACE
9581 };
9582 let result = unsafe {
9586 libc::syscall(
9587 libc::SYS_renameat2,
9588 parent,
9589 stage.as_ptr(),
9590 parent,
9591 dest.as_ptr(),
9592 flags,
9593 )
9594 };
9595 if result == 0 {
9596 Ok(())
9597 } else {
9598 Err(std::io::Error::last_os_error().into())
9599 }
9600}
9601
9602#[cfg(target_os = "macos")]
9603fn install_stage_at(
9604 parent: std::os::fd::RawFd,
9605 stage: &std::ffi::CStr,
9606 dest: &std::ffi::CStr,
9607 dest_exists: bool,
9608) -> LinkResult<()> {
9609 let flags = if dest_exists {
9610 libc::RENAME_SWAP
9611 } else {
9612 libc::RENAME_EXCL
9613 };
9614 let result =
9615 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
9616 if result == 0 {
9617 Ok(())
9618 } else {
9619 Err(std::io::Error::last_os_error().into())
9620 }
9621}
9622
9623#[cfg(unix)]
9624fn write_pull_entries_beneath_dir(
9625 root: &std::fs::File,
9626 entries: &[(String, Vec<u8>)],
9627) -> LinkResult<()> {
9628 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9629
9630 for (path, content) in entries {
9631 let components: Vec<&str> = path.split('/').collect();
9632 let (leaf, parents) = components
9633 .split_last()
9634 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9635 let mut directory = root.try_clone()?;
9636 for component in parents {
9637 let name = c_name(component.as_bytes(), path)?;
9638 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9639 if made != 0 {
9640 let error = std::io::Error::last_os_error();
9641 if error.raw_os_error() != Some(libc::EEXIST) {
9642 return Err(error.into());
9643 }
9644 }
9645 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9646 }
9647
9648 let leaf_name = c_name(leaf.as_bytes(), path)?;
9649 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9650 let inspected = unsafe {
9651 libc::fstatat(
9652 directory.as_raw_fd(),
9653 leaf_name.as_ptr(),
9654 &mut existing,
9655 libc::AT_SYMLINK_NOFOLLOW,
9656 )
9657 };
9658 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
9659 return Err(LinkError::UnsafePath { path: path.clone() });
9660 }
9661
9662 let nonce = std::time::SystemTime::now()
9663 .duration_since(std::time::UNIX_EPOCH)
9664 .unwrap_or_default()
9665 .as_nanos();
9666 let temp_name = format!(
9667 ".dbmd-pull-{}-{nonce}-{}",
9668 std::process::id(),
9669 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
9670 );
9671 let temp = c_name(temp_name.as_bytes(), path)?;
9672 let fd = unsafe {
9673 libc::openat(
9674 directory.as_raw_fd(),
9675 temp.as_ptr(),
9676 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9677 0o600,
9678 )
9679 };
9680 if fd < 0 {
9681 return Err(std::io::Error::last_os_error().into());
9682 }
9683 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9684 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
9685 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9686 return Err(error.into());
9687 }
9688 drop(file);
9689 let renamed = unsafe {
9690 libc::renameat(
9691 directory.as_raw_fd(),
9692 temp.as_ptr(),
9693 directory.as_raw_fd(),
9694 leaf_name.as_ptr(),
9695 )
9696 };
9697 if renamed != 0 {
9698 let error = std::io::Error::last_os_error();
9699 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9700 return Err(error.into());
9701 }
9702 directory.sync_all()?;
9703 }
9704 root.sync_all()?;
9705 Ok(())
9706}
9707
9708#[cfg(unix)]
9709fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9710 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9711
9712 let path = &entry.path;
9713 let components: Vec<&str> = path.split('/').collect();
9714 let (leaf, parents) = components
9715 .split_last()
9716 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9717 let mut directory = root.try_clone()?;
9718 for component in parents {
9719 let name = c_name(component.as_bytes(), path)?;
9720 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9721 if made != 0 {
9722 let error = std::io::Error::last_os_error();
9723 if error.raw_os_error() != Some(libc::EEXIST) {
9724 return Err(error.into());
9725 }
9726 }
9727 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9728 }
9729 let leaf_name = c_name(leaf.as_bytes(), path)?;
9730 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9731 if unsafe {
9732 libc::fstatat(
9733 directory.as_raw_fd(),
9734 leaf_name.as_ptr(),
9735 &mut existing,
9736 libc::AT_SYMLINK_NOFOLLOW,
9737 )
9738 } == 0
9739 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
9740 {
9741 return Err(LinkError::UnsafePath { path: path.clone() });
9742 }
9743 let nonce = SystemTime::now()
9744 .duration_since(UNIX_EPOCH)
9745 .unwrap_or_default()
9746 .as_nanos();
9747 let temp_name = format!(
9748 ".dbmd-pull-{}-{nonce}-{}",
9749 std::process::id(),
9750 content_sha256(path.as_bytes())
9751 );
9752 let temp = c_name(temp_name.as_bytes(), path)?;
9753 let fd = unsafe {
9754 libc::openat(
9755 directory.as_raw_fd(),
9756 temp.as_ptr(),
9757 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9758 0o600,
9759 )
9760 };
9761 if fd < 0 {
9762 return Err(std::io::Error::last_os_error().into());
9763 }
9764 let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
9765 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
9766 let mut digest = Sha256::new();
9767 let mut total = 0_u64;
9768 let mut buffer = [0_u8; 64 * 1024];
9769 let copied = (|| -> std::io::Result<()> {
9770 loop {
9771 let read = input.read(&mut buffer)?;
9772 if read == 0 {
9773 break;
9774 }
9775 total = total.saturating_add(read as u64);
9776 if total > entry.bytes {
9777 return Err(std::io::Error::new(
9778 std::io::ErrorKind::InvalidData,
9779 "staged sync source grew beyond its verified length",
9780 ));
9781 }
9782 digest.update(&buffer[..read]);
9783 output.write_all(&buffer[..read])?;
9784 }
9785 Ok(())
9786 })();
9787 if let Err(error) = copied {
9788 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9789 return Err(error.into());
9790 }
9791 drop(output);
9792 if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
9793 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9794 return Err(invalid_feed(
9795 "private staged sync source failed final integrity verification",
9796 ));
9797 }
9798 if unsafe {
9799 libc::renameat(
9800 directory.as_raw_fd(),
9801 temp.as_ptr(),
9802 directory.as_raw_fd(),
9803 leaf_name.as_ptr(),
9804 )
9805 } != 0
9806 {
9807 let error = std::io::Error::last_os_error();
9808 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9809 return Err(error.into());
9810 }
9811 Ok(())
9812}
9813
9814#[cfg(unix)]
9815fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9816 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9817
9818 let path = &entry.path;
9819 let components: Vec<&str> = path.split('/').collect();
9820 let (leaf, parents) = components
9821 .split_last()
9822 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9823 let mut directory = root.try_clone()?;
9824 for component in parents {
9825 directory = open_dir_at(
9826 directory.as_raw_fd(),
9827 &c_name(component.as_bytes(), path)?,
9828 path,
9829 )?;
9830 }
9831 let leaf = c_name(leaf.as_bytes(), path)?;
9832 let fd = unsafe {
9833 libc::openat(
9834 directory.as_raw_fd(),
9835 leaf.as_ptr(),
9836 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9837 )
9838 };
9839 if fd < 0 {
9840 return Err(std::io::Error::last_os_error().into());
9841 }
9842 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9843 if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
9844 return Err(invalid_feed(
9845 "private pull stage changed before its durability barrier",
9846 ));
9847 }
9848 file.sync_all()?;
9849 Ok(())
9850}
9851
9852#[cfg(unix)]
9853fn run_pull_source_workers(
9854 root: &std::fs::File,
9855 entries: &[V2StagedFile],
9856 operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
9857) -> LinkResult<()> {
9858 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9859
9860 let next = AtomicUsize::new(0);
9861 let failed = AtomicBool::new(false);
9862 let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
9863 let mut first_error = None;
9864 std::thread::scope(|scope| {
9865 let (sender, receiver) = std::sync::mpsc::channel();
9866 for _ in 0..worker_count {
9867 let sender = sender.clone();
9868 let next = &next;
9869 let failed = &failed;
9870 scope.spawn(move || {
9871 while !failed.load(Ordering::Acquire) {
9872 let index = next.fetch_add(1, Ordering::Relaxed);
9873 let Some(entry) = entries.get(index) else {
9874 break;
9875 };
9876 let result = operation(root, entry);
9877 if result.is_err() {
9878 failed.store(true, Ordering::Release);
9879 }
9880 if sender.send(result).is_err() {
9881 break;
9882 }
9883 }
9884 });
9885 }
9886 drop(sender);
9887 for result in receiver {
9888 if let Err(error) = result {
9889 if first_error.is_none() {
9890 first_error = Some(error);
9891 }
9892 }
9893 }
9894 });
9895 if let Some(error) = first_error {
9896 return Err(error);
9897 }
9898 if next.load(Ordering::Relaxed) < entries.len() {
9899 return Err(invalid_feed(
9900 "a bounded pull worker stopped before reporting every file",
9901 ));
9902 }
9903 Ok(())
9904}
9905
9906#[cfg(unix)]
9907fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9908 use std::os::fd::AsRawFd as _;
9909
9910 for name in directory_entry_names(root)? {
9911 if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9912 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9913 let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9914 sync_pull_directory_tree(&child, &child_display)?;
9915 }
9916 }
9917 root.sync_all()?;
9918 Ok(())
9919}
9920
9921#[cfg(unix)]
9922fn write_pull_sources_beneath_dir(
9923 root: &std::fs::File,
9924 entries: &[V2StagedFile],
9925) -> LinkResult<()> {
9926 run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
9933 run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
9934 sync_pull_directory_tree(root, "v2 pull stage")
9935}
9936
9937#[cfg(unix)]
9938fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
9939 use std::os::fd::AsRawFd as _;
9940 for path in paths {
9941 if !safe_store_rel_path(path) {
9942 return Err(LinkError::UnsafePath { path: path.clone() });
9943 }
9944 let components = path.split('/').collect::<Vec<_>>();
9945 let Some((leaf, parents)) = components.split_last() else {
9946 return Err(LinkError::UnsafePath { path: path.clone() });
9947 };
9948 let mut directory = root.try_clone()?;
9949 let mut missing = false;
9950 for component in parents {
9951 let name = c_name(component.as_bytes(), path)?;
9952 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
9953 None => {
9954 missing = true;
9955 break;
9956 }
9957 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
9958 Some(true) => {
9959 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9960 }
9961 }
9962 }
9963 if missing {
9964 continue;
9965 }
9966 let leaf = c_name(leaf.as_bytes(), path)?;
9967 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
9968 None => {}
9969 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
9970 Some(false) => {
9971 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
9972 return Err(std::io::Error::last_os_error().into());
9973 }
9974 directory.sync_all()?;
9975 }
9976 }
9977 }
9978 Ok(())
9979}
9980
9981#[cfg(unix)]
9982fn install_pulled_delta(
9983 dest: &Path,
9984 entries: &[(String, Vec<u8>)],
9985 deleted: &[String],
9986 rebuild_indexes: bool,
9987) -> LinkResult<()> {
9988 use ring::rand::SecureRandom as _;
9989 use std::os::fd::AsRawFd as _;
9990 use std::os::unix::ffi::OsStrExt as _;
9991
9992 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9993 let name = dest
9994 .file_name()
9995 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9996 .ok_or_else(|| LinkError::UnsafePath {
9997 path: dest.display().to_string(),
9998 })?;
9999 let parent_dir = open_or_create_dir_nofollow(parent)?;
10000 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10001 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10002 None => false,
10003 Some(true) => true,
10004 Some(false) => {
10005 return Err(LinkError::UnsafePath {
10006 path: dest.display().to_string(),
10007 });
10008 }
10009 };
10010
10011 let mut nonce = [0_u8; 16];
10012 ring::rand::SystemRandom::new()
10013 .fill(&mut nonce)
10014 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10015 let stage_label = format!(
10016 ".{}.dbmd-pull-stage-{}",
10017 name.to_string_lossy(),
10018 URL_SAFE_NO_PAD.encode(nonce)
10019 );
10020 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10021 let stage_dir = create_dir_exclusive_at(
10022 parent_dir.as_raw_fd(),
10023 &stage_name,
10024 &dest.display().to_string(),
10025 )?;
10026
10027 let prepared = (|| -> LinkResult<()> {
10028 if dest_exists {
10029 let live = open_dir_at(
10030 parent_dir.as_raw_fd(),
10031 &dest_name,
10032 &dest.display().to_string(),
10033 )?;
10034 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10035 }
10036 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10037 write_pull_entries_beneath_dir(&stage_dir, entries)?;
10038 if rebuild_indexes {
10039 let stage_store =
10040 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10041 .map_err(|error| LinkError::InvalidPack {
10042 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10043 })?;
10044 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10045 LinkError::InvalidPack {
10046 message: format!("could not materialize v2 local catalogs: {error}"),
10047 }
10048 })?;
10049 }
10050 stage_dir.sync_all()?;
10051 Ok(())
10052 })();
10053 if let Err(error) = prepared {
10054 let _ = remove_tree_at(
10055 parent_dir.as_raw_fd(),
10056 &stage_name,
10057 &dest.display().to_string(),
10058 );
10059 return Err(error);
10060 }
10061
10062 if let Err(error) =
10063 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10064 {
10065 let _ = remove_tree_at(
10066 parent_dir.as_raw_fd(),
10067 &stage_name,
10068 &dest.display().to_string(),
10069 );
10070 return Err(error);
10071 }
10072 parent_dir.sync_all()?;
10073 if dest_exists {
10074 let _ = remove_tree_at(
10078 parent_dir.as_raw_fd(),
10079 &stage_name,
10080 &dest.display().to_string(),
10081 );
10082 let _ = parent_dir.sync_all();
10083 }
10084 Ok(())
10085}
10086
10087#[cfg(unix)]
10088fn install_pulled_delta_sources(
10089 dest: &Path,
10090 entries: &[V2StagedFile],
10091 deleted: &[String],
10092 rebuild_indexes: bool,
10093 _previous: Option<&V2SyncBaseline>,
10094 _next: &V2VerifiedHead,
10095) -> LinkResult<()> {
10096 use ring::rand::SecureRandom as _;
10097 use std::os::fd::AsRawFd as _;
10098 use std::os::unix::ffi::OsStrExt as _;
10099
10100 if let Ok(store) = Store::open_strict(dest) {
10104 return install_established_v2_delta(
10105 store,
10106 entries,
10107 deleted,
10108 rebuild_indexes,
10109 _previous,
10110 _next,
10111 );
10112 }
10113
10114 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10115 let name = dest
10116 .file_name()
10117 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10118 .ok_or_else(|| LinkError::UnsafePath {
10119 path: dest.display().to_string(),
10120 })?;
10121 let parent_dir = open_or_create_dir_nofollow(parent)?;
10122 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10123 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10124 None => false,
10125 Some(true) => true,
10126 Some(false) => {
10127 return Err(LinkError::UnsafePath {
10128 path: dest.display().to_string(),
10129 })
10130 }
10131 };
10132 let mut nonce = [0_u8; 16];
10133 ring::rand::SystemRandom::new()
10134 .fill(&mut nonce)
10135 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10136 let stage_label = format!(
10137 ".{}.dbmd-pull-stage-{}",
10138 name.to_string_lossy(),
10139 URL_SAFE_NO_PAD.encode(nonce)
10140 );
10141 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10142 let stage_dir = create_dir_exclusive_at(
10143 parent_dir.as_raw_fd(),
10144 &stage_name,
10145 &dest.display().to_string(),
10146 )?;
10147 let prepared = (|| -> LinkResult<()> {
10148 if dest_exists {
10149 let live = open_dir_at(
10150 parent_dir.as_raw_fd(),
10151 &dest_name,
10152 &dest.display().to_string(),
10153 )?;
10154 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10155 }
10156 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10157 write_pull_sources_beneath_dir(&stage_dir, entries)?;
10158 if rebuild_indexes {
10159 let stage_store =
10160 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10161 .map_err(|error| LinkError::InvalidPack {
10162 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10163 })?;
10164 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10165 LinkError::InvalidPack {
10166 message: format!("could not materialize v2 local catalogs: {error}"),
10167 }
10168 })?;
10169 }
10170 stage_dir.sync_all()?;
10171 Ok(())
10172 })();
10173 if let Err(error) = prepared {
10174 let _ = remove_tree_at(
10175 parent_dir.as_raw_fd(),
10176 &stage_name,
10177 &dest.display().to_string(),
10178 );
10179 return Err(error);
10180 }
10181 if let Err(error) =
10182 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10183 {
10184 let _ = remove_tree_at(
10185 parent_dir.as_raw_fd(),
10186 &stage_name,
10187 &dest.display().to_string(),
10188 );
10189 return Err(error);
10190 }
10191 parent_dir.sync_all()?;
10192 if dest_exists {
10193 let _ = remove_tree_at(
10194 parent_dir.as_raw_fd(),
10195 &stage_name,
10196 &dest.display().to_string(),
10197 );
10198 let _ = parent_dir.sync_all();
10199 }
10200 Ok(())
10201}
10202
10203#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10204struct V2PullCoordinate {
10205 head_seq: Option<u64>,
10206 commit_hash: Option<String>,
10207 view_kind: Option<String>,
10208 view_revision: Option<String>,
10209}
10210
10211#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10212struct V2PullFileCoordinate {
10213 sha256: String,
10214 bytes: u64,
10215}
10216
10217#[derive(Debug, Clone, Deserialize, Serialize)]
10218struct V2PullJournalEntry {
10219 path: String,
10220 old: Option<V2PullFileCoordinate>,
10221 new: Option<V2PullFileCoordinate>,
10222 backup: Option<String>,
10223}
10224
10225#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10226#[serde(rename_all = "snake_case")]
10227enum V2PullPhase {
10228 Preparing,
10229 Ready,
10230}
10231
10232#[derive(Debug, Clone, Deserialize, Serialize)]
10233struct V2PullJournal {
10234 v: u8,
10235 phase: V2PullPhase,
10236 brain: String,
10237 previous: V2PullCoordinate,
10238 next: V2PullCoordinate,
10239 backup_dir: String,
10240 entries: Vec<V2PullJournalEntry>,
10241}
10242
10243const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
10244
10245fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
10246 V2PullCoordinate {
10247 head_seq: baseline.and_then(|value| value.head_seq),
10248 commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
10249 view_kind: baseline.and_then(|value| value.view_kind.clone()),
10250 view_revision: baseline.and_then(|value| value.view_revision.clone()),
10251 }
10252}
10253
10254fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
10255 V2PullCoordinate {
10256 head_seq: head.pointer.as_ref().map(|value| value.seq),
10257 commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
10258 view_kind: Some(head.view_kind.clone()),
10259 view_revision: Some(head.view_revision.clone()),
10260 }
10261}
10262
10263fn v2_pull_file_coordinate(
10264 store: &Store,
10265 path: &str,
10266 limit: u64,
10267) -> LinkResult<Option<V2PullFileCoordinate>> {
10268 let file = match store.open_regular(Path::new(path)) {
10269 Ok(file) => file,
10270 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10271 Err(error) => return Err(error.into()),
10272 };
10273 let bytes = file.metadata()?.len();
10274 if bytes > limit || bytes > MAX_STORE_BYTES {
10275 return Err(invalid_feed(
10276 "pull transaction file exceeds its declared bound",
10277 ));
10278 }
10279 Ok(Some(V2PullFileCoordinate {
10280 sha256: content_sha256_reader(file)?,
10281 bytes,
10282 }))
10283}
10284
10285fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
10286 let mut bytes = serde_json::to_vec_pretty(journal)
10287 .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
10288 bytes.push(b'\n');
10289 Ok(bytes)
10290}
10291
10292fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
10293 let backup_prefix = ".dbmd/pull-backup-";
10294 let suffix = journal
10295 .backup_dir
10296 .strip_prefix(backup_prefix)
10297 .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
10298 let mut paths = std::collections::BTreeSet::new();
10299 if journal.v != 1
10300 || !crate::ulid::is_ulid(&journal.brain)
10301 || !crate::ulid::is_ulid(suffix)
10302 || journal.entries.is_empty()
10303 || journal.entries.len() > MAX_PUSH_FILES + 4
10304 || journal.previous == journal.next
10305 {
10306 return Err(invalid_feed("v2 pull journal failed validation"));
10307 }
10308 for (index, entry) in journal.entries.iter().enumerate() {
10309 if !safe_store_rel_path(&entry.path)
10310 || entry.path == V2_PULL_JOURNAL
10311 || entry.path.starts_with(backup_prefix)
10312 || !paths.insert(entry.path.clone())
10313 || (entry.old.is_none() && entry.new.is_none())
10314 || entry
10315 .old
10316 .iter()
10317 .chain(entry.new.iter())
10318 .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
10319 || entry.backup.as_deref()
10320 != entry
10321 .old
10322 .as_ref()
10323 .map(|_| format!("{index:08x}"))
10324 .as_deref()
10325 {
10326 return Err(invalid_feed("v2 pull journal entry failed validation"));
10327 }
10328 }
10329 Ok(())
10330}
10331
10332fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
10333 #[cfg(unix)]
10334 {
10335 use std::os::unix::fs::PermissionsExt as _;
10336 match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
10337 Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
10338 return Err(invalid_feed(
10339 "v2 pull journal is accessible to group/other; set mode 0600",
10340 ));
10341 }
10342 Ok(_) => {}
10343 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10344 Err(error) => return Err(error.into()),
10345 }
10346 }
10347 let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
10348 Ok(bytes) => bytes,
10349 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10350 Err(error) => return Err(error.into()),
10351 };
10352 let journal: V2PullJournal =
10353 serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
10354 validate_v2_pull_journal(&journal)?;
10355 Ok(Some(journal))
10356}
10357
10358fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10359 match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
10363 Ok(()) => {}
10364 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
10365 Err(error) => return Err(error.into()),
10366 }
10367 match store.remove_private_tree(Path::new(&journal.backup_dir)) {
10368 Ok(()) => Ok(()),
10369 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
10370 Err(error) => Err(error.into()),
10371 }
10372}
10373
10374fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
10375 let names = match store.directory_names(Path::new(".dbmd")) {
10376 Ok(names) => names,
10377 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
10378 Err(error) => return Err(error.into()),
10379 };
10380 for name in names {
10381 let Some(name) = name.to_str() else {
10382 continue;
10383 };
10384 let Some(suffix) = name.strip_prefix("pull-backup-") else {
10385 continue;
10386 };
10387 if crate::ulid::is_ulid(suffix) {
10388 store.remove_private_tree(&Path::new(".dbmd").join(name))?;
10389 }
10390 }
10391 Ok(())
10392}
10393
10394fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10395 for entry in &journal.entries {
10397 let limit = entry
10398 .old
10399 .as_ref()
10400 .into_iter()
10401 .chain(entry.new.iter())
10402 .map(|value| value.bytes)
10403 .max()
10404 .unwrap_or(0);
10405 let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
10406 if current != entry.old && current != entry.new {
10407 return Err(LinkError::InvalidPack {
10408 message: format!(
10409 "cannot recover interrupted pull because `{}` changed afterward",
10410 entry.path
10411 ),
10412 });
10413 }
10414 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10415 let path = Path::new(&journal.backup_dir).join(backup);
10416 let file = store.open_regular(&path)?;
10417 if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
10418 return Err(invalid_feed("v2 pull recovery backup failed verification"));
10419 }
10420 }
10421 }
10422 for entry in journal.entries.iter().rev() {
10423 match (&entry.old, &entry.backup) {
10424 (Some(old), Some(backup)) => {
10425 let bytes =
10426 store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
10427 store.write_atomic(Path::new(&entry.path), &bytes)?;
10428 }
10429 (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
10430 store.remove_file(Path::new(&entry.path))?;
10431 }
10432 (None, None) => {}
10433 _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
10434 }
10435 }
10436 crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
10437 message: format!("could not rebuild catalogs after pull recovery: {error}"),
10438 })?;
10439 cleanup_v2_pull_journal(store, journal)
10440}
10441
10442fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
10443 let Ok(store) = Store::open_strict(dest) else {
10444 return Ok(());
10445 };
10446 if let Some(journal) = load_v2_pull_journal(&store)? {
10447 if journal.brain != brain {
10448 return Err(invalid_feed("v2 pull journal belongs to another brain"));
10449 }
10450 if journal.phase == V2PullPhase::Preparing {
10451 cleanup_v2_pull_journal(&store, &journal)?;
10452 } else {
10453 let baseline = load_v2_baseline(cfg, brain, dest)?;
10454 let current = v2_pull_baseline_coordinate(baseline.as_ref());
10455 if current == journal.next {
10456 cleanup_v2_pull_journal(&store, &journal)?;
10457 } else {
10458 if current != journal.previous {
10459 return Err(invalid_feed(
10460 "cannot recover interrupted pull because its baseline changed afterward",
10461 ));
10462 }
10463 rollback_v2_pull(&store, &journal)?;
10464 }
10465 }
10466 }
10467 prune_orphan_v2_pull_backups(&store)
10472}
10473
10474fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
10475 let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
10476 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
10477 })?;
10478 if let Some(journal) = load_v2_pull_journal(&store)? {
10479 cleanup_v2_pull_journal(&store, &journal)?;
10480 }
10481 Ok(())
10482}
10483
10484#[cfg(windows)]
10485fn install_windows_initial_sources(
10486 dest: &Path,
10487 entries: &[V2StagedFile],
10488 rebuild_indexes: bool,
10489) -> LinkResult<()> {
10490 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10491 let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
10492 path: dest.display().to_string(),
10493 })?;
10494 let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
10495 if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
10496 return Err(LinkError::UnsafePath {
10497 path: dest.display().to_string(),
10498 });
10499 }
10500 let stage_name = format!(
10501 ".{}.dbmd-pull-stage-{}",
10502 name.to_string_lossy(),
10503 crate::ulid::mint()
10504 );
10505 let stage_path = parent.join(&stage_name);
10506 let stage_capability =
10507 crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
10508 let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
10509 let prepared = (|| -> LinkResult<()> {
10510 for entry in entries {
10511 let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
10512 if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
10513 return Err(invalid_feed(
10514 "private staged sync source failed final integrity verification",
10515 ));
10516 }
10517 stage.write_atomic(Path::new(&entry.path), &bytes)?;
10518 }
10519 let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
10520 .map_err(|error| LinkError::InvalidPack {
10521 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10522 })?;
10523 if rebuild_indexes {
10524 crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
10525 message: format!("could not materialize v2 local catalogs: {error}"),
10526 })?;
10527 }
10528 Ok(())
10529 })();
10530 if let Err(error) = prepared {
10531 let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
10532 return Err(error);
10533 }
10534 crate::fsx::rename_directory_beneath(
10535 &parent_capability,
10536 Path::new(&stage_name),
10537 Path::new(name),
10538 )?;
10539 Ok(())
10540}
10541
10542fn install_established_v2_delta(
10543 store: Store,
10544 entries: &[V2StagedFile],
10545 deleted: &[String],
10546 rebuild_indexes: bool,
10547 previous: Option<&V2SyncBaseline>,
10548 next: &V2VerifiedHead,
10549) -> LinkResult<()> {
10550 if load_v2_pull_journal(&store)?.is_some() {
10551 return Err(invalid_feed(
10552 "an interrupted pull must be recovered before installing",
10553 ));
10554 }
10555 let mut sources = std::collections::BTreeMap::new();
10556 for entry in entries {
10557 if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
10558 return Err(invalid_feed("pull mutation repeats a path"));
10559 }
10560 }
10561 let mut paths = sources.keys().cloned().collect::<Vec<_>>();
10562 paths.extend(deleted.iter().cloned());
10563 paths.sort();
10564 paths.dedup();
10565 if paths.is_empty() {
10566 return Ok(());
10567 }
10568 let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
10569 let mut journal = V2PullJournal {
10570 v: 1,
10571 phase: V2PullPhase::Preparing,
10572 brain: next.brain_id.clone(),
10573 previous: v2_pull_baseline_coordinate(previous),
10574 next: v2_pull_head_coordinate(next),
10575 backup_dir: backup_dir.clone(),
10576 entries: Vec::with_capacity(paths.len()),
10577 };
10578 for path in &paths {
10579 let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
10580 let new = sources.get(path).map(|entry| V2PullFileCoordinate {
10581 sha256: entry.sha256.clone(),
10582 bytes: entry.bytes,
10583 });
10584 if old == new {
10585 continue;
10586 }
10587 let index = journal.entries.len();
10588 journal.entries.push(V2PullJournalEntry {
10589 path: path.clone(),
10590 backup: old.as_ref().map(|_| format!("{index:08x}")),
10591 old,
10592 new,
10593 });
10594 }
10595 if journal.entries.is_empty() {
10596 return Ok(());
10597 }
10598 let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
10599 entry
10600 .old
10601 .as_ref()
10602 .map_or(Some(total), |old| total.checked_add(old.bytes))
10603 });
10604 if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
10605 return Err(LinkError::InvalidPack {
10606 message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
10607 });
10608 }
10609 validate_v2_pull_journal(&journal)?;
10610 store.write_private_atomic_new(
10611 Path::new(V2_PULL_JOURNAL),
10612 &v2_pull_journal_bytes(&journal)?,
10613 )?;
10614 let prepared = (|| -> LinkResult<()> {
10615 store.create_private_dir_all(Path::new(&backup_dir))?;
10616 for entry in &journal.entries {
10617 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10618 let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
10619 if content_sha256(&bytes) != old.sha256 {
10620 return Err(invalid_feed("live pull source changed during backup"));
10621 }
10622 store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
10623 }
10624 }
10625 journal.phase = V2PullPhase::Ready;
10626 store.write_private_atomic(
10627 Path::new(V2_PULL_JOURNAL),
10628 &v2_pull_journal_bytes(&journal)?,
10629 )?;
10630 Ok(())
10631 })();
10632 if let Err(error) = prepared {
10633 let cleanup = cleanup_v2_pull_journal(&store, &journal);
10634 return match cleanup {
10635 Ok(()) => Err(error),
10636 Err(cleanup) => Err(LinkError::InvalidPack {
10637 message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
10638 }),
10639 };
10640 }
10641 let installed = (|| -> LinkResult<()> {
10642 for entry in &journal.entries {
10643 if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
10644 return Err(LinkError::InvalidPack {
10645 message: format!("local path `{}` changed during pull", entry.path),
10646 });
10647 }
10648 if let Some(source) = sources.get(&entry.path) {
10649 let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
10650 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
10651 return Err(invalid_feed(
10652 "private staged sync source failed final integrity verification",
10653 ));
10654 }
10655 store.write_atomic(Path::new(&entry.path), &bytes)?;
10656 } else if entry.old.is_some() {
10657 store.remove_file(Path::new(&entry.path))?;
10658 }
10659 }
10660 if rebuild_indexes {
10661 crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
10662 message: format!("could not materialize v2 local catalogs: {error}"),
10663 })?;
10664 }
10665 Ok(())
10666 })();
10667 if let Err(error) = installed {
10668 return match rollback_v2_pull(&store, &journal) {
10669 Ok(()) => Err(error),
10670 Err(rollback) => Err(LinkError::InvalidPack {
10671 message: format!("{error}; durable pull rollback also failed: {rollback}"),
10672 }),
10673 };
10674 }
10675 Ok(())
10676}
10677
10678#[cfg(windows)]
10679fn install_pulled_delta_sources(
10680 dest: &Path,
10681 entries: &[V2StagedFile],
10682 deleted: &[String],
10683 rebuild_indexes: bool,
10684 previous: Option<&V2SyncBaseline>,
10685 next: &V2VerifiedHead,
10686) -> LinkResult<()> {
10687 match Store::open_strict(dest) {
10688 Ok(store) => {
10689 install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
10690 }
10691 Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
10692 }
10693}
10694
10695#[cfg(not(any(unix, windows)))]
10696fn install_pulled_delta_sources(
10697 _dest: &Path,
10698 _entries: &[V2StagedFile],
10699 _deleted: &[String],
10700 _rebuild_indexes: bool,
10701 _previous: Option<&V2SyncBaseline>,
10702 _next: &V2VerifiedHead,
10703) -> LinkResult<()> {
10704 Err(LinkError::UnsupportedPlatform {
10705 operation: "atomic v2 pull install",
10706 })
10707}
10708
10709#[cfg(unix)]
10710fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
10711 install_pulled_delta(dest, entries, &[], false)
10712}
10713
10714#[cfg(not(windows))]
10715fn is_safe_slug(slug: &str) -> bool {
10716 !slug.is_empty()
10717 && slug.len() <= 63
10718 && !slug.starts_with('-')
10719 && !slug.ends_with('-')
10720 && slug
10721 .bytes()
10722 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
10723}
10724
10725fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
10726 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
10727}
10728
10729fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
10730 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
10731}
10732
10733fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
10734 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
10735}
10736
10737fn preflight_zip_central_directory(
10738 bytes: &[u8],
10739 offset: usize,
10740 size: usize,
10741 count: u64,
10742) -> LinkResult<()> {
10743 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
10744 let end = offset
10745 .checked_add(size)
10746 .filter(|end| *end <= bytes.len())
10747 .ok_or_else(|| LinkError::InvalidPack {
10748 message: "ZIP central directory is out of bounds".to_string(),
10749 })?;
10750 let mut cursor = offset;
10751 for _ in 0..count {
10752 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
10753 return Err(LinkError::InvalidPack {
10754 message: "ZIP central directory entry count is inconsistent".to_string(),
10755 });
10756 }
10757 if le_u16(bytes, cursor + 34) != Some(0) {
10758 return Err(LinkError::InvalidPack {
10759 message: "multi-disk ZIP archives are not supported".to_string(),
10760 });
10761 }
10762 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
10763 total.checked_add(le_u16(bytes, cursor + at)? as usize)
10764 });
10765 cursor = cursor
10766 .checked_add(46)
10767 .and_then(|fixed| fixed.checked_add(variable?))
10768 .filter(|cursor| *cursor <= end)
10769 .ok_or_else(|| LinkError::InvalidPack {
10770 message: "ZIP central directory entry is truncated".to_string(),
10771 })?;
10772 }
10773 if cursor != end {
10774 return Err(LinkError::InvalidPack {
10775 message: "ZIP central directory size is inconsistent".to_string(),
10776 });
10777 }
10778 Ok(())
10779}
10780
10781fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
10785 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
10786 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
10787 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
10788 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
10789 let eocd = bytes[search_start..]
10790 .windows(4)
10791 .rposition(|window| window == EOCD_SIG)
10792 .map(|offset| search_start + offset)
10793 .ok_or_else(|| LinkError::InvalidPack {
10794 message: "ZIP has no end-of-central-directory record".to_string(),
10795 })?;
10796 let invalid_end = || LinkError::InvalidPack {
10797 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
10798 };
10799 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
10800 if eocd
10801 .checked_add(22)
10802 .and_then(|end| end.checked_add(comment_len))
10803 != Some(bytes.len())
10804 {
10805 return Err(invalid_end());
10809 }
10810 let disk = le_u16(bytes, eocd + 4);
10811 let central_disk = le_u16(bytes, eocd + 6);
10812 if disk != Some(0) || central_disk != Some(0) {
10813 return Err(LinkError::InvalidPack {
10814 message: "multi-disk ZIP archives are not supported".to_string(),
10815 });
10816 }
10817 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
10818 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
10819 if entries_on_disk != ordinary {
10820 return Err(LinkError::InvalidPack {
10821 message: "multi-disk ZIP archives are not supported".to_string(),
10822 });
10823 }
10824 let zip64_locator = eocd
10825 .checked_sub(20)
10826 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
10827 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
10828 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
10829 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
10830 if central_offset
10831 .checked_add(central_size)
10832 .filter(|end| *end == eocd)
10833 .is_none()
10834 {
10835 return Err(invalid_end());
10836 }
10837 (ordinary as u64, central_offset, central_size)
10838 } else {
10839 let Some(locator) = zip64_locator else {
10840 return Err(invalid_end());
10841 };
10842 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
10843 return Err(LinkError::InvalidPack {
10844 message: "multi-disk ZIP64 archives are not supported".to_string(),
10845 });
10846 }
10847 let record = le_u64(bytes, locator + 8)
10848 .and_then(|offset| usize::try_from(offset).ok())
10849 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
10850 .ok_or_else(|| LinkError::InvalidPack {
10851 message: "ZIP64 archive has an invalid end record".to_string(),
10852 })?;
10853 let record_size = le_u64(bytes, record + 4)
10854 .and_then(|size| usize::try_from(size).ok())
10855 .filter(|size| *size >= 44)
10856 .ok_or_else(invalid_end)?;
10857 if record
10858 .checked_add(12)
10859 .and_then(|end| end.checked_add(record_size))
10860 != Some(locator)
10861 || le_u32(bytes, record + 16) != Some(0)
10862 || le_u32(bytes, record + 20) != Some(0)
10863 {
10864 return Err(invalid_end());
10865 }
10866 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
10867 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
10868 let central_size = le_u64(bytes, record + 40)
10869 .and_then(|size| usize::try_from(size).ok())
10870 .ok_or_else(invalid_end)?;
10871 let central_offset = le_u64(bytes, record + 48)
10872 .and_then(|offset| usize::try_from(offset).ok())
10873 .ok_or_else(invalid_end)?;
10874 if zip64_on_disk != zip64_total
10875 || central_offset
10876 .checked_add(central_size)
10877 .filter(|end| *end == record)
10878 .is_none()
10879 {
10880 return Err(invalid_end());
10881 }
10882 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10883 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10884 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10885 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10886 {
10887 return Err(invalid_end());
10888 }
10889 (zip64_total, central_offset, central_size)
10890 };
10891 if count == 0 || count > max_entries as u64 {
10892 return Err(LinkError::InvalidPack {
10893 message: format!("invalid file count {count}"),
10894 });
10895 }
10896 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10897 Ok(())
10898}
10899
10900fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10901 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10902 let mut archive =
10903 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10904 message: format!("ZIP parse failed: {err}"),
10905 })?;
10906 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10907 return Err(LinkError::InvalidPack {
10908 message: format!("invalid file count {}", archive.len()),
10909 });
10910 }
10911 let mut total = 0u64;
10912 let mut seen = std::collections::HashSet::new();
10913 let mut entries = Vec::with_capacity(archive.len());
10914 for index in 0..archive.len() {
10915 let mut file = archive
10916 .by_index(index)
10917 .map_err(|err| LinkError::InvalidPack {
10918 message: format!("ZIP entry failed: {err}"),
10919 })?;
10920 if file.is_dir() {
10921 continue;
10922 }
10923 let path = file.name().to_string();
10924 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10925 return Err(LinkError::UnsafePath { path });
10926 }
10927 if file
10928 .unix_mode()
10929 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
10930 {
10931 return Err(LinkError::InvalidPack {
10932 message: format!("non-file entry `{path}`"),
10933 });
10934 }
10935 if !seen.insert(path.clone()) {
10936 return Err(LinkError::InvalidPack {
10937 message: format!("duplicate path `{path}`"),
10938 });
10939 }
10940 let remaining = MAX_STORE_BYTES.saturating_sub(total);
10941 if file.size() > remaining {
10942 return Err(LinkError::InvalidPack {
10943 message: "expanded content exceeds the 512 MB limit".to_string(),
10944 });
10945 }
10946 let mut content = Vec::new();
10947 (&mut file)
10948 .take(remaining + 1)
10949 .read_to_end(&mut content)
10950 .map_err(|err| LinkError::InvalidPack {
10951 message: format!("could not decompress `{path}`: {err}"),
10952 })?;
10953 if content.len() as u64 > remaining {
10954 return Err(LinkError::InvalidPack {
10955 message: "expanded content exceeds the 512 MB limit".to_string(),
10956 });
10957 }
10958 if content.len() as u64 != file.size() {
10959 return Err(LinkError::InvalidPack {
10960 message: format!("length mismatch for `{path}`"),
10961 });
10962 }
10963 total += content.len() as u64;
10964 entries.push((path, content));
10965 }
10966 if entries.is_empty() {
10967 return Err(LinkError::InvalidPack {
10968 message: "pack contains no files".to_string(),
10969 });
10970 }
10971 Ok(entries)
10972}
10973
10974fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
10975 let mut expected = std::collections::BTreeMap::new();
10976 for file in signed {
10977 if !safe_store_rel_path(&file.path) {
10978 return Err(LinkError::UnsafePath {
10979 path: file.path.clone(),
10980 });
10981 }
10982 if !is_sha256(&file.sha256)
10983 || expected
10984 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10985 .is_some()
10986 {
10987 return Err(invalid_feed(
10988 "signed snapshot manifest contains an invalid or duplicate file",
10989 ));
10990 }
10991 }
10992 if expected.len() != entries.len() {
10993 return Err(invalid_feed(
10994 "downloaded pack file set differs from the signed snapshot manifest",
10995 ));
10996 }
10997 for (path, bytes) in entries {
10998 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
10999 return Err(invalid_feed(format!(
11000 "downloaded pack contains unsigned path `{path}`"
11001 )));
11002 };
11003 if *declared_bytes != bytes.len() as u64
11004 || *sha256 != format!("{:x}", Sha256::digest(bytes))
11005 {
11006 return Err(invalid_feed(format!(
11007 "downloaded file `{path}` differs from its signed manifest"
11008 )));
11009 }
11010 }
11011 Ok(())
11012}
11013
11014pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
11021 require_hardened_filesystem("sync push")?;
11022 preflight_push_ownership(store)?;
11023 let mut out: Vec<(String, String)> = Vec::new();
11024 let mut total = 0u64;
11025
11026 let mut read_text = |rel: &str| -> LinkResult<String> {
11027 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
11028 total = total
11029 .checked_add(bytes.len() as u64)
11030 .ok_or_else(|| LinkError::PushTooLarge {
11031 detail: "uncompressed byte count overflow".to_string(),
11032 })?;
11033 if total > MAX_STORE_BYTES {
11034 return Err(LinkError::PushTooLarge {
11035 detail: format!("{total} uncompressed bytes"),
11036 });
11037 }
11038 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
11039 path: rel.to_string(),
11040 })
11041 };
11042
11043 out.push(("DB.md".to_string(), read_text("DB.md")?));
11044 if store
11045 .regular_file_exists(Path::new("assets.jsonl"))
11046 .unwrap_or(false)
11047 {
11048 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
11049 }
11050
11051 for rel in store.walk()? {
11052 let rel_str = rel.to_string_lossy().replace('\\', "/");
11053 if !safe_store_rel_path(&rel_str) {
11054 return Err(LinkError::UnsafePath { path: rel_str });
11057 }
11058 let content = read_text(&rel_str)?;
11059 out.push((rel_str, content));
11060 }
11061
11062 out.sort_by(|a, b| a.0.cmp(&b.0));
11063 Ok(out)
11064}
11065
11066fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
11070 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
11071 return Err(LinkError::from(std::io::Error::new(
11072 std::io::ErrorKind::PermissionDenied,
11073 format!("cannot push: nested db.md store at {}", nested.display()),
11074 )));
11075 }
11076
11077 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
11078 return Err(LinkError::from(std::io::Error::new(
11079 std::io::ErrorKind::PermissionDenied,
11080 format!(
11081 "cannot push: {} is a symlink outside the store ownership model",
11082 symlink.display()
11083 ),
11084 )));
11085 }
11086 Ok(())
11087}
11088
11089pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
11095 require_safe_ref(brain)?;
11096 let remote = verified_remote_head(cfg, brain, false)?;
11097 if files.len() > MAX_PUSH_FILES {
11098 return Err(LinkError::PushTooLarge {
11099 detail: format!("{} files", files.len()),
11100 });
11101 }
11102 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
11103 if raw_total > MAX_STORE_BYTES {
11104 return Err(LinkError::PushTooLarge {
11105 detail: format!("{raw_total} uncompressed bytes"),
11106 });
11107 }
11108
11109 if cfg.brain_key.is_none() {
11113 let body = json!({
11114 "files": files
11115 .iter()
11116 .map(|(p, c)| json!({ "path": p, "content": c }))
11117 .collect::<Vec<_>>(),
11118 });
11119 if body.to_string().len() <= MAX_PUSH_BYTES {
11120 let path = format!("/api/hub/brains/{brain}/push");
11121 let pushed = ensure_ok(
11122 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11123 "sync push",
11124 )?;
11125 return Ok(pushed);
11126 }
11127 }
11128
11129 let pack = build_store_pack(files)?;
11130 if pack.len() as u64 > MAX_PACK_BYTES {
11131 return Err(LinkError::PushTooLarge {
11132 detail: format!("{} pack bytes", pack.len()),
11133 });
11134 }
11135 let sha256 = format!("{:x}", Sha256::digest(&pack));
11136 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
11137 if let Some(key) = &cfg.brain_key {
11138 if !remote.head.verified {
11139 return Err(invalid_feed(
11140 "self-custody push requires a fully verified, unscoped feed head",
11141 ));
11142 }
11143 let identity = remote
11144 .identity
11145 .as_ref()
11146 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
11147 let current_multikey = format!("ed25519:{}", identity.fingerprint);
11148 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
11149 return Err(invalid_feed(
11150 "configured brain key is not the verified current brain identity",
11151 ));
11152 }
11153 let next_seq = remote
11156 .head
11157 .seq
11158 .checked_add(1)
11159 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
11160 let mut manifest: Vec<WireFeedFile> = files
11161 .iter()
11162 .map(|(path, content)| WireFeedFile {
11163 path: path.clone(),
11164 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
11165 bytes: content.len() as u64,
11166 })
11167 .collect();
11168 manifest.sort_by(|a, b| a.path.cmp(&b.path));
11169 let ts = crate::now()
11170 .with_timezone(&chrono::Utc)
11171 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
11172 .to_string();
11173 let entry = self_custody_entry(
11174 key,
11175 next_seq,
11176 ts,
11177 &sha256,
11178 &manifest,
11179 remote.head.feed_hash.as_deref(),
11180 )?;
11181 meta["entry"] = Value::String(entry);
11182 }
11183 let presigned = ensure_ok(
11184 request(
11185 cfg,
11186 "POST",
11187 &format!("/api/hub/brains/{brain}/packs/presign"),
11188 Some(&meta),
11189 Auth::Required,
11190 )?,
11191 "prepare pack upload",
11192 )?;
11193 let url = presigned
11194 .get("url")
11195 .and_then(Value::as_str)
11196 .ok_or_else(|| LinkError::InvalidPack {
11197 message: "the hub returned no upload URL".to_string(),
11198 })?;
11199 put_presigned(
11200 cfg,
11201 url,
11202 presigned.get("headers").unwrap_or(&Value::Null),
11203 &pack,
11204 )?;
11205 let committed = ensure_ok(
11206 request(
11207 cfg,
11208 "POST",
11209 &format!("/api/hub/brains/{brain}/packs/commit"),
11210 Some(&meta),
11211 Auth::Required,
11212 )?,
11213 "commit pack",
11214 )?;
11215 Ok(committed)
11216}
11217
11218fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
11219 const LOCAL_HEADER: u32 = 0x0403_4b50;
11220 const CENTRAL_HEADER: u32 = 0x0201_4b50;
11221 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
11222 const VERSION_20: u16 = 20;
11223 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
11224 const UTF8_FLAG: u16 = 1 << 11;
11225 const STORED: u16 = 0;
11226 const DOS_TIME_MIDNIGHT: u16 = 0;
11227 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
11228 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
11229
11230 struct CentralEntry<'a> {
11231 name: &'a [u8],
11232 crc32: u32,
11233 size: u32,
11234 local_offset: u32,
11235 }
11236
11237 fn push_u16(out: &mut Vec<u8>, value: u16) {
11238 out.extend_from_slice(&value.to_le_bytes());
11239 }
11240
11241 fn push_u32(out: &mut Vec<u8>, value: u32) {
11242 out.extend_from_slice(&value.to_le_bytes());
11243 }
11244
11245 if files.is_empty() {
11246 return Err(LinkError::InvalidPack {
11247 message: "cannot create an empty snapshot pack".to_string(),
11248 });
11249 }
11250 if files.len() > u16::MAX as usize {
11251 return Err(LinkError::PushTooLarge {
11252 detail: format!(
11253 "{} files (canonical ZIP32 packs cap at {})",
11254 files.len(),
11255 u16::MAX
11256 ),
11257 });
11258 }
11259
11260 let mut sorted: Vec<_> = files.iter().collect();
11261 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
11262 let mut previous: Option<&str> = None;
11263 for (path, content) in &sorted {
11264 if !safe_store_rel_path(path) {
11265 return Err(LinkError::UnsafePath {
11266 path: (*path).clone(),
11267 });
11268 }
11269 if previous == Some(path.as_str()) {
11270 return Err(LinkError::InvalidPack {
11271 message: format!("duplicate path `{path}`"),
11272 });
11273 }
11274 previous = Some(path.as_str());
11275 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
11276 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11277 })?;
11278 }
11279
11280 let mut out = Vec::new();
11281 let mut central = Vec::with_capacity(sorted.len());
11282 for (path, content) in sorted {
11283 let name = path.as_bytes();
11284 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
11285 message: format!("ZIP entry name is too long: `{path}`"),
11286 })?;
11287 let bytes = content.as_bytes();
11288 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
11289 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11290 })?;
11291 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11292 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11293 })?;
11294 let crc32 = crc32fast::hash(bytes);
11295
11296 push_u32(&mut out, LOCAL_HEADER);
11299 push_u16(&mut out, VERSION_20);
11300 push_u16(&mut out, UTF8_FLAG);
11301 push_u16(&mut out, STORED);
11302 push_u16(&mut out, DOS_TIME_MIDNIGHT);
11303 push_u16(&mut out, DOS_DATE_1980_01_01);
11304 push_u32(&mut out, crc32);
11305 push_u32(&mut out, size);
11306 push_u32(&mut out, size);
11307 push_u16(&mut out, name_len);
11308 push_u16(&mut out, 0); out.extend_from_slice(name);
11310 out.extend_from_slice(bytes);
11311
11312 central.push(CentralEntry {
11313 name,
11314 crc32,
11315 size,
11316 local_offset,
11317 });
11318 }
11319
11320 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11321 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11322 })?;
11323 for entry in ¢ral {
11324 push_u32(&mut out, CENTRAL_HEADER);
11325 push_u16(&mut out, MADE_BY_UNIX_20);
11326 push_u16(&mut out, VERSION_20);
11327 push_u16(&mut out, UTF8_FLAG);
11328 push_u16(&mut out, STORED);
11329 push_u16(&mut out, DOS_TIME_MIDNIGHT);
11330 push_u16(&mut out, DOS_DATE_1980_01_01);
11331 push_u32(&mut out, entry.crc32);
11332 push_u32(&mut out, entry.size);
11333 push_u32(&mut out, entry.size);
11334 push_u16(&mut out, entry.name.len() as u16);
11335 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);
11340 push_u32(&mut out, entry.local_offset);
11341 out.extend_from_slice(entry.name);
11342 }
11343 let central_size = u32::try_from(out.len())
11344 .ok()
11345 .and_then(|end| end.checked_sub(central_offset))
11346 .ok_or_else(|| LinkError::PushTooLarge {
11347 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
11348 })?;
11349 let entry_count = central.len() as u16;
11350
11351 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
11352 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
11355 push_u16(&mut out, entry_count);
11356 push_u32(&mut out, central_size);
11357 push_u32(&mut out, central_offset);
11358 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
11361 return Err(LinkError::PushTooLarge {
11362 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
11363 });
11364 }
11365 Ok(out)
11366}
11367
11368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11374pub enum Capability {
11375 Read,
11377 Write,
11379}
11380
11381impl Capability {
11382 pub fn as_str(self) -> &'static str {
11384 match self {
11385 Capability::Read => "read",
11386 Capability::Write => "write",
11387 }
11388 }
11389}
11390
11391pub fn grant_issue(
11397 cfg: &HubConfig,
11398 brain: &str,
11399 grantee: &str,
11400 can: Capability,
11401 scope: Option<&str>,
11402 until: Option<&str>,
11403) -> LinkResult<Value> {
11404 require_safe_ref(brain)?;
11405 let is_key_grantee = URL_SAFE_NO_PAD
11410 .decode(grantee)
11411 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
11412 .unwrap_or(false);
11413 if let Some(head) = v2_verified_head(cfg, brain)? {
11414 if is_key_grantee {
11415 let scope = scope.unwrap_or("");
11416 let preset = match can {
11417 Capability::Read => "viewer",
11418 Capability::Write => "editor",
11419 };
11420 let entropy = format!(
11421 "{}\0{}\0{}\0{}\0{}\0{}\0{}",
11422 normalized_origin(&cfg.hub)?,
11423 head.brain_id,
11424 head.control_revision,
11425 grantee,
11426 preset,
11427 scope,
11428 until.unwrap_or("")
11429 );
11430 let mut body = json!({
11431 "context": "external",
11432 "expected_control_revision": head.control_revision,
11433 "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
11434 "preset": preset,
11435 "principal_kind": "key",
11436 "public_key": grantee,
11437 "scope": scope,
11438 "scope_kind": "prefix",
11439 });
11440 if let Some(value) = until {
11441 body["expires_at"] = json!(value);
11442 }
11443 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11444 let response = ensure_ok(
11445 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11446 "v2 grant issue",
11447 )?;
11448 let expected_fingerprint = identity_fingerprint(grantee)?;
11449 if response.get("v").and_then(Value::as_u64) != Some(2)
11450 || response
11451 .get("id")
11452 .and_then(Value::as_str)
11453 .is_none_or(|id| !crate::ulid::is_ulid(id))
11454 || response.get("principal_kind").and_then(Value::as_str) != Some("key")
11455 || response.get("principal_id").and_then(Value::as_str)
11456 != Some(expected_fingerprint.as_str())
11457 || response
11458 .get("control_revision")
11459 .and_then(Value::as_str)
11460 .is_none_or(|value| !is_sha256(value))
11461 {
11462 return Err(invalid_feed(
11463 "v2 grant issue response is not authority-bound",
11464 ));
11465 }
11466 return Ok(response);
11467 }
11468 let mut body = json!({ "email": grantee, "capability": can.as_str() });
11474 if let Some(value) = scope {
11475 body["scopePrefix"] = json!(value);
11476 }
11477 if let Some(value) = until {
11478 body["expiresAt"] = json!(value);
11479 }
11480 let path = format!("/api/hub/brains/{}/grants", head.brain_id);
11481 return ensure_ok(
11482 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11483 "account grant issue",
11484 );
11485 }
11486 let _ = verified_remote_head(cfg, brain, false)?;
11487 let mut body = if is_key_grantee {
11488 json!({ "keySpki": grantee, "capability": can.as_str() })
11489 } else {
11490 json!({ "email": grantee, "capability": can.as_str() })
11491 };
11492 if let Some(s) = scope {
11493 body["scopePrefix"] = json!(s);
11494 }
11495 if let Some(u) = until {
11496 body["expiresAt"] = json!(u);
11497 }
11498 let path = format!("/api/hub/brains/{brain}/grants");
11499 ensure_ok(
11500 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11501 "grant issue",
11502 )
11503}
11504
11505pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
11507 require_safe_ref(brain)?;
11508 if let Some(head) = v2_verified_head(cfg, brain)? {
11509 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11510 let response = ensure_ok(
11511 request(cfg, "GET", &path, None, Auth::Required)?,
11512 "v2 grant list",
11513 )?;
11514 if response.get("v").and_then(Value::as_u64) != Some(2)
11515 || response.get("control_revision").and_then(Value::as_str)
11516 != Some(head.control_revision.as_str())
11517 || !response.get("grants").is_some_and(Value::is_array)
11518 {
11519 return Err(invalid_feed(
11520 "v2 grant list is not bound to the verified authority",
11521 ));
11522 }
11523 return Ok(response);
11524 }
11525 let _ = verified_remote_head(cfg, brain, false)?;
11526 let path = format!("/api/hub/brains/{brain}/grants");
11527 ensure_ok(
11528 request(cfg, "GET", &path, None, Auth::Required)?,
11529 "grant list",
11530 )
11531}
11532
11533pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
11536 require_safe_ref(brain)?;
11537 require_safe_grant_id(grant_id)?;
11538 if let Some(head) = v2_verified_head(cfg, brain)? {
11539 let entropy = format!(
11540 "{}\0{}\0{}\0{}",
11541 normalized_origin(&cfg.hub)?,
11542 head.brain_id,
11543 head.control_revision,
11544 grant_id
11545 );
11546 let body = json!({
11547 "expected_control_revision": head.control_revision,
11548 "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
11549 });
11550 let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
11551 let response = ensure_ok(
11552 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11553 "v2 grant revoke",
11554 )?;
11555 if response.get("v").and_then(Value::as_u64) != Some(2)
11556 || response.get("id").and_then(Value::as_str) != Some(grant_id)
11557 || response.get("revoked").and_then(Value::as_bool) != Some(true)
11558 || response
11559 .get("control_revision")
11560 .and_then(Value::as_str)
11561 .is_none_or(|value| !is_sha256(value))
11562 {
11563 return Err(invalid_feed(
11564 "v2 grant revocation response is not authority-bound",
11565 ));
11566 }
11567 return Ok(response);
11568 }
11569 let _ = verified_remote_head(cfg, brain, false)?;
11570 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
11571 ensure_ok(
11572 request(cfg, "DELETE", &path, None, Auth::Required)?,
11573 "grant revoke",
11574 )
11575}
11576
11577#[derive(Debug)]
11582struct VerifiedV2Proposal {
11583 value: Value,
11584 changes: Value,
11585 blobs: Vec<(String, u64, String)>,
11586}
11587
11588fn require_proposal_id(id: &str) -> LinkResult<()> {
11589 if crate::ulid::is_ulid(id) {
11590 Ok(())
11591 } else {
11592 Err(invalid_feed("proposal id is not a lowercase ULID"))
11593 }
11594}
11595
11596fn verified_v2_proposal(
11597 cfg: &HubConfig,
11598 head: &V2VerifiedHead,
11599 proposal_id: &str,
11600) -> LinkResult<VerifiedV2Proposal> {
11601 require_proposal_id(proposal_id)?;
11602 if head.view_kind != "full" {
11603 return Err(invalid_feed(
11604 "proposal review requires a full readable view",
11605 ));
11606 }
11607 let path = format!(
11608 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11609 head.brain_id
11610 );
11611 let value = ensure_ok(
11612 request_capped(
11613 cfg,
11614 "GET",
11615 &path,
11616 None,
11617 Auth::Required,
11618 MAX_FEED_RESPONSE_BYTES,
11619 )?,
11620 "v2 proposal",
11621 )?;
11622 verify_v2_proposal_value(head, proposal_id, value)
11623}
11624
11625fn verify_v2_proposal_value(
11626 head: &V2VerifiedHead,
11627 proposal_id: &str,
11628 value: Value,
11629) -> LinkResult<VerifiedV2Proposal> {
11630 if value.get("v").and_then(Value::as_u64) != Some(2) {
11631 return Err(invalid_feed("proposal response has an invalid version"));
11632 }
11633 let proposal = value
11634 .get("proposal")
11635 .and_then(Value::as_object)
11636 .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
11637 if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
11638 return Err(invalid_feed("proposal response changed its id"));
11639 }
11640 let payload_hash = proposal
11641 .get("payload_sha256")
11642 .and_then(Value::as_str)
11643 .filter(|hash| is_sha256(hash))
11644 .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
11645 let clear_hash = proposal
11646 .get("clear_sha256")
11647 .and_then(Value::as_str)
11648 .filter(|hash| is_sha256(hash))
11649 .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
11650 let submission_hash = proposal
11651 .get("submission_claim_sha256")
11652 .and_then(Value::as_str)
11653 .filter(|hash| is_sha256(hash))
11654 .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
11655 let submission = STANDARD
11656 .decode(
11657 proposal
11658 .get("submission_claim_base64")
11659 .and_then(Value::as_str)
11660 .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
11661 )
11662 .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
11663 let submission_value: Value = serde_json::from_slice(&submission)
11664 .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
11665 if crate::linkmd_v2::canonical_bytes(&submission_value)
11666 .map_err(|error| invalid_feed(error.to_string()))?
11667 != submission
11668 || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
11669 .map_err(|error| invalid_feed(error.to_string()))?
11670 != submission_hash
11671 {
11672 return Err(invalid_feed(
11673 "proposal submission claim is not canonical or addressed",
11674 ));
11675 }
11676 let envelope = submission_value
11677 .as_object()
11678 .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
11679 let claim = envelope
11680 .get("claim")
11681 .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
11682 let claim_object = claim
11683 .as_object()
11684 .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
11685 let actor_root = claim_object
11686 .get("actor_root")
11687 .and_then(Value::as_object)
11688 .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
11689 let public_key = envelope
11690 .get("public_key")
11691 .and_then(Value::as_str)
11692 .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
11693 let fingerprint = envelope
11694 .get("fingerprint")
11695 .and_then(Value::as_str)
11696 .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
11697 let signature = envelope
11698 .get("sig")
11699 .and_then(Value::as_str)
11700 .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
11701 let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
11702 .map_err(|error| invalid_feed(error.to_string()))?;
11703 let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
11704 let signer = format!("{fingerprint}:{public_key}");
11705 let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
11706 let grants = actor_root.get("grants").and_then(Value::as_array);
11707 let grants_are_canonical = grants.is_some_and(|items| {
11708 let mut prior: Option<&str> = None;
11709 items.iter().all(|item| {
11710 let Some(grant) = item.as_str() else {
11711 return false;
11712 };
11713 if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
11714 return false;
11715 }
11716 prior = Some(grant);
11717 true
11718 })
11719 });
11720 let optional_actor_field = |name: &str| {
11721 actor_root.get(name).is_some_and(|value| {
11722 value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
11723 })
11724 };
11725 let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
11726 if claim_object.get("v").and_then(Value::as_u64) != Some(2)
11727 || format!("{:x}", Sha256::digest(&der)) != fingerprint
11728 || head
11729 .trust
11730 .hub_signer
11731 .as_ref()
11732 .is_some_and(|known| known != &signer)
11733 || !matches!(
11734 actor_class,
11735 Some(
11736 "user"
11737 | "owned_agent"
11738 | "foreign_key"
11739 | "curation"
11740 | "inbox"
11741 | "restore"
11742 | "migration"
11743 | "operator_recovery"
11744 )
11745 )
11746 || actor_root
11747 .get("principal")
11748 .and_then(Value::as_str)
11749 .is_none_or(|value| value.is_empty())
11750 || actor_root
11751 .get("credential")
11752 .and_then(Value::as_str)
11753 .is_none_or(|value| value.is_empty())
11754 || !optional_actor_field("organization")
11755 || !optional_actor_field("role")
11756 || !grants_are_canonical
11757 || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
11758 || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11759 || !claim_object
11760 .get("mutation_id")
11761 .and_then(Value::as_str)
11762 .is_some_and(|value| {
11763 !value.is_empty()
11764 && value.len() <= 128
11765 && value.chars().enumerate().all(|(index, char)| {
11766 char.is_ascii_alphanumeric()
11767 || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
11768 })
11769 })
11770 || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
11771 || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
11772 || !claim_object
11773 .get("control_revision")
11774 .and_then(Value::as_str)
11775 .is_some_and(is_sha256)
11776 || submitted_at.is_none_or(|value| {
11777 chrono::DateTime::parse_from_rfc3339(value).is_err()
11778 || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
11779 })
11780 || !proposal
11781 .get("state")
11782 .and_then(Value::as_str)
11783 .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
11784 || proposal
11785 .get("expires_at")
11786 .and_then(Value::as_str)
11787 .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
11788 || proposal
11789 .get("proposer")
11790 .and_then(Value::as_object)
11791 .and_then(|value| value.get("class"))
11792 .and_then(Value::as_str)
11793 != actor_class
11794 {
11795 return Err(invalid_feed(
11796 "proposal submission claim does not bind the verified proposal",
11797 ));
11798 }
11799 let changes_b64 = proposal
11800 .get("changes_base64")
11801 .and_then(Value::as_str)
11802 .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
11803 let changes_bytes = STANDARD
11804 .decode(changes_b64)
11805 .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
11806 let changes: Value = serde_json::from_slice(&changes_bytes)
11807 .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
11808 if crate::linkmd_v2::canonical_bytes(&changes)
11809 .map_err(|error| invalid_feed(error.to_string()))?
11810 != changes_bytes
11811 || changes.get("v").and_then(Value::as_u64) != Some(2)
11812 || !changes.get("operations").is_some_and(Value::is_array)
11813 {
11814 return Err(invalid_feed("proposal changeset is not canonical v2"));
11815 }
11816 let blob_values = proposal
11817 .get("blobs")
11818 .and_then(Value::as_array)
11819 .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
11820 let mut blobs = Vec::with_capacity(blob_values.len());
11821 let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
11822 let mut prior_hash: Option<String> = None;
11823 for item in blob_values {
11824 let hash = item
11825 .get("sha256")
11826 .and_then(Value::as_str)
11827 .filter(|hash| is_sha256(hash))
11828 .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
11829 let bytes = item
11830 .get("bytes")
11831 .and_then(Value::as_u64)
11832 .filter(|bytes| *bytes <= MAX_STORE_BYTES)
11833 .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
11834 if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
11835 return Err(invalid_feed(
11836 "proposal blob declarations are not unique and sorted",
11837 ));
11838 }
11839 prior_hash = Some(hash.to_string());
11840 let endpoint = item
11841 .get("endpoint")
11842 .and_then(Value::as_str)
11843 .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
11844 let expected_endpoint = format!(
11845 "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
11846 head.brain_id
11847 );
11848 if endpoint != expected_endpoint {
11849 return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
11850 }
11851 descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
11852 blobs.push((hash.to_string(), bytes, endpoint.to_string()));
11853 }
11854 let descriptor = json!({
11855 "base": proposal.get("base").cloned().unwrap_or(Value::Null),
11856 "blobs": descriptor_blobs,
11857 "changes_base64": changes_b64,
11858 "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
11859 "v": 2,
11860 });
11861 let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
11862 .map_err(|error| invalid_feed(error.to_string()))?;
11863 if content_sha256(&descriptor_bytes) != clear_hash {
11864 return Err(invalid_feed(
11865 "proposal clear payload differs from its signed submission claim",
11866 ));
11867 }
11868 Ok(VerifiedV2Proposal {
11869 value,
11870 changes,
11871 blobs,
11872 })
11873}
11874
11875pub fn proposal_list(
11876 cfg: &HubConfig,
11877 brain: &str,
11878 state: &str,
11879 after: Option<&str>,
11880 limit: usize,
11881) -> LinkResult<Value> {
11882 require_safe_ref(brain)?;
11883 if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11884 return Err(invalid_feed("proposal state is invalid"));
11885 }
11886 if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11887 return Err(invalid_feed("proposal cursor is invalid"));
11888 }
11889 let head = v2_verified_head(cfg, brain)?
11890 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11891 let path = format!(
11892 "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11893 head.brain_id,
11894 limit.clamp(1, 100),
11895 after.map_or_else(String::new, |value| format!("&after={value}"))
11896 );
11897 ensure_ok(
11898 request_capped(
11899 cfg,
11900 "GET",
11901 &path,
11902 None,
11903 Auth::Required,
11904 MAX_FEED_RESPONSE_BYTES,
11905 )?,
11906 "v2 proposal list",
11907 )
11908}
11909
11910pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11911 require_safe_ref(brain)?;
11912 let head = v2_verified_head(cfg, brain)?
11913 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11914 Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11915}
11916
11917pub fn proposal_reject(
11918 cfg: &HubConfig,
11919 brain: &str,
11920 proposal_id: &str,
11921 mutation_id: &str,
11922 reason: &str,
11923) -> LinkResult<Value> {
11924 require_safe_ref(brain)?;
11925 require_proposal_id(proposal_id)?;
11926 let head = v2_verified_head(cfg, brain)?
11927 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11928 let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
11929 let body = json!({
11930 "mutation_id": mutation_id,
11931 "control_revision": head.control_revision,
11932 "reason": reason,
11933 });
11934 let path = format!(
11935 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11936 head.brain_id
11937 );
11938 ensure_ok(
11939 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11940 "v2 proposal rejection",
11941 )
11942}
11943
11944pub fn proposal_accept_exact(
11945 cfg: &HubConfig,
11946 brain: &str,
11947 proposal_id: &str,
11948 mutation_id: &str,
11949 reason: &str,
11950) -> LinkResult<Value> {
11951 require_safe_ref(brain)?;
11952 require_proposal_id(proposal_id)?;
11953 let head = v2_verified_head(cfg, brain)?
11954 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11955 let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
11956 let operations = proposal
11957 .changes
11958 .get("operations")
11959 .and_then(Value::as_array)
11960 .cloned()
11961 .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
11962 if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
11963 return Err(invalid_feed("proposal operation count is invalid"));
11964 }
11965 let mut downloaded = std::collections::BTreeMap::new();
11966 for (hash, bytes, endpoint) in &proposal.blobs {
11967 let body = ensure_raw_ok(
11968 request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
11969 "v2 proposal blob",
11970 )?;
11971 if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
11972 return Err(invalid_feed("proposal blob does not match its declaration"));
11973 }
11974 downloaded.insert(hash.clone(), body);
11975 }
11976 let remote = files_for_v2_view(
11977 &head,
11978 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
11979 );
11980 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
11981 let mut expected_candidate = remote.clone();
11982 let mut expected_candidate_assets = remote_assets;
11983 for operation in &operations {
11984 let op = operation
11985 .get("op")
11986 .and_then(Value::as_str)
11987 .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
11988 match op {
11989 "put" | "restore" => {
11990 let path = operation
11991 .get("path")
11992 .and_then(Value::as_str)
11993 .ok_or_else(|| invalid_feed("proposal write has no path"))?;
11994 crate::linkmd_v2::normalize_path(path)
11995 .map_err(|error| invalid_feed(error.to_string()))?;
11996 let hash = operation
11997 .get("blob")
11998 .and_then(Value::as_str)
11999 .filter(|hash| is_sha256(hash))
12000 .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
12001 let bytes = operation
12002 .get("bytes")
12003 .and_then(Value::as_u64)
12004 .ok_or_else(|| invalid_feed("proposal write has no size"))?;
12005 expected_candidate.insert(
12006 path.to_string(),
12007 V2BaselineFile {
12008 sha256: hash.to_string(),
12009 bytes,
12010 proof: None,
12011 },
12012 );
12013 }
12014 "delete" | "withdraw_from_hosting" => {
12015 let path = operation
12016 .get("path")
12017 .and_then(Value::as_str)
12018 .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
12019 crate::linkmd_v2::normalize_path(path)
12020 .map_err(|error| invalid_feed(error.to_string()))?;
12021 expected_candidate.remove(path);
12022 }
12023 "rename" => {
12024 let from = operation
12025 .get("from")
12026 .and_then(Value::as_str)
12027 .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
12028 let to = operation
12029 .get("to")
12030 .and_then(Value::as_str)
12031 .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
12032 crate::linkmd_v2::normalize_path(from)
12033 .and_then(|_| crate::linkmd_v2::normalize_path(to))
12034 .map_err(|error| invalid_feed(error.to_string()))?;
12035 let hash = operation
12036 .get("blob")
12037 .and_then(Value::as_str)
12038 .filter(|hash| is_sha256(hash))
12039 .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
12040 let bytes = operation
12041 .get("bytes")
12042 .and_then(Value::as_u64)
12043 .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
12044 expected_candidate.remove(from);
12045 expected_candidate.insert(
12046 to.to_string(),
12047 V2BaselineFile {
12048 sha256: hash.to_string(),
12049 bytes,
12050 proof: None,
12051 },
12052 );
12053 }
12054 "asset_delete" => {
12055 let path = operation
12056 .get("path")
12057 .and_then(Value::as_str)
12058 .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
12059 expected_candidate_assets.remove(path);
12060 }
12061 "asset_withdraw" => {
12062 let path = operation
12063 .get("path")
12064 .and_then(Value::as_str)
12065 .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
12066 let asset = expected_candidate_assets
12067 .get_mut(path)
12068 .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
12069 asset.disposition = "withheld".to_string();
12070 asset.leaf_hash.clear();
12071 }
12072 "asset_put" | "asset_resume" => {
12073 let path = operation
12074 .get("path")
12075 .and_then(Value::as_str)
12076 .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
12077 let asset = operation
12078 .get("asset")
12079 .and_then(Value::as_object)
12080 .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
12081 let blob_sha256 = asset
12082 .get("blob_sha256")
12083 .and_then(Value::as_str)
12084 .filter(|hash| is_sha256(hash))
12085 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12086 let bytes = asset
12087 .get("bytes")
12088 .and_then(Value::as_u64)
12089 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12090 let media_type = asset
12091 .get("media_type")
12092 .and_then(Value::as_str)
12093 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12094 let wrappers = asset
12095 .get("wrappers")
12096 .and_then(Value::as_array)
12097 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12098 .iter()
12099 .map(|wrapper| {
12100 wrapper
12101 .as_str()
12102 .map(str::to_string)
12103 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12104 })
12105 .collect::<LinkResult<Vec<_>>>()?;
12106 let required = asset
12107 .get("required")
12108 .and_then(Value::as_bool)
12109 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12110 let disposition = asset
12111 .get("disposition")
12112 .and_then(Value::as_str)
12113 .filter(|value| matches!(*value, "hosted" | "withheld"))
12114 .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
12115 expected_candidate_assets.insert(
12116 path.to_string(),
12117 V2BaselineAsset {
12118 blob_sha256: blob_sha256.to_string(),
12119 bytes,
12120 media_type: media_type.to_string(),
12121 wrappers,
12122 required,
12123 disposition: disposition.to_string(),
12124 leaf_hash: String::new(),
12125 },
12126 );
12127 }
12128 _ => return Err(invalid_feed("proposal operation kind is unsupported")),
12129 }
12130 }
12131 let base = head.pointer.as_ref().map(|pointer| {
12132 json!({
12133 "seq": pointer.seq,
12134 "commit_hash": pointer.commit_hash,
12135 "content_root": pointer.content_root,
12136 "asset_root": pointer.asset_root,
12137 })
12138 });
12139 let mut body = json!({
12140 "mutation_id": mutation_id,
12141 "base": base,
12142 "rebase": "strict",
12143 "reason": reason,
12144 "operations": operations,
12145 "blobs": downloaded
12146 .iter()
12147 .map(|(sha256, bytes)| json!({
12148 "sha256": sha256,
12149 "bytes": bytes.len(),
12150 "content_base64": STANDARD.encode(bytes),
12151 }))
12152 .collect::<Vec<_>>(),
12153 "proposal_id": proposal_id,
12154 "proposal_mode": "exact",
12155 });
12156 let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
12157 total
12158 .checked_add(bytes.len())
12159 .ok_or_else(|| LinkError::PushTooLarge {
12160 detail: "proposal changed-byte total overflow".to_string(),
12161 })
12162 })?;
12163 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
12164 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12165 for operation in &operations {
12166 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
12167 return Err(invalid_feed("proposal upload operation has no kind"));
12168 };
12169 let hash = match kind {
12170 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
12171 "asset_put" | "asset_resume" => operation
12172 .get("asset")
12173 .and_then(|asset| asset.get("blob_sha256"))
12174 .and_then(Value::as_str),
12175 _ => None,
12176 };
12177 let Some(hash) = hash else { continue };
12178 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
12179 if kind == "rename" {
12180 for field in ["from", "to"] {
12181 coordinates.insert(
12182 operation
12183 .get(field)
12184 .and_then(Value::as_str)
12185 .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
12186 .to_string(),
12187 );
12188 }
12189 } else {
12190 let path = operation
12191 .get("path")
12192 .and_then(Value::as_str)
12193 .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
12194 coordinates.insert(if kind.starts_with("asset_") {
12195 format!("assets/{path}")
12196 } else {
12197 path.to_string()
12198 });
12199 }
12200 }
12201 let declarations = downloaded
12202 .iter()
12203 .map(|(sha256, bytes)| {
12204 json!({
12205 "sha256": sha256,
12206 "bytes": bytes.len(),
12207 "coordinates": coordinates_by_hash
12208 .get(sha256)
12209 .into_iter()
12210 .flatten()
12211 .collect::<Vec<_>>(),
12212 })
12213 })
12214 .collect::<Vec<_>>();
12215 let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
12216 for batch in batch_upload_declarations(declarations) {
12217 let reserved = reserve_upload_window(
12218 cfg,
12219 &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
12220 &json!({ "blobs": batch }),
12221 "prepare proposal blob transport",
12222 )?;
12223 let reserved_items = reserved
12224 .get("uploads")
12225 .and_then(Value::as_array)
12226 .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
12227 items.extend(reserved_items.iter().cloned());
12228 }
12229 if items.len() != downloaded.len() {
12230 return Err(invalid_feed("proposal upload reservation changed the set"));
12231 }
12232 let mut references = Vec::with_capacity(items.len());
12233 for item in items {
12234 let hash = item
12235 .get("sha256")
12236 .and_then(Value::as_str)
12237 .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
12238 let bytes = downloaded
12239 .get(hash)
12240 .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
12241 let reservation_id = item
12242 .get("reservation_id")
12243 .and_then(Value::as_str)
12244 .filter(|id| crate::ulid::is_ulid(id))
12245 .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
12246 let expected_coordinates = coordinates_by_hash
12247 .get(hash)
12248 .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
12249 let returned_coordinates = item
12250 .get("coordinates")
12251 .and_then(Value::as_array)
12252 .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
12253 if returned_coordinates.len() != expected_coordinates.len()
12254 || returned_coordinates
12255 .iter()
12256 .zip(expected_coordinates)
12257 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
12258 {
12259 return Err(invalid_feed(
12260 "proposal upload reservation changed its coordinates",
12261 ));
12262 }
12263 match item.get("status").and_then(Value::as_str) {
12264 Some("upload") => put_presigned(
12265 cfg,
12266 item.get("url")
12267 .and_then(Value::as_str)
12268 .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
12269 item.get("headers").unwrap_or(&Value::Null),
12270 bytes,
12271 )?,
12272 Some("already_present") => {}
12273 _ => return Err(invalid_feed("proposal upload status is invalid")),
12274 }
12275 references.push(json!({
12276 "sha256": hash,
12277 "bytes": bytes.len(),
12278 "reservation_id": reservation_id,
12279 }));
12280 }
12281 body["blobs"] = Value::Array(references);
12282 }
12283 stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
12287 let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
12288 let mut result = ensure_ok(
12289 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
12290 "exact proposal acceptance",
12291 )?;
12292 let mut candidate_hub_signer = None;
12293 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
12294 let request_id = result
12295 .get("request_id")
12296 .and_then(Value::as_str)
12297 .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
12298 .to_string();
12299 let challenge = result
12300 .get("signing_challenge")
12301 .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
12302 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
12303 cfg,
12304 &head,
12305 &expected_candidate,
12306 &expected_candidate_assets,
12307 mutation_id,
12308 &v2_signed_request_view(&body, &operations),
12309 challenge,
12310 )?;
12311 body["signing_challenge_id"] = Value::String(challenge_id);
12312 body["signature_base64url"] = Value::String(signature);
12313 candidate_hub_signer = Some(actor_signer);
12314 result = ensure_ok(
12315 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
12316 "signed exact proposal acceptance",
12317 )?;
12318 }
12319 let refreshed = v2_verified_head(cfg, brain)?
12320 .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
12321 if candidate_hub_signer
12322 .as_ref()
12323 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
12324 || refreshed
12325 .pointer
12326 .as_ref()
12327 .map(|pointer| pointer.commit_hash.as_str())
12328 != result.get("commit_hash").and_then(Value::as_str)
12329 || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12330 || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
12331 {
12332 return Err(LinkError::RemoteAdvancedDuringSync);
12333 }
12334 accept_v2_head(cfg, &refreshed)?;
12335 Ok(result)
12336}
12337
12338pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
12349 require_valid_handle(handle)?;
12350 if body.len() as u64 > MAX_PROPOSE_BYTES {
12351 return Err(LinkError::ProposeTooLarge {
12352 bytes: body.len() as u64,
12353 });
12354 }
12355 let payload = json!({ "app": app, "body": body });
12356 let (path, auth) = if crate::ulid::is_ulid(handle) {
12361 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
12362 } else {
12363 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
12364 };
12365 ensure_ok(
12366 request(cfg, "POST", &path, Some(&payload), auth)?,
12367 "propose",
12368 )
12369}
12370
12371#[derive(Debug, serde::Serialize)]
12377pub struct Head {
12378 pub brain: String,
12380 pub seq: u64,
12382 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
12384 pub updated_at: Option<String>,
12385 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
12387 pub feed_hash: Option<String>,
12388 pub verified: bool,
12391}
12392
12393struct BoundedVecVisitor<T, const MAX: usize> {
12394 label: &'static str,
12395 marker: std::marker::PhantomData<T>,
12396}
12397
12398impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
12399where
12400 T: Deserialize<'de>,
12401{
12402 type Value = Vec<T>;
12403
12404 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12405 write!(formatter, "at most {MAX} {}", self.label)
12406 }
12407
12408 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
12409 where
12410 A: serde::de::SeqAccess<'de>,
12411 {
12412 if sequence.size_hint().is_some_and(|size| size > MAX) {
12413 return Err(serde::de::Error::custom(format!(
12414 "{} exceeds the {MAX}-item limit",
12415 self.label
12416 )));
12417 }
12418 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
12419 while let Some(value) = sequence.next_element()? {
12420 if values.len() == MAX {
12421 return Err(serde::de::Error::custom(format!(
12422 "{} exceeds the {MAX}-item limit",
12423 self.label
12424 )));
12425 }
12426 values.push(value);
12427 }
12428 Ok(values)
12429 }
12430}
12431
12432fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
12433 deserializer: D,
12434 label: &'static str,
12435) -> Result<Vec<T>, D::Error>
12436where
12437 D: serde::Deserializer<'de>,
12438 T: Deserialize<'de>,
12439{
12440 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
12441 label,
12442 marker: std::marker::PhantomData,
12443 })
12444}
12445
12446fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
12447where
12448 D: serde::Deserializer<'de>,
12449{
12450 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
12451}
12452
12453fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12454where
12455 D: serde::Deserializer<'de>,
12456{
12457 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
12458}
12459
12460fn deserialize_previous_identities<'de, D>(
12461 deserializer: D,
12462) -> Result<Vec<PreviousIdentity>, D::Error>
12463where
12464 D: serde::Deserializer<'de>,
12465{
12466 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
12467 deserializer,
12468 "previous identities",
12469 )
12470}
12471
12472fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12473where
12474 D: serde::Deserializer<'de>,
12475{
12476 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
12477 deserializer,
12478 "rotation statements",
12479 )
12480}
12481
12482fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
12483where
12484 D: serde::Deserializer<'de>,
12485{
12486 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
12487}
12488
12489#[derive(Debug, Clone, Deserialize, Serialize)]
12490struct FeedFile {
12491 path: String,
12492 sha256: String,
12493 bytes: u64,
12494}
12495
12496#[cfg(test)]
12497#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12498enum V1DisclosureError {
12499 DuplicateFile,
12500 DuplicateRemoved,
12501 PushManifestMismatch,
12502 EditMissingChange,
12503 EditFalseFile,
12504 RemovedMismatch,
12505}
12506
12507#[cfg(test)]
12511fn verify_v1_manifest_disclosure(
12512 kind: &str,
12513 previous: &[FeedFile],
12514 resulting: &[FeedFile],
12515 files: &[FeedFile],
12516 removed: &[String],
12517) -> Result<(), V1DisclosureError> {
12518 fn as_map(
12519 files: &[FeedFile],
12520 ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
12521 let mut result = std::collections::BTreeMap::new();
12522 for file in files {
12523 if result
12524 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
12525 .is_some()
12526 {
12527 return Err(V1DisclosureError::DuplicateFile);
12528 }
12529 }
12530 Ok(result)
12531 }
12532 let previous = as_map(previous)?;
12533 let resulting = as_map(resulting)?;
12534 let disclosed = as_map(files)?;
12535 let removed_set: std::collections::BTreeSet<&str> =
12536 removed.iter().map(String::as_str).collect();
12537 if removed_set.len() != removed.len() {
12538 return Err(V1DisclosureError::DuplicateRemoved);
12539 }
12540 let expected_removed: std::collections::BTreeSet<&str> = previous
12541 .keys()
12542 .copied()
12543 .filter(|path| !resulting.contains_key(path))
12544 .collect();
12545 if removed_set != expected_removed {
12546 return Err(V1DisclosureError::RemovedMismatch);
12547 }
12548 if kind == "push" {
12549 return if disclosed == resulting {
12550 Ok(())
12551 } else {
12552 Err(V1DisclosureError::PushManifestMismatch)
12553 };
12554 }
12555 if kind != "edit" {
12556 return Err(V1DisclosureError::EditFalseFile);
12557 }
12558 if disclosed
12559 .iter()
12560 .any(|(path, value)| resulting.get(path) != Some(value))
12561 {
12562 return Err(V1DisclosureError::EditFalseFile);
12563 }
12564 for (path, value) in &resulting {
12565 if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
12566 return Err(V1DisclosureError::EditMissingChange);
12567 }
12568 }
12569 Ok(())
12570}
12571
12572#[derive(Debug, Clone, Deserialize, Serialize)]
12573struct FeedEntry {
12574 v: u8,
12575 seq: u64,
12576 ts: String,
12577 brain: String,
12578 public_key: String,
12579 kind: String,
12580 op: String,
12581 pack_sha256: String,
12582 #[serde(deserialize_with = "deserialize_feed_files")]
12583 files: Vec<FeedFile>,
12584 #[serde(deserialize_with = "deserialize_removed_paths")]
12585 removed: Vec<String>,
12586 prev_entry_hash: Option<String>,
12587 sig: String,
12588}
12589
12590#[derive(Serialize)]
12591struct UnsignedFeedEntry<'a> {
12592 v: u8,
12593 seq: u64,
12594 ts: &'a str,
12595 brain: &'a str,
12596 public_key: &'a str,
12597 kind: &'a str,
12598 op: &'a str,
12599 pack_sha256: &'a str,
12600 files: &'a [FeedFile],
12601 removed: &'a [String],
12602 prev_entry_hash: &'a Option<String>,
12603}
12604
12605#[derive(Debug, Clone, Deserialize, Serialize)]
12606struct FeedItem {
12607 hash: String,
12608 entry: FeedEntry,
12609}
12610
12611#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12612struct FeedIdentity {
12613 fingerprint: String,
12614 #[serde(rename = "publicKeySpki")]
12615 public_key_spki: String,
12616 #[serde(default, deserialize_with = "deserialize_previous_identities")]
12620 previous: Vec<PreviousIdentity>,
12621 #[serde(default, deserialize_with = "deserialize_rotations")]
12624 rotations: Vec<String>,
12625}
12626
12627#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12628struct PreviousIdentity {
12629 fingerprint: String,
12630 #[serde(rename = "publicKeySpki")]
12631 public_key_spki: String,
12632}
12633
12634#[derive(Debug, Deserialize)]
12635struct FeedResponse {
12636 #[serde(rename = "headSeq")]
12637 head_seq: u64,
12638 #[serde(rename = "feedHash")]
12639 feed_hash: Option<String>,
12640 identity: Option<FeedIdentity>,
12641 #[serde(deserialize_with = "deserialize_feed_items")]
12642 entries: Vec<FeedItem>,
12643 #[serde(rename = "scopeLimited")]
12644 scope_limited: bool,
12645}
12646
12647#[derive(Debug, Deserialize, Serialize)]
12648#[serde(deny_unknown_fields)]
12649struct RotationStatement {
12650 v: u8,
12651 op: String,
12652 brain: String,
12653 public_key: String,
12654 new_brain: String,
12655 new_public_key: String,
12656 prior_head_seq: u64,
12657 prior_feed_hash: Option<String>,
12658 ts: String,
12659 sig: String,
12660}
12661
12662#[derive(Debug, Clone, Deserialize, Serialize)]
12663struct TrustState {
12664 v: u8,
12665 origin: String,
12666 #[serde(default)]
12670 requested: String,
12671 brain: String,
12673 #[serde(default, skip_serializing_if = "Option::is_none")]
12676 home: Option<String>,
12677 anchor: String,
12678 current: String,
12679 #[serde(rename = "headSeq")]
12680 head_seq: u64,
12681 #[serde(rename = "feedHash")]
12682 feed_hash: Option<String>,
12683 #[serde(default)]
12687 rotations: Vec<String>,
12688 #[serde(default, skip_serializing_if = "Option::is_none")]
12691 hub_signer: Option<String>,
12692 #[serde(default, skip_serializing_if = "Option::is_none")]
12695 protocol_profile: Option<String>,
12696}
12697
12698fn accepted_as_v2(state: &TrustState) -> bool {
12699 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
12700}
12701
12702fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
12703 let directory = open_trust_dir(cfg)?;
12704 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
12705 return Ok(true);
12706 }
12707 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
12708 return Ok(false);
12709 };
12710 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
12711}
12712
12713#[derive(Debug, Clone, Deserialize, Serialize)]
12714struct AliasBinding {
12715 v: u8,
12716 origin: String,
12717 requested: String,
12718 brain: String,
12719 #[serde(default, skip_serializing_if = "Option::is_none")]
12720 home: Option<String>,
12721}
12722
12723struct VerifiedRemote {
12724 head: Head,
12725 identity: Option<FeedIdentity>,
12726 head_entry: Option<FeedItem>,
12727 entries: Vec<FeedItem>,
12729 anchor: Option<String>,
12730}
12731
12732fn invalid_feed(message: impl Into<String>) -> LinkError {
12733 LinkError::InvalidFeed {
12734 message: message.into(),
12735 }
12736}
12737
12738fn is_sha256(value: &str) -> bool {
12739 value.len() == 64
12740 && value
12741 .bytes()
12742 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
12743}
12744
12745fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
12746 let der = URL_SAFE_NO_PAD
12747 .decode(public_key_spki)
12748 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
12749 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
12750 return Err(invalid_feed(
12751 "identity public key is not a valid Ed25519 SPKI",
12752 ));
12753 }
12754 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
12755}
12756
12757fn verify_identity_chain(
12761 identity: &FeedIdentity,
12762 pinned: Option<&TrustState>,
12763) -> LinkResult<String> {
12764 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
12765 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
12766 {
12767 return Err(invalid_feed(
12768 "identity rotation history exceeds the client cap",
12769 ));
12770 }
12771 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
12772 return Err(invalid_feed(
12773 "current identity fingerprint does not match its public key",
12774 ));
12775 }
12776 for previous in &identity.previous {
12777 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
12778 return Err(invalid_feed(
12779 "previous identity fingerprint does not match its public key",
12780 ));
12781 }
12782 }
12783 if identity.rotations.len() != identity.previous.len() {
12784 return Err(invalid_feed(
12785 "identity history is missing an old-key-signed rotation statement",
12786 ));
12787 }
12788
12789 let mut chain: Vec<(&str, &str)> = identity
12793 .previous
12794 .iter()
12795 .rev()
12796 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
12797 .collect();
12798 chain.push((&identity.fingerprint, &identity.public_key_spki));
12799
12800 for (index, raw) in identity.rotations.iter().enumerate() {
12801 let statement: RotationStatement = serde_json::from_str(raw)
12802 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
12803 let (old_fingerprint, old_spki) = chain[index];
12804 let (new_fingerprint, new_spki) = chain[index + 1];
12805 if statement.v != 1
12806 || statement.op != "rotate"
12807 || statement.brain != format!("ed25519:{old_fingerprint}")
12808 || statement.public_key != old_spki
12809 || statement.new_brain != format!("ed25519:{new_fingerprint}")
12810 || statement.new_public_key != new_spki
12811 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
12812 || (statement.prior_head_seq > 0
12813 && statement
12814 .prior_feed_hash
12815 .as_deref()
12816 .is_none_or(|hash| !is_sha256(hash)))
12817 {
12818 return Err(invalid_feed(
12819 "rotation statement does not connect adjacent identities",
12820 ));
12821 }
12822 let unsigned = serde_json::to_string(&UnsignedRotation {
12823 v: statement.v,
12824 op: &statement.op,
12825 brain: &statement.brain,
12826 public_key: &statement.public_key,
12827 new_brain: &statement.new_brain,
12828 new_public_key: &statement.new_public_key,
12829 prior_head_seq: statement.prior_head_seq,
12830 prior_feed_hash: statement.prior_feed_hash.as_deref(),
12831 ts: statement.ts.clone(),
12832 })
12833 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
12834 let exact = format!(
12835 "{},\"sig\":\"{}\"}}",
12836 &unsigned[..unsigned.len() - 1],
12837 statement.sig
12838 );
12839 if exact != *raw {
12840 return Err(invalid_feed(
12841 "rotation statement is not in normative serialization",
12842 ));
12843 }
12844 let der = URL_SAFE_NO_PAD
12845 .decode(old_spki)
12846 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
12847 let signature = URL_SAFE_NO_PAD
12848 .decode(&statement.sig)
12849 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
12850 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
12851 .verify(unsigned.as_bytes(), &signature)
12852 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
12853 if index > 0 {
12854 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
12855 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
12856 if statement.prior_head_seq < prior.prior_head_seq {
12857 return Err(invalid_feed("rotation feed boundaries move backward"));
12858 }
12859 }
12860 }
12861
12862 let anchor = format!("ed25519:{}", chain[0].0);
12863 let current = format!("ed25519:{}", identity.fingerprint);
12864 if let Some(pin) = pinned {
12865 if pin.anchor != anchor {
12866 return Err(invalid_feed(
12867 "served identity chain does not descend from the pinned anchor",
12868 ));
12869 }
12870 if !chain
12871 .iter()
12872 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12873 {
12874 return Err(invalid_feed(
12875 "served identity chain forked away from the last pinned identity",
12876 ));
12877 }
12878 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
12879 return Err(invalid_feed("served identity discarded its rotation chain"));
12880 }
12881 if pin.v >= 2
12882 && (identity.rotations.len() < pin.rotations.len()
12883 || identity.rotations[..pin.rotations.len()] != pin.rotations)
12884 {
12885 return Err(invalid_feed(
12886 "served identity rewrote the locally accepted rotation history",
12887 ));
12888 }
12889 }
12890 Ok(anchor)
12891}
12892
12893fn verify_rotation_feed_boundaries(
12894 identity: &FeedIdentity,
12895 pinned: Option<&TrustState>,
12896 observed: &[FeedItem],
12897 advertised_seq: u64,
12898) -> LinkResult<()> {
12899 let mut chain: Vec<String> = identity
12900 .previous
12901 .iter()
12902 .rev()
12903 .map(|previous| format!("ed25519:{}", previous.fingerprint))
12904 .collect();
12905 chain.push(format!("ed25519:{}", identity.fingerprint));
12906 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
12907
12908 for (index, raw) in identity.rotations.iter().enumerate() {
12909 let rotation: RotationStatement = serde_json::from_str(raw)
12910 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12911 if rotation.prior_head_seq > advertised_seq {
12912 return Err(invalid_feed(
12913 "rotation claims a feed boundary beyond the advertised head",
12914 ));
12915 }
12916 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
12917 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
12918 return Err(invalid_feed(
12919 "newly disclosed rotation predates the local feed checkpoint",
12920 ));
12921 }
12922 }
12923 let actual = if rotation.prior_head_seq == 0 {
12924 None
12925 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
12926 pinned.and_then(|pin| pin.feed_hash.as_deref())
12927 } else {
12928 observed
12929 .iter()
12930 .find(|item| item.entry.seq == rotation.prior_head_seq)
12931 .map(|item| item.hash.as_str())
12932 };
12933 if let Some(actual) = actual {
12934 if rotation.prior_feed_hash.as_deref() != Some(actual) {
12935 return Err(invalid_feed(
12936 "rotation statement does not commit the verified feed boundary",
12937 ));
12938 }
12939 } else if rotation.prior_head_seq == 0 {
12940 } else if pinned.is_some_and(|pin| {
12943 pinned_index.is_some_and(|pin_index| index >= pin_index)
12944 || rotation.prior_head_seq >= pin.head_seq
12945 }) {
12946 return Err(invalid_feed(
12947 "rotation feed boundary was not present in the verified chain",
12948 ));
12949 }
12950 }
12951 Ok(())
12952}
12953
12954fn reject_retired_signer_after_checkpoint(
12959 identity: &FeedIdentity,
12960 pinned: Option<&TrustState>,
12961 item: &FeedItem,
12962) -> LinkResult<()> {
12963 let Some(pin) = pinned else {
12964 return Ok(());
12965 };
12966 if item.entry.seq <= pin.head_seq {
12967 return Ok(());
12968 }
12969 let mut chain: Vec<String> = identity
12970 .previous
12971 .iter()
12972 .rev()
12973 .map(|previous| format!("ed25519:{}", previous.fingerprint))
12974 .collect();
12975 chain.push(format!("ed25519:{}", identity.fingerprint));
12976 let pinned_index = chain
12977 .iter()
12978 .position(|key| key == &pin.current)
12979 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
12980 let signer_index = chain
12981 .iter()
12982 .position(|key| key == &item.entry.brain)
12983 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
12984 if signer_index < pinned_index {
12985 return Err(invalid_feed(
12986 "a retired identity attempted to sign after the local checkpoint",
12987 ));
12988 }
12989 Ok(())
12990}
12991
12992fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
12993 let origin = normalized_origin(&cfg.hub)?;
12994 let key = format!(
12995 "{:x}",
12996 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
12997 );
12998 Ok(format!("{key}.json"))
12999}
13000
13001fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
13002 let origin = normalized_origin(&cfg.hub)?;
13003 let key = format!(
13004 "{:x}",
13005 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
13006 );
13007 Ok(format!("alias-{key}.json"))
13008}
13009
13010#[cfg(any(unix, windows))]
13011struct TrustLock {
13012 _file: std::fs::File,
13013}
13014
13015#[cfg(unix)]
13016fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13017 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13018
13019 let lock_string = format!(".{state_name}.lock");
13020 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
13021 let fd = unsafe {
13022 libc::openat(
13023 directory.as_raw_fd(),
13024 lock_name.as_ptr(),
13025 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13026 0o600,
13027 )
13028 };
13029 if fd < 0 {
13030 return Err(std::io::Error::last_os_error().into());
13031 }
13032 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13033 if !file.metadata()?.is_file() {
13034 return Err(LinkError::UnsafePath { path: lock_string });
13035 }
13036 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
13037 return Err(std::io::Error::last_os_error().into());
13038 }
13039 Ok(TrustLock { _file: file })
13040}
13041
13042#[cfg(windows)]
13043fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13044 let lock_name = format!(".{state_name}.lock");
13045 let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
13046 Ok(TrustLock { _file: file })
13047}
13048
13049#[cfg(any(unix, windows))]
13050fn lock_trust_many(
13051 cfg: &HubConfig,
13052 directory: &std::fs::File,
13053 refs: &[&str],
13054) -> LinkResult<Vec<TrustLock>> {
13055 let mut names = refs
13056 .iter()
13057 .map(|reference| trust_file_name(cfg, reference))
13058 .collect::<LinkResult<Vec<_>>>()?;
13059 names.sort();
13060 names.dedup();
13061 names
13062 .iter()
13063 .map(|name| lock_trust_name(directory, name))
13064 .collect()
13065}
13066
13067#[cfg(not(any(unix, windows)))]
13068fn lock_trust_many(
13069 _cfg: &HubConfig,
13070 _directory: &TrustDirectory,
13071 _refs: &[&str],
13072) -> LinkResult<Vec<()>> {
13073 Err(LinkError::UnsupportedPlatform {
13074 operation: "verified link.md state",
13075 })
13076}
13077
13078#[cfg(any(unix, windows))]
13079type TrustDirectory = std::fs::File;
13080
13081#[cfg(not(any(unix, windows)))]
13082struct TrustDirectory;
13083
13084#[cfg(unix)]
13085fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13086 use std::os::fd::AsRawFd as _;
13087
13088 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
13089 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
13090 return Err(std::io::Error::last_os_error().into());
13091 }
13092 directory.sync_all()?;
13093 Ok(directory)
13094}
13095
13096#[cfg(windows)]
13097fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13098 let marker = cfg.state_dir.join("trust").join(".directory");
13099 crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
13100 Ok(crate::fsx::open_directory_nofollow(
13101 marker.parent().expect("trust marker has a parent"),
13102 )?)
13103}
13104
13105#[cfg(not(any(unix, windows)))]
13106fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13107 Err(LinkError::UnsupportedPlatform {
13108 operation: "verified link.md state",
13109 })
13110}
13111
13112#[cfg(unix)]
13113fn load_trust_in(
13114 cfg: &HubConfig,
13115 directory: &TrustDirectory,
13116 requested: &str,
13117) -> LinkResult<Option<TrustState>> {
13118 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13119
13120 let name_string = trust_file_name(cfg, requested)?;
13121 let name = c_name(name_string.as_bytes(), &name_string)?;
13122 let fd = unsafe {
13123 libc::openat(
13124 directory.as_raw_fd(),
13125 name.as_ptr(),
13126 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13127 )
13128 };
13129 if fd < 0 {
13130 let error = std::io::Error::last_os_error();
13131 if error.kind() == std::io::ErrorKind::NotFound {
13132 return Ok(None);
13133 }
13134 return Err(LinkError::UnsafePath { path: name_string });
13135 }
13136 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13137 if !file.metadata()?.is_file() {
13138 return Err(LinkError::UnsafePath { path: name_string });
13139 }
13140 let mut bytes = Vec::new();
13141 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
13142 if bytes.len() > 1024 * 1024 {
13143 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
13144 }
13145 let mut state: TrustState = serde_json::from_slice(&bytes)
13146 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13147 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13148 return Err(invalid_feed(
13149 "local identity/feed checkpoint does not match this hub and brain",
13150 ));
13151 }
13152 if state.v == 1 {
13153 if state.brain != requested {
13157 return Err(invalid_feed(
13158 "legacy checkpoint is not bound to the requested brain id",
13159 ));
13160 }
13161 state.requested = requested.to_string();
13162 } else if state.requested != requested {
13163 return Err(invalid_feed(
13164 "local identity/feed checkpoint is bound to a different requested ref",
13165 ));
13166 }
13167 Ok(Some(state))
13168}
13169
13170#[cfg(windows)]
13171fn load_trust_in(
13172 cfg: &HubConfig,
13173 directory: &TrustDirectory,
13174 requested: &str,
13175) -> LinkResult<Option<TrustState>> {
13176 let name = trust_file_name(cfg, requested)?;
13177 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13178 let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
13179 Ok(bytes) => bytes,
13180 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13181 Err(_) => return Err(LinkError::UnsafePath { path: name }),
13182 };
13183 let mut state: TrustState = serde_json::from_slice(&bytes)
13184 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13185 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13186 return Err(invalid_feed(
13187 "local identity/feed checkpoint does not match this hub and brain",
13188 ));
13189 }
13190 if state.v == 1 {
13191 if state.brain != requested {
13192 return Err(invalid_feed(
13193 "legacy checkpoint is not bound to the requested brain id",
13194 ));
13195 }
13196 state.requested = requested.to_string();
13197 } else if state.requested != requested {
13198 return Err(invalid_feed(
13199 "local identity/feed checkpoint is bound to a different requested ref",
13200 ));
13201 }
13202 Ok(Some(state))
13203}
13204
13205#[cfg(not(any(unix, windows)))]
13206fn load_trust_in(
13207 _cfg: &HubConfig,
13208 _directory: &TrustDirectory,
13209 _brain: &str,
13210) -> LinkResult<Option<TrustState>> {
13211 Err(LinkError::UnsupportedPlatform {
13212 operation: "verified link.md state",
13213 })
13214}
13215
13216#[cfg(all(test, any(unix, windows)))]
13217fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
13218 let directory = open_trust_dir(cfg)?;
13219 load_trust_in(cfg, &directory, requested)
13220}
13221
13222#[cfg(unix)]
13223fn save_trust_in(
13224 cfg: &HubConfig,
13225 directory: &TrustDirectory,
13226 state: &TrustState,
13227) -> LinkResult<()> {
13228 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13229
13230 let name_string = trust_file_name(cfg, &state.requested)?;
13231 let name = c_name(name_string.as_bytes(), &name_string)?;
13232 let mut bytes = serde_json::to_vec(state)
13233 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13234 bytes.push(b'\n');
13235
13236 let nonce = std::time::SystemTime::now()
13237 .duration_since(std::time::UNIX_EPOCH)
13238 .unwrap_or_default()
13239 .as_nanos();
13240 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13241 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13242 let fd = unsafe {
13243 libc::openat(
13244 directory.as_raw_fd(),
13245 temp.as_ptr(),
13246 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13247 0o600,
13248 )
13249 };
13250 if fd < 0 {
13251 return Err(std::io::Error::last_os_error().into());
13252 }
13253 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13254 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13255 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13256 return Err(error.into());
13257 }
13258 drop(file);
13259 if unsafe {
13260 libc::renameat(
13261 directory.as_raw_fd(),
13262 temp.as_ptr(),
13263 directory.as_raw_fd(),
13264 name.as_ptr(),
13265 )
13266 } != 0
13267 {
13268 let error = std::io::Error::last_os_error();
13269 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13270 return Err(error.into());
13271 }
13272 directory.sync_all()?;
13273 Ok(())
13274}
13275
13276#[cfg(windows)]
13277fn save_trust_in(
13278 cfg: &HubConfig,
13279 directory: &TrustDirectory,
13280 state: &TrustState,
13281) -> LinkResult<()> {
13282 let name = trust_file_name(cfg, &state.requested)?;
13283 let mut bytes = serde_json::to_vec(state)
13284 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13285 bytes.push(b'\n');
13286 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13287 Ok(())
13288}
13289
13290#[cfg(not(any(unix, windows)))]
13291fn save_trust_in(
13292 _cfg: &HubConfig,
13293 _directory: &TrustDirectory,
13294 _state: &TrustState,
13295) -> LinkResult<()> {
13296 Err(LinkError::UnsupportedPlatform {
13297 operation: "verified link.md state",
13298 })
13299}
13300
13301#[cfg(unix)]
13302fn load_alias_in(
13303 cfg: &HubConfig,
13304 directory: &TrustDirectory,
13305 requested: &str,
13306) -> LinkResult<Option<AliasBinding>> {
13307 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13308
13309 let name_string = alias_file_name(cfg, requested)?;
13310 let name = c_name(name_string.as_bytes(), &name_string)?;
13311 let fd = unsafe {
13312 libc::openat(
13313 directory.as_raw_fd(),
13314 name.as_ptr(),
13315 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13316 )
13317 };
13318 if fd < 0 {
13319 let error = std::io::Error::last_os_error();
13320 if error.kind() == std::io::ErrorKind::NotFound {
13321 return Ok(None);
13322 }
13323 return Err(LinkError::UnsafePath { path: name_string });
13324 }
13325 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13326 if !file.metadata()?.is_file() {
13327 return Err(LinkError::UnsafePath { path: name_string });
13328 }
13329 let mut bytes = Vec::new();
13330 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
13331 if bytes.len() > 64 * 1024 {
13332 return Err(invalid_feed("local alias binding is oversized"));
13333 }
13334 let alias: AliasBinding = serde_json::from_slice(&bytes)
13335 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13336 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13337 {
13338 return Err(invalid_feed(
13339 "local alias binding does not match this hub and requested ref",
13340 ));
13341 }
13342 Ok(Some(alias))
13343}
13344
13345#[cfg(windows)]
13346fn load_alias_in(
13347 cfg: &HubConfig,
13348 directory: &TrustDirectory,
13349 requested: &str,
13350) -> LinkResult<Option<AliasBinding>> {
13351 let name = alias_file_name(cfg, requested)?;
13352 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13353 let bytes = match reader.read(Path::new(&name), 64 * 1024) {
13354 Ok(bytes) => bytes,
13355 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13356 Err(_) => return Err(LinkError::UnsafePath { path: name }),
13357 };
13358 let alias: AliasBinding = serde_json::from_slice(&bytes)
13359 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13360 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13361 {
13362 return Err(invalid_feed(
13363 "local alias binding does not match this hub and requested ref",
13364 ));
13365 }
13366 Ok(Some(alias))
13367}
13368
13369#[cfg(not(any(unix, windows)))]
13370fn load_alias_in(
13371 _cfg: &HubConfig,
13372 _directory: &TrustDirectory,
13373 _requested: &str,
13374) -> LinkResult<Option<AliasBinding>> {
13375 Err(LinkError::UnsupportedPlatform {
13376 operation: "verified link.md state",
13377 })
13378}
13379
13380#[cfg(unix)]
13381fn save_alias_in(
13382 cfg: &HubConfig,
13383 directory: &TrustDirectory,
13384 alias: &AliasBinding,
13385) -> LinkResult<()> {
13386 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13387
13388 let name_string = alias_file_name(cfg, &alias.requested)?;
13389 let name = c_name(name_string.as_bytes(), &name_string)?;
13390 let mut bytes = serde_json::to_vec(alias)
13391 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13392 bytes.push(b'\n');
13393 let nonce = std::time::SystemTime::now()
13394 .duration_since(std::time::UNIX_EPOCH)
13395 .unwrap_or_default()
13396 .as_nanos();
13397 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13398 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13399 let fd = unsafe {
13400 libc::openat(
13401 directory.as_raw_fd(),
13402 temp.as_ptr(),
13403 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13404 0o600,
13405 )
13406 };
13407 if fd < 0 {
13408 return Err(std::io::Error::last_os_error().into());
13409 }
13410 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13411 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13412 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13413 return Err(error.into());
13414 }
13415 drop(file);
13416 if unsafe {
13417 libc::renameat(
13418 directory.as_raw_fd(),
13419 temp.as_ptr(),
13420 directory.as_raw_fd(),
13421 name.as_ptr(),
13422 )
13423 } != 0
13424 {
13425 let error = std::io::Error::last_os_error();
13426 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13427 return Err(error.into());
13428 }
13429 directory.sync_all()?;
13430 Ok(())
13431}
13432
13433#[cfg(windows)]
13434fn save_alias_in(
13435 cfg: &HubConfig,
13436 directory: &TrustDirectory,
13437 alias: &AliasBinding,
13438) -> LinkResult<()> {
13439 let name = alias_file_name(cfg, &alias.requested)?;
13440 let mut bytes = serde_json::to_vec(alias)
13441 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13442 bytes.push(b'\n');
13443 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13444 Ok(())
13445}
13446
13447#[cfg(not(any(unix, windows)))]
13448fn save_alias_in(
13449 _cfg: &HubConfig,
13450 _directory: &TrustDirectory,
13451 _alias: &AliasBinding,
13452) -> LinkResult<()> {
13453 Err(LinkError::UnsupportedPlatform {
13454 operation: "verified link.md state",
13455 })
13456}
13457
13458fn load_canonical_pin(
13463 cfg: &HubConfig,
13464 directory: &TrustDirectory,
13465 requested: &str,
13466 resolved_brain: &str,
13467) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
13468 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
13469 if requested == resolved_brain {
13470 return Ok((canonical, None));
13471 }
13472
13473 let mut alias = load_alias_in(cfg, directory, requested)?;
13474 if let Some(binding) = &alias {
13475 if binding.brain != resolved_brain {
13476 return Err(LinkError::AliasRebindRequired {
13477 alias: requested.to_string(),
13478 from: binding.brain.clone(),
13479 to: resolved_brain.to_string(),
13480 });
13481 }
13482 return Ok((canonical, alias));
13483 }
13484
13485 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
13489 if legacy.brain != resolved_brain {
13490 return Err(invalid_feed(
13491 "legacy alias checkpoint names a different canonical brain",
13492 ));
13493 }
13494 if let Some(existing) = &canonical {
13495 if existing.brain != legacy.brain
13496 || existing.anchor != legacy.anchor
13497 || existing.current != legacy.current
13498 || existing.head_seq != legacy.head_seq
13499 || existing.feed_hash != legacy.feed_hash
13500 || existing.rotations != legacy.rotations
13501 {
13502 return Err(invalid_feed(
13503 "legacy alias checkpoint conflicts with the canonical checkpoint",
13504 ));
13505 }
13506 } else {
13507 let mut promoted = legacy.clone();
13508 promoted.requested = resolved_brain.to_string();
13509 promoted.home = None;
13510 save_trust_in(cfg, directory, &promoted)?;
13511 canonical = Some(promoted);
13512 }
13513 alias = Some(AliasBinding {
13514 v: 1,
13515 origin: normalized_origin(&cfg.hub)?,
13516 requested: requested.to_string(),
13517 brain: resolved_brain.to_string(),
13518 home: legacy.home,
13519 });
13520 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
13521 }
13522 Ok((canonical, alias))
13523}
13524
13525pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
13530 require_hardened_filesystem("verified alias rebind")?;
13531 require_safe_ref(alias)?;
13532 require_safe_ref(from)?;
13533 require_safe_ref(to)?;
13534 if crate::ulid::is_ulid(alias)
13535 || !crate::ulid::is_ulid(from)
13536 || !crate::ulid::is_ulid(to)
13537 || from == to
13538 {
13539 return Err(LinkError::InvalidPack {
13540 message:
13541 "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
13542 .to_string(),
13543 });
13544 }
13545
13546 let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
13547 message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
13548 })?;
13549 accept_v2_head(cfg, &verified)?;
13550
13551 let alias_response = ensure_ok(
13552 request(
13553 cfg,
13554 "GET",
13555 &format!("/api/hub/brains/{alias}/v2/head"),
13556 None,
13557 Auth::Required,
13558 )?,
13559 "resolve alias for explicit rebind",
13560 )?;
13561 let resolved: V2HeadResponse = serde_json::from_value(alias_response)
13562 .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
13563 if resolved.v != 2 || resolved.brain_id != to {
13564 return Err(LinkError::RemoteAdvancedDuringSync);
13565 }
13566
13567 let directory = open_trust_dir(cfg)?;
13568 let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
13569 let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
13570 message: "the requested alias has no existing local binding to replace".to_string(),
13571 })?;
13572 if binding.brain != from {
13573 return Err(LinkError::AliasRebindRequired {
13574 alias: alias.to_string(),
13575 from: binding.brain,
13576 to: to.to_string(),
13577 });
13578 }
13579 save_alias_in(
13580 cfg,
13581 &directory,
13582 &AliasBinding {
13583 v: 1,
13584 origin: normalized_origin(&cfg.hub)?,
13585 requested: alias.to_string(),
13586 brain: to.to_string(),
13587 home: binding.home,
13588 },
13589 )?;
13590 Ok(json!({
13591 "v": 2,
13592 "alias": alias,
13593 "from": from,
13594 "to": to,
13595 "outcome": "alias_rebound",
13596 }))
13597}
13598
13599fn save_canonical_pin_and_alias(
13600 cfg: &HubConfig,
13601 directory: &TrustDirectory,
13602 requested: &str,
13603 resolved_brain: &str,
13604 mut state: TrustState,
13605 existing_alias: Option<&AliasBinding>,
13606) -> LinkResult<()> {
13607 state.requested = resolved_brain.to_string();
13608 state.brain = resolved_brain.to_string();
13609 state.home = None;
13610 save_trust_in(cfg, directory, &state)?;
13611 if requested != resolved_brain {
13612 save_alias_in(
13613 cfg,
13614 directory,
13615 &AliasBinding {
13616 v: 1,
13617 origin: normalized_origin(&cfg.hub)?,
13618 requested: requested.to_string(),
13619 brain: resolved_brain.to_string(),
13620 home: existing_alias.and_then(|alias| alias.home.clone()),
13621 },
13622 )?;
13623 }
13624 Ok(())
13625}
13626
13627fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
13628 const ED25519_SPKI_PREFIX: &[u8] = &[
13629 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13630 ];
13631 let entry = &item.entry;
13632 let public_der = URL_SAFE_NO_PAD
13633 .decode(&entry.public_key)
13634 .map_err(|_| invalid_feed("public key is not base64url"))?;
13635 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
13636 || !public_der.starts_with(ED25519_SPKI_PREFIX)
13637 {
13638 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
13639 }
13640 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
13641 if entry.brain != format!("ed25519:{fingerprint}") {
13642 return Err(invalid_feed(
13643 "brain fingerprint does not match its public key",
13644 ));
13645 }
13646 let _ = verify_identity_chain(identity, None)?;
13648 let mut chain: Vec<(&str, &str)> = identity
13649 .previous
13650 .iter()
13651 .rev()
13652 .map(|previous| {
13653 (
13654 previous.fingerprint.as_str(),
13655 previous.public_key_spki.as_str(),
13656 )
13657 })
13658 .collect();
13659 chain.push((&identity.fingerprint, &identity.public_key_spki));
13660 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
13661 *known_fingerprint == fingerprint && *spki == entry.public_key
13662 });
13663 let Some(signer_index) = signer_index else {
13664 return Err(invalid_feed(
13665 "entry signer is not this brain's identity (current or rotated-from)",
13666 ));
13667 };
13668 let lower_boundary = if signer_index == 0 {
13669 None
13670 } else {
13671 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
13672 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13673 Some(prior.prior_head_seq)
13674 };
13675 let upper_boundary = if signer_index == identity.rotations.len() {
13676 None
13677 } else {
13678 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
13679 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13680 Some(next.prior_head_seq)
13681 };
13682 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
13683 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
13684 {
13685 return Err(invalid_feed(
13686 "entry signer is outside its authenticated rotation epoch",
13687 ));
13688 }
13689 let unsigned = UnsignedFeedEntry {
13690 v: entry.v,
13691 seq: entry.seq,
13692 ts: &entry.ts,
13693 brain: &entry.brain,
13694 public_key: &entry.public_key,
13695 kind: &entry.kind,
13696 op: &entry.op,
13697 pack_sha256: &entry.pack_sha256,
13698 files: &entry.files,
13699 removed: &entry.removed,
13700 prev_entry_hash: &entry.prev_entry_hash,
13701 };
13702 let message =
13703 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
13704 let signature = URL_SAFE_NO_PAD
13705 .decode(&entry.sig)
13706 .map_err(|_| invalid_feed("signature is not base64url"))?;
13707 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
13708 .verify(&message, &signature)
13709 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
13710
13711 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
13712 exact.push(b'\n');
13713 let actual_hash = format!("{:x}", Sha256::digest(&exact));
13714 if actual_hash != item.hash {
13715 return Err(invalid_feed("entry SHA-256 does not match"));
13716 }
13717 Ok(())
13718}
13719
13720#[derive(Serialize)]
13726struct UnsignedRotation<'a> {
13727 v: u8,
13728 op: &'a str,
13729 brain: &'a str,
13730 public_key: &'a str,
13731 new_brain: &'a str,
13732 new_public_key: &'a str,
13733 prior_head_seq: u64,
13734 prior_feed_hash: Option<&'a str>,
13735 ts: String,
13736}
13737
13738#[derive(Debug, Deserialize, Serialize)]
13743#[serde(deny_unknown_fields)]
13744struct RotationJournal {
13745 v: u8,
13746 origin: String,
13747 brain: String,
13748 old_brain: String,
13749 new_brain: String,
13750 prior_head_seq: u64,
13751 prior_feed_hash: Option<String>,
13752 statement: String,
13753}
13754
13755fn rotation_journal_path(key_path: &Path) -> PathBuf {
13756 let mut path = key_path.as_os_str().to_os_string();
13757 path.push(".rotation.json");
13758 PathBuf::from(path)
13759}
13760
13761fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
13762 #[cfg(unix)]
13763 let file = {
13764 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13765 use std::os::unix::ffi::OsStrExt as _;
13766 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13767 .map_err(|error| {
13768 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
13769 })?;
13770 let leaf_name = path
13771 .file_name()
13772 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
13773 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
13774 let fd = unsafe {
13775 libc::openat(
13776 parent.as_raw_fd(),
13777 leaf.as_ptr(),
13778 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13779 )
13780 };
13781 if fd < 0 {
13782 return Err(bad_agent_key(
13783 "the rotation journal must be an existing regular file without symlink ancestors",
13784 ));
13785 }
13786 unsafe { std::fs::File::from_raw_fd(fd) }
13787 };
13788 #[cfg(not(unix))]
13789 let file = std::fs::File::open(path)
13790 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
13791 let metadata = file
13792 .metadata()
13793 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
13794 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
13795 return Err(bad_agent_key(
13796 "the rotation journal must be a bounded regular file",
13797 ));
13798 }
13799 #[cfg(unix)]
13800 {
13801 use std::os::unix::fs::PermissionsExt as _;
13802 if metadata.permissions().mode() & 0o077 != 0 {
13803 return Err(bad_agent_key(
13804 "the rotation journal is accessible to group/other; set mode 0600",
13805 ));
13806 }
13807 }
13808 serde_json::from_reader(file)
13809 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
13810}
13811
13812fn remove_rotation_journal(path: &Path) {
13813 #[cfg(unix)]
13814 {
13815 use std::os::fd::AsRawFd as _;
13816 use std::os::unix::ffi::OsStrExt as _;
13817 let Ok(parent) =
13818 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13819 else {
13820 return;
13821 };
13822 let Some(leaf_name) = path.file_name() else {
13823 return;
13824 };
13825 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
13826 return;
13827 };
13828 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
13829 let _ = parent.sync_all();
13830 }
13831 }
13832 #[cfg(not(unix))]
13833 {
13834 let _ = std::fs::remove_file(path);
13835 }
13836}
13837
13838fn validate_rotation_journal(
13839 journal: &RotationJournal,
13840 cfg: &HubConfig,
13841 canonical_brain: &str,
13842 old_key: &AgentSigningKey,
13843 new_key: &AgentSigningKey,
13844 head: &Head,
13845) -> LinkResult<()> {
13846 if journal.v != 1
13847 || journal.origin != normalized_origin(&cfg.hub)?
13848 || journal.brain != canonical_brain
13849 || journal.old_brain != old_key.multikey
13850 || journal.new_brain != new_key.multikey
13851 || journal.prior_head_seq != head.seq
13852 || journal.prior_feed_hash != head.feed_hash
13853 {
13854 return Err(invalid_feed(
13855 "rotation journal does not match the verified key and feed boundary",
13856 ));
13857 }
13858 let statement: RotationStatement = serde_json::from_str(&journal.statement)
13859 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
13860 if statement.prior_head_seq != journal.prior_head_seq
13861 || statement.prior_feed_hash != journal.prior_feed_hash
13862 || statement.brain != old_key.multikey
13863 || statement.public_key != old_key.public_key_spki
13864 || statement.new_brain != new_key.multikey
13865 || statement.new_public_key != new_key.public_key_spki
13866 {
13867 return Err(invalid_feed(
13868 "rotation journal statement does not match its durable intent",
13869 ));
13870 }
13871 let identity = FeedIdentity {
13872 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13873 public_key_spki: new_key.public_key_spki.clone(),
13874 previous: vec![PreviousIdentity {
13875 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
13876 public_key_spki: old_key.public_key_spki.clone(),
13877 }],
13878 rotations: vec![journal.statement.clone()],
13879 };
13880 verify_identity_chain(&identity, None)?;
13881 Ok(())
13882}
13883
13884#[derive(Debug, Serialize)]
13886pub struct RotationReport {
13887 pub brain: String,
13889 pub multikey: String,
13891 #[serde(rename = "keyFile")]
13893 pub key_file: String,
13894 pub previous: Vec<String>,
13896}
13897
13898pub fn rotate_brain_key(
13904 cfg: &HubConfig,
13905 brain: &str,
13906 old_key: &AgentSigningKey,
13907 out: &Path,
13908) -> LinkResult<RotationReport> {
13909 require_hardened_filesystem("key rotation")?;
13910 require_safe_ref(brain)?;
13911 let new_key = if out.exists() {
13915 load_signing_key(out)?
13916 } else {
13917 let rng = ring::rand::SystemRandom::new();
13918 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
13919 .map_err(|_| bad_agent_key("key generation failed"))?;
13920 let pair = agent_keypair(pkcs8.as_ref())?;
13921 let (public_key_spki, multikey) = public_identity_for(&pair);
13922 write_secret_new(
13923 out,
13924 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
13925 )?;
13926 AgentSigningKey {
13927 pkcs8: pkcs8.as_ref().to_vec(),
13928 multikey,
13929 public_key_spki,
13930 }
13931 };
13932 let new_spki = new_key.public_key_spki.clone();
13933 let new_multikey = new_key.multikey.clone();
13934 let journal_path = rotation_journal_path(out);
13935 let before_v2 = v2_verified_head(cfg, brain)?;
13936 let (canonical_brain, served_identity, observed_head, v2_profile) =
13937 if let Some(head) = before_v2 {
13938 let observed = Head {
13939 brain: head.brain_id.clone(),
13940 seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13941 updated_at: head
13942 .pointer
13943 .as_ref()
13944 .map(|pointer| pointer.signed_at.clone()),
13945 feed_hash: head
13946 .pointer
13947 .as_ref()
13948 .map(|pointer| pointer.feed_hash.clone()),
13949 verified: true,
13950 };
13951 let identity = v2_identity(&head.identity);
13952 let canonical = head.brain_id.clone();
13953 accept_v2_head(cfg, &head)?;
13954 (canonical, identity, observed, true)
13955 } else {
13956 let remote = verified_remote_head(cfg, brain, false)?;
13957 let identity = remote
13958 .identity
13959 .clone()
13960 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
13961 (remote.head.brain.clone(), identity, remote.head, false)
13962 };
13963 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
13964 let already_rotated = served_multikey == new_multikey;
13965 if already_rotated && !journal_path.exists() {
13970 remove_rotation_journal(&journal_path);
13971 return Ok(RotationReport {
13972 brain: brain.to_string(),
13973 multikey: new_multikey,
13974 key_file: out.display().to_string(),
13975 previous: served_identity
13976 .previous
13977 .iter()
13978 .map(|identity| format!("ed25519:{}", identity.fingerprint))
13979 .collect(),
13980 });
13981 }
13982 if !already_rotated && served_multikey != old_key.multikey {
13983 return Err(invalid_feed(
13984 "the supplied old key is not the brain's verified current identity",
13985 ));
13986 }
13987
13988 let journal = if journal_path.exists() {
13989 read_rotation_journal(&journal_path)?
13990 } else {
13991 let ts = crate::now()
13992 .with_timezone(&chrono::Utc)
13993 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
13994 .to_string();
13995 let unsigned = serde_json::to_string(&UnsignedRotation {
13996 v: 1,
13997 op: "rotate",
13998 brain: &old_key.multikey,
13999 public_key: &old_key.public_key_spki,
14000 new_brain: &new_multikey,
14001 new_public_key: &new_spki,
14002 prior_head_seq: observed_head.seq,
14003 prior_feed_hash: observed_head.feed_hash.as_deref(),
14004 ts,
14005 })
14006 .expect("serialize rotation");
14007 let old_pair = agent_keypair(&old_key.pkcs8)?;
14008 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14009 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
14010 let journal = RotationJournal {
14011 v: 1,
14012 origin: normalized_origin(&cfg.hub)?,
14013 brain: canonical_brain.clone(),
14014 old_brain: old_key.multikey.clone(),
14015 new_brain: new_multikey.clone(),
14016 prior_head_seq: observed_head.seq,
14017 prior_feed_hash: observed_head.feed_hash.clone(),
14018 statement,
14019 };
14020 let mut exact = serde_json::to_vec(&journal)
14021 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
14022 exact.push(b'\n');
14023 if write_secret_new(&journal_path, &exact).is_err() {
14024 read_rotation_journal(&journal_path)?
14027 } else {
14028 journal
14029 }
14030 };
14031 validate_rotation_journal(
14032 &journal,
14033 cfg,
14034 &canonical_brain,
14035 old_key,
14036 &new_key,
14037 &observed_head,
14038 )?;
14039
14040 let body = json!({ "statement": journal.statement });
14041 let path = format!("/api/hub/brains/{brain}/rotate");
14042 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
14043 let attempted_failure = match attempted {
14044 Ok(response) if (200..300).contains(&response.status) => None,
14045 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
14046 Err(error) => Some(error),
14047 };
14048
14049 let identity = if v2_profile {
14053 match v2_verified_head(cfg, brain) {
14054 Ok(Some(after)) => {
14055 let identity = v2_identity(&after.identity);
14056 accept_v2_head(cfg, &after)?;
14057 identity
14058 }
14059 Ok(None) => {
14060 return Err(attempted_failure.unwrap_or_else(|| {
14061 invalid_feed("rotated v2 brain no longer serves a v2 head")
14062 }));
14063 }
14064 Err(error) => return Err(attempted_failure.unwrap_or(error)),
14065 }
14066 } else {
14067 match verified_remote_head(cfg, brain, false) {
14068 Ok(after) => after
14069 .identity
14070 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
14071 Err(error) => return Err(attempted_failure.unwrap_or(error)),
14072 }
14073 };
14074 if format!("ed25519:{}", identity.fingerprint) != new_multikey
14075 || identity.public_key_spki != new_spki
14076 {
14077 return Err(attempted_failure.unwrap_or_else(|| {
14078 invalid_feed("hub acknowledged rotation without committing the verified new identity")
14079 }));
14080 }
14081 if v2_profile {
14082 if let Some(error) = attempted_failure {
14083 return Err(error);
14088 }
14089 }
14090 let previous = identity
14091 .previous
14092 .iter()
14093 .map(|prior| format!("ed25519:{}", prior.fingerprint))
14094 .collect();
14095 remove_rotation_journal(&journal_path);
14096
14097 Ok(RotationReport {
14098 brain: brain.to_string(),
14099 multikey: new_multikey,
14100 key_file: out.display().to_string(),
14101 previous,
14102 })
14103}
14104
14105#[derive(Debug, Serialize)]
14111pub struct MirrorReport {
14112 pub brain: String,
14114 #[serde(rename = "headSeq")]
14116 pub head_seq: u64,
14117 #[serde(rename = "feedHash")]
14119 pub feed_hash: Option<String>,
14120 pub entries: u64,
14122 pub pinned: String,
14124 pub files: usize,
14126}
14127
14128pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
14130
14131#[derive(Debug)]
14133pub struct VerifiedMirrorMaterial {
14134 pub brain: String,
14135 pub head_seq: u64,
14136 pub feed_hash: Option<String>,
14137 pub identity: serde_json::Value,
14138 pub entries: Vec<(u64, String, String)>,
14140 pub pack_sha256: Option<String>,
14141}
14142
14143#[derive(Deserialize)]
14144#[serde(deny_unknown_fields)]
14145struct StoredMirrorHead {
14146 brain: String,
14147 #[serde(rename = "headSeq")]
14148 head_seq: u64,
14149 #[serde(rename = "feedHash")]
14150 feed_hash: Option<String>,
14151}
14152
14153pub fn verify_mirror_material(
14156 head_bytes: &[u8],
14157 identity_bytes: &[u8],
14158 feed_bytes: &[Vec<u8>],
14159 snapshot_pack: Option<&[u8]>,
14160 expected_anchor: &str,
14161) -> LinkResult<VerifiedMirrorMaterial> {
14162 let snapshot_hash = snapshot_pack
14163 .filter(|pack| !pack.is_empty())
14164 .map(content_sha256);
14165 verify_mirror_material_with_pack_hash(
14166 head_bytes,
14167 identity_bytes,
14168 feed_bytes,
14169 snapshot_hash.as_deref(),
14170 expected_anchor,
14171 )
14172}
14173
14174pub fn verify_mirror_material_with_pack_hash(
14178 head_bytes: &[u8],
14179 identity_bytes: &[u8],
14180 feed_bytes: &[Vec<u8>],
14181 snapshot_pack_sha256: Option<&str>,
14182 expected_anchor: &str,
14183) -> LinkResult<VerifiedMirrorMaterial> {
14184 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
14185 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
14186 require_safe_ref(&head.brain)?;
14187 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
14188 return Err(invalid_feed(
14189 "stored mirror feed count does not match its bounded head sequence",
14190 ));
14191 }
14192 let aggregate = feed_bytes
14193 .iter()
14194 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
14195 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
14196 if aggregate > MAX_FEED_REPLAY_BYTES {
14197 return Err(invalid_feed(
14198 "stored mirror feed metadata exceeds the aggregate limit",
14199 ));
14200 }
14201 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
14202 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
14203 let anchor = verify_identity_chain(&identity, None)?;
14204 if anchor != expected_anchor {
14205 return Err(invalid_feed(
14206 "stored mirror identity does not descend from the explicitly trusted anchor",
14207 ));
14208 }
14209
14210 let mut entries = Vec::with_capacity(feed_bytes.len());
14211 let mut items = Vec::with_capacity(feed_bytes.len());
14212 let mut previous_hash = None;
14213 let mut pack_sha256 = None;
14214 for (index, bytes) in feed_bytes.iter().enumerate() {
14215 let exact = bytes
14216 .strip_suffix(b"\n")
14217 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
14218 if exact.ends_with(b"\n") {
14219 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
14220 }
14221 let entry: FeedEntry = serde_json::from_slice(exact)
14222 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
14223 let expected_seq = index as u64 + 1;
14224 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
14225 return Err(invalid_feed(
14226 "stored mirror feed is not contiguous and hash-chained",
14227 ));
14228 }
14229 let canonical = serde_json::to_vec(&entry)
14230 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
14231 if canonical != exact {
14232 return Err(invalid_feed(
14233 "stored feed entry is not in normative serialization",
14234 ));
14235 }
14236 let hash = content_sha256(bytes);
14237 let item = FeedItem {
14238 hash: hash.clone(),
14239 entry,
14240 };
14241 verify_feed_item(&item, &identity)?;
14242 previous_hash = Some(hash.clone());
14243 if expected_seq == head.head_seq {
14244 pack_sha256 = Some(item.entry.pack_sha256.clone());
14245 }
14246 entries.push((
14247 expected_seq,
14248 std::str::from_utf8(exact)
14249 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
14250 .to_string(),
14251 hash,
14252 ));
14253 items.push(item);
14254 }
14255 if previous_hash != head.feed_hash {
14256 return Err(invalid_feed(
14257 "stored mirror feed does not converge on its advertised head",
14258 ));
14259 }
14260 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
14261 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
14262 (0, None, None) => {}
14263 (_, Some(actual), Some(expected)) if actual == expected => {}
14264 _ => {
14265 return Err(LinkError::InvalidPack {
14266 message: "stored snapshot pack does not match the signed head digest".to_string(),
14267 });
14268 }
14269 }
14270 let identity_value = serde_json::to_value(&identity)
14271 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
14272 Ok(VerifiedMirrorMaterial {
14273 brain: head.brain,
14274 head_seq: head.head_seq,
14275 feed_hash: head.feed_hash,
14276 identity: identity_value,
14277 entries,
14278 pack_sha256,
14279 })
14280}
14281
14282pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
14285 format!(
14286 "{:x}",
14287 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
14288 )
14289}
14290
14291pub fn content_sha256(bytes: &[u8]) -> String {
14294 format!("{:x}", Sha256::digest(bytes))
14295}
14296
14297pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
14299 let mut digest = Sha256::new();
14300 let mut buffer = [0u8; 64 * 1024];
14301 loop {
14302 let read = reader.read(&mut buffer)?;
14303 if read == 0 {
14304 break;
14305 }
14306 digest.update(&buffer[..read]);
14307 }
14308 Ok(format!("{:x}", digest.finalize()))
14309}
14310
14311#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
14319pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
14320 require_hardened_filesystem("mirror")?;
14321 require_safe_ref(brain)?;
14322 #[cfg(windows)]
14323 {
14324 let _ = (cfg, dest);
14325 return Err(LinkError::UnsupportedPlatform {
14326 operation: "atomic whole-mirror replacement on Windows",
14327 });
14328 }
14329 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
14330 let name = dest
14331 .file_name()
14332 .and_then(|name| name.to_str())
14333 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
14334 .ok_or_else(|| LinkError::UnsafePath {
14335 path: dest.display().to_string(),
14336 })?;
14337 #[cfg(unix)]
14338 let parent_dir = open_or_create_dir_nofollow(parent)?;
14339 #[cfg(unix)]
14340 use std::os::fd::AsRawFd as _;
14341 #[cfg(unix)]
14342 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
14343 #[cfg(unix)]
14344 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
14345 None => false,
14346 Some(true) => true,
14347 Some(false) => {
14348 return Err(LinkError::UnsafePath {
14349 path: dest.display().to_string(),
14350 });
14351 }
14352 };
14353
14354 #[cfg(unix)]
14357 let legacy_backup_name = c_name(
14358 format!(".{name}.dbmd-backup").as_bytes(),
14359 &dest.display().to_string(),
14360 )?;
14361 #[cfg(unix)]
14362 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
14363 return Err(LinkError::UnsafePath {
14364 path: parent
14365 .join(format!(".{name}.dbmd-backup"))
14366 .display()
14367 .to_string(),
14368 });
14369 }
14370
14371 let nonce = std::time::SystemTime::now()
14372 .duration_since(std::time::UNIX_EPOCH)
14373 .unwrap_or_default()
14374 .as_nanos();
14375 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
14376 #[cfg(unix)]
14377 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
14378 #[cfg(unix)]
14379 let stage_dir = create_dir_exclusive_at(
14380 parent_dir.as_raw_fd(),
14381 &stage_name,
14382 &dest.display().to_string(),
14383 )?;
14384
14385 let assembled = (|| -> LinkResult<MirrorReport> {
14386 let remote = verified_remote_head(cfg, brain, true)?;
14387 let brain_id = remote.head.brain.clone();
14388 let identity = remote
14389 .identity
14390 .as_ref()
14391 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
14392 let anchor = remote
14393 .anchor
14394 .clone()
14395 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
14396 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
14397 let snapshot_entries = parse_store_pack(pack.clone())?;
14398 let snapshot_count = snapshot_entries.len();
14399 let mut staged_entries = snapshot_entries;
14400 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
14401 for item in &remote.entries {
14402 let mut exact = serde_json::to_vec(&item.entry)
14403 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
14404 exact.push(b'\n');
14405 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
14406 return Err(invalid_feed(
14407 "serialized mirror entry differs from its verified hash",
14408 ));
14409 }
14410 staged_entries.push((
14411 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
14412 exact,
14413 ));
14414 }
14415 let mut identity_bytes = serde_json::to_vec(identity)
14416 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
14417 identity_bytes.push(b'\n');
14418 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
14419 let mut head_bytes = serde_json::to_vec(&json!({
14420 "brain": brain_id,
14421 "headSeq": remote.head.seq,
14422 "feedHash": remote.head.feed_hash,
14423 }))
14424 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
14425 head_bytes.push(b'\n');
14426 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
14427 staged_entries.push((
14428 CONFIG_REL_PATH.to_string(),
14429 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
14430 ));
14431 #[cfg(unix)]
14432 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
14433
14434 Ok(MirrorReport {
14435 brain: brain_id,
14436 head_seq: remote.head.seq,
14437 feed_hash: remote.head.feed_hash,
14438 entries: remote.entries.len() as u64,
14439 pinned: anchor,
14440 files: snapshot_count,
14441 })
14442 })();
14443
14444 let report = match assembled {
14445 Ok(report) => report,
14446 Err(error) => {
14447 #[cfg(unix)]
14448 let _ = remove_tree_at(
14449 parent_dir.as_raw_fd(),
14450 &stage_name,
14451 &dest.display().to_string(),
14452 );
14453 return Err(error);
14454 }
14455 };
14456
14457 #[cfg(unix)]
14458 if let Err(error) =
14459 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
14460 {
14461 let _ = remove_tree_at(
14462 parent_dir.as_raw_fd(),
14463 &stage_name,
14464 &dest.display().to_string(),
14465 );
14466 return Err(error);
14467 }
14468 #[cfg(unix)]
14471 if dest_exists {
14472 remove_tree_at(
14473 parent_dir.as_raw_fd(),
14474 &stage_name,
14475 &dest.display().to_string(),
14476 )?;
14477 }
14478 #[cfg(unix)]
14479 parent_dir.sync_all()?;
14480 Ok(report)
14481}
14482
14483fn verified_remote_head(
14484 cfg: &HubConfig,
14485 brain: &str,
14486 require_full_chain: bool,
14487) -> LinkResult<VerifiedRemote> {
14488 require_hardened_filesystem("verified link.md state")?;
14489 require_safe_ref(brain)?;
14490 let trust_directory = open_trust_dir(cfg)?;
14494 let path = format!("/api/hub/brains/{brain}");
14495 let body = ensure_ok(
14496 request(cfg, "GET", &path, None, Auth::Required)?,
14497 "subscribe",
14498 )?;
14499 let resolved_brain = body
14500 .get("id")
14501 .and_then(Value::as_str)
14502 .filter(|id| crate::ulid::is_ulid(id))
14503 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
14504 .to_string();
14505 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
14506 return Err(invalid_feed(
14507 "brain card id differs from the explicitly requested brain id",
14508 ));
14509 }
14510 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
14515 let (pinned, alias_binding) =
14516 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
14517 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
14518 let advertised_hash = body
14519 .get("feedHash")
14520 .and_then(Value::as_str)
14521 .map(str::to_string);
14522 let updated_at = body
14523 .get("updatedAt")
14524 .and_then(Value::as_str)
14525 .map(str::to_string);
14526 if let Some(pin) = &pinned {
14527 if seq < pin.head_seq {
14528 return Err(invalid_feed(format!(
14529 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
14530 pin.head_seq
14531 )));
14532 }
14533 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
14534 return Err(invalid_feed(
14535 "feed equivocation: the checkpoint sequence now has a different hash",
14536 ));
14537 }
14538 }
14539 if seq == 0 {
14540 if advertised_hash.is_some() {
14541 return Err(invalid_feed("an empty feed advertised a head hash"));
14542 }
14543 let identity: FeedIdentity = serde_json::from_value(
14544 body.get("identity")
14545 .cloned()
14546 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
14547 )
14548 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
14549 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
14550 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
14555 save_canonical_pin_and_alias(
14556 cfg,
14557 &trust_directory,
14558 brain,
14559 &resolved_brain,
14560 TrustState {
14561 v: 2,
14562 origin: normalized_origin(&cfg.hub)?,
14563 requested: resolved_brain.clone(),
14564 brain: resolved_brain.clone(),
14565 home: None,
14566 anchor: anchor.clone(),
14567 current: format!("ed25519:{}", identity.fingerprint),
14568 head_seq: 0,
14569 feed_hash: None,
14570 rotations: identity.rotations.clone(),
14571 hub_signer: None,
14572 protocol_profile: None,
14573 },
14574 alias_binding.as_ref(),
14575 )?;
14576 return Ok(VerifiedRemote {
14577 head: Head {
14578 brain: resolved_brain,
14579 seq,
14580 updated_at,
14581 feed_hash: None,
14582 verified: true,
14583 },
14584 identity: Some(identity),
14585 head_entry: None,
14586 entries: Vec::new(),
14587 anchor: Some(anchor),
14588 });
14589 }
14590 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
14591 return Err(invalid_feed(
14592 "non-empty feed did not advertise a valid SHA-256 head",
14593 ));
14594 }
14595
14596 let replay_head_only = !require_full_chain
14600 && pinned
14601 .as_ref()
14602 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
14603 let mut after = if replay_head_only {
14604 seq - 1
14605 } else if require_full_chain || pinned.is_none() {
14606 0
14607 } else {
14608 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
14609 };
14610 let mut expected_seq = after + 1;
14611 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
14612 None
14613 } else {
14614 pinned
14615 .as_ref()
14616 .and_then(|checkpoint| checkpoint.feed_hash.clone())
14617 };
14618 let mut identity: Option<FeedIdentity> = None;
14619 let mut anchor: Option<String> = None;
14620 let mut head_entry: Option<FeedItem> = None;
14621 let mut all_entries = Vec::new();
14622 let mut observed_entries = Vec::new();
14623 let replay_count = seq
14624 .checked_sub(after)
14625 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
14626 if replay_count > MAX_FEED_REPLAY_ENTRIES {
14627 return Err(invalid_feed(format!(
14628 "feed replay requires {replay_count} entries, over the client cap"
14629 )));
14630 }
14631 let mut replay_bytes = 0u64;
14632
14633 loop {
14634 let feed_bytes = ensure_raw_ok(
14635 request_raw(
14636 cfg,
14637 "GET",
14638 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
14639 None,
14640 Auth::Required,
14641 MAX_FEED_RESPONSE_BYTES,
14642 )?,
14643 "subscribe feed",
14644 )?;
14645 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
14646 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
14647 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
14648 return Err(invalid_feed("brain card and feed head disagree"));
14649 }
14650 if feed.entries.len() > FEED_PAGE_LIMIT {
14651 return Err(invalid_feed("feed page exceeds the requested entry limit"));
14652 }
14653 if feed.scope_limited {
14654 if require_full_chain {
14655 return Err(invalid_feed(
14656 "path-scoped grants cannot verify a full snapshot chain",
14657 ));
14658 }
14659 return Ok(VerifiedRemote {
14660 head: Head {
14661 brain: resolved_brain,
14662 seq,
14663 updated_at,
14664 feed_hash: advertised_hash,
14665 verified: false,
14666 },
14667 identity: None,
14668 head_entry: None,
14669 entries: Vec::new(),
14670 anchor: None,
14671 });
14672 }
14673 let page_identity = feed
14674 .identity
14675 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14676 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
14677 if identity
14678 .as_ref()
14679 .is_some_and(|existing| existing != &page_identity)
14680 {
14681 return Err(invalid_feed("identity changed while reading the feed"));
14682 }
14683 if anchor
14684 .as_ref()
14685 .is_some_and(|existing| existing != &page_anchor)
14686 {
14687 return Err(invalid_feed(
14688 "identity anchor changed while reading the feed",
14689 ));
14690 }
14691 identity = Some(page_identity.clone());
14692 if anchor.is_none() {
14693 anchor = Some(page_anchor);
14694 }
14695 if feed.entries.is_empty() {
14696 return Err(invalid_feed("feed page was empty before the signed head"));
14697 }
14698
14699 for item in feed.entries {
14700 if item.entry.seq != expected_seq {
14701 return Err(invalid_feed(format!(
14702 "expected entry {expected_seq}, feed served {}",
14703 item.entry.seq
14704 )));
14705 }
14706 if item.entry.seq > seq {
14707 return Err(invalid_feed("feed advanced past the card snapshot"));
14708 }
14709 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
14710 return Err(invalid_feed(format!(
14711 "entry {} does not chain to the local checkpoint",
14712 item.entry.seq
14713 )));
14714 }
14715 verify_feed_item(&item, &page_identity)?;
14716 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
14717 replay_bytes = replay_bytes.saturating_add(
14718 serde_json::to_vec(&item)
14719 .map_err(|_| invalid_feed("could not size feed entry"))?
14720 .len() as u64,
14721 );
14722 if replay_bytes > MAX_FEED_REPLAY_BYTES {
14723 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
14724 }
14725 previous_hash = Some(item.hash.clone());
14726 after = item.entry.seq;
14727 expected_seq = expected_seq
14728 .checked_add(1)
14729 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
14730 if require_full_chain {
14731 all_entries.push(item.clone());
14732 }
14733 observed_entries.push(item.clone());
14734 head_entry = Some(item);
14735 }
14736 if after == seq {
14737 break;
14738 }
14739 }
14740
14741 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
14742 return Err(invalid_feed(
14743 "verified chain does not converge on the advertised head",
14744 ));
14745 }
14746 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14747 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
14748 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
14749 save_canonical_pin_and_alias(
14750 cfg,
14751 &trust_directory,
14752 brain,
14753 &resolved_brain,
14754 TrustState {
14755 v: 2,
14756 origin: normalized_origin(&cfg.hub)?,
14757 requested: resolved_brain.clone(),
14758 brain: resolved_brain.clone(),
14759 home: None,
14760 anchor: anchor.clone(),
14761 current: format!("ed25519:{}", identity.fingerprint),
14762 head_seq: seq,
14763 feed_hash: advertised_hash.clone(),
14764 rotations: identity.rotations.clone(),
14765 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
14766 protocol_profile: pinned
14767 .as_ref()
14768 .and_then(|state| state.protocol_profile.clone()),
14769 },
14770 alias_binding.as_ref(),
14771 )?;
14772 Ok(VerifiedRemote {
14773 head: Head {
14774 brain: resolved_brain,
14775 seq,
14776 updated_at,
14777 feed_hash: advertised_hash,
14778 verified: true,
14779 },
14780 identity: Some(identity),
14781 head_entry,
14782 entries: all_entries,
14783 anchor: Some(anchor),
14784 })
14785}
14786
14787pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
14792 if let Some(verified) = v2_verified_head(cfg, brain)? {
14793 let observation = Head {
14794 brain: verified.brain_id.clone(),
14795 seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14796 updated_at: verified
14797 .pointer
14798 .as_ref()
14799 .map(|pointer| pointer.signed_at.clone()),
14800 feed_hash: verified
14801 .pointer
14802 .as_ref()
14803 .map(|pointer| pointer.feed_hash.clone()),
14804 verified: true,
14805 };
14806 accept_v2_head(cfg, &verified)?;
14807 return Ok(observation);
14808 }
14809 Ok(verified_remote_head(cfg, brain, false)?.head)
14810}
14811
14812#[cfg(test)]
14813mod tests {
14814 use super::*;
14815
14816 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
14817
14818 fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
14819 json!({
14820 "sha256": "a".repeat(64),
14821 "bytes": 10,
14822 "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
14823 })
14824 }
14825
14826 #[test]
14827 fn upload_reservations_batch_by_count_and_by_size() {
14828 let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
14832 let batches = batch_upload_declarations(declarations.clone());
14833
14834 assert!(batches.len() > 1, "5,000 blobs must not ride one request");
14835 for batch in &batches {
14836 assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
14837 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14838 .expect("batch serializes")
14839 .len();
14840 assert!(
14841 bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
14842 "batch body {bytes} exceeds the reservation budget"
14843 );
14844 }
14845 let flattened: Vec<Value> = batches.into_iter().flatten().collect();
14846 assert_eq!(
14847 flattened, declarations,
14848 "batching must preserve the set and order"
14849 );
14850 }
14851
14852 #[test]
14853 fn only_load_shaped_hub_answers_are_worth_asking_again() {
14854 for status in [408, 429, 500, 502, 503, 504] {
14859 assert!(is_retryable_hub_status(status), "{status} is load-shaped");
14860 }
14861 for status in [400, 401, 403, 404, 409, 413, 422] {
14862 assert!(
14863 !is_retryable_hub_status(status),
14864 "{status} states something about the request"
14865 );
14866 }
14867 let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
14869 assert!(total >= 60_000, "backoff totals only {total}ms");
14870 }
14871
14872 #[test]
14873 fn a_batch_shares_a_connection_only_within_one_authority() {
14874 let cfg = HubConfig {
14879 hub: "https://www.sevrahq.com".to_string(),
14880 key: Some("k".to_string()),
14881 agent_key: None,
14882 brain_key: None,
14883 state_dir: PathBuf::from("."),
14884 store_selected: false,
14885 };
14886 assert!(shared_staging_agent(&cfg, &[]).is_none());
14887 assert!(
14888 shared_staging_agent(
14889 &cfg,
14890 &[
14891 "https://one.example.com/a?sig=1",
14892 "https://two.example.com/b?sig=2",
14893 ]
14894 )
14895 .is_none(),
14896 "two authorities must not share a pinned pool"
14897 );
14898 assert!(
14899 shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
14900 "an unsafe object-store URL must not produce an agent"
14901 );
14902 assert!(
14903 shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
14904 "credentials in the URL must not produce an agent"
14905 );
14906 }
14907
14908 #[test]
14909 fn a_staged_change_states_only_operations_and_blobs() {
14910 let operations = vec![json!({
14914 "op": "put",
14915 "path": "records/a.md",
14916 "blob": "a".repeat(64),
14917 "bytes": 3,
14918 })];
14919 let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
14920 let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
14921 let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
14922 let keys: Vec<&str> = parsed
14923 .as_object()
14924 .expect("manifest is an object")
14925 .keys()
14926 .map(String::as_str)
14927 .collect();
14928 assert_eq!(keys, ["blobs", "operations"]);
14929 assert_eq!(parsed["operations"], Value::Array(operations));
14930 assert_eq!(parsed["blobs"], blobs);
14931 }
14932
14933 #[test]
14934 fn a_staged_push_signs_the_change_not_the_transport() {
14935 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14940 let staged = json!({
14941 "mutation_id": "dbmd-1",
14942 "rebase": "strict",
14943 "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
14944 });
14945 let view = v2_signed_request_view(&staged, &operations);
14946 assert_eq!(view["operations"], Value::Array(operations.clone()));
14947 assert!(view.get("staged_change").is_none());
14948 assert_eq!(view["mutation_id"], staged["mutation_id"]);
14949
14950 let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
14951 assert_eq!(v2_signed_request_view(&inline, &[]), inline);
14952 }
14953
14954 #[test]
14955 fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
14956 let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
14957 let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
14958 .expect_err("an oversized change must not be staged");
14959 assert!(
14960 matches!(error, LinkError::PushTooLarge { .. }),
14961 "expected a size refusal, got {error:?}"
14962 );
14963 }
14964
14965 #[test]
14966 fn a_push_that_fits_the_request_is_left_inline() {
14967 let cfg = HubConfig {
14971 hub: "http://127.0.0.1:9".to_string(),
14972 key: Some("k".to_string()),
14973 agent_key: None,
14974 brain_key: None,
14975 state_dir: PathBuf::from("."),
14976 store_selected: false,
14977 };
14978 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14979 let mut body = json!({
14980 "mutation_id": "dbmd-1",
14981 "operations": operations,
14982 "blobs": [],
14983 });
14984 stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
14985 assert!(body.get("staged_change").is_none());
14986 assert_eq!(body["operations"], Value::Array(operations));
14987 }
14988
14989 #[test]
14990 fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
14991 let declarations: Vec<Value> = (0..2_000)
14995 .map(|index| {
14996 json!({
14997 "sha256": "a".repeat(64),
14998 "bytes": 10,
14999 "coordinates": (0..24)
15000 .map(|slot| format!(
15001 "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
15002 ))
15003 .collect::<Vec<_>>(),
15004 })
15005 })
15006 .collect();
15007 let batches = batch_upload_declarations(declarations);
15008 assert!(
15009 batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
15010 "wide coordinate sets must bound the batch by size"
15011 );
15012 for batch in &batches {
15013 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15014 .expect("batch serializes")
15015 .len();
15016 assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
15017 }
15018 }
15019
15020 #[test]
15021 fn a_small_push_still_rides_exactly_one_request() {
15022 let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
15023 assert_eq!(batch_upload_declarations(declarations).len(), 1);
15024 assert!(batch_upload_declarations(Vec::new()).is_empty());
15025 }
15026
15027 #[test]
15028 fn exact_source_move_becomes_one_provenance_preserving_rename() {
15029 let hash = "a".repeat(64);
15030 let operations = vec![
15031 json!({
15032 "op": "put",
15033 "path": "sources/curated/item.md",
15034 "expected": { "kind": "absent" },
15035 "blob": hash,
15036 "bytes": 19,
15037 }),
15038 json!({
15039 "op": "delete",
15040 "path": "sources/inbox/item.md",
15041 "expected": { "kind": "blob", "hash": hash },
15042 }),
15043 ];
15044
15045 assert_eq!(
15046 infer_exact_source_promotions(operations),
15047 vec![json!({
15048 "op": "rename",
15049 "from": "sources/inbox/item.md",
15050 "to": "sources/curated/item.md",
15051 "expected_from": { "kind": "blob", "hash": hash },
15052 "expected_to": { "kind": "absent" },
15053 "blob": hash,
15054 "bytes": 19,
15055 })]
15056 );
15057 }
15058
15059 #[test]
15060 fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
15061 let hash = "b".repeat(64);
15062 let operations = vec![
15063 json!({
15064 "op": "delete",
15065 "path": "sources/inbox/a.md",
15066 "expected": { "kind": "blob", "hash": hash },
15067 }),
15068 json!({
15069 "op": "delete",
15070 "path": "sources/inbox/b.md",
15071 "expected": { "kind": "blob", "hash": hash },
15072 }),
15073 json!({
15074 "op": "put",
15075 "path": "sources/curated/item.md",
15076 "expected": { "kind": "absent" },
15077 "blob": hash,
15078 "bytes": 19,
15079 }),
15080 ];
15081
15082 assert_eq!(
15083 infer_exact_source_promotions(operations.clone()),
15084 operations,
15085 "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
15086 );
15087 }
15088
15089 #[test]
15090 fn accepted_source_promotion_advances_the_local_baseline_exactly() {
15091 let hash = "c".repeat(64);
15092 let mut candidate = std::collections::BTreeMap::from([(
15093 "sources/inbox/item.md".to_string(),
15094 V2BaselineFile {
15095 sha256: hash.clone(),
15096 bytes: 19,
15097 proof: None,
15098 },
15099 )]);
15100 let mut candidate_assets = std::collections::BTreeMap::new();
15101 let operations = vec![
15102 json!({
15103 "op": "rename",
15104 "from": "sources/inbox/item.md",
15105 "to": "sources/curated/item.md",
15106 "expected_from": { "kind": "blob", "hash": hash },
15107 "expected_to": { "kind": "absent" },
15108 "blob": hash,
15109 "bytes": 19,
15110 }),
15111 json!({
15112 "op": "put",
15113 "path": "records/rsvps/item.md",
15114 "expected": { "kind": "absent" },
15115 "blob": "d".repeat(64),
15116 "bytes": 23,
15117 }),
15118 ];
15119
15120 assert!(!apply_generated_v2_operations(
15121 &operations,
15122 &std::collections::BTreeMap::new(),
15123 &mut candidate,
15124 &mut candidate_assets,
15125 )
15126 .unwrap());
15127 assert!(!candidate.contains_key("sources/inbox/item.md"));
15128 assert_eq!(
15129 candidate
15130 .get("sources/curated/item.md")
15131 .map(|file| (&file.sha256, file.bytes)),
15132 Some((&hash, 19))
15133 );
15134 assert_eq!(
15135 candidate
15136 .get("records/rsvps/item.md")
15137 .map(|file| (file.sha256.as_str(), file.bytes)),
15138 Some((
15139 "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
15140 23
15141 ))
15142 );
15143 }
15144
15145 fn merge_fixture(
15146 base: Option<&str>,
15147 remote: Option<&str>,
15148 local: Option<&str>,
15149 keep_local: bool,
15150 ) -> V2PulledMerge<String> {
15151 let map = |value: Option<&str>| {
15152 value
15153 .map(|value| [("records/a.md".to_string(), value.to_string())])
15154 .into_iter()
15155 .flatten()
15156 .collect::<std::collections::BTreeMap<_, _>>()
15157 };
15158 merge_v2_pulled_records(
15159 &map(base),
15160 &map(remote),
15161 &map(local),
15162 |value, _| value.clone(),
15163 |value, _| value.clone(),
15164 |_| keep_local,
15165 )
15166 }
15167
15168 #[test]
15169 fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
15170 let path = "records/a.md".to_string();
15171
15172 let local_add = merge_fixture(None, None, Some("local"), false);
15173 assert_eq!(
15174 local_add.records.get(&path).map(String::as_str),
15175 Some("local")
15176 );
15177 assert!(local_add.accept_remote.is_empty());
15178 assert!(local_add.conflicts.is_empty());
15179
15180 let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
15181 assert_eq!(
15182 local_edit.records.get(&path).map(String::as_str),
15183 Some("local")
15184 );
15185 assert!(local_edit.accept_remote.is_empty());
15186 assert!(local_edit.conflicts.is_empty());
15187
15188 let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
15189 assert!(!local_delete.records.contains_key(&path));
15190 assert!(local_delete.accept_remote.is_empty());
15191 assert!(local_delete.conflicts.is_empty());
15192
15193 let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
15194 assert_eq!(
15195 remote_edit.records.get(&path).map(String::as_str),
15196 Some("remote")
15197 );
15198 assert!(remote_edit.accept_remote.contains(&path));
15199 assert!(remote_edit.conflicts.is_empty());
15200
15201 let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
15202 assert!(!remote_delete.records.contains_key(&path));
15203 assert!(remote_delete.accept_remote.contains(&path));
15204 assert!(remote_delete.conflicts.is_empty());
15205
15206 let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
15207 assert_eq!(
15208 same_edit.records.get(&path).map(String::as_str),
15209 Some("same")
15210 );
15211 assert!(same_edit.accept_remote.contains(&path));
15212 assert!(same_edit.conflicts.is_empty());
15213
15214 let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
15215 assert_eq!(conflict.conflicts, vec![path.clone()]);
15216 assert_eq!(
15217 conflict.records.get(&path).map(String::as_str),
15218 Some("local")
15219 );
15220 assert!(conflict.accept_remote.is_empty());
15221
15222 let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
15223 assert_eq!(
15224 kept_home.records.get(&path).map(String::as_str),
15225 Some("local")
15226 );
15227 assert!(kept_home.accept_remote.is_empty());
15228 assert!(kept_home.conflicts.is_empty());
15229 }
15230
15231 #[test]
15232 fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
15233 let path = "sources/report.pdf";
15234 let record = crate::AssetRecord {
15235 path: path.to_string(),
15236 sha256: "a".repeat(64),
15237 bytes: 42,
15238 media_type: "application/pdf".to_string(),
15239 wrappers: vec!["gzip".to_string()],
15240 required: true,
15241 };
15242 let mut remote = V2BaselineAsset {
15243 blob_sha256: record.sha256.clone(),
15244 bytes: record.bytes,
15245 media_type: record.media_type.clone(),
15246 wrappers: record.wrappers.clone(),
15247 required: record.required,
15248 disposition: "withheld".to_string(),
15249 leaf_hash: "b".repeat(64),
15250 };
15251
15252 assert!(v2_asset_resumes_hosting(
15253 Some(&remote),
15254 path,
15255 &record,
15256 "hosted"
15257 ));
15258 assert!(!v2_asset_resumes_hosting(
15259 Some(&remote),
15260 path,
15261 &record,
15262 "withheld"
15263 ));
15264
15265 remote.disposition = "hosted".to_string();
15266 assert!(!v2_asset_resumes_hosting(
15267 Some(&remote),
15268 path,
15269 &record,
15270 "hosted"
15271 ));
15272
15273 remote.disposition = "withheld".to_string();
15274 remote.blob_sha256 = "c".repeat(64);
15275 assert!(!v2_asset_resumes_hosting(
15276 Some(&remote),
15277 path,
15278 &record,
15279 "hosted"
15280 ));
15281 assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
15282 }
15283
15284 #[test]
15285 fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
15286 let path = "records/team/alpha.md".to_string();
15287 let deleted_path = "records/team/deleted.md".to_string();
15288 let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
15289 sha256,
15290 bytes,
15291 file: None,
15292 };
15293 let files = vec![
15294 V2ConflictFile {
15295 path: path.clone(),
15296 base: coordinate(None, None),
15297 local: coordinate(Some("b".repeat(64)), Some(7)),
15298 remote: coordinate(Some("a".repeat(64)), Some(5)),
15299 },
15300 V2ConflictFile {
15301 path: deleted_path.clone(),
15302 base: coordinate(Some("c".repeat(64)), Some(9)),
15303 local: coordinate(Some("d".repeat(64)), Some(11)),
15304 remote: coordinate(None, None),
15305 },
15306 ];
15307 let proven = V2BaselineFile {
15308 sha256: "a".repeat(64),
15309 bytes: 5,
15310 proof: None,
15311 };
15312 let current = [(path.clone(), proven.clone())]
15313 .into_iter()
15314 .collect::<std::collections::BTreeMap<_, _>>();
15315
15316 let (selected, deleted) = v2_take_remote_selection(&files, ¤t).unwrap();
15317 assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
15318 assert_eq!(deleted, vec![deleted_path.clone()]);
15319
15320 let changed = [(
15321 path.clone(),
15322 V2BaselineFile {
15323 sha256: "e".repeat(64),
15324 bytes: 5,
15325 proof: None,
15326 },
15327 )]
15328 .into_iter()
15329 .collect::<std::collections::BTreeMap<_, _>>();
15330 assert!(v2_take_remote_selection(&files, &changed).is_err());
15331
15332 let resurrected = [
15333 (path, proven),
15334 (
15335 deleted_path,
15336 V2BaselineFile {
15337 sha256: "f".repeat(64),
15338 bytes: 13,
15339 proof: None,
15340 },
15341 ),
15342 ]
15343 .into_iter()
15344 .collect::<std::collections::BTreeMap<_, _>>();
15345 assert!(v2_take_remote_selection(&files, &resurrected).is_err());
15346 }
15347
15348 #[cfg(target_os = "linux")]
15349 #[test]
15350 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
15351 use std::os::fd::AsRawFd as _;
15352
15353 let sandbox = tempfile::TempDir::new().unwrap();
15354 let parent = std::fs::File::open(sandbox.path()).unwrap();
15355 let stage = std::ffi::CString::new("stage").unwrap();
15356 let destination = std::ffi::CString::new("brain").unwrap();
15357
15358 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15359 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
15360 install_stage_at(
15361 parent.as_raw_fd(),
15362 stage.as_c_str(),
15363 destination.as_c_str(),
15364 false,
15365 )
15366 .unwrap();
15367 assert!(!sandbox.path().join("stage").exists());
15368 assert_eq!(
15369 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15370 b"created"
15371 );
15372
15373 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15374 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
15375 install_stage_at(
15376 parent.as_raw_fd(),
15377 stage.as_c_str(),
15378 destination.as_c_str(),
15379 true,
15380 )
15381 .unwrap();
15382 assert_eq!(
15383 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15384 b"replacement"
15385 );
15386 assert_eq!(
15387 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
15388 b"created",
15389 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
15390 );
15391 }
15392
15393 struct SignedRemoteFixture {
15394 card: String,
15395 feed: String,
15396 key: AgentSigningKey,
15397 identity: FeedIdentity,
15398 }
15399
15400 fn signed_remote_fixture() -> SignedRemoteFixture {
15401 let rng = ring::rand::SystemRandom::new();
15402 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15403 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15404 let (public_key, multikey) = public_identity_for(&pair);
15405 let identity = FeedIdentity {
15406 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15407 public_key_spki: public_key.clone(),
15408 previous: Vec::new(),
15409 rotations: Vec::new(),
15410 };
15411 let mut entry = FeedEntry {
15412 v: 1,
15413 seq: 1,
15414 ts: "2026-07-30T12:00:00.000Z".to_string(),
15415 brain: multikey.clone(),
15416 public_key: public_key.clone(),
15417 kind: "push".to_string(),
15418 op: "snapshot".to_string(),
15419 pack_sha256: "a".repeat(64),
15420 files: Vec::new(),
15421 removed: Vec::new(),
15422 prev_entry_hash: None,
15423 sig: String::new(),
15424 };
15425 let unsigned = UnsignedFeedEntry {
15426 v: entry.v,
15427 seq: entry.seq,
15428 ts: &entry.ts,
15429 brain: &entry.brain,
15430 public_key: &entry.public_key,
15431 kind: &entry.kind,
15432 op: &entry.op,
15433 pack_sha256: &entry.pack_sha256,
15434 files: &entry.files,
15435 removed: &entry.removed,
15436 prev_entry_hash: &entry.prev_entry_hash,
15437 };
15438 entry.sig =
15439 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15440 let mut exact = serde_json::to_vec(&entry).unwrap();
15441 exact.push(b'\n');
15442 let hash = content_sha256(&exact);
15443 let card = json!({
15444 "id": TEST_BRAIN_ID,
15445 "headSeq": 1,
15446 "feedHash": hash,
15447 "identity": identity.clone(),
15448 })
15449 .to_string();
15450 let feed = json!({
15451 "headSeq": 1,
15452 "feedHash": hash,
15453 "identity": identity.clone(),
15454 "entries": [{"hash": hash, "entry": entry}],
15455 "scopeLimited": false,
15456 })
15457 .to_string();
15458 SignedRemoteFixture {
15459 card,
15460 feed,
15461 key: AgentSigningKey {
15462 pkcs8: pkcs8.as_ref().to_vec(),
15463 multikey,
15464 public_key_spki: public_key,
15465 },
15466 identity,
15467 }
15468 }
15469
15470 #[test]
15471 fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
15472 let file = |path: &str, byte: char| FeedFile {
15473 path: path.to_string(),
15474 sha256: byte.to_string().repeat(64),
15475 bytes: 1,
15476 };
15477 let a0 = file("records/a.md", 'a');
15478 let a1 = file("records/a.md", 'b');
15479 let stable = file("records/stable.md", 'c');
15480 let added = file("records/added.md", 'd');
15481 let removed_file = file("records/removed.md", 'e');
15482 let previous = vec![a0, stable.clone(), removed_file.clone()];
15483 let resulting = vec![a1.clone(), stable.clone(), added.clone()];
15484 let removed = vec![removed_file.path.clone()];
15485
15486 assert_eq!(
15487 verify_v1_manifest_disclosure(
15488 "edit",
15489 &previous,
15490 &resulting,
15491 &[a1.clone(), added.clone()],
15492 &removed,
15493 ),
15494 Ok(())
15495 );
15496 assert_eq!(
15497 verify_v1_manifest_disclosure(
15498 "edit",
15499 &previous,
15500 &resulting,
15501 &[stable.clone(), added.clone(), a1.clone()],
15502 &removed,
15503 ),
15504 Ok(())
15505 );
15506 assert_eq!(
15507 verify_v1_manifest_disclosure(
15508 "edit",
15509 &previous,
15510 &resulting,
15511 std::slice::from_ref(&added),
15512 &removed,
15513 ),
15514 Err(V1DisclosureError::EditMissingChange)
15515 );
15516 assert_eq!(
15517 verify_v1_manifest_disclosure(
15518 "edit",
15519 &previous,
15520 &resulting,
15521 &[file("records/a.md", 'f'), added.clone()],
15522 &removed,
15523 ),
15524 Err(V1DisclosureError::EditFalseFile)
15525 );
15526 assert_eq!(
15527 verify_v1_manifest_disclosure(
15528 "edit",
15529 &previous,
15530 &resulting,
15531 &[a1.clone(), added.clone()],
15532 &[],
15533 ),
15534 Err(V1DisclosureError::RemovedMismatch)
15535 );
15536 assert_eq!(
15537 verify_v1_manifest_disclosure(
15538 "push",
15539 &previous,
15540 &resulting,
15541 &[added.clone(), stable, a1],
15542 &removed,
15543 ),
15544 Ok(())
15545 );
15546 assert_eq!(
15547 verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
15548 Err(V1DisclosureError::PushManifestMismatch)
15549 );
15550 }
15551
15552 #[test]
15553 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
15554 let fixture = signed_remote_fixture();
15555 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
15556 let item = feed["entries"][0].to_string();
15557 let oversized_page = format!(
15558 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
15559 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
15560 .collect::<Vec<_>>()
15561 .join(",")
15562 );
15563 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
15564
15565 let oversized_identity = format!(
15566 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
15567 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
15568 .collect::<Vec<_>>()
15569 .join(",")
15570 );
15571 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
15572
15573 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
15574 let oversized_entry = format!(
15575 "{{\"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\"}}",
15576 "a".repeat(64),
15577 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
15578 .collect::<Vec<_>>()
15579 .join(",")
15580 );
15581 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
15582 }
15583
15584 #[test]
15585 fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
15586 let id = "01arz3ndektsv4rrffq69g5fav";
15587 let digest = "a".repeat(64);
15588 assert_eq!(
15589 V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
15590 V2BulkConfirmation {
15591 id: id.to_string(),
15592 digest,
15593 }
15594 );
15595 for invalid in [
15596 "",
15597 "01arz3ndektsv4rrffq69g5fav",
15598 "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15599 "01arz3ndektsv4rrffq69g5fav:ABCDEF",
15600 "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15601 ] {
15602 assert!(matches!(
15603 V2BulkConfirmation::parse(invalid),
15604 Err(LinkError::InvalidPack { .. })
15605 ));
15606 }
15607 }
15608
15609 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
15610 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15611 use std::net::TcpListener;
15612
15613 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15614 let url = format!("http://{}", listener.local_addr().unwrap());
15615 let handle = std::thread::spawn(move || {
15616 for (status, body) in responses {
15617 let (stream, _) = listener.accept().unwrap();
15618 let mut reader = BufReader::new(stream);
15619 let mut line = String::new();
15620 reader.read_line(&mut line).unwrap();
15621 let mut content_length = 0usize;
15622 loop {
15623 line.clear();
15624 reader.read_line(&mut line).unwrap();
15625 if line == "\r\n" || line == "\n" || line.is_empty() {
15626 break;
15627 }
15628 if let Some((name, value)) = line.split_once(':') {
15629 if name.eq_ignore_ascii_case("content-length") {
15630 content_length = value.trim().parse().unwrap();
15631 }
15632 }
15633 }
15634 let mut request_body = vec![0_u8; content_length];
15635 reader.read_exact(&mut request_body).unwrap();
15636 let response = format!(
15637 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15638 body.len()
15639 );
15640 reader.get_mut().write_all(response.as_bytes()).unwrap();
15641 }
15642 });
15643 (url, handle)
15644 }
15645
15646 fn routed_json_hub(
15647 requests: usize,
15648 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
15649 ) -> (String, std::thread::JoinHandle<()>) {
15650 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15651 use std::net::TcpListener;
15652
15653 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15654 let url = format!("http://{}", listener.local_addr().unwrap());
15655 let handle = std::thread::spawn(move || {
15656 for _ in 0..requests {
15657 let (stream, _) = listener.accept().unwrap();
15658 let mut reader = BufReader::new(stream);
15659 let mut line = String::new();
15660 reader.read_line(&mut line).unwrap();
15661 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
15662 let mut content_length = 0usize;
15663 loop {
15664 line.clear();
15665 reader.read_line(&mut line).unwrap();
15666 if line == "\r\n" || line == "\n" || line.is_empty() {
15667 break;
15668 }
15669 if let Some((name, value)) = line.split_once(':') {
15670 if name.eq_ignore_ascii_case("content-length") {
15671 content_length = value.trim().parse().unwrap();
15672 }
15673 }
15674 }
15675 let mut request_body = vec![0_u8; content_length];
15676 reader.read_exact(&mut request_body).unwrap();
15677 let (status, body) = respond(&path);
15678 let response = format!(
15679 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15680 body.len()
15681 );
15682 reader.get_mut().write_all(response.as_bytes()).unwrap();
15683 }
15684 });
15685 (url, handle)
15686 }
15687
15688 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
15689 HubConfig {
15690 hub,
15691 key: Some("test-key".to_string()),
15692 agent_key: None,
15693 brain_key: None,
15694 state_dir,
15695 store_selected: false,
15696 }
15697 }
15698
15699 #[cfg(any(unix, windows))]
15700 #[test]
15701 fn an_expired_asset_capability_is_refreshed_without_losing_cached_progress() {
15702 use std::sync::{Arc, Mutex};
15703
15704 let bytes = b"immutable asset bytes".to_vec();
15705 let sha256 = content_sha256(&bytes);
15706 let commit_hash = "c".repeat(64);
15707 let base_url = Arc::new(Mutex::new(String::new()));
15708 let server_base = Arc::clone(&base_url);
15709 let object_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15710 let server_attempt = Arc::clone(&object_attempt);
15711 let response_bytes = bytes.clone();
15712 let response_sha = sha256.clone();
15713 let response_commit = commit_hash.clone();
15714 let (hub, server) = routed_json_hub(4, move |path| {
15715 if path.contains("/v2/assets/downloads") {
15716 let attempt = server_attempt.load(std::sync::atomic::Ordering::SeqCst) + 1;
15717 let url = format!("{}/asset/{attempt}", server_base.lock().unwrap());
15718 return (
15719 200,
15720 json!({
15721 "v": 2,
15722 "commit": response_commit,
15723 "downloads": [{
15724 "path": "assets/proof.bin",
15725 "sha256": response_sha,
15726 "bytes": response_bytes.len(),
15727 "url": url,
15728 "method": "GET"
15729 }]
15730 })
15731 .to_string(),
15732 );
15733 }
15734 let attempt = server_attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15735 if attempt == 0 {
15736 (403, "{}".to_string())
15737 } else {
15738 (200, String::from_utf8(response_bytes.clone()).unwrap())
15739 }
15740 });
15741 *base_url.lock().unwrap() = hub.clone();
15742
15743 let temp = tempfile::tempdir().unwrap();
15744 let cache = temp.path().join("cache");
15745 std::fs::create_dir(&cache).unwrap();
15746 let cfg = test_hub_config(hub, temp.path().to_path_buf());
15747 let pointer = V2PointerBody {
15748 v: 2,
15749 brain: TEST_BRAIN_ID.to_string(),
15750 seq: 1,
15751 commit_hash,
15752 feed_hash: "f".repeat(64),
15753 content_root: Some("a".repeat(64)),
15754 asset_root: Some("b".repeat(64)),
15755 materializer: "m".repeat(64),
15756 signer_epoch: 1,
15757 control_revision: "d".repeat(64),
15758 backup_preparation: "ready".to_string(),
15759 prior_pointer_hash: None,
15760 signed_at: "2026-08-23T00:00:00Z".to_string(),
15761 };
15762 let path = "assets/proof.bin".to_string();
15763 let asset = V2BaselineAsset {
15764 blob_sha256: sha256.clone(),
15765 bytes: bytes.len() as u64,
15766 media_type: "application/octet-stream".to_string(),
15767 wrappers: Vec::new(),
15768 required: true,
15769 disposition: "hosted".to_string(),
15770 leaf_hash: "e".repeat(64),
15771 };
15772
15773 let staged = stage_v2_asset_download_window(
15774 &cfg,
15775 TEST_BRAIN_ID,
15776 &pointer,
15777 &cache,
15778 &[(&path, &asset)],
15779 )
15780 .expect("a fresh authority-checked capability recovers an expired one");
15781 assert_eq!(staged.len(), 1);
15782 assert_eq!(staged[0].path, path);
15783 assert_eq!(std::fs::read(cache.join(sha256)).unwrap(), bytes);
15784 assert_eq!(object_attempt.load(std::sync::atomic::Ordering::SeqCst), 2);
15785 server.join().unwrap();
15786 }
15787
15788 #[cfg(any(unix, windows))]
15789 #[test]
15790 fn asset_capability_windows_cannot_exceed_the_worker_bound() {
15791 let temp = tempfile::tempdir().unwrap();
15792 let cfg = test_hub_config("http://127.0.0.1:1".to_string(), temp.path().to_path_buf());
15793 let pointer = V2PointerBody {
15794 v: 2,
15795 brain: TEST_BRAIN_ID.to_string(),
15796 seq: 1,
15797 commit_hash: "c".repeat(64),
15798 feed_hash: "f".repeat(64),
15799 content_root: Some("a".repeat(64)),
15800 asset_root: Some("b".repeat(64)),
15801 materializer: "m".repeat(64),
15802 signer_epoch: 1,
15803 control_revision: "d".repeat(64),
15804 backup_preparation: "ready".to_string(),
15805 prior_pointer_hash: None,
15806 signed_at: "2026-08-23T00:00:00Z".to_string(),
15807 };
15808 let paths = (0..=V2_DOWNLOAD_CAPABILITY_FILES)
15809 .map(|index| format!("assets/{index}.bin"))
15810 .collect::<Vec<_>>();
15811 let assets = paths
15812 .iter()
15813 .map(|_| V2BaselineAsset {
15814 blob_sha256: "a".repeat(64),
15815 bytes: 1,
15816 media_type: "application/octet-stream".to_string(),
15817 wrappers: Vec::new(),
15818 required: true,
15819 disposition: "hosted".to_string(),
15820 leaf_hash: "b".repeat(64),
15821 })
15822 .collect::<Vec<_>>();
15823 let pending = paths.iter().zip(&assets).collect::<Vec<_>>();
15824
15825 let error =
15826 stage_v2_asset_download_window(&cfg, TEST_BRAIN_ID, &pointer, temp.path(), &pending)
15827 .expect_err("an oversized window must fail before any network request");
15828 assert!(matches!(error, LinkError::InvalidFeed { .. }));
15829 }
15830
15831 #[test]
15832 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
15833 use ring::signature::KeyPair as _;
15834
15835 let rng = ring::rand::SystemRandom::new();
15836 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15837 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15838 let (spki, multikey) = public_identity_for(&pair);
15839 let key = AgentSigningKey {
15840 pkcs8: pkcs8.as_ref().to_vec(),
15841 multikey,
15842 public_key_spki: spki,
15843 };
15844 let header = linkmd_sig_header(
15845 &key,
15846 "https://hub-a.example",
15847 "post",
15848 "/api/hub/brains/brain/push?mode=exact",
15849 Some("{\"ok\":true}"),
15850 )
15851 .unwrap();
15852 assert!(header.starts_with("LinkMD-Sig v2,"));
15853 let ts = header
15854 .split(",ts=")
15855 .nth(1)
15856 .unwrap()
15857 .split(',')
15858 .next()
15859 .unwrap();
15860 let signature = URL_SAFE_NO_PAD
15861 .decode(header.rsplit(",sig=").next().unwrap())
15862 .unwrap();
15863 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
15864 let accepted = format!(
15865 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15866 );
15867 let replayed = format!(
15868 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15869 );
15870 let public = pair.public_key().as_ref();
15871 assert!(UnparsedPublicKey::new(&ED25519, public)
15872 .verify(accepted.as_bytes(), &signature)
15873 .is_ok());
15874 assert!(
15875 UnparsedPublicKey::new(&ED25519, public)
15876 .verify(replayed.as_bytes(), &signature)
15877 .is_err(),
15878 "a proof captured at hub A must not authenticate at hub B"
15879 );
15880 }
15881
15882 #[test]
15883 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
15884 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15885 let card = json!({
15886 "id": other,
15887 "headSeq": 0,
15888 "identity": signed_remote_fixture().identity,
15889 })
15890 .to_string();
15891 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15892 let state = tempfile::tempdir().unwrap();
15893 let cfg = test_hub_config(hub, state.path().to_path_buf());
15894 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15895 assert!(
15896 error.contains("differs from the explicitly requested"),
15897 "{error}"
15898 );
15899 server.join().unwrap();
15900 }
15901
15902 #[test]
15903 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
15904 let first = signed_remote_fixture().identity;
15905 let second = signed_remote_fixture().identity;
15906 let card = |identity: FeedIdentity| {
15907 json!({
15908 "id": TEST_BRAIN_ID,
15909 "headSeq": 0,
15910 "identity": identity,
15911 })
15912 .to_string()
15913 };
15914 let (hub, server) = scripted_json_hub(vec![
15915 (404, "{}".to_string()),
15916 (200, card(first)),
15917 (404, "{}".to_string()),
15918 (200, card(second)),
15919 ]);
15920 let state = tempfile::tempdir().unwrap();
15921 let cfg = test_hub_config(hub, state.path().to_path_buf());
15922 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15923 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15924 assert!(
15925 error.contains("pinned anchor") || error.contains("forked away"),
15926 "{error}"
15927 );
15928 server.join().unwrap();
15929 }
15930
15931 #[test]
15932 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
15933 let old = signed_remote_fixture();
15934 let new = signed_remote_fixture();
15935 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
15936 let unsigned = serde_json::to_string(&UnsignedRotation {
15937 v: 1,
15938 op: "rotate",
15939 brain: &old.key.multikey,
15940 public_key: &old.key.public_key_spki,
15941 new_brain: &new.key.multikey,
15942 new_public_key: &new.key.public_key_spki,
15943 prior_head_seq: 1,
15944 prior_feed_hash: Some(&"a".repeat(64)),
15945 ts: "2026-07-30T12:00:00.000Z".to_string(),
15946 })
15947 .unwrap();
15948 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
15949 let rotation = format!(
15950 "{},\"sig\":\"{}\"}}",
15951 &unsigned[..unsigned.len() - 1],
15952 signature
15953 );
15954 let identity = FeedIdentity {
15955 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
15956 public_key_spki: new.key.public_key_spki,
15957 previous: vec![PreviousIdentity {
15958 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
15959 public_key_spki: old.key.public_key_spki,
15960 }],
15961 rotations: vec![rotation],
15962 };
15963 let card = json!({
15964 "id": TEST_BRAIN_ID,
15965 "headSeq": 0,
15966 "feedHash": null,
15967 "identity": identity,
15968 })
15969 .to_string();
15970 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15971 let state = tempfile::tempdir().unwrap();
15972 let cfg = test_hub_config(hub, state.path().to_path_buf());
15973 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15974 assert!(
15975 error.contains("rotation claims a feed boundary beyond the advertised head"),
15976 "{error}"
15977 );
15978 assert!(
15979 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
15980 "an inconsistent empty-head identity must not become the TOFU checkpoint"
15981 );
15982 server.join().unwrap();
15983 }
15984
15985 #[test]
15986 fn trust_checkpoint_rejects_a_later_fork() {
15987 let fixture = signed_remote_fixture();
15988 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
15989 fork["feedHash"] = Value::String("b".repeat(64));
15990 let (hub, server) = scripted_json_hub(vec![
15991 (404, "{}".to_string()),
15992 (200, fixture.card),
15993 (200, fixture.feed),
15994 (404, "{}".to_string()),
15995 (200, fork.to_string()),
15996 ]);
15997 let state = tempfile::tempdir().unwrap();
15998 let cfg = test_hub_config(hub, state.path().to_path_buf());
15999 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16000 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
16001 server.join().unwrap();
16002 }
16003
16004 #[test]
16005 fn alias_and_canonical_id_share_one_identity_checkpoint() {
16006 let trusted = signed_remote_fixture();
16007 let attacker = signed_remote_fixture();
16008 let (hub, server) = scripted_json_hub(vec![
16009 (404, "{}".to_string()),
16010 (200, trusted.card),
16011 (200, trusted.feed),
16012 (404, "{}".to_string()),
16013 (200, attacker.card),
16014 ]);
16015 let state = tempfile::tempdir().unwrap();
16016 let cfg = test_hub_config(hub, state.path().to_path_buf());
16017 assert!(head(&cfg, "trusted-slug").unwrap().verified);
16018 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16019 assert!(
16020 error.contains("equivocation")
16021 || error.contains("pinned")
16022 || error.contains("identity"),
16023 "{error}"
16024 );
16025 server.join().unwrap();
16026 }
16027
16028 #[test]
16029 fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
16030 let state = tempfile::tempdir().unwrap();
16031 let cfg = test_hub_config(
16032 "https://hub.example".to_string(),
16033 state.path().to_path_buf(),
16034 );
16035 let directory = open_trust_dir(&cfg).unwrap();
16036 let old = TEST_BRAIN_ID;
16037 let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16038 save_alias_in(
16039 &cfg,
16040 &directory,
16041 &AliasBinding {
16042 v: 1,
16043 origin: normalized_origin(&cfg.hub).unwrap(),
16044 requested: "company-brain".to_string(),
16045 brain: old.to_string(),
16046 home: Some("company-brain".to_string()),
16047 },
16048 )
16049 .unwrap();
16050
16051 let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
16052 assert!(matches!(
16053 error,
16054 LinkError::AliasRebindRequired {
16055 alias,
16056 from,
16057 to
16058 } if alias == "company-brain" && from == old && to == new
16059 ));
16060 let unchanged = load_alias_in(&cfg, &directory, "company-brain")
16061 .unwrap()
16062 .unwrap();
16063 assert_eq!(unchanged.brain, old);
16064 assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
16065 }
16066
16067 #[test]
16068 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
16069 let alpha = signed_remote_fixture();
16070 let beta = signed_remote_fixture();
16071 let alpha_card = alpha.card.clone();
16072 let alpha_feed = alpha.feed.clone();
16073 let beta_card = beta.card.clone();
16074 let beta_feed = beta.feed.clone();
16075 let (hub, server) = routed_json_hub(5, move |path| {
16076 if path.ends_with("/v2/head") {
16077 (404, "{}".to_string())
16078 } else if path.contains("/alpha/feed?") {
16079 (200, alpha_feed.clone())
16080 } else if path.contains("/beta/feed?") {
16081 (200, beta_feed.clone())
16082 } else if path.ends_with("/alpha") {
16083 (200, alpha_card.clone())
16084 } else if path.ends_with("/beta") {
16085 (200, beta_card.clone())
16086 } else {
16087 (500, r#"{"error":"unexpected path"}"#.to_string())
16088 }
16089 });
16090 let state = tempfile::tempdir().unwrap();
16091 let cfg = test_hub_config(hub, state.path().to_path_buf());
16092 let alpha_cfg = cfg.clone();
16093 let beta_cfg = cfg;
16094 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
16095 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
16096 let results = [first.join().unwrap(), second.join().unwrap()];
16097 assert_eq!(
16098 results.iter().filter(|result| result.is_ok()).count(),
16099 1,
16100 "only one alias identity may establish canonical TOFU: {results:?}"
16101 );
16102 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
16103 server.join().unwrap();
16104 }
16105
16106 #[cfg(unix)]
16107 #[test]
16108 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
16109 use std::os::unix::fs::symlink;
16110
16111 let fixture = signed_remote_fixture();
16112 let card = json!({
16113 "id": TEST_BRAIN_ID,
16114 "headSeq": 0,
16115 "feedHash": Value::Null,
16116 "identity": fixture.identity,
16117 })
16118 .to_string();
16119 let work = tempfile::tempdir().unwrap();
16120 let outside = tempfile::tempdir().unwrap();
16121 let state = work.path().join("state");
16122 let moved = work.path().join("state-held");
16123 let swap_state = state.clone();
16124 let swap_moved = moved.clone();
16125 let outside_path = outside.path().to_path_buf();
16126 let (hub, server) = routed_json_hub(1, move |_| {
16127 std::fs::rename(&swap_state, &swap_moved).unwrap();
16129 symlink(&outside_path, &swap_state).unwrap();
16130 (200, card.clone())
16131 });
16132 let cfg = test_hub_config(hub, state);
16133
16134 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
16135 assert_eq!(verified.head.seq, 0);
16136 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
16137 assert!(std::fs::read_dir(moved.join("trust"))
16138 .unwrap()
16139 .flatten()
16140 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
16141 server.join().unwrap();
16142 }
16143
16144 #[test]
16145 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
16146 let remote = signed_remote_fixture();
16147 let unrelated = signed_remote_fixture().key;
16148 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
16149 let state = tempfile::tempdir().unwrap();
16150 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
16151 cfg.brain_key = Some(unrelated);
16152 let error = sync_push(
16153 &cfg,
16154 TEST_BRAIN_ID,
16155 &[("DB.md".to_string(), "signed local content".to_string())],
16156 )
16157 .unwrap_err()
16158 .to_string();
16159 assert!(
16160 error.contains("not the verified current brain identity"),
16161 "{error}"
16162 );
16163 server.join().unwrap();
16164 }
16165
16166 #[test]
16167 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
16168 let remote = signed_remote_fixture();
16169 let new = signed_remote_fixture().key;
16170 let state = tempfile::tempdir().unwrap();
16171 let new_file = state.path().join("new.key");
16172 std::fs::write(
16173 &new_file,
16174 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
16175 )
16176 .unwrap();
16177 #[cfg(unix)]
16178 {
16179 use std::os::unix::fs::PermissionsExt as _;
16180 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
16181 }
16182 let forged = json!({
16183 "brain": TEST_BRAIN_ID,
16184 "identity": {
16185 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
16186 "publicKeySpki": new.public_key_spki,
16187 }
16188 })
16189 .to_string();
16190 let (hub, server) = scripted_json_hub(vec![
16191 (404, "{}".to_string()),
16192 (200, remote.card.clone()),
16193 (200, remote.feed.clone()),
16194 (200, forged),
16195 (200, remote.card),
16196 (200, remote.feed),
16197 ]);
16198 let cfg = test_hub_config(hub, state.path().to_path_buf());
16199 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
16200 .unwrap_err()
16201 .to_string();
16202 assert!(
16203 error.contains("without committing the verified new identity"),
16204 "{error}"
16205 );
16206 server.join().unwrap();
16207 }
16208
16209 #[test]
16210 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
16211 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16212 let raw = format!(
16213 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16214 );
16215 let pack = build_store_pack(&[
16216 (
16217 "DB.md".to_string(),
16218 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
16219 ),
16220 ("records/clients/truth.md".to_string(), raw.clone()),
16221 ])
16222 .unwrap();
16223 let by_id = resolve_from_verified_pack(
16224 "01j5qc3v9k4ym8rwbn2tqe6f7d",
16225 &AddressTarget::Id(record_id.to_string()),
16226 pack.clone(),
16227 )
16228 .unwrap();
16229 assert_eq!(by_id["document"]["summary"], "Signed truth");
16230 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
16231 assert_eq!(
16232 by_id["document"]["contentSha"],
16233 content_sha256(raw.as_bytes())
16234 );
16235
16236 let by_path = resolve_from_verified_pack(
16237 "01j5qc3v9k4ym8rwbn2tqe6f7d",
16238 &AddressTarget::Path("records/clients/truth.md".to_string()),
16239 pack,
16240 )
16241 .unwrap();
16242 assert_eq!(by_path["document"]["id"], record_id);
16243 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
16244
16245 let wrong_id = resolve_from_verified_record_bytes(
16246 TEST_BRAIN_ID,
16247 &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
16248 "records/clients/truth.md".to_string(),
16249 raw.as_bytes().to_vec(),
16250 )
16251 .unwrap_err()
16252 .to_string();
16253 assert!(wrong_id.contains("id differs"), "{wrong_id}");
16254
16255 let wrong_path = resolve_from_verified_record_bytes(
16256 TEST_BRAIN_ID,
16257 &AddressTarget::Path("records/clients/other.md".to_string()),
16258 "records/clients/truth.md".to_string(),
16259 raw.into_bytes(),
16260 )
16261 .unwrap_err()
16262 .to_string();
16263 assert!(wrong_path.contains("path differs"), "{wrong_path}");
16264 }
16265
16266 #[test]
16267 fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
16268 let path = "records/clients/truth.md";
16269 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16270 let raw = format!(
16271 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16272 );
16273 let sha256 = content_sha256(raw.as_bytes());
16274 let mut nonce = 0_u128;
16275 let tree = crate::linkmd_v2::build_content_tree(
16276 &[crate::linkmd_v2::ContentFile {
16277 path: path.to_string(),
16278 blob_hash: sha256.clone(),
16279 bytes: raw.len() as u64,
16280 }],
16281 None,
16282 &mut || {
16283 nonce += 1;
16284 format!("{nonce:032x}")
16285 },
16286 )
16287 .unwrap();
16288 let root = tree.root.clone().unwrap();
16289 let mut directory_root = root.clone();
16290 let mut proof = Vec::new();
16291 for component in path.split('/') {
16292 let inclusion =
16293 crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
16294 let child = match &inclusion {
16295 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
16296 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
16297 panic!("fixture path must have an inclusion proof")
16298 }
16299 };
16300 proof.push(json!({
16301 "directory_root": directory_root,
16302 "component": component,
16303 "proof": inclusion,
16304 }));
16305 directory_root = child;
16306 }
16307 let commit_hash = "c".repeat(64);
16308 let pointer = V2PointerBody {
16309 v: 2,
16310 brain: TEST_BRAIN_ID.to_string(),
16311 seq: 1,
16312 commit_hash: commit_hash.clone(),
16313 feed_hash: "f".repeat(64),
16314 content_root: Some(root.clone()),
16315 asset_root: None,
16316 materializer: "dbmd-projection-v1".to_string(),
16317 signer_epoch: 1,
16318 control_revision: "d".repeat(64),
16319 backup_preparation: "e".repeat(64),
16320 prior_pointer_hash: None,
16321 signed_at: "2026-08-21T12:00:00.000Z".to_string(),
16322 };
16323 let manifest = json!({
16324 "v": 2,
16325 "commit": commit_hash,
16326 "content_root": root,
16327 "files": [{
16328 "path": path,
16329 "sha256": sha256,
16330 "bytes": raw.len(),
16331 "proof": proof,
16332 }],
16333 "next_cursor": Value::Null,
16334 })
16335 .to_string();
16336
16337 let path_manifest = manifest.clone();
16338 let (hub, server) = routed_json_hub(1, move |request| {
16339 assert_eq!(
16340 request,
16341 format!(
16342 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
16343 "c".repeat(64)
16344 )
16345 );
16346 (200, path_manifest.clone())
16347 });
16348 let state = tempfile::tempdir().unwrap();
16349 let cfg = test_hub_config(hub, state.path().to_path_buf());
16350 let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
16351 .unwrap()
16352 .unwrap();
16353 assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
16354 assert!(by_path.proof.is_some());
16355 server.join().unwrap();
16356
16357 let (hub, server) = routed_json_hub(1, move |request| {
16358 assert_eq!(
16359 request,
16360 format!(
16361 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
16362 "c".repeat(64)
16363 )
16364 );
16365 (404, r#"{"error":"File not found"}"#.to_string())
16366 });
16367 let state = tempfile::tempdir().unwrap();
16368 let cfg = test_hub_config(hub, state.path().to_path_buf());
16369 assert!(
16370 v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
16371 .unwrap()
16372 .is_none()
16373 );
16374 server.join().unwrap();
16375
16376 let id_manifest = manifest;
16377 let (hub, server) = routed_json_hub(1, move |request| {
16378 assert_eq!(
16379 request,
16380 format!(
16381 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
16382 "c".repeat(64)
16383 )
16384 );
16385 (200, id_manifest.clone())
16386 });
16387 let state = tempfile::tempdir().unwrap();
16388 let cfg = test_hub_config(hub, state.path().to_path_buf());
16389 let (located_path, by_id) =
16390 v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
16391 assert_eq!(located_path, path);
16392 assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
16393 server.join().unwrap();
16394 }
16395
16396 #[test]
16397 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
16398 let unsorted = vec![
16399 ("records/a.md".to_string(), "alpha\n".to_string()),
16400 ("DB.md".to_string(), "# db\n".to_string()),
16401 ];
16402 let sorted = vec![
16403 ("DB.md".to_string(), "# db\n".to_string()),
16404 ("records/a.md".to_string(), "alpha\n".to_string()),
16405 ];
16406 let pack = build_store_pack(&unsorted).unwrap();
16407
16408 assert_eq!(pack.len(), 219);
16413 assert_eq!(
16414 content_sha256(&pack),
16415 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
16416 );
16417 assert_eq!(pack, build_store_pack(&sorted).unwrap());
16418 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
16419 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
16420 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
16421
16422 assert_eq!(
16423 parse_store_pack(pack).unwrap(),
16424 vec![
16425 ("DB.md".to_string(), b"# db\n".to_vec()),
16426 ("records/a.md".to_string(), b"alpha\n".to_vec()),
16427 ]
16428 );
16429 }
16430
16431 #[test]
16432 fn canonical_store_pack_validates_every_path_before_writing() {
16433 let duplicate = vec![
16434 ("DB.md".to_string(), "first".to_string()),
16435 ("DB.md".to_string(), "second".to_string()),
16436 ];
16437 assert!(build_store_pack(&duplicate)
16438 .unwrap_err()
16439 .to_string()
16440 .contains("duplicate path"));
16441 assert!(matches!(
16442 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
16443 Err(LinkError::UnsafePath { .. })
16444 ));
16445 }
16446
16447 #[test]
16448 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
16449 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16450 let mut bytes = vec![0_u8];
16453 let zip64_offset = bytes.len() as u64;
16454 bytes.extend_from_slice(b"PK\x06\x06");
16455 bytes.extend_from_slice(&44_u64.to_le_bytes());
16456 bytes.extend_from_slice(&[0_u8; 12]);
16457 bytes.extend_from_slice(&COUNT.to_le_bytes());
16458 bytes.extend_from_slice(&COUNT.to_le_bytes());
16459 bytes.extend_from_slice(&1_u64.to_le_bytes());
16460 bytes.extend_from_slice(&0_u64.to_le_bytes());
16461 bytes.extend_from_slice(b"PK\x06\x07");
16462 bytes.extend_from_slice(&0_u32.to_le_bytes());
16463 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16464 bytes.extend_from_slice(&1_u32.to_le_bytes());
16465 bytes.extend_from_slice(b"PK\x05\x06");
16466 bytes.extend_from_slice(&0_u16.to_le_bytes());
16467 bytes.extend_from_slice(&0_u16.to_le_bytes());
16468 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16469 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16470 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16471 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16472 bytes.extend_from_slice(&0_u16.to_le_bytes());
16473
16474 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16475 .unwrap_err()
16476 .to_string();
16477 assert!(error.contains("invalid file count"), "{error}");
16478 }
16479
16480 #[test]
16481 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
16482 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16483 let mut bytes = vec![0_u8];
16484 let zip64_offset = bytes.len() as u64;
16485 bytes.extend_from_slice(b"PK\x06\x06");
16486 bytes.extend_from_slice(&44_u64.to_le_bytes());
16487 bytes.extend_from_slice(&[0_u8; 12]);
16488 bytes.extend_from_slice(&COUNT.to_le_bytes());
16489 bytes.extend_from_slice(&COUNT.to_le_bytes());
16490 bytes.extend_from_slice(&1_u64.to_le_bytes());
16491 bytes.extend_from_slice(&0_u64.to_le_bytes());
16492 bytes.extend_from_slice(b"PK\x06\x07");
16493 bytes.extend_from_slice(&0_u32.to_le_bytes());
16494 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16495 bytes.extend_from_slice(&1_u32.to_le_bytes());
16496 bytes.extend_from_slice(b"PK\x05\x06");
16497 bytes.extend_from_slice(&0_u16.to_le_bytes());
16498 bytes.extend_from_slice(&0_u16.to_le_bytes());
16499 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16500 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16501 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16502 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16503 bytes.extend_from_slice(&0_u16.to_le_bytes());
16504 let fake_eocd = bytes.len() as u32;
16508 bytes.extend_from_slice(b"PK\x05\x06");
16509 bytes.extend_from_slice(&0_u16.to_le_bytes());
16510 bytes.extend_from_slice(&0_u16.to_le_bytes());
16511 bytes.extend_from_slice(&1_u16.to_le_bytes());
16512 bytes.extend_from_slice(&1_u16.to_le_bytes());
16513 bytes.extend_from_slice(&0_u32.to_le_bytes());
16514 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
16515 bytes.extend_from_slice(&0_u16.to_le_bytes());
16516
16517 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16518 .unwrap_err()
16519 .to_string();
16520 assert!(error.contains("central directory"), "{error}");
16521 }
16522
16523 #[test]
16524 fn strict_http_status_handling_rejects_redirects_without_panicking() {
16525 let error = ensure_ok(
16526 HubResponse {
16527 status: 302,
16528 body: Some(json!({"redirect": "/elsewhere"})),
16529 },
16530 "mutation",
16531 )
16532 .unwrap_err();
16533 assert!(matches!(error, LinkError::Http { status: 302, .. }));
16534
16535 let error = ensure_raw_ok(
16536 RawHubResponse {
16537 status: 302,
16538 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
16539 },
16540 "feed",
16541 )
16542 .unwrap_err();
16543 assert!(matches!(error, LinkError::Http { status: 302, .. }));
16544 }
16545
16546 #[cfg(unix)]
16547 #[test]
16548 fn collect_push_files_refuses_external_symlink_and_nested_store() {
16549 use std::os::unix::fs::symlink;
16550
16551 let root = tempfile::tempdir().unwrap();
16552 std::fs::write(
16553 root.path().join("DB.md"),
16554 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
16555 )
16556 .unwrap();
16557 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
16558
16559 let external = tempfile::tempdir().unwrap();
16560 let secret = external.path().join("secret.md");
16561 std::fs::write(&secret, "TOP SECRET").unwrap();
16562 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
16563
16564 let store = Store::open_strict(root.path()).unwrap();
16565 let err = collect_push_files(&store).unwrap_err().to_string();
16566 assert!(err.contains("cannot push"), "{err}");
16567 assert!(
16568 !err.contains("TOP SECRET"),
16569 "external bytes must never leak"
16570 );
16571
16572 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
16573 let nested = root.path().join("records/nested");
16574 std::fs::create_dir_all(&nested).unwrap();
16575 std::fs::write(
16576 nested.join("DB.md"),
16577 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
16578 )
16579 .unwrap();
16580 let err = collect_push_files(&store).unwrap_err().to_string();
16581 assert!(err.contains("nested db.md store"), "{err}");
16582 }
16583
16584 #[cfg(unix)]
16585 #[test]
16586 fn remote_push_uses_opened_root_after_path_replacement() {
16587 use std::os::unix::fs::symlink;
16588
16589 let sandbox = tempfile::tempdir().unwrap();
16590 let root = sandbox.path().join("store");
16591 std::fs::create_dir_all(root.join("records/notes")).unwrap();
16592 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16593 std::fs::write(
16594 root.join("records/notes/owned.md"),
16595 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
16596 )
16597 .unwrap();
16598 let store = Store::open_strict(&root).unwrap();
16599 let detached = sandbox.path().join("detached");
16600 std::fs::rename(&root, &detached).unwrap();
16601
16602 let replacement = sandbox.path().join("replacement");
16603 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
16604 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16605 std::fs::write(
16606 replacement.join("records/notes/secret.md"),
16607 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
16608 )
16609 .unwrap();
16610 symlink(&replacement, &root).unwrap();
16611
16612 let files = collect_push_files(&store).unwrap();
16613 let wire_text = files
16614 .iter()
16615 .map(|(path, content)| format!("{path}\n{content}"))
16616 .collect::<Vec<_>>()
16617 .join("\n");
16618 assert!(wire_text.contains("owned upload"));
16619 assert!(!wire_text.contains("replacement sentinel"));
16620 assert!(!wire_text.contains("records/notes/secret.md"));
16621
16622 let remote = signed_remote_fixture();
16623 let (hub, server) = scripted_json_hub(vec![
16624 (200, remote.card),
16625 (200, remote.feed),
16626 (200, json!({"ok": true}).to_string()),
16627 ]);
16628 let state = tempfile::tempdir().unwrap();
16629 let cfg = test_hub_config(hub, state.path().to_path_buf());
16630 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
16631 assert_eq!(pushed, json!({"ok": true}));
16632 server.join().unwrap();
16633 }
16634
16635 #[test]
16636 fn signed_feed_item_verifies_identity_hash_and_signature() {
16637 use ring::rand::SystemRandom;
16638 use ring::signature::{Ed25519KeyPair, KeyPair};
16639
16640 const PREFIX: &[u8] = &[
16641 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
16642 ];
16643 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
16644 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16645 let mut spki = PREFIX.to_vec();
16646 spki.extend_from_slice(pair.public_key().as_ref());
16647 let public_key = URL_SAFE_NO_PAD.encode(&spki);
16648 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
16649 let mut entry = FeedEntry {
16650 v: 1,
16651 seq: 1,
16652 ts: "2026-07-14T00:00:00.000Z".to_string(),
16653 brain: format!("ed25519:{fingerprint}"),
16654 public_key: public_key.clone(),
16655 kind: "push".to_string(),
16656 op: "snapshot".to_string(),
16657 pack_sha256: "a".repeat(64),
16658 files: vec![FeedFile {
16659 path: "DB.md".to_string(),
16660 sha256: "b".repeat(64),
16661 bytes: 3,
16662 }],
16663 removed: vec![],
16664 prev_entry_hash: None,
16665 sig: String::new(),
16666 };
16667 let unsigned = UnsignedFeedEntry {
16668 v: entry.v,
16669 seq: entry.seq,
16670 ts: &entry.ts,
16671 brain: &entry.brain,
16672 public_key: &entry.public_key,
16673 kind: &entry.kind,
16674 op: &entry.op,
16675 pack_sha256: &entry.pack_sha256,
16676 files: &entry.files,
16677 removed: &entry.removed,
16678 prev_entry_hash: &entry.prev_entry_hash,
16679 };
16680 entry.sig =
16681 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16682 let mut exact = serde_json::to_vec(&entry).unwrap();
16683 exact.push(b'\n');
16684 let item = FeedItem {
16685 hash: format!("{:x}", Sha256::digest(&exact)),
16686 entry,
16687 };
16688 let identity = FeedIdentity {
16689 fingerprint,
16690 public_key_spki: public_key,
16691 previous: Vec::new(),
16692 rotations: Vec::new(),
16693 };
16694 assert!(verify_feed_item(&item, &identity).is_ok());
16695 let mut tampered = item;
16696 tampered.entry.pack_sha256 = "c".repeat(64);
16697 assert!(verify_feed_item(&tampered, &identity).is_err());
16698 }
16699
16700 #[test]
16701 fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
16702 let rng = ring::rand::SystemRandom::new();
16703 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16704 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16705 let (spki, multikey) = public_identity_for(&pair);
16706 let identity = V2HeadIdentity {
16707 custody: "self".to_string(),
16708 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16709 public_key_spki: spki.clone(),
16710 previous: Vec::new(),
16711 rotations: Vec::new(),
16712 };
16713 let unsigned = json!({
16714 "actor_ref": "a".repeat(64),
16715 "asset_root": Value::Null,
16716 "brain": multikey,
16717 "changes_sha256": "b".repeat(64),
16718 "control_revision": "c".repeat(64),
16719 "materializer": "dbmd-projection-v1",
16720 "op": "changeset",
16721 "parent_asset_root": Value::Null,
16722 "parent_commit": Value::Null,
16723 "parent_root": Value::Null,
16724 "prev_entry_hash": Value::Null,
16725 "public_key": spki,
16726 "seq": 1,
16727 "signer_epoch": 1,
16728 "state_root": "d".repeat(64),
16729 "ts": "2026-08-19T12:00:00.000Z",
16730 "v": 2,
16731 "v1_bridge": {
16732 "feed_hash": "e".repeat(64),
16733 "head_seq": 7,
16734 "pack_sha256": "f".repeat(64),
16735 },
16736 });
16737 let sign_value = |value: Value| {
16738 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16739 let mut object = value.as_object().unwrap().clone();
16740 object.insert(
16741 "sig".to_string(),
16742 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16743 );
16744 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16745 };
16746 assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
16747
16748 let mut extra = unsigned.clone();
16749 extra
16750 .as_object_mut()
16751 .unwrap()
16752 .insert("future".to_string(), Value::Bool(true));
16753 assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
16754
16755 let mut missing = unsigned.clone();
16756 missing.as_object_mut().unwrap().remove("v1_bridge");
16757 assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
16758
16759 let mut invalid_bridge = unsigned;
16760 invalid_bridge.as_object_mut().unwrap().insert(
16761 "v1_bridge".to_string(),
16762 json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
16763 );
16764 assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
16765 }
16766
16767 #[test]
16768 fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
16769 let vector: Value = serde_json::from_str(include_str!(
16770 "../tests/vectors/linkmd-v2-commit-bridge.json"
16771 ))
16772 .unwrap();
16773 let identity_value = vector.get("identity").unwrap();
16774 let identity = V2HeadIdentity {
16775 custody: "self".to_string(),
16776 fingerprint: identity_value
16777 .get("fingerprint")
16778 .and_then(Value::as_str)
16779 .unwrap()
16780 .to_string(),
16781 public_key_spki: identity_value
16782 .get("public_key_spki")
16783 .and_then(Value::as_str)
16784 .unwrap()
16785 .to_string(),
16786 previous: Vec::new(),
16787 rotations: Vec::new(),
16788 };
16789 let private = URL_SAFE_NO_PAD
16790 .decode(
16791 identity_value
16792 .get("private_key_pkcs8")
16793 .and_then(Value::as_str)
16794 .unwrap(),
16795 )
16796 .unwrap();
16797 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
16798 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
16799 .unwrap();
16800 let base = vector.get("body").unwrap().as_object().unwrap();
16801
16802 for item in vector.get("valid").unwrap().as_array().unwrap() {
16803 let mut body = base.clone();
16804 body.insert(
16805 "v1_bridge".to_string(),
16806 item.get("v1_bridge").unwrap().clone(),
16807 );
16808 body.insert(
16809 "sig".to_string(),
16810 item.get("signature_base64url").unwrap().clone(),
16811 );
16812 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16813 assert!(verified_v2_commit_object(&signed, &identity).is_ok());
16814 assert_eq!(
16815 crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
16816 item.get("commit_hash").and_then(Value::as_str).unwrap()
16817 );
16818 assert_eq!(
16819 format!("{:x}", Sha256::digest(&signed)),
16820 item.get("feed_hash").and_then(Value::as_str).unwrap()
16821 );
16822 }
16823
16824 for item in vector.get("invalid").unwrap().as_array().unwrap() {
16825 let mut body = base.clone();
16826 if let Some(remove) = item.get("remove").and_then(Value::as_array) {
16827 for field in remove {
16828 body.remove(field.as_str().unwrap());
16829 }
16830 }
16831 if let Some(set) = item.get("set").and_then(Value::as_object) {
16832 for (field, value) in set {
16833 body.insert(field.clone(), value.clone());
16834 }
16835 }
16836 let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
16837 body.insert(
16838 "sig".to_string(),
16839 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16840 );
16841 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16842 assert!(
16843 verified_v2_commit_object(&signed, &identity).is_err(),
16844 "accepted invalid shared vector {}",
16845 item.get("reason").and_then(Value::as_str).unwrap()
16846 );
16847 }
16848 }
16849
16850 #[test]
16851 fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
16852 let vector: Value = serde_json::from_str(include_str!(
16853 "../tests/vectors/linkmd-v2-changeset-withheld.json"
16854 ))
16855 .unwrap();
16856 assert_eq!(
16857 vector.get("profile").and_then(Value::as_str),
16858 Some("link.md-v2-changeset-withheld")
16859 );
16860 let canonical =
16861 crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
16862 let expected = STANDARD
16863 .decode(
16864 vector
16865 .get("canonical_base64")
16866 .and_then(Value::as_str)
16867 .unwrap(),
16868 )
16869 .unwrap();
16870 assert_eq!(canonical, expected);
16871 assert_eq!(
16872 crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
16873 vector.get("domain_hash").and_then(Value::as_str).unwrap()
16874 );
16875 }
16876
16877 #[test]
16878 fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
16879 let remote = signed_remote_fixture();
16880 let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
16881 let legacy_item = legacy.entries.first().unwrap();
16882 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
16883 let body = json!({
16884 "actor_ref": "a".repeat(64),
16885 "asset_root": Value::Null,
16886 "brain": remote.key.multikey,
16887 "changes_sha256": "b".repeat(64),
16888 "control_revision": "c".repeat(64),
16889 "materializer": "dbmd-projection-v1",
16890 "op": "changeset",
16891 "parent_asset_root": Value::Null,
16892 "parent_commit": Value::Null,
16893 "parent_root": Value::Null,
16894 "prev_entry_hash": Value::Null,
16895 "public_key": remote.key.public_key_spki,
16896 "seq": 1,
16897 "signer_epoch": 1,
16898 "state_root": "d".repeat(64),
16899 "ts": "2026-08-19T12:00:00.000Z",
16900 "v": 2,
16901 "v1_bridge": {
16902 "feed_hash": legacy_item.hash,
16903 "head_seq": legacy_item.entry.seq,
16904 "pack_sha256": legacy_item.entry.pack_sha256,
16905 },
16906 });
16907 let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
16908 let mut signed = body.as_object().unwrap().clone();
16909 signed.insert(
16910 "sig".to_string(),
16911 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16912 );
16913 let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
16914 let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
16915 let feed_hash = content_sha256(&raw);
16916 let pointer = V2PointerBody {
16917 v: 2,
16918 brain: TEST_BRAIN_ID.to_string(),
16919 seq: 1,
16920 commit_hash: commit_hash.clone(),
16921 feed_hash: feed_hash.clone(),
16922 content_root: Some("d".repeat(64)),
16923 asset_root: None,
16924 materializer: "dbmd-projection-v1".to_string(),
16925 signer_epoch: 1,
16926 control_revision: "c".repeat(64),
16927 backup_preparation: "e".repeat(64),
16928 prior_pointer_hash: None,
16929 signed_at: "2026-08-19T12:00:00.000Z".to_string(),
16930 };
16931 let v2_page = json!({
16932 "v": 2,
16933 "head_seq": 1,
16934 "head_commit_hash": commit_hash,
16935 "head_feed_hash": feed_hash,
16936 "entries": [{
16937 "seq": 1,
16938 "commit_hash": pointer.commit_hash,
16939 "feed_hash": pointer.feed_hash,
16940 "bytes_base64": STANDARD.encode(&raw),
16941 }],
16942 "next_after": 1,
16943 "complete": true,
16944 })
16945 .to_string();
16946 let identity = V2HeadIdentity {
16947 custody: "self".to_string(),
16948 fingerprint: remote.identity.fingerprint.clone(),
16949 public_key_spki: remote.identity.public_key_spki.clone(),
16950 previous: Vec::new(),
16951 rotations: Vec::new(),
16952 };
16953 let checkpoint = TrustState {
16954 v: 2,
16955 origin: "unused".to_string(),
16956 requested: TEST_BRAIN_ID.to_string(),
16957 brain: TEST_BRAIN_ID.to_string(),
16958 home: None,
16959 anchor: remote.key.multikey.clone(),
16960 current: remote.key.multikey,
16961 head_seq: legacy_item.entry.seq,
16962 feed_hash: Some(legacy_item.hash.clone()),
16963 rotations: Vec::new(),
16964 hub_signer: None,
16965 protocol_profile: None,
16966 };
16967 let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
16968 let state = tempfile::tempdir().unwrap();
16969 let cfg = test_hub_config(hub, state.path().to_path_buf());
16970 verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
16971 server.join().unwrap();
16972
16973 let mut wrong = checkpoint;
16974 wrong.feed_hash = Some("0".repeat(64));
16975 let (hub, server) = scripted_json_hub(vec![(
16976 200,
16977 json!({
16978 "v": 2,
16979 "head_seq": 1,
16980 "head_commit_hash": pointer.commit_hash,
16981 "head_feed_hash": pointer.feed_hash,
16982 "entries": [{
16983 "seq": 1,
16984 "commit_hash": pointer.commit_hash,
16985 "feed_hash": pointer.feed_hash,
16986 "bytes_base64": STANDARD.encode(&raw),
16987 }],
16988 "next_after": 1,
16989 "complete": true,
16990 })
16991 .to_string(),
16992 )]);
16993 let state = tempfile::tempdir().unwrap();
16994 let cfg = test_hub_config(hub, state.path().to_path_buf());
16995 assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
16996 server.join().unwrap();
16997 }
16998
16999 #[test]
17000 fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
17001 let rng = ring::rand::SystemRandom::new();
17002 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17003 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17004 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17005 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17006 let (old_spki, old_multikey) = public_identity_for(&old);
17007 let (new_spki, new_multikey) = public_identity_for(&new);
17008 let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
17009 v: 1,
17010 op: "rotate",
17011 brain: &old_multikey,
17012 public_key: &old_spki,
17013 new_brain: &new_multikey,
17014 new_public_key: &new_spki,
17015 prior_head_seq: 1,
17016 prior_feed_hash: Some(&"9".repeat(64)),
17017 ts: "2026-08-19T12:01:00.000Z".to_string(),
17018 })
17019 .unwrap();
17020 let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
17021 let rotation = format!(
17022 "{},\"sig\":\"{}\"}}",
17023 &rotation_unsigned[..rotation_unsigned.len() - 1],
17024 rotation_sig
17025 );
17026 let identity = V2HeadIdentity {
17027 custody: "self".to_string(),
17028 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17029 public_key_spki: new_spki.clone(),
17030 previous: vec![V2PreviousIdentity {
17031 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17032 public_key_spki: old_spki.clone(),
17033 }],
17034 rotations: vec![rotation],
17035 };
17036 let commit = |seq: u64,
17037 epoch: u64,
17038 multikey: &str,
17039 spki: &str,
17040 pair: &ring::signature::Ed25519KeyPair| {
17041 let value = json!({
17042 "actor_ref": "a".repeat(64),
17043 "asset_root": Value::Null,
17044 "brain": multikey,
17045 "changes_sha256": "b".repeat(64),
17046 "control_revision": "c".repeat(64),
17047 "materializer": "dbmd-projection-v1",
17048 "op": "changeset",
17049 "parent_asset_root": Value::Null,
17050 "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
17051 "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
17052 "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
17053 "public_key": spki,
17054 "seq": seq,
17055 "signer_epoch": epoch,
17056 "state_root": "1".repeat(64),
17057 "ts": "2026-08-19T12:00:00.000Z",
17058 "v": 2,
17059 "v1_bridge": Value::Null,
17060 });
17061 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
17062 let mut object = value.as_object().unwrap().clone();
17063 object.insert(
17064 "sig".to_string(),
17065 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17066 );
17067 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
17068 };
17069
17070 assert!(verified_v2_commit_object(
17071 &commit(1, 1, &old_multikey, &old_spki, &old),
17072 &identity,
17073 )
17074 .is_ok());
17075 assert!(verified_v2_commit_object(
17076 &commit(2, 2, &new_multikey, &new_spki, &new),
17077 &identity,
17078 )
17079 .is_ok());
17080 assert!(verified_v2_commit_object(
17081 &commit(2, 1, &old_multikey, &old_spki, &old),
17082 &identity,
17083 )
17084 .is_err());
17085 assert!(verified_v2_commit_object(
17086 &commit(1, 2, &new_multikey, &new_spki, &new),
17087 &identity,
17088 )
17089 .is_err());
17090 }
17091
17092 #[test]
17093 fn a_self_custody_entry_verifies_like_any_hub_entry() {
17094 let rng = ring::rand::SystemRandom::new();
17095 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17096 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17097 let (spki, multikey) = public_identity_for(&pair);
17098 let key = AgentSigningKey {
17099 pkcs8: pkcs8.as_ref().to_vec(),
17100 multikey: multikey.clone(),
17101 public_key_spki: spki.clone(),
17102 };
17103 let files = vec![WireFeedFile {
17104 path: "DB.md".to_string(),
17105 sha256: "a".repeat(64),
17106 bytes: 3,
17107 }];
17108 let raw = self_custody_entry(
17109 &key,
17110 1,
17111 "2026-07-23T12:00:00.000Z".to_string(),
17112 &"c".repeat(64),
17113 &files,
17114 None,
17115 )
17116 .unwrap();
17117 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
17121 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
17122 let item = FeedItem { hash, entry };
17123 let identity = FeedIdentity {
17124 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
17125 public_key_spki: spki,
17126 previous: Vec::new(),
17127 rotations: Vec::new(),
17128 };
17129 assert!(verify_feed_item(&item, &identity).is_ok());
17130 }
17131
17132 #[test]
17133 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
17134 let rng = ring::rand::SystemRandom::new();
17135 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17136 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17137 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17138 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17139 let (old_spki, old_multikey) = public_identity_for(&old);
17140 let (new_spki, new_multikey) = public_identity_for(&new);
17141 let unsigned = serde_json::to_string(&UnsignedRotation {
17142 v: 1,
17143 op: "rotate",
17144 brain: &old_multikey,
17145 public_key: &old_spki,
17146 new_brain: &new_multikey,
17147 new_public_key: &new_spki,
17148 prior_head_seq: 1,
17149 prior_feed_hash: Some(&"a".repeat(64)),
17150 ts: "2026-07-30T12:00:00.000Z".to_string(),
17151 })
17152 .unwrap();
17153 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
17154 let rotation = format!(
17155 "{},\"sig\":\"{}\"}}",
17156 &unsigned[..unsigned.len() - 1],
17157 signature
17158 );
17159 let identity = FeedIdentity {
17160 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17161 public_key_spki: new_spki,
17162 previous: vec![PreviousIdentity {
17163 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17164 public_key_spki: old_spki,
17165 }],
17166 rotations: vec![rotation],
17167 };
17168 let pin = TrustState {
17169 v: 2,
17170 origin: "https://hub.example".to_string(),
17171 requested: "brain".to_string(),
17172 brain: "brain".to_string(),
17173 home: None,
17174 anchor: old_multikey.clone(),
17175 current: old_multikey.clone(),
17176 head_seq: 1,
17177 feed_hash: Some("a".repeat(64)),
17178 rotations: Vec::new(),
17179 hub_signer: None,
17180 protocol_profile: None,
17181 };
17182 assert_eq!(
17183 verify_identity_chain(&identity, Some(&pin)).unwrap(),
17184 old_multikey
17185 );
17186 let mut accepted = pin.clone();
17187 accepted.current = new_multikey.clone();
17188 accepted.rotations = identity.rotations.clone();
17189 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
17190 v: 1,
17191 op: "rotate",
17192 brain: &old_multikey,
17193 public_key: &identity.previous[0].public_key_spki,
17194 new_brain: &new_multikey,
17195 new_public_key: &identity.public_key_spki,
17196 prior_head_seq: 1,
17197 prior_feed_hash: Some(&"a".repeat(64)),
17198 ts: "2026-07-30T12:00:01.000Z".to_string(),
17199 })
17200 .unwrap();
17201 let alternate_signature =
17202 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
17203 let mut rewritten = identity.clone();
17204 rewritten.rotations[0] = format!(
17205 "{},\"sig\":\"{}\"}}",
17206 &alternate_unsigned[..alternate_unsigned.len() - 1],
17207 alternate_signature
17208 );
17209 assert!(
17210 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
17211 "an alternate valid statement must not rewrite accepted history"
17212 );
17213
17214 let mut stale_entry = FeedEntry {
17215 v: 1,
17216 seq: 2,
17217 ts: "2026-07-30T12:01:00.000Z".to_string(),
17218 brain: pin.current.clone(),
17219 public_key: identity.previous[0].public_key_spki.clone(),
17220 kind: "push".to_string(),
17221 op: "snapshot".to_string(),
17222 pack_sha256: "b".repeat(64),
17223 files: Vec::new(),
17224 removed: Vec::new(),
17225 prev_entry_hash: pin.feed_hash.clone(),
17226 sig: String::new(),
17227 };
17228 let stale_unsigned = UnsignedFeedEntry {
17229 v: stale_entry.v,
17230 seq: stale_entry.seq,
17231 ts: &stale_entry.ts,
17232 brain: &stale_entry.brain,
17233 public_key: &stale_entry.public_key,
17234 kind: &stale_entry.kind,
17235 op: &stale_entry.op,
17236 pack_sha256: &stale_entry.pack_sha256,
17237 files: &stale_entry.files,
17238 removed: &stale_entry.removed,
17239 prev_entry_hash: &stale_entry.prev_entry_hash,
17240 };
17241 stale_entry.sig = URL_SAFE_NO_PAD.encode(
17242 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
17243 .as_ref(),
17244 );
17245 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
17246 stale_exact.push(b'\n');
17247 let stale_item = FeedItem {
17248 hash: content_sha256(&stale_exact),
17249 entry: stale_entry,
17250 };
17251 assert!(
17252 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
17253 .is_err(),
17254 "a key retired before the checkpoint must never append after it"
17255 );
17256 assert!(
17257 verify_feed_item(&stale_item, &identity).is_err(),
17258 "an old key must never append after its signed rotation boundary"
17259 );
17260
17261 let mut missing = identity.clone();
17262 missing.rotations.clear();
17263 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
17264
17265 let mut tampered = identity;
17266 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
17267 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
17268 }
17269
17270 #[cfg(unix)]
17271 #[test]
17272 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
17273 use std::os::unix::fs::symlink;
17274
17275 let dir = tempfile::tempdir().unwrap();
17276 let target = dir.path().join("valuable.txt");
17277 let planted = dir.path().join("agent.key");
17278 std::fs::write(&target, "do not overwrite").unwrap();
17279 symlink(&target, &planted).unwrap();
17280
17281 assert!(matches!(
17282 generate_agent_key(&planted),
17283 Err(LinkError::BadAgentKey { .. })
17284 ));
17285 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
17286 }
17287
17288 #[cfg(unix)]
17289 #[test]
17290 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
17291 use std::os::unix::fs::symlink;
17292
17293 let root = tempfile::tempdir().unwrap();
17294 let outside = tempfile::tempdir().unwrap();
17295 symlink(outside.path(), root.path().join("redirect")).unwrap();
17296
17297 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
17298 assert!(!outside.path().join("agent.key").exists());
17299 }
17300
17301 #[test]
17304 fn address_bare_brain_with_and_without_sigil() {
17305 for raw in ["@acme-ops", "acme-ops"] {
17306 let a = Address::parse(raw).expect(raw);
17307 assert_eq!(a.brain, "acme-ops");
17308 assert_eq!(a.target, None);
17309 }
17310 }
17311
17312 #[test]
17313 fn address_ulid_target_parses_as_id() {
17314 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
17315 assert_eq!(a.brain, "acme");
17316 assert_eq!(
17317 a.target,
17318 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
17319 );
17320 }
17321
17322 #[test]
17323 fn address_md_path_target_parses_as_path() {
17324 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
17325 assert_eq!(
17326 a.target,
17327 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
17328 );
17329 }
17330
17331 #[test]
17332 fn address_rejects_malformed_forms() {
17333 for raw in [
17334 "",
17335 "@",
17336 "@/x",
17337 "@acme/",
17338 "@acme/../etc/passwd",
17339 "@acme/records/.hidden.md",
17340 "@ACME", "@acme/notes/x.txt", "@a b", ] {
17344 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
17345 }
17346 }
17347
17348 #[test]
17351 fn safe_paths_accept_store_shapes_and_reject_escapes() {
17352 for ok in [
17353 "DB.md",
17354 "assets.jsonl",
17355 "records/clients/lumio.md",
17356 "sources/emails/2026/07/x.md",
17357 ] {
17358 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
17359 }
17360 for bad in [
17361 "",
17362 "/etc/passwd",
17363 "../up.md",
17364 "records/../../up.md",
17365 "records//x.md",
17366 ".dbmd/config",
17367 "records/.hidden/x.md",
17368 "records/a b.md",
17369 "records\\win.md",
17370 ] {
17371 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
17372 }
17373 }
17374
17375 #[cfg(unix)]
17376 #[test]
17377 fn opened_destination_capability_survives_an_ancestor_path_swap() {
17378 use std::os::unix::fs::symlink;
17379
17380 let work = tempfile::tempdir().unwrap();
17381 let outside = tempfile::tempdir().unwrap();
17382 let original = work.path().join("destination");
17383 let moved = work.path().join("destination-moved");
17384 let directory = open_or_create_dir_nofollow(&original).unwrap();
17385
17386 std::fs::rename(&original, &moved).unwrap();
17387 symlink(outside.path(), &original).unwrap();
17388 write_pull_entries_beneath_dir(
17389 &directory,
17390 &[("records/note.md".to_string(), b"held inode".to_vec())],
17391 )
17392 .unwrap();
17393
17394 assert_eq!(
17395 std::fs::read(moved.join("records/note.md")).unwrap(),
17396 b"held inode"
17397 );
17398 assert!(!outside.path().join("records/note.md").exists());
17399 }
17400
17401 #[test]
17405 fn hub_config_flag_beats_file_and_requires_some_source() {
17406 let dir = tempfile::tempdir().unwrap();
17407 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
17408 std::fs::write(
17409 dir.path().join(CONFIG_REL_PATH),
17410 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
17411 )
17412 .unwrap();
17413
17414 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
17415 assert_eq!(from_flag.hub, "https://flag.example.com");
17416
17417 let from_file = hub_config(None, dir.path()).unwrap();
17418 assert_eq!(from_file.hub, "https://file.example.com");
17419
17420 let none = hub_config(None, tempfile::tempdir().unwrap().path());
17421 assert!(matches!(none, Err(LinkError::NoHub)));
17422 }
17423
17424 #[test]
17425 fn https_guard_allows_loopback_only_for_plain_http() {
17426 assert!(assert_safe_hub("https://hub.example.com").is_ok());
17427 assert!(assert_safe_hub("http://localhost:3000").is_ok());
17428 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
17429 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
17430 assert!(matches!(
17431 assert_safe_hub("http://hub.example.com"),
17432 Err(LinkError::UnsafeHub { .. })
17433 ));
17434 assert!(matches!(
17435 assert_safe_hub("hub.example.com"),
17436 Err(LinkError::UnsafeHub { .. })
17437 ));
17438 assert!(matches!(
17439 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
17440 Err(LinkError::UnsafeHub { .. })
17441 ));
17442 assert!(matches!(
17443 assert_safe_hub("https://hub.example.com@attacker.example"),
17444 Err(LinkError::UnsafeHub { .. })
17445 ));
17446 assert!(matches!(
17447 assert_safe_hub("https://hub.example.com/base"),
17448 Err(LinkError::UnsafeHub { .. })
17449 ));
17450 }
17451
17452 #[test]
17453 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
17454 for blocked in [
17455 "127.0.0.1",
17456 "10.0.0.1",
17457 "100.64.0.1",
17458 "169.254.169.254",
17459 "172.16.0.1",
17460 "192.168.0.1",
17461 "192.88.99.1",
17462 "198.18.0.1",
17463 "203.0.113.1",
17464 "::1",
17465 "fe80::1",
17466 "fd00::1",
17467 "2001:db8::1",
17468 "2001:1::1",
17469 "2002:7f00:1::",
17470 "3fff::1",
17471 ] {
17472 assert!(
17473 !is_public_registry_ip(blocked.parse().unwrap()),
17474 "must block {blocked}"
17475 );
17476 }
17477 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
17478 assert!(is_public_registry_ip(
17479 "2606:4700:4700::1111".parse().unwrap()
17480 ));
17481 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
17482 }
17483
17484 #[test]
17485 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
17486 use ureq::Resolver as _;
17487
17488 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
17489 let resolver = PinnedRegistryResolver {
17490 netloc: "home.example:443".to_string(),
17491 addresses: vec![pinned],
17492 };
17493 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
17494 assert!(resolver.resolve("127.0.0.1:443").is_err());
17495 assert_eq!(
17496 resolver.resolve("home.example:443").unwrap(),
17497 vec![pinned],
17498 "subsequent connects reuse the validated answer instead of DNS"
17499 );
17500 }
17501
17502 #[test]
17503 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
17504 let cfg = HubConfig {
17505 hub: "https://hub.example".to_string(),
17506 key: None,
17507 agent_key: None,
17508 brain_key: None,
17509 state_dir: tempfile::tempdir().unwrap().keep(),
17510 store_selected: false,
17511 };
17512 assert!(
17513 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
17514 "a production hub must not turn its presigned URL into an SSRF primitive"
17515 );
17516
17517 let store_selected = HubConfig {
17518 hub: "https://127.0.0.1".to_string(),
17519 store_selected: true,
17520 ..cfg
17521 };
17522 assert!(
17523 hub_agent(&store_selected).is_err(),
17524 "bytes in a cloned store must not select a private-network hub"
17525 );
17526 }
17527
17528 #[test]
17529 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
17530 assert_eq!(
17531 one_past_bounded_limit(MAX_PACK_BYTES),
17532 Some(MAX_PACK_BYTES + 1),
17533 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
17534 );
17535 assert_eq!(
17536 presigned_download_read_limit(),
17537 MAX_PACK_BYTES + 1,
17538 "the presigned reader is capped by the client constant, not a hub response"
17539 );
17540 assert_eq!(
17541 one_past_bounded_limit(u64::MAX),
17542 None,
17543 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
17544 );
17545 }
17546
17547 #[test]
17548 fn https_guard_matches_the_scheme_case_insensitively() {
17549 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
17552 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
17553 assert!(matches!(
17555 assert_safe_hub("HTTP://hub.example.com"),
17556 Err(LinkError::UnsafeHub { .. })
17557 ));
17558 }
17559
17560 #[test]
17561 fn clean_key_refuses_paste_artifacts_without_echoing() {
17562 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
17563 for bad in ["vc account", "vc\naccount", "ключ", ""] {
17564 let err = clean_key(bad).unwrap_err();
17565 assert!(matches!(err, LinkError::BadKey));
17566 assert!(
17567 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
17568 "error must not echo the key"
17569 );
17570 }
17571 }
17572
17573 fn dead_hub() -> HubConfig {
17579 HubConfig {
17580 hub: "http://127.0.0.1:9".to_string(),
17581 key: Some("k".to_string()),
17582 agent_key: None,
17583 brain_key: None,
17584 state_dir: PathBuf::from("."),
17585 store_selected: false,
17586 }
17587 }
17588
17589 #[test]
17590 fn request_retries_a_connection_failure_before_sending() {
17591 use std::io::{Read as _, Write as _};
17592 use std::net::TcpListener;
17593 use std::thread;
17594 use std::time::Duration;
17595
17596 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
17597 let address = probe.local_addr().unwrap();
17598 drop(probe);
17599 let server = thread::spawn(move || {
17600 thread::sleep(Duration::from_millis(40));
17601 let listener = TcpListener::bind(address).unwrap();
17602 let (mut stream, _) = listener.accept().unwrap();
17603 let mut request_bytes = [0_u8; 1024];
17604 let _ = stream.read(&mut request_bytes).unwrap();
17605 stream
17606 .write_all(
17607 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17608 )
17609 .unwrap();
17610 });
17611 let cfg = HubConfig {
17612 hub: format!("http://{address}"),
17613 key: None,
17614 agent_key: None,
17615 brain_key: None,
17616 state_dir: tempfile::tempdir().unwrap().keep(),
17617 store_selected: false,
17618 };
17619
17620 let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
17621 assert_eq!(response.status, 200);
17622 assert_eq!(response.body, Some(json!({ "ok": true })));
17623 server.join().unwrap();
17624 }
17625
17626 #[test]
17627 fn a_commit_goes_back_for_a_receipt_it_lost() {
17628 use std::io::{Read as _, Write as _};
17629 use std::net::TcpListener;
17630 use std::thread;
17631
17632 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17638 let address = listener.local_addr().unwrap();
17639 let server = thread::spawn(move || {
17640 let (mut first, _) = listener.accept().unwrap();
17642 let mut bytes = [0_u8; 4096];
17643 let _ = first.read(&mut bytes).unwrap();
17644 first
17645 .write_all(
17646 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
17647 )
17648 .unwrap();
17649 drop(first);
17650 let (mut second, _) = listener.accept().unwrap();
17652 let _ = second.read(&mut bytes).unwrap();
17653 second
17654 .write_all(
17655 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\"}",
17656 )
17657 .unwrap();
17658 });
17659 let cfg = HubConfig {
17660 hub: format!("http://{address}"),
17661 key: Some("k".to_string()),
17662 agent_key: None,
17663 brain_key: None,
17664 state_dir: tempfile::tempdir().unwrap().keep(),
17665 store_selected: false,
17666 };
17667
17668 let response = request_patient(
17669 &cfg,
17670 "POST",
17671 "/api/hub/brains/b/v2/commits",
17672 Some(&json!({ "mutation_id": "dbmd-1" })),
17673 Auth::Required,
17674 )
17675 .expect("the receipt is collected on the second ask");
17676 assert_eq!(response.status, 200);
17677 assert_eq!(
17678 response
17679 .body
17680 .as_ref()
17681 .and_then(|value| value.get("outcome"))
17682 .and_then(Value::as_str),
17683 Some("converged"),
17684 "an already-applied mutation answers with its receipt"
17685 );
17686 server.join().unwrap();
17687 }
17688
17689 #[test]
17690 fn a_mutation_body_that_dies_mid_stream_is_a_transport_failure() {
17691 use std::io::{Read as _, Write as _};
17692 use std::net::TcpListener;
17693 use std::thread;
17694
17695 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17701 let address = listener.local_addr().unwrap();
17702 let server = thread::spawn(move || {
17703 let (mut stream, _) = listener.accept().unwrap();
17704 let mut request_bytes = [0_u8; 1024];
17705 let _ = stream.read(&mut request_bytes).unwrap();
17706 stream
17708 .write_all(
17709 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17710 )
17711 .unwrap();
17712 });
17713 let cfg = HubConfig {
17714 hub: format!("http://{address}"),
17715 key: None,
17716 agent_key: None,
17717 brain_key: None,
17718 state_dir: tempfile::tempdir().unwrap().keep(),
17719 store_selected: false,
17720 };
17721
17722 let error = request(&cfg, "POST", "/truncated", None, Auth::None)
17723 .expect_err("a truncated body must not read as success");
17724 match error {
17725 LinkError::Transport { hub, .. } => {
17726 assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
17727 }
17728 other => panic!("expected a transport failure, got {other:?}"),
17729 }
17730 server.join().unwrap();
17731 }
17732
17733 #[test]
17734 fn a_safe_get_retries_when_its_body_dies_mid_stream() {
17735 use std::io::{Read as _, Write as _};
17736 use std::net::{TcpListener, TcpStream};
17737 use std::thread;
17738
17739 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17740 let address = listener.local_addr().unwrap();
17741 let server = thread::spawn(move || {
17742 let read_request = |stream: &mut TcpStream| {
17743 let mut request = Vec::new();
17744 let mut bytes = [0_u8; 1024];
17745 loop {
17746 let read = stream.read(&mut bytes).unwrap();
17747 if read == 0 {
17748 break;
17749 }
17750 request.extend_from_slice(&bytes[..read]);
17751 let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17752 else {
17753 continue;
17754 };
17755 let headers = String::from_utf8_lossy(&request[..header_end]);
17756 let content_length = headers
17757 .lines()
17758 .find_map(|line| {
17759 let (name, value) = line.split_once(':')?;
17760 name.eq_ignore_ascii_case("content-length")
17761 .then(|| value.trim().parse::<usize>().ok())
17762 .flatten()
17763 })
17764 .unwrap_or(0);
17765 if request.len() >= header_end + 4 + content_length {
17766 break;
17767 }
17768 }
17769 };
17770 let (mut first, _) = listener.accept().unwrap();
17771 read_request(&mut first);
17772 first
17773 .write_all(
17774 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17775 )
17776 .unwrap();
17777 drop(first);
17778
17779 let (mut second, _) = listener.accept().unwrap();
17780 read_request(&mut second);
17781 second
17782 .write_all(
17783 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17784 )
17785 .unwrap();
17786 });
17787 let cfg = HubConfig {
17788 hub: format!("http://{address}"),
17789 key: None,
17790 agent_key: None,
17791 brain_key: None,
17792 state_dir: tempfile::tempdir().unwrap().keep(),
17793 store_selected: false,
17794 };
17795
17796 let response = request(&cfg, "GET", "/retry-body", None, Auth::None)
17797 .expect("a safe read retries the interrupted body");
17798 assert_eq!(response.status, 200);
17799 assert_eq!(response.body, Some(json!({ "ok": true })));
17800 server.join().unwrap();
17801 }
17802
17803 #[test]
17804 fn an_explicit_read_only_post_retries_when_its_body_dies_mid_stream() {
17805 use std::io::{Read as _, Write as _};
17806 use std::net::{TcpListener, TcpStream};
17807 use std::thread;
17808
17809 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17810 let address = listener.local_addr().unwrap();
17811 let server = thread::spawn(move || {
17812 let read_request = |stream: &mut TcpStream| {
17813 let mut request = Vec::new();
17814 let mut bytes = [0_u8; 1024];
17815 loop {
17816 let read = stream.read(&mut bytes).unwrap();
17817 if read == 0 {
17818 break;
17819 }
17820 request.extend_from_slice(&bytes[..read]);
17821 let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17822 else {
17823 continue;
17824 };
17825 let headers = String::from_utf8_lossy(&request[..header_end]);
17826 let content_length = headers
17827 .lines()
17828 .find_map(|line| {
17829 let (name, value) = line.split_once(':')?;
17830 name.eq_ignore_ascii_case("content-length")
17831 .then(|| value.trim().parse::<usize>().ok())
17832 .flatten()
17833 })
17834 .unwrap_or(0);
17835 if request.len() >= header_end + 4 + content_length {
17836 break;
17837 }
17838 }
17839 };
17840 let (mut first, _) = listener.accept().unwrap();
17841 read_request(&mut first);
17842 first
17843 .write_all(
17844 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17845 )
17846 .unwrap();
17847 drop(first);
17848
17849 let (mut second, _) = listener.accept().unwrap();
17850 read_request(&mut second);
17851 second
17852 .write_all(
17853 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17854 )
17855 .unwrap();
17856 });
17857 let cfg = HubConfig {
17858 hub: format!("http://{address}"),
17859 key: None,
17860 agent_key: None,
17861 brain_key: None,
17862 state_dir: tempfile::tempdir().unwrap().keep(),
17863 store_selected: false,
17864 };
17865
17866 let response = request_raw_retryable_read(
17867 &cfg,
17868 "POST",
17869 "/v2/stream",
17870 Some(&json!({ "files": ["proof"] })),
17871 Auth::None,
17872 1_024,
17873 )
17874 .expect("an explicitly safe POST retries the interrupted body");
17875 assert_eq!(response.status, 200);
17876 assert_eq!(
17877 serde_json::from_slice::<Value>(&response.body).unwrap(),
17878 json!({ "ok": true })
17879 );
17880 server.join().unwrap();
17881 }
17882
17883 #[test]
17884 fn object_store_transport_errors_never_render_presigned_urls() {
17885 use std::net::TcpListener;
17886
17887 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17888 let address = listener.local_addr().unwrap();
17889 drop(listener);
17890 let signature = "do-not-render-this-presigned-signature";
17891 let raw =
17892 format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
17893 let error = ureq::get(&raw)
17894 .timeout(std::time::Duration::from_millis(250))
17895 .call()
17896 .expect_err("the closed local port must fail");
17897 let ureq::Error::Transport(transport) = error else {
17898 panic!("expected a transport failure");
17899 };
17900
17901 let rendered = object_store_transport_error(transport).to_string();
17902 assert!(rendered.contains("the object store"));
17903 assert!(rendered.contains("network error"));
17904 assert!(!rendered.contains(&raw));
17905 assert!(!rendered.contains(signature));
17906 assert!(!rendered.contains("X-Amz-"));
17907 }
17908
17909 #[test]
17910 fn endpoint_cap_refuses_a_body_before_json_parsing() {
17911 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
17912 let cfg = HubConfig {
17913 hub,
17914 key: None,
17915 agent_key: None,
17916 brain_key: None,
17917 state_dir: tempfile::tempdir().unwrap().keep(),
17918 store_selected: false,
17919 };
17920
17921 assert!(matches!(
17922 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
17923 Err(LinkError::ResponseTooLarge { .. })
17924 ));
17925 server.join().unwrap();
17926 }
17927
17928 #[test]
17929 fn overall_deadline_stops_a_dribbled_response_body() {
17930 use std::io::{Read as _, Write as _};
17931 use std::net::TcpListener;
17932 use std::time::{Duration, Instant};
17933
17934 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17935 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
17936 let server = std::thread::spawn(move || {
17937 let (mut stream, _) = listener.accept().unwrap();
17938 let mut request = [0_u8; 1024];
17939 let _ = stream.read(&mut request);
17940 stream
17941 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
17942 .unwrap();
17943 for byte in [b'x'; 32] {
17944 if stream.write_all(&[byte]).is_err() {
17945 break;
17946 }
17947 std::thread::sleep(Duration::from_millis(40));
17948 }
17949 });
17950 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17951 let started = Instant::now();
17952 let response = http.get(&url).call().unwrap();
17953 let mut body = Vec::new();
17954 let error = response
17955 .into_reader()
17956 .read_to_end(&mut body)
17957 .expect_err("per-read progress must not reset the overall deadline");
17958 assert!(
17959 started.elapsed() < Duration::from_millis(700),
17960 "dribbled body exceeded the wall-clock budget: {error}"
17961 );
17962 server.join().unwrap();
17963 }
17964
17965 #[test]
17966 fn overall_deadline_stops_a_stalled_upload() {
17967 use std::net::TcpListener;
17968 use std::time::{Duration, Instant};
17969
17970 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17971 let url = format!("http://{}/upload", listener.local_addr().unwrap());
17972 let server = std::thread::spawn(move || {
17973 let (_stream, _) = listener.accept().unwrap();
17974 std::thread::sleep(Duration::from_millis(600));
17977 });
17978 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17979 let body = vec![0x5a; 32 * 1024 * 1024];
17980 let started = Instant::now();
17981 let error = http
17982 .put(&url)
17983 .send_bytes(&body)
17984 .expect_err("stalled request-body writes must time out");
17985 assert!(
17986 started.elapsed() < Duration::from_millis(700),
17987 "stalled upload exceeded the wall-clock budget: {error}"
17988 );
17989 server.join().unwrap();
17990 }
17991
17992 #[test]
17993 fn presigned_source_retries_share_one_upload_deadline() {
17994 use std::net::TcpListener;
17995 use std::time::{Duration, Instant};
17996
17997 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17998 let address = listener.local_addr().unwrap();
17999 let signature = "do-not-render-this-stalled-upload-signature";
18000 let url = format!(
18001 "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
18002 );
18003 let server = std::thread::spawn(move || {
18004 let (_stream, _) = listener.accept().unwrap();
18005 std::thread::sleep(Duration::from_millis(600));
18009 });
18010
18011 let directory = tempfile::tempdir().unwrap();
18012 std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
18013 std::fs::create_dir(directory.path().join("records")).unwrap();
18014 let relative = "records/stalled.bin";
18015 let bytes = vec![0x5a; 32 * 1024 * 1024];
18016 std::fs::write(directory.path().join(relative), &bytes).unwrap();
18017 let store = Store::open_strict(directory.path()).unwrap();
18018 let cfg = HubConfig {
18019 hub: format!("http://{address}"),
18020 key: None,
18021 agent_key: None,
18022 brain_key: None,
18023 state_dir: tempfile::tempdir().unwrap().keep(),
18024 store_selected: false,
18025 };
18026 let source = V2UploadSource {
18027 path: relative.to_string(),
18028 bytes: bytes.len() as u64,
18029 };
18030
18031 let started = Instant::now();
18032 let error = put_presigned_source_with_budget(
18033 &cfg,
18034 &url,
18035 &json!({ "content-length": source.bytes.to_string() }),
18036 &store,
18037 &source,
18038 None,
18039 Duration::from_millis(150),
18040 )
18041 .expect_err("a black-holed upload must leave at its shared deadline");
18042 assert!(
18043 started.elapsed() < Duration::from_millis(700),
18044 "presigned retries exceeded their shared budget: {error}"
18045 );
18046 let rendered = error.to_string();
18047 assert!(rendered.contains("the object store"));
18048 assert!(!rendered.contains(&url));
18049 assert!(!rendered.contains(signature));
18050 server.join().unwrap();
18051 }
18052
18053 #[test]
18054 fn verb_entry_gates_accept_the_hub_ref_shapes() {
18055 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
18056 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
18057 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
18058 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
18059 }
18060 }
18061
18062 #[test]
18063 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
18064 let cfg = dead_hub();
18065 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
18066 assert!(
18067 matches!(
18068 sync_pull(&cfg, bad, None),
18069 Err(LinkError::BadAddress { .. })
18070 ),
18071 "sync_pull must refuse {bad:?}"
18072 );
18073 assert!(
18074 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
18075 "sync_push must refuse {bad:?}"
18076 );
18077 assert!(
18078 matches!(
18079 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
18080 Err(LinkError::BadAddress { .. })
18081 ),
18082 "grant_issue must refuse {bad:?}"
18083 );
18084 assert!(
18085 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
18086 "grant_list must refuse {bad:?}"
18087 );
18088 assert!(
18089 matches!(
18090 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
18091 Err(LinkError::BadAddress { .. })
18092 ),
18093 "grant_revoke must refuse brain {bad:?}"
18094 );
18095 assert!(
18096 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
18097 "head must refuse {bad:?}"
18098 );
18099 }
18100 }
18101
18102 #[test]
18103 fn grant_revoke_refuses_url_reshaping_grant_ids() {
18104 let cfg = dead_hub();
18105 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
18106 assert!(
18107 matches!(
18108 grant_revoke(&cfg, "acme", bad),
18109 Err(LinkError::BadGrantId { .. })
18110 ),
18111 "grant_revoke must refuse grant id {bad:?}"
18112 );
18113 }
18114 }
18115
18116 #[test]
18117 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
18118 let cfg = dead_hub();
18119 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
18120 assert!(
18121 matches!(
18122 propose(&cfg, bad, "intake", "hi"),
18123 Err(LinkError::BadAddress { .. })
18124 ),
18125 "propose must refuse handle {bad:?}"
18126 );
18127 }
18128 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
18129 assert!(matches!(
18130 propose(&cfg, "acme-site", "intake", &oversize),
18131 Err(LinkError::ProposeTooLarge { .. })
18132 ));
18133 assert!(matches!(
18136 propose(&cfg, "acme-site", "intake", "hi"),
18137 Err(LinkError::Transport { .. })
18138 ));
18139 }
18140
18141 #[test]
18142 fn resolve_refuses_a_hand_built_unsafe_address() {
18143 let cfg = dead_hub();
18144 for brain in ["../up", "a/b", "a?x", "a#f"] {
18145 let addr = Address {
18146 brain: brain.to_string(),
18147 target: None,
18148 };
18149 assert!(
18150 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18151 "resolve must refuse brain {brain:?}"
18152 );
18153 }
18154 for target in [
18155 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
18156 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
18158 AddressTarget::Path("records/x.md#frag".to_string()),
18159 ] {
18160 let addr = Address {
18161 brain: "acme".to_string(),
18162 target: Some(target.clone()),
18163 };
18164 assert!(
18165 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18166 "resolve must refuse target {target:?}"
18167 );
18168 }
18169 }
18170
18171 #[test]
18172 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
18173 let mut local = std::collections::BTreeMap::new();
18174 local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
18175 local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
18176 let mut remote = std::collections::BTreeMap::new();
18177 remote.insert(
18178 "records/a.md".to_string(),
18179 V2BaselineFile {
18180 sha256: "c".repeat(64),
18181 bytes: 1,
18182 proof: None,
18183 },
18184 );
18185 remote.insert(
18186 "records/b.md".to_string(),
18187 V2BaselineFile {
18188 sha256: "b".repeat(64),
18189 bytes: 1,
18190 proof: None,
18191 },
18192 );
18193 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18194 }
18195
18196 #[test]
18197 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
18198 let local = std::collections::BTreeMap::new();
18199 let mut remote = std::collections::BTreeMap::new();
18200 remote.insert(
18201 "private/local.md".to_string(),
18202 V2BaselineFile {
18203 sha256: "d".repeat(64),
18204 bytes: 1,
18205 proof: None,
18206 },
18207 );
18208 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
18209 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18210 }
18211
18212 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
18213 V2VerifiedHead {
18214 requested: TEST_BRAIN_ID.to_string(),
18215 brain_id: TEST_BRAIN_ID.to_string(),
18216 view_kind: "scoped".to_string(),
18217 view_revision: revision.to_string(),
18218 control_revision: revision.to_string(),
18219 identity: V2HeadIdentity {
18220 custody: "hub".to_string(),
18221 fingerprint: "test".to_string(),
18222 public_key_spki: "test".to_string(),
18223 previous: Vec::new(),
18224 rotations: Vec::new(),
18225 },
18226 pointer: None,
18227 trust: TrustState {
18228 v: 2,
18229 origin: "https://hub.example".to_string(),
18230 requested: TEST_BRAIN_ID.to_string(),
18231 brain: TEST_BRAIN_ID.to_string(),
18232 home: None,
18233 anchor: "ed25519:test".to_string(),
18234 current: "ed25519:test".to_string(),
18235 head_seq: 0,
18236 feed_hash: None,
18237 rotations: Vec::new(),
18238 hub_signer: None,
18239 protocol_profile: Some("link-v2".to_string()),
18240 },
18241 alias: None,
18242 }
18243 }
18244
18245 #[test]
18246 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
18247 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
18248 assert!(accepted_as_v2(&trust));
18249
18250 trust.protocol_profile = None;
18251 trust.hub_signer = Some("ed25519:hub".to_string());
18252 assert!(accepted_as_v2(&trust));
18253
18254 trust.hub_signer = None;
18255 assert!(!accepted_as_v2(&trust));
18256 }
18257
18258 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
18259 V2SyncBaseline {
18260 v: 2,
18261 origin: "https://hub.example".to_string(),
18262 brain: TEST_BRAIN_ID.to_string(),
18263 checkout_id: Some("c".repeat(64)),
18264 head_seq: Some(0),
18265 commit_hash: None,
18266 content_root: None,
18267 asset_root: None,
18268 assets: std::collections::BTreeMap::new(),
18269 view_kind: Some("scoped".to_string()),
18270 view_revision: Some(revision.to_string()),
18271 control_revision: Some(revision.to_string()),
18272 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
18273 files: std::collections::BTreeMap::new(),
18274 local_policy_digest: None,
18275 local_eligibility: std::collections::BTreeMap::new(),
18276 remote_copy_remains: std::collections::BTreeMap::new(),
18277 }
18278 }
18279
18280 #[test]
18281 fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
18282 let cfg = test_hub_config(
18283 "https://hub.example".to_string(),
18284 tempfile::tempdir().unwrap().keep(),
18285 );
18286 let mut baseline = scoped_test_baseline(&"a".repeat(64));
18287 baseline.assets.insert(
18288 "assets/archive.bin".to_string(),
18289 V2BaselineAsset {
18290 blob_sha256: "b".repeat(64),
18291 bytes: MAX_STORE_BYTES + 1,
18292 media_type: "application/octet-stream".to_string(),
18293 wrappers: vec!["records/archive.md".to_string()],
18294 required: true,
18295 disposition: "hosted".to_string(),
18296 leaf_hash: "c".repeat(64),
18297 },
18298 );
18299
18300 let accepted = serde_json::to_vec(&baseline).unwrap();
18301 assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
18302
18303 baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
18304 let refused = serde_json::to_vec(&baseline).unwrap();
18305 assert!(matches!(
18306 parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
18307 Err(LinkError::InvalidFeed { .. })
18308 ));
18309 }
18310
18311 #[test]
18312 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
18313 let directory = tempfile::tempdir().unwrap();
18314 std::fs::write(
18315 directory.path().join("DB.md"),
18316 scoped_projection_bytes(TEST_BRAIN_ID),
18317 )
18318 .unwrap();
18319 let store = Store::open_strict(directory.path()).unwrap();
18320 let head = scoped_test_head(&"a".repeat(64));
18321 let baseline = scoped_test_baseline(&"a".repeat(64));
18322 let mut view = v2_local_files(&store).unwrap();
18323 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
18324 assert!(!view.riding.contains_key("DB.md"));
18325 assert!(!view.eligibility.contains_key("DB.md"));
18326 }
18327
18328 #[test]
18329 fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
18330 let directory = tempfile::tempdir().unwrap();
18331 std::fs::write(
18332 directory.path().join("DB.md"),
18333 scoped_projection_bytes(TEST_BRAIN_ID),
18334 )
18335 .unwrap();
18336 let store = Store::open_strict(directory.path()).unwrap();
18337 let head = scoped_test_head(&"a".repeat(64));
18338 let baseline = scoped_test_baseline(&"a".repeat(64));
18339
18340 let mut carried = v2_local_files(&store).unwrap();
18341 remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
18342 let handed_off =
18343 local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
18344 assert!(!handed_off.riding.contains_key("DB.md"));
18345
18346 let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
18347 assert!(!freshly_scanned.riding.contains_key("DB.md"));
18348
18349 std::fs::write(
18350 directory.path().join("DB.md"),
18351 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18352 )
18353 .unwrap();
18354 let tampered = Store::open_strict(directory.path()).unwrap();
18355 assert!(matches!(
18356 local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
18357 Err(LinkError::ScopedProjectionModified)
18358 ));
18359 }
18360
18361 #[test]
18362 fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
18363 let directory = tempfile::tempdir().unwrap();
18364 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18365 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18366 std::fs::write(
18367 directory.path().join("DB.md"),
18368 b"---\nname: Kept home test\n---\n",
18369 )
18370 .unwrap();
18371 std::fs::write(
18372 directory.path().join("records/notes/a.md"),
18373 b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
18374 )
18375 .unwrap();
18376 std::fs::write(
18377 directory.path().join("sources/private/secret.md"),
18378 b"---\ntype: note\n---\nlocal only\n",
18379 )
18380 .unwrap();
18381 std::fs::write(
18382 directory.path().join("sources/private/unlinked.md"),
18383 b"---\ntype: note\n---\nnot disclosed\n",
18384 )
18385 .unwrap();
18386 std::fs::write(
18387 directory.path().join(".sevralocal"),
18388 b"sources/private/**\n",
18389 )
18390 .unwrap();
18391
18392 let store = Store::open_strict(directory.path()).unwrap();
18393 let view = v2_local_files(&store).unwrap();
18394 assert!(!view.riding.contains_key("sources/private/secret.md"));
18395 assert_eq!(
18396 view.withheld_links,
18397 vec![V2WithheldLink {
18398 source: "records/notes/a.md".to_string(),
18399 target: "sources/private/secret.md".to_string(),
18400 }]
18401 );
18402 }
18403
18404 #[test]
18405 fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
18406 let directory = tempfile::tempdir().unwrap();
18411 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18412 std::fs::write(
18413 directory.path().join("DB.md"),
18414 b"---\nname: Restored export\n---\n",
18415 )
18416 .unwrap();
18417 std::fs::write(
18418 directory.path().join("records/notes/a.md"),
18419 b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
18420 )
18421 .unwrap();
18422 std::fs::write(
18423 directory.path().join(".sevralocal"),
18424 b"sources/private/**\n",
18425 )
18426 .unwrap();
18427
18428 let store = Store::open_strict(directory.path()).unwrap();
18429 let view = v2_local_files(&store).unwrap();
18430 assert_eq!(
18431 view.withheld_links,
18432 vec![V2WithheldLink {
18433 source: "records/notes/a.md".to_string(),
18434 target: "sources/private/absent.md".to_string(),
18435 }]
18436 );
18437 std::fs::write(
18439 directory.path().join("records/notes/b.md"),
18440 b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
18441 )
18442 .unwrap();
18443 let store = Store::open_strict(directory.path()).unwrap();
18444 let view = v2_local_files(&store).unwrap();
18445 assert!(
18446 !view
18447 .withheld_links
18448 .iter()
18449 .any(|link| link.target == "records/notes/nowhere.md"),
18450 "an unclaimed dangling target must not be declared withheld"
18451 );
18452 }
18453
18454 #[test]
18455 fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
18456 let directory = tempfile::tempdir().unwrap();
18457 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18458 std::fs::write(
18459 directory.path().join("DB.md"),
18460 b"---\nname: Withdrawal test\n---\n",
18461 )
18462 .unwrap();
18463 let source = b"---\ntype: note\n---\nlocal evidence\n";
18464 std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
18465 std::fs::write(
18466 directory.path().join(".sevralocal"),
18467 b"sources/private/**\n",
18468 )
18469 .unwrap();
18470 let store = Store::open_strict(directory.path()).unwrap();
18471 let view = v2_local_files(&store).unwrap();
18472 let mut remote = std::collections::BTreeMap::new();
18473 remote.insert(
18474 "sources/private/evidence.md".to_string(),
18475 V2BaselineFile {
18476 sha256: content_sha256(source),
18477 bytes: source.len() as u64,
18478 proof: None,
18479 },
18480 );
18481 assert_eq!(
18482 v2_content_withdrawal_operation(
18483 &store,
18484 &view,
18485 &remote,
18486 "sources/private/evidence.md",
18487 "approved retention change",
18488 )
18489 .unwrap(),
18490 json!({
18491 "op": "withdraw_from_hosting",
18492 "path": "sources/private/evidence.md",
18493 "expected": { "kind": "blob", "hash": content_sha256(source) },
18494 "reason": "approved retention change",
18495 })
18496 );
18497
18498 std::fs::write(
18499 directory.path().join("sources/private/evidence.md"),
18500 b"changed after review",
18501 )
18502 .unwrap();
18503 assert!(matches!(
18504 v2_content_withdrawal_operation(
18505 &store,
18506 &view,
18507 &remote,
18508 "sources/private/evidence.md",
18509 "approved retention change",
18510 ),
18511 Err(LinkError::InvalidPack { .. })
18512 ));
18513 }
18514
18515 #[test]
18516 fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
18517 let directory = tempfile::tempdir().unwrap();
18518 std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
18519 std::fs::write(
18520 directory.path().join("DB.md"),
18521 b"---\nname: Asset withdrawal test\n---\n",
18522 )
18523 .unwrap();
18524 let bytes = b"private binary";
18525 std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
18526 std::fs::write(
18527 directory.path().join(".sevralocal"),
18528 b"sources/files/private.pdf\n",
18529 )
18530 .unwrap();
18531 let store = Store::open_strict(directory.path()).unwrap();
18532 let view = v2_local_files(&store).unwrap();
18533 let local = crate::AssetRecord {
18534 path: "sources/files/private.pdf".to_string(),
18535 sha256: content_sha256(bytes),
18536 bytes: bytes.len() as u64,
18537 media_type: "application/pdf".to_string(),
18538 wrappers: vec!["sources/files/private.md".to_string()],
18539 required: true,
18540 };
18541 let current = V2BaselineAsset {
18542 blob_sha256: local.sha256.clone(),
18543 bytes: local.bytes,
18544 media_type: local.media_type.clone(),
18545 wrappers: local.wrappers.clone(),
18546 required: local.required,
18547 disposition: "hosted".to_string(),
18548 leaf_hash: "d".repeat(64),
18549 };
18550 assert_eq!(
18551 v2_asset_withdrawal_operation(
18552 &store,
18553 &view,
18554 &local.path,
18555 &local,
18556 ¤t,
18557 "approved retention change",
18558 )
18559 .unwrap(),
18560 json!({
18561 "op": "asset_withdraw",
18562 "path": local.path,
18563 "expected": { "kind": "asset", "hash": "d".repeat(64) },
18564 "reason": "approved retention change",
18565 })
18566 );
18567
18568 let mut mismatched = current.clone();
18569 mismatched.required = false;
18570 assert!(matches!(
18571 v2_asset_withdrawal_operation(
18572 &store,
18573 &view,
18574 &local.path,
18575 &local,
18576 &mismatched,
18577 "approved retention change",
18578 ),
18579 Err(LinkError::InvalidPack { .. })
18580 ));
18581 }
18582
18583 #[test]
18584 fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
18585 let first = v2_checkout_id(None).unwrap();
18586 assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
18587 assert_ne!(first, v2_checkout_id(None).unwrap());
18588 assert!(is_sha256(&first));
18589 }
18590
18591 #[test]
18592 fn scoped_projection_edit_and_scope_transition_fail_closed() {
18593 let directory = tempfile::tempdir().unwrap();
18594 std::fs::write(
18595 directory.path().join("DB.md"),
18596 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
18597 )
18598 .unwrap();
18599 let store = Store::open_strict(directory.path()).unwrap();
18600 let head = scoped_test_head(&"a".repeat(64));
18601 let baseline = scoped_test_baseline(&"a".repeat(64));
18602 let mut view = v2_local_files(&store).unwrap();
18603 assert!(matches!(
18604 remove_scoped_projection(&head, Some(&baseline), &mut view),
18605 Err(LinkError::ScopedProjectionModified)
18606 ));
18607
18608 let changed = scoped_test_head(&"b".repeat(64));
18609 assert!(matches!(
18610 ensure_v2_view_compatible(&changed, Some(&baseline)),
18611 Err(LinkError::ScopedViewChanged)
18612 ));
18613
18614 let mut same_view_new_control = head.clone();
18615 same_view_new_control.control_revision = "c".repeat(64);
18616 assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
18617 assert!(!same_v2_head(&head, &same_view_new_control));
18618 }
18619
18620 #[test]
18621 fn verified_baseline_reuse_requires_exact_content_assets_view_and_authority() {
18622 let mut head = scoped_test_head(&"a".repeat(64));
18623 head.control_revision = "b".repeat(64);
18624 head.pointer = Some(V2PointerBody {
18625 v: 2,
18626 brain: TEST_BRAIN_ID.to_string(),
18627 seq: 7,
18628 commit_hash: "c".repeat(64),
18629 feed_hash: "d".repeat(64),
18630 content_root: Some("e".repeat(64)),
18631 asset_root: Some("f".repeat(64)),
18632 materializer: "dbmd-projection-v1".to_string(),
18633 signer_epoch: 1,
18634 control_revision: head.control_revision.clone(),
18635 backup_preparation: "0".repeat(64),
18636 prior_pointer_hash: Some("1".repeat(64)),
18637 signed_at: "2026-08-24T00:00:00.000Z".to_string(),
18638 });
18639 let mut baseline = scoped_test_baseline(&head.view_revision);
18640 baseline.head_seq = Some(7);
18641 baseline.commit_hash = Some("c".repeat(64));
18642 baseline.content_root = Some("e".repeat(64));
18643 baseline.asset_root = Some("f".repeat(64));
18644 baseline.control_revision = Some(head.control_revision.clone());
18645 assert!(v2_baseline_matches_head(&head, &baseline));
18646
18647 let mut changed = baseline.clone();
18648 changed.head_seq = Some(8);
18649 assert!(!v2_baseline_matches_head(&head, &changed));
18650 let mut changed = baseline.clone();
18651 changed.commit_hash = Some("2".repeat(64));
18652 assert!(!v2_baseline_matches_head(&head, &changed));
18653 let mut changed = baseline.clone();
18654 changed.content_root = Some("3".repeat(64));
18655 assert!(!v2_baseline_matches_head(&head, &changed));
18656 let mut changed = baseline.clone();
18657 changed.asset_root = Some("4".repeat(64));
18658 assert!(!v2_baseline_matches_head(&head, &changed));
18659 let mut changed = baseline.clone();
18660 changed.view_revision = Some("5".repeat(64));
18661 assert!(!v2_baseline_matches_head(&head, &changed));
18662 let mut changed = baseline.clone();
18663 changed.control_revision = Some("6".repeat(64));
18664 assert!(!v2_baseline_matches_head(&head, &changed));
18665
18666 let mut changed_head = head.clone();
18667 changed_head.view_kind = "full".to_string();
18668 assert!(!v2_baseline_matches_head(&changed_head, &baseline));
18669 }
18670
18671 #[test]
18672 fn legacy_baseline_without_authority_revision_refreshes_before_reuse() {
18673 let sandbox = tempfile::tempdir().unwrap();
18674 let cfg = test_hub_config(
18675 "https://hub.example".to_string(),
18676 sandbox.path().to_path_buf(),
18677 );
18678 let head = scoped_test_head(&"a".repeat(64));
18679 let baseline = scoped_test_baseline(&head.view_revision);
18680 let mut encoded = serde_json::to_value(&baseline).unwrap();
18681 encoded.as_object_mut().unwrap().remove("control_revision");
18682 let parsed =
18683 parse_v2_baseline(&cfg, TEST_BRAIN_ID, &serde_json::to_vec(&encoded).unwrap()).unwrap();
18684 assert!(parsed.control_revision.is_none());
18685 assert!(!v2_baseline_matches_head(&head, &parsed));
18686 }
18687
18688 #[test]
18689 fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
18690 let scoped = scoped_test_head(&"a".repeat(64));
18691 let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
18692 assert!(matches!(
18693 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
18694 Err(LinkError::ScopedProjectionModified)
18695 ));
18696
18697 let mut full = scoped.clone();
18698 full.view_kind = "full".to_string();
18699 let mut full_baseline = scoped_baseline.clone();
18700 full_baseline.view_kind = Some("full".to_string());
18701 full_baseline.projection_sha256 = None;
18702 assert!(matches!(
18703 ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
18704 Err(LinkError::InvalidPack { .. })
18705 ));
18706
18707 assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
18708 assert!(
18709 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
18710 );
18711 }
18712
18713 #[test]
18714 fn scoped_view_metadata_is_explicitly_non_authoritative() {
18715 let head = scoped_test_head(&"a".repeat(64));
18716 let value: Value =
18717 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
18718 assert_eq!(value["kind"], "link.md-scoped-view");
18719 assert_eq!(value["authoritative"], false);
18720 assert_eq!(value["visible_files"], 7);
18721 assert_eq!(value["brain"], TEST_BRAIN_ID);
18722 }
18723
18724 #[test]
18725 fn local_scoped_marker_requires_the_exact_generated_projection() {
18726 let directory = tempfile::tempdir().unwrap();
18727 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
18728 std::fs::write(
18729 directory.path().join("DB.md"),
18730 scoped_projection_bytes(TEST_BRAIN_ID),
18731 )
18732 .unwrap();
18733 let head = scoped_test_head(&"a".repeat(64));
18734 std::fs::write(
18735 directory.path().join(".dbmd/view.json"),
18736 scoped_view_metadata(&head, 0).unwrap(),
18737 )
18738 .unwrap();
18739 let store = Store::open_strict(directory.path()).unwrap();
18740 assert!(has_verified_local_scoped_view(&store));
18741
18742 std::fs::write(
18743 directory.path().join("DB.md"),
18744 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18745 )
18746 .unwrap();
18747 let altered = Store::open_strict(directory.path()).unwrap();
18748 assert!(!has_verified_local_scoped_view(&altered));
18749 }
18750
18751 fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
18752 use ring::signature::KeyPair as _;
18753
18754 let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
18755 let rng = ring::rand::SystemRandom::new();
18756 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18757 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18758 let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
18759 let public_key = URL_SAFE_NO_PAD.encode(&public_der);
18760 let fingerprint = format!("{:x}", Sha256::digest(&public_der));
18761 let blob = b"new";
18762 let blob_hash = content_sha256(blob);
18763 let changes = json!({
18764 "mutation_id": "sync:proposal-fixture",
18765 "operations": [{
18766 "blob": blob_hash,
18767 "bytes": blob.len(),
18768 "expected": null,
18769 "op": "put",
18770 "path": "records/new.md",
18771 }],
18772 "reason": "fixture",
18773 "v": 2,
18774 });
18775 let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
18776 let changes_base64 = STANDARD.encode(&changes_bytes);
18777 let descriptor = json!({
18778 "base": null,
18779 "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
18780 "changes_base64": changes_base64,
18781 "rebase": "strict",
18782 "v": 2,
18783 });
18784 let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
18785 let payload_hash = "b".repeat(64);
18786 let submitted_at = "2026-08-19T12:00:00.000Z";
18787 let claim = json!({
18788 "actor_root": {
18789 "actor_class": "foreign_key",
18790 "credential": "ed25519:fixture",
18791 "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
18792 "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
18793 "principal": "key:fixture",
18794 "role": null,
18795 },
18796 "brain": TEST_BRAIN_ID,
18797 "clear_sha256": clear_hash,
18798 "control_revision": "c".repeat(64),
18799 "mutation_id": "sync:proposal-fixture",
18800 "payload_sha256": payload_hash,
18801 "proposal_id": proposal_id,
18802 "submitted_at": submitted_at,
18803 "v": 2,
18804 });
18805 let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
18806 let envelope = json!({
18807 "claim": claim,
18808 "fingerprint": fingerprint,
18809 "public_key": public_key,
18810 "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
18811 });
18812 let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18813 let submission_hash =
18814 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
18815 let mut head = scoped_test_head(&"c".repeat(64));
18816 head.view_kind = "full".to_string();
18817 head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
18818 let value = json!({
18819 "proposal": {
18820 "base": null,
18821 "blobs": [{
18822 "bytes": blob.len(),
18823 "endpoint": format!(
18824 "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
18825 ),
18826 "sha256": blob_hash,
18827 }],
18828 "changes_base64": changes_base64,
18829 "clear_sha256": clear_hash,
18830 "expires_at": "2026-08-26T12:00:00.000Z",
18831 "id": proposal_id,
18832 "payload_sha256": payload_hash,
18833 "proposer": { "class": "foreign_key" },
18834 "rebase": "strict",
18835 "state": "pending",
18836 "submission_claim_base64": STANDARD.encode(envelope_bytes),
18837 "submission_claim_sha256": submission_hash,
18838 "submitted_at": submitted_at,
18839 },
18840 "v": 2,
18841 });
18842 (head, proposal_id, value)
18843 }
18844
18845 #[test]
18846 fn v2_proposal_verifier_accepts_exact_signed_payload() {
18847 let (head, proposal_id, value) = signed_proposal_fixture();
18848 let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
18849 assert_eq!(verified.blobs.len(), 1);
18850 assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
18851 }
18852
18853 #[test]
18854 fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
18855 let (head, proposal_id, value) = signed_proposal_fixture();
18856
18857 let mut changed = value.clone();
18858 changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
18859 assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
18860
18861 let mut redirected = value.clone();
18862 redirected["proposal"]["blobs"][0]["endpoint"] =
18863 Value::String("https://attacker.example/blob".to_string());
18864 assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
18865
18866 let mut forged = value;
18867 let encoded = forged["proposal"]["submission_claim_base64"]
18868 .as_str()
18869 .unwrap();
18870 let mut envelope: Value =
18871 serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
18872 envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
18873 let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18874 forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
18875 forged["proposal"]["submission_claim_sha256"] = Value::String(
18876 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
18877 );
18878 assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
18879 }
18880
18881 #[cfg(unix)]
18882 #[test]
18883 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
18884 let sandbox = tempfile::tempdir().unwrap();
18885 let destination = sandbox.path().join("brain");
18886 let entries = vec![
18887 (
18888 "DB.md".to_string(),
18889 scoped_projection_bytes(TEST_BRAIN_ID),
18890 ),
18891 (
18892 "records/contacts/a.md".to_string(),
18893 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
18894 .to_vec(),
18895 ),
18896 ];
18897 install_pulled_delta(&destination, &entries, &[], true).unwrap();
18898 assert!(destination.join("index.md").is_file());
18899 assert!(destination.join("records/index.md").is_file());
18900 assert!(destination.join("records/contacts/index.md").is_file());
18901 assert!(destination.join("records/contacts/index.jsonl").is_file());
18902 }
18903
18904 #[cfg(unix)]
18905 #[test]
18906 fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
18907 let sandbox = tempfile::tempdir().unwrap();
18908 let destination = sandbox.path().join("brain");
18909 let cache = sandbox.path().join("cache");
18910 std::fs::create_dir(&cache).unwrap();
18911 let db = scoped_projection_bytes(TEST_BRAIN_ID);
18912 let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
18913 let db_source = cache.join("db");
18914 let shared_source = cache.join("shared");
18915 crate::fsx::write_atomic(&db_source, &db).unwrap();
18916 crate::fsx::write_atomic(&shared_source, shared).unwrap();
18917 let mut entries = vec![V2StagedFile {
18918 path: "DB.md".to_string(),
18919 source: db_source,
18920 sha256: content_sha256(&db),
18921 bytes: db.len() as u64,
18922 }];
18923 for index in 0..512 {
18924 entries.push(V2StagedFile {
18925 path: format!("records/items/{index:05}.md"),
18926 source: shared_source.clone(),
18927 sha256: content_sha256(shared),
18928 bytes: shared.len() as u64,
18929 });
18930 }
18931 install_pulled_delta_sources(
18932 &destination,
18933 &entries,
18934 &[],
18935 false,
18936 None,
18937 &scoped_test_head(&"c".repeat(64)),
18938 )
18939 .unwrap();
18940 assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
18941 for index in 0..512 {
18942 assert_eq!(
18943 std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
18944 shared
18945 );
18946 }
18947 assert!(
18948 std::fs::read_dir(sandbox.path())
18949 .unwrap()
18950 .all(|entry| !entry
18951 .unwrap()
18952 .file_name()
18953 .to_string_lossy()
18954 .contains("pull-stage")),
18955 "the private stage must be atomically installed or removed"
18956 );
18957 }
18958
18959 #[cfg(unix)]
18960 #[test]
18961 fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
18962 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
18963
18964 let sandbox = tempfile::tempdir().unwrap();
18965 let root = sandbox.path().join("brain");
18966 std::fs::create_dir_all(root.join("records/items")).unwrap();
18967 let db = scoped_projection_bytes(TEST_BRAIN_ID);
18968 let old = b"---\ntype: note\n---\n\nold\n";
18969 let new = b"---\ntype: note\n---\n\nnew\n";
18970 let removed = b"---\ntype: note\n---\n\nremove me\n";
18971 std::fs::write(root.join("DB.md"), &db).unwrap();
18972 std::fs::write(root.join("records/items/change.md"), old).unwrap();
18973 std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
18974 for index in 0..512 {
18975 std::fs::write(
18976 root.join(format!("records/items/untouched-{index:04}.md")),
18977 old,
18978 )
18979 .unwrap();
18980 }
18981 let untouched = root.join("records/items/untouched-0256.md");
18982 let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
18983 let source = sandbox.path().join("changed-source");
18984 crate::fsx::write_atomic(&source, new).unwrap();
18985 let same_source = sandbox.path().join("unchanged-source");
18986 crate::fsx::write_atomic(&same_source, old).unwrap();
18987 let same_entry = V2StagedFile {
18988 path: "records/items/change.md".to_string(),
18989 source: same_source,
18990 sha256: content_sha256(old),
18991 bytes: old.len() as u64,
18992 };
18993 let entry = V2StagedFile {
18994 path: "records/items/change.md".to_string(),
18995 source,
18996 sha256: content_sha256(new),
18997 bytes: new.len() as u64,
18998 };
18999 let head = scoped_test_head(&"c".repeat(64));
19000
19001 install_established_v2_delta(
19005 Store::open_strict(&root).unwrap(),
19006 &[same_entry],
19007 &["records/items/already-absent.md".to_string()],
19008 true,
19009 None,
19010 &head,
19011 )
19012 .unwrap();
19013 assert_eq!(
19014 std::fs::metadata(&untouched).unwrap().ino(),
19015 untouched_inode
19016 );
19017 assert!(!root.join(V2_PULL_JOURNAL).exists());
19018
19019 install_established_v2_delta(
19020 Store::open_strict(&root).unwrap(),
19021 &[entry],
19022 &["records/items/delete.md".to_string()],
19023 false,
19024 None,
19025 &head,
19026 )
19027 .unwrap();
19028 assert_eq!(
19029 std::fs::read(root.join("records/items/change.md")).unwrap(),
19030 new
19031 );
19032 assert!(!root.join("records/items/delete.md").exists());
19033 assert_eq!(
19034 std::fs::metadata(&untouched).unwrap().ino(),
19035 untouched_inode
19036 );
19037 assert!(root.join(V2_PULL_JOURNAL).is_file());
19038 assert_eq!(
19039 std::fs::metadata(root.join(V2_PULL_JOURNAL))
19040 .unwrap()
19041 .permissions()
19042 .mode()
19043 & 0o777,
19044 0o600
19045 );
19046 let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
19047 .unwrap()
19048 .unwrap();
19049 assert_eq!(
19050 std::fs::metadata(root.join(&journal.backup_dir))
19051 .unwrap()
19052 .permissions()
19053 .mode()
19054 & 0o777,
19055 0o700
19056 );
19057 for entry in &journal.entries {
19058 if let Some(backup) = &entry.backup {
19059 assert_eq!(
19060 std::fs::metadata(root.join(&journal.backup_dir).join(backup))
19061 .unwrap()
19062 .permissions()
19063 .mode()
19064 & 0o777,
19065 0o600
19066 );
19067 }
19068 }
19069
19070 let cfg = test_hub_config(
19071 "https://example.test".to_string(),
19072 sandbox.path().join("state"),
19073 );
19074 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19075 assert_eq!(
19076 std::fs::read(root.join("records/items/change.md")).unwrap(),
19077 old
19078 );
19079 assert_eq!(
19080 std::fs::read(root.join("records/items/delete.md")).unwrap(),
19081 removed
19082 );
19083 assert_eq!(
19084 std::fs::metadata(&untouched).unwrap().ino(),
19085 untouched_inode
19086 );
19087 assert!(!root.join(V2_PULL_JOURNAL).exists());
19088 }
19089
19090 #[test]
19091 fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
19092 let body = b"bounded bytes";
19093 let path = "records/example.md".to_string();
19094 let file = V2BaselineFile {
19095 sha256: content_sha256(body),
19096 bytes: body.len() as u64,
19097 proof: None,
19098 };
19099 let header = serde_json::to_vec(&json!({
19100 "bytes": body.len(),
19101 "path": path,
19102 "sha256": file.sha256,
19103 "v": 2,
19104 }))
19105 .unwrap();
19106 let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
19107 stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
19108 stream.extend_from_slice(&header);
19109 stream.extend_from_slice(body);
19110 stream.extend_from_slice(&0_u32.to_be_bytes());
19111 let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
19112 assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
19113
19114 let mut tampered = stream.clone();
19115 let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
19116 tampered[body_offset] ^= 1;
19117 assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
19118
19119 let mut trailing = stream;
19120 trailing.push(0);
19121 assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
19122 }
19123
19124 #[test]
19125 fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
19126 let sandbox = tempfile::TempDir::new().unwrap();
19127 let root = sandbox.path().join("brain");
19128 std::fs::create_dir_all(&root).unwrap();
19129 std::fs::write(
19130 root.join("DB.md"),
19131 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19132 )
19133 .unwrap();
19134 let store = Store::open_strict(&root).unwrap();
19135 let incomplete = crate::ulid::mint();
19136 store
19137 .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
19138 .unwrap();
19139 let expired = crate::ulid::mint();
19140 store
19141 .create_dir_all(&v2_conflict_relative(&expired, "files"))
19142 .unwrap();
19143 let plan = V2ConflictPlan {
19144 v: 2,
19145 class: "content_resolution_required".to_string(),
19146 bundle: expired.clone(),
19147 brain: TEST_BRAIN_ID.to_string(),
19148 origin: "https://example.test".to_string(),
19149 created_unix: 0,
19150 expires_unix: 0,
19151 base_seq: None,
19152 base_commit: None,
19153 remote_seq: 0,
19154 remote_commit: None,
19155 remote_content_root: None,
19156 view_kind: "full".to_string(),
19157 view_revision: "a".repeat(64),
19158 files: vec![V2ConflictFile {
19159 path: "records/value.md".to_string(),
19160 base: V2ConflictCoordinate {
19161 sha256: None,
19162 bytes: None,
19163 file: None,
19164 },
19165 local: V2ConflictCoordinate {
19166 sha256: None,
19167 bytes: None,
19168 file: None,
19169 },
19170 remote: V2ConflictCoordinate {
19171 sha256: None,
19172 bytes: None,
19173 file: None,
19174 },
19175 }],
19176 };
19177 let mut bytes = serde_json::to_vec(&plan).unwrap();
19178 bytes.push(b'\n');
19179 store
19180 .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
19181 .unwrap();
19182
19183 let listed = sync_conflicts(&root, false, false).unwrap();
19184 assert_eq!(listed["bundles"], 2);
19185 assert_eq!(listed["pruned"], 0);
19186 let pruned = sync_conflicts(&root, true, false).unwrap();
19187 assert_eq!(pruned["bundles"], 0);
19188 assert_eq!(pruned["pruned"], 2);
19189 assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
19190 assert!(!root.join(".dbmd/conflicts").join(expired).exists());
19191 }
19192
19193 #[test]
19194 fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
19195 let sandbox = tempfile::TempDir::new().unwrap();
19196 let root = sandbox.path().join("brain");
19197 std::fs::create_dir_all(&root).unwrap();
19198 std::fs::write(
19199 root.join("DB.md"),
19200 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19201 )
19202 .unwrap();
19203 let store = Store::open_strict(&root).unwrap();
19204 let bundle = crate::ulid::mint();
19205 store
19206 .create_dir_all(&v2_conflict_relative(&bundle, "files"))
19207 .unwrap();
19208 store
19209 .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
19210 .unwrap();
19211
19212 assert!(sync_conflicts(&root, true, false).is_err());
19213 assert!(sync_conflicts(&root, false, true).is_err());
19214 let pruned = sync_conflicts(&root, true, true).unwrap();
19215 assert_eq!(pruned["pruned"], 1);
19216 assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
19217 }
19218
19219 #[test]
19220 fn ready_pull_journal_rolls_back_exact_preimages() {
19221 let sandbox = tempfile::TempDir::new().unwrap();
19222 let root = sandbox.path().join("brain");
19223 std::fs::create_dir_all(root.join("records")).unwrap();
19224 std::fs::write(
19225 root.join("DB.md"),
19226 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19227 )
19228 .unwrap();
19229 let path = "records/value.md";
19230 let old = b"---\ntype: note\n---\n\nold\n";
19231 let new = b"---\ntype: note\n---\n\nnew\n";
19232 std::fs::write(root.join(path), old).unwrap();
19233 let store = Store::open_strict(&root).unwrap();
19234 let bundle = crate::ulid::mint();
19235 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19236 store
19237 .create_private_dir_all(Path::new(&backup_dir))
19238 .unwrap();
19239 store
19240 .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
19241 .unwrap();
19242 let journal = V2PullJournal {
19243 v: 1,
19244 phase: V2PullPhase::Ready,
19245 brain: TEST_BRAIN_ID.to_string(),
19246 previous: V2PullCoordinate {
19247 head_seq: None,
19248 commit_hash: None,
19249 view_kind: None,
19250 view_revision: None,
19251 },
19252 next: V2PullCoordinate {
19253 head_seq: Some(2),
19254 commit_hash: Some("c".repeat(64)),
19255 view_kind: Some("full".to_string()),
19256 view_revision: Some("d".repeat(64)),
19257 },
19258 backup_dir: backup_dir.clone(),
19259 entries: vec![V2PullJournalEntry {
19260 path: path.to_string(),
19261 old: Some(V2PullFileCoordinate {
19262 sha256: content_sha256(old),
19263 bytes: old.len() as u64,
19264 }),
19265 new: Some(V2PullFileCoordinate {
19266 sha256: content_sha256(new),
19267 bytes: new.len() as u64,
19268 }),
19269 backup: Some("00000000".to_string()),
19270 }],
19271 };
19272 validate_v2_pull_journal(&journal).unwrap();
19273 store
19274 .write_private_atomic_new(
19275 Path::new(V2_PULL_JOURNAL),
19276 &v2_pull_journal_bytes(&journal).unwrap(),
19277 )
19278 .unwrap();
19279 store.write_atomic(Path::new(path), new).unwrap();
19280
19281 let cfg = test_hub_config(
19282 "https://example.test".to_string(),
19283 sandbox.path().join("state"),
19284 );
19285 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19286 assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
19287 assert!(!root.join(V2_PULL_JOURNAL).exists());
19288 assert!(!root.join(backup_dir).exists());
19289 }
19290
19291 #[test]
19292 fn preparing_pull_journal_discards_only_private_staging() {
19293 let sandbox = tempfile::TempDir::new().unwrap();
19294 let root = sandbox.path().join("brain");
19295 std::fs::create_dir_all(root.join(".dbmd")).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 store = Store::open_strict(&root).unwrap();
19302 let bundle = crate::ulid::mint();
19303 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19304 store
19305 .create_private_dir_all(Path::new(&backup_dir))
19306 .unwrap();
19307 let journal = V2PullJournal {
19308 v: 1,
19309 phase: V2PullPhase::Preparing,
19310 brain: TEST_BRAIN_ID.to_string(),
19311 previous: V2PullCoordinate {
19312 head_seq: None,
19313 commit_hash: None,
19314 view_kind: None,
19315 view_revision: None,
19316 },
19317 next: V2PullCoordinate {
19318 head_seq: Some(1),
19319 commit_hash: Some("a".repeat(64)),
19320 view_kind: Some("full".to_string()),
19321 view_revision: Some("b".repeat(64)),
19322 },
19323 backup_dir: backup_dir.clone(),
19324 entries: vec![V2PullJournalEntry {
19325 path: "records/new.md".to_string(),
19326 old: None,
19327 new: Some(V2PullFileCoordinate {
19328 sha256: "c".repeat(64),
19329 bytes: 1,
19330 }),
19331 backup: None,
19332 }],
19333 };
19334 store
19335 .write_private_atomic_new(
19336 Path::new(V2_PULL_JOURNAL),
19337 &v2_pull_journal_bytes(&journal).unwrap(),
19338 )
19339 .unwrap();
19340 let cfg = test_hub_config(
19341 "https://example.test".to_string(),
19342 sandbox.path().join("state"),
19343 );
19344
19345 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19346
19347 assert!(root.join("DB.md").is_file());
19348 assert!(!root.join(V2_PULL_JOURNAL).exists());
19349 assert!(!root.join(backup_dir).exists());
19350 }
19351
19352 #[test]
19353 fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
19354 let sandbox = tempfile::TempDir::new().unwrap();
19355 let root = sandbox.path().join("brain");
19356 std::fs::create_dir_all(root.join("records")).unwrap();
19357 std::fs::write(
19358 root.join("DB.md"),
19359 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19360 )
19361 .unwrap();
19362 let new = b"---\ntype: note\n---\n\nnew\n";
19363 std::fs::write(root.join("records/value.md"), new).unwrap();
19364 let store = Store::open_strict(&root).unwrap();
19365 let bundle = crate::ulid::mint();
19366 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19367 store
19368 .create_private_dir_all(Path::new(&backup_dir))
19369 .unwrap();
19370 let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
19371 store.create_private_dir_all(Path::new(&orphan)).unwrap();
19372 let next = V2PullCoordinate {
19373 head_seq: Some(2),
19374 commit_hash: Some("c".repeat(64)),
19375 view_kind: Some("full".to_string()),
19376 view_revision: Some("d".repeat(64)),
19377 };
19378 let journal = V2PullJournal {
19379 v: 1,
19380 phase: V2PullPhase::Ready,
19381 brain: TEST_BRAIN_ID.to_string(),
19382 previous: V2PullCoordinate {
19383 head_seq: Some(1),
19384 commit_hash: Some("a".repeat(64)),
19385 view_kind: Some("full".to_string()),
19386 view_revision: Some("b".repeat(64)),
19387 },
19388 next: next.clone(),
19389 backup_dir: backup_dir.clone(),
19390 entries: vec![V2PullJournalEntry {
19391 path: "records/value.md".to_string(),
19392 old: Some(V2PullFileCoordinate {
19393 sha256: "e".repeat(64),
19394 bytes: new.len() as u64,
19395 }),
19396 new: Some(V2PullFileCoordinate {
19397 sha256: content_sha256(new),
19398 bytes: new.len() as u64,
19399 }),
19400 backup: Some("00000000".to_string()),
19401 }],
19402 };
19403 store
19404 .write_private_atomic_new(
19405 Path::new(V2_PULL_JOURNAL),
19406 &v2_pull_journal_bytes(&journal).unwrap(),
19407 )
19408 .unwrap();
19409 let cfg = test_hub_config(
19410 "https://example.test".to_string(),
19411 sandbox.path().join("state"),
19412 );
19413 save_v2_baseline(
19414 &cfg,
19415 TEST_BRAIN_ID,
19416 &root,
19417 &V2SyncBaseline {
19418 v: 2,
19419 origin: "https://example.test".to_string(),
19420 brain: TEST_BRAIN_ID.to_string(),
19421 checkout_id: Some("c".repeat(64)),
19422 head_seq: next.head_seq,
19423 commit_hash: next.commit_hash.clone(),
19424 content_root: Some("f".repeat(64)),
19425 asset_root: None,
19426 assets: Default::default(),
19427 view_kind: next.view_kind.clone(),
19428 view_revision: next.view_revision.clone(),
19429 control_revision: Some("d".repeat(64)),
19430 projection_sha256: None,
19431 files: Default::default(),
19432 local_policy_digest: None,
19433 local_eligibility: Default::default(),
19434 remote_copy_remains: Default::default(),
19435 },
19436 )
19437 .unwrap();
19438
19439 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19440
19441 assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
19442 assert!(!root.join(V2_PULL_JOURNAL).exists());
19443 assert!(!root.join(backup_dir).exists());
19444 assert!(!root.join(orphan).exists());
19445 }
19446}