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_inherits_withheld_absence(
4176 base: Option<&V2BaselineAsset>,
4177 base_record: Option<&crate::AssetRecord>,
4178 local_record: Option<&crate::AssetRecord>,
4179 raw_present: bool,
4180) -> bool {
4181 !raw_present
4182 && base.is_some_and(|asset| asset.disposition == "withheld")
4183 && local_record == base_record
4184}
4185
4186fn v2_asset_record_manifest_bytes(
4187 assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4188) -> LinkResult<Vec<u8>> {
4189 let mut bytes = Vec::new();
4190 for (path, asset) in assets {
4191 if asset.path != *path {
4192 return Err(invalid_feed(
4193 "local asset manifest key differs from its record path",
4194 ));
4195 }
4196 serde_json::to_writer(&mut bytes, asset)
4197 .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4198 bytes.push(b'\n');
4199 }
4200 Ok(bytes)
4201}
4202
4203fn v2_local_asset_records(
4204 store: &Store,
4205) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4206 let assets = crate::assets::read_manifest(store)
4207 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?;
4208 if assets.iter().any(|asset| asset.bytes > MAX_ASSET_BYTES) {
4209 return Err(LinkError::InvalidPack {
4210 message: "local asset exceeds the link.md v2 per-blob limit".to_string(),
4211 });
4212 }
4213 Ok(assets
4214 .into_iter()
4215 .map(|asset| (asset.path.clone(), asset))
4216 .collect())
4217}
4218
4219fn v2_asset_records_match_remote(
4220 local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4221 remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4222) -> bool {
4223 local.len() == remote.len()
4224 && remote
4225 .iter()
4226 .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4227}
4228
4229#[derive(Debug, Clone, PartialEq, Eq)]
4230struct V2PulledMerge<T> {
4231 records: std::collections::BTreeMap<String, T>,
4232 accept_remote: std::collections::BTreeSet<String>,
4233 conflicts: Vec<String>,
4234}
4235
4236fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4242 base: &std::collections::BTreeMap<String, Base>,
4243 remote: &std::collections::BTreeMap<String, Remote>,
4244 local: &std::collections::BTreeMap<String, Record>,
4245 base_record: BaseRecord,
4246 remote_record: RemoteRecord,
4247 keep_local: KeepLocal,
4248) -> V2PulledMerge<Record>
4249where
4250 Record: Clone + Eq,
4251 BaseRecord: Fn(&Base, &str) -> Record,
4252 RemoteRecord: Fn(&Remote, &str) -> Record,
4253 KeepLocal: Fn(&str) -> bool,
4254{
4255 let paths = base
4256 .keys()
4257 .chain(remote.keys())
4258 .chain(local.keys())
4259 .cloned()
4260 .collect::<std::collections::BTreeSet<_>>();
4261 let mut records = local.clone();
4262 let mut accept_remote = std::collections::BTreeSet::new();
4263 let mut conflicts = Vec::new();
4264 for path in paths {
4265 if keep_local(&path) {
4266 continue;
4267 }
4268 let base_value = base.get(&path).map(|value| base_record(value, &path));
4269 let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4270 let local_value = local.get(&path).cloned();
4271 if local_value != base_value && remote_value != base_value && local_value != remote_value {
4272 conflicts.push(path);
4273 continue;
4274 }
4275 if local_value == base_value || local_value == remote_value {
4276 accept_remote.insert(path.clone());
4277 match remote_value {
4278 Some(value) => {
4279 records.insert(path, value);
4280 }
4281 None => {
4282 records.remove(&path);
4283 }
4284 }
4285 }
4286 }
4287 V2PulledMerge {
4288 records,
4289 accept_remote,
4290 conflicts,
4291 }
4292}
4293
4294fn sign_verified_v2_candidate(
4295 cfg: &HubConfig,
4296 head: &V2VerifiedHead,
4297 expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4298 expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4299 mutation_id: &str,
4300 request_body: &Value,
4301 challenge_value: &Value,
4302) -> LinkResult<(String, String, String)> {
4303 if head.view_kind != "full" {
4304 return Err(invalid_feed(
4305 "a scoped self-custody writer must use the proposal workflow",
4306 ));
4307 }
4308 if head.identity.custody != "self" {
4309 return Err(invalid_feed(
4310 "a hub-custodied brain unexpectedly requested an external signature",
4311 ));
4312 }
4313 let key = cfg
4314 .brain_key
4315 .as_ref()
4316 .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4317 if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4318 || key.public_key_spki != head.identity.public_key_spki
4319 {
4320 return Err(bad_agent_key(
4321 "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4322 ));
4323 }
4324 let challenge_id = challenge_value
4325 .get("id")
4326 .and_then(Value::as_str)
4327 .filter(|id| crate::ulid::is_ulid(id))
4328 .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4329 let expected_endpoint = format!(
4330 "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4331 head.brain_id
4332 );
4333 if challenge_value
4334 .get("candidate_endpoint")
4335 .and_then(Value::as_str)
4336 != Some(expected_endpoint.as_str())
4337 {
4338 return Err(invalid_feed(
4339 "self-custody challenge candidate endpoint is not origin-bound",
4340 ));
4341 }
4342
4343 let mut files = std::collections::BTreeMap::new();
4344 let mut after = String::new();
4345 type CandidateCoordinate = (
4346 String,
4347 String,
4348 String,
4349 String,
4350 Option<String>,
4351 Option<String>,
4352 u64,
4353 Option<String>,
4354 );
4355 let mut pinned: Option<CandidateCoordinate> = None;
4356 loop {
4357 let encoded_after: String =
4358 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4359 let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4360 let value = ensure_ok(
4361 request_capped(
4362 cfg,
4363 "GET",
4364 &path,
4365 None,
4366 Auth::Required,
4367 MAX_FEED_RESPONSE_BYTES,
4368 )?,
4369 "v2 self-custody candidate",
4370 )?;
4371 let page: V2SigningCandidatePage = serde_json::from_value(value)
4372 .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4373 if page.v != 2
4374 || page.challenge_id != challenge_id
4375 || page.mutation_id != mutation_id
4376 || page.candidate.seq != page.parent.seq + 1
4377 || page.files.len() > 500
4378 || page.expires_at.is_empty()
4379 {
4380 return Err(invalid_feed(
4381 "self-custody candidate is not bound to this mutation",
4382 ));
4383 }
4384 let coordinate = (
4385 page.request_hash.clone(),
4386 page.candidate.signing_bytes_base64.clone(),
4387 page.candidate.changes_base64.clone(),
4388 page.candidate.actor_claim_base64.clone(),
4389 page.candidate.content_root.clone(),
4390 page.candidate.asset_root.clone(),
4391 page.parent.seq,
4392 page.parent.commit_hash.clone(),
4393 );
4394 if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4395 return Err(invalid_feed(
4396 "self-custody candidate changed between manifest pages",
4397 ));
4398 }
4399 pinned = Some(coordinate);
4400 let root = page
4401 .candidate
4402 .content_root
4403 .as_deref()
4404 .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4405 for file in page.files {
4406 verify_v2_file_proof(root, &file)?;
4407 if files
4408 .insert(
4409 file.path.clone(),
4410 V2BaselineFile {
4411 sha256: file.sha256,
4412 bytes: file.bytes,
4413 proof: Some(file.proof),
4414 },
4415 )
4416 .is_some()
4417 {
4418 return Err(invalid_feed(
4419 "self-custody candidate repeats a manifest path",
4420 ));
4421 }
4422 if files.len() > MAX_PUSH_FILES {
4423 return Err(invalid_feed(
4424 "self-custody candidate exceeds the file-count bound",
4425 ));
4426 }
4427 }
4428 match page.next_cursor {
4429 None => break,
4430 Some(next) if next > after => after = next,
4431 Some(_) => {
4432 return Err(invalid_feed(
4433 "self-custody candidate cursor did not advance",
4434 ))
4435 }
4436 }
4437 }
4438 if files.len() != expected.len()
4439 || files.iter().any(|(path, file)| {
4440 expected.get(path).is_none_or(|expected| {
4441 expected.sha256 != file.sha256 || expected.bytes != file.bytes
4442 })
4443 })
4444 {
4445 return Err(invalid_feed(
4446 "self-custody candidate contains an unexpected file mutation",
4447 ));
4448 }
4449 let mut assets = std::collections::BTreeMap::new();
4450 after.clear();
4451 loop {
4452 let encoded_after: String =
4453 url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4454 let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4455 let value = ensure_ok(
4456 request_capped(
4457 cfg,
4458 "GET",
4459 &path,
4460 None,
4461 Auth::Required,
4462 MAX_FEED_RESPONSE_BYTES,
4463 )?,
4464 "v2 self-custody asset candidate",
4465 )?;
4466 let page: V2SigningCandidatePage = serde_json::from_value(value)
4467 .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4468 let coordinate = (
4469 page.request_hash.clone(),
4470 page.candidate.signing_bytes_base64.clone(),
4471 page.candidate.changes_base64.clone(),
4472 page.candidate.actor_claim_base64.clone(),
4473 page.candidate.content_root.clone(),
4474 page.candidate.asset_root.clone(),
4475 page.parent.seq,
4476 page.parent.commit_hash.clone(),
4477 );
4478 if page.v != 2
4479 || page.challenge_id != challenge_id
4480 || page.mutation_id != mutation_id
4481 || page.assets.len() > 500
4482 || pinned.as_ref() != Some(&coordinate)
4483 {
4484 return Err(invalid_feed(
4485 "self-custody asset candidate changed or is not bound",
4486 ));
4487 }
4488 let root = page.candidate.asset_root.as_deref();
4489 if !page.assets.is_empty() && root.is_none() {
4490 return Err(invalid_feed("asset candidate has no asset root"));
4491 }
4492 for item in page.assets {
4493 verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4494 if assets
4495 .insert(
4496 item.path.clone(),
4497 V2BaselineAsset {
4498 blob_sha256: item.blob_sha256,
4499 bytes: item.bytes,
4500 media_type: item.media_type,
4501 wrappers: item.wrappers,
4502 required: item.required,
4503 disposition: item.disposition,
4504 leaf_hash: item.leaf_hash,
4505 },
4506 )
4507 .is_some()
4508 {
4509 return Err(invalid_feed("self-custody candidate repeats an asset"));
4510 }
4511 }
4512 match page.next_cursor {
4513 None => break,
4514 Some(next) if next > after => after = next,
4515 Some(_) => {
4516 return Err(invalid_feed(
4517 "self-custody asset candidate cursor did not advance",
4518 ))
4519 }
4520 }
4521 }
4522 if assets.len() != expected_assets.len()
4523 || assets.iter().any(|(path, asset)| {
4524 expected_assets.get(path).is_none_or(|expected| {
4525 asset.blob_sha256 != expected.blob_sha256
4526 || asset.bytes != expected.bytes
4527 || asset.media_type != expected.media_type
4528 || asset.wrappers != expected.wrappers
4529 || asset.required != expected.required
4530 || asset.disposition != expected.disposition
4531 })
4532 })
4533 {
4534 return Err(invalid_feed(
4535 "self-custody candidate contains an unexpected asset mutation",
4536 ));
4537 }
4538 let Some((
4539 request_hash,
4540 signing_b64,
4541 changes_b64,
4542 actor_b64,
4543 root,
4544 asset_root,
4545 parent_seq,
4546 parent,
4547 )) = pinned
4548 else {
4549 return Err(invalid_feed("self-custody candidate has no manifest"));
4550 };
4551 let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4552 let current_commit = head
4553 .pointer
4554 .as_ref()
4555 .map(|pointer| pointer.commit_hash.clone());
4556 if parent_seq != current_seq || parent != current_commit {
4557 return Err(LinkError::RemoteAdvancedDuringSync);
4558 }
4559 let changes = STANDARD
4560 .decode(changes_b64)
4561 .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4562 let mut expected_changes = json!({
4563 "mutation_id": mutation_id,
4564 "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4565 "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4566 "v": 2,
4567 });
4568 if let Some(withheld_links) = request_body.get("withheld_links") {
4569 expected_changes["withheld_links"] = withheld_links.clone();
4570 }
4571 if let Some(checkout_id) = request_body.get("checkout_id") {
4572 expected_changes["checkout_id"] = checkout_id.clone();
4573 }
4574 let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4575 .map_err(|error| invalid_feed(error.to_string()))?;
4576 if changes != expected_changes_bytes {
4577 return Err(invalid_feed(
4578 "self-custody changeset differs from the requested mutation",
4579 ));
4580 }
4581 let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4582 .map_err(|error| invalid_feed(error.to_string()))?;
4583 let request_value = json!({
4584 "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4585 "brain": head.brain_id,
4586 "changes_sha256": changes_hash,
4587 "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4588 "v": 2,
4589 "v1_bridge": Value::Null,
4590 });
4591 let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4592 .map_err(|error| invalid_feed(error.to_string()))?;
4593 if request_hash != expected_request_hash {
4594 return Err(invalid_feed(
4595 "self-custody request hash differs from the requested mutation",
4596 ));
4597 }
4598 let actor = STANDARD
4599 .decode(actor_b64)
4600 .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4601 let actor_value: Value = serde_json::from_slice(&actor)
4602 .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4603 if crate::linkmd_v2::canonical_bytes(&actor_value)
4604 .map_err(|error| invalid_feed(error.to_string()))?
4605 != actor
4606 {
4607 return Err(invalid_feed("self-custody actor claim is not canonical"));
4608 }
4609 let actor_object = actor_value
4610 .as_object()
4611 .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4612 let actor_claim = actor_object
4613 .get("claim")
4614 .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4615 let actor_public_key = actor_object
4616 .get("public_key")
4617 .and_then(Value::as_str)
4618 .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4619 let actor_fingerprint = actor_object
4620 .get("fingerprint")
4621 .and_then(Value::as_str)
4622 .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4623 let actor_signature = actor_object
4624 .get("sig")
4625 .and_then(Value::as_str)
4626 .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4627 let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4628 .map_err(|error| invalid_feed(error.to_string()))?;
4629 let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4630 let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4631 let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4632 let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4633 let impact = actor_claim
4634 .get("result")
4635 .and_then(|result| result.get("impact"))
4636 .and_then(Value::as_object);
4637 let impact_fields = [
4638 "creates",
4639 "updates",
4640 "deletes",
4641 "withdrawals",
4642 "renames",
4643 "restores",
4644 "asset_changes",
4645 "public_expansions",
4646 "executable_activations",
4647 ];
4648 let impact_is_valid = impact.is_some_and(|impact| {
4649 impact.len() == impact_fields.len() + 1
4650 && impact.get("v").and_then(Value::as_u64) == Some(1)
4651 && impact_fields
4652 .iter()
4653 .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4654 });
4655 if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4656 || head
4657 .trust
4658 .hub_signer
4659 .as_ref()
4660 .is_some_and(|known| known != &expected_actor_signer)
4661 || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4662 || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4663 || actor_claim
4664 .get("candidate")
4665 .and_then(|candidate| candidate.get("changes_sha256"))
4666 .and_then(Value::as_str)
4667 != Some(changes_hash.as_str())
4668 || actor_claim
4669 .get("candidate")
4670 .and_then(|candidate| candidate.get("state_root"))
4671 != Some(&expected_actor_root)
4672 || actor_claim
4673 .get("candidate")
4674 .and_then(|candidate| candidate.get("asset_root"))
4675 != Some(&expected_actor_asset_root)
4676 || actor_claim
4677 .get("candidate")
4678 .and_then(|candidate| candidate.get("control_revision"))
4679 .and_then(Value::as_str)
4680 != Some(head.control_revision.as_str())
4681 || !impact_is_valid
4682 {
4683 return Err(invalid_feed(
4684 "self-custody actor claim does not bind the verified authority",
4685 ));
4686 }
4687 let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4688 .map_err(|error| invalid_feed(error.to_string()))?;
4689 let signing = STANDARD
4690 .decode(signing_b64)
4691 .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4692 let signing_value: Value = serde_json::from_slice(&signing)
4693 .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4694 if crate::linkmd_v2::canonical_bytes(&signing_value)
4695 .map_err(|error| invalid_feed(error.to_string()))?
4696 != signing
4697 {
4698 return Err(invalid_feed("self-custody signing bytes are not canonical"));
4699 }
4700 let pointer = head.pointer.as_ref();
4701 let expected_materializer = pointer
4702 .map(|value| value.materializer.as_str())
4703 .unwrap_or("dbmd-projection-v1");
4704 let expected_parent_commit = request_body
4705 .get("base")
4706 .and_then(|base| base.get("commit_hash"))
4707 .cloned()
4708 .unwrap_or(Value::Null);
4709 let expected_parent_root = request_body
4710 .get("base")
4711 .and_then(|base| base.get("content_root"))
4712 .cloned()
4713 .unwrap_or(Value::Null);
4714 let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4715 let expected_parent_asset_root = request_body
4716 .get("base")
4717 .and_then(|base| base.get("asset_root"))
4718 .cloned()
4719 .unwrap_or(Value::Null);
4720 let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4721 let expected_prev_entry = pointer
4722 .map(|value| Value::String(value.feed_hash.clone()))
4723 .unwrap_or(Value::Null);
4724 let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4725 .map_err(|_| invalid_feed("brain identity history is too large"))?
4726 + 1;
4727 if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4728 || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4729 || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4730 || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4731 || signing_value.get("public_key").and_then(Value::as_str)
4732 != Some(key.public_key_spki.as_str())
4733 || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4734 || signing_value.get("parent_root") != Some(&expected_parent_root)
4735 || signing_value.get("state_root") != Some(&expected_state_root)
4736 || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4737 || signing_value.get("asset_root") != Some(&expected_asset_root)
4738 || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4739 || signing_value.get("changes_sha256").and_then(Value::as_str)
4740 != Some(changes_hash.as_str())
4741 || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4742 || signing_value
4743 .get("control_revision")
4744 .and_then(Value::as_str)
4745 != Some(head.control_revision.as_str())
4746 || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4747 || signing_value.get("v1_bridge") != Some(&Value::Null)
4748 || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4749 {
4750 return Err(invalid_feed(
4751 "self-custody signing bytes do not bind the verified candidate",
4752 ));
4753 }
4754 let pair = agent_keypair(&key.pkcs8)?;
4755 let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4756 Ok((challenge_id.to_string(), signature, expected_actor_signer))
4757}
4758
4759fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4760 let origin = normalized_origin(&cfg.hub)?;
4761 let absolute = if checkout.is_absolute() {
4762 checkout.to_path_buf()
4763 } else {
4764 std::env::current_dir()?.join(checkout)
4765 };
4766 Ok(format!(
4767 "sync-{}.json",
4768 content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4769 ))
4770}
4771
4772fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4773 if let Some(value) = existing {
4774 if !is_sha256(value) {
4775 return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4776 }
4777 return Ok(value.to_string());
4778 }
4779 use ring::rand::SecureRandom as _;
4780 let mut random = [0_u8; 32];
4781 ring::rand::SystemRandom::new()
4782 .fill(&mut random)
4783 .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4784 Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4785}
4786
4787#[cfg(any(unix, windows))]
4788fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4789 let directory = open_trust_dir(cfg)?;
4790 let origin = normalized_origin(&cfg.hub)?;
4791 let name = format!(
4792 "operation-{}.lock",
4793 content_sha256(format!("{origin}\0{brain}").as_bytes())
4794 );
4795 lock_trust_name(&directory, &name)
4796}
4797
4798#[cfg(not(any(unix, windows)))]
4799fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4800 Err(LinkError::UnsupportedPlatform {
4801 operation: "serialized link.md v2 sync",
4802 })
4803}
4804
4805fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4806 left.brain_id == right.brain_id
4807 && left.view_kind == right.view_kind
4808 && left.view_revision == right.view_revision
4809 && left.control_revision == right.control_revision
4810 && match (&left.pointer, &right.pointer) {
4811 (None, None) => true,
4812 (Some(left), Some(right)) => {
4813 left.seq == right.seq
4814 && left.commit_hash == right.commit_hash
4815 && left.content_root == right.content_root
4816 && left.asset_root == right.asset_root
4817 && left.feed_hash == right.feed_hash
4818 }
4819 _ => false,
4820 }
4821}
4822
4823fn v2_baseline_matches_head(head: &V2VerifiedHead, baseline: &V2SyncBaseline) -> bool {
4829 let pointer = head.pointer.as_ref();
4830 baseline.head_seq == Some(pointer.map_or(0, |value| value.seq))
4831 && baseline.commit_hash.as_deref() == pointer.map(|value| value.commit_hash.as_str())
4832 && baseline.content_root.as_deref()
4833 == pointer.and_then(|value| value.content_root.as_deref())
4834 && baseline.asset_root.as_deref() == pointer.and_then(|value| value.asset_root.as_deref())
4835 && baseline.view_kind.as_deref() == Some(head.view_kind.as_str())
4836 && baseline.view_revision.as_deref() == Some(head.view_revision.as_str())
4837 && baseline.control_revision.as_deref() == Some(head.control_revision.as_str())
4838}
4839
4840fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4841 format!(
4842 "---\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"
4843 )
4844 .into_bytes()
4845}
4846
4847fn scoped_projection_sha256(brain: &str) -> String {
4848 content_sha256(&scoped_projection_bytes(brain))
4849}
4850
4851#[derive(Deserialize)]
4852struct LocalScopedViewMarker {
4853 v: u8,
4854 kind: String,
4855 authoritative: bool,
4856 brain: String,
4857 projection_sha256: String,
4858}
4859
4860pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4864 let marker = store
4865 .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4866 .ok()
4867 .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4868 let Some(marker) = marker else {
4869 return false;
4870 };
4871 if marker.v != 1
4872 || marker.kind != "link.md-scoped-view"
4873 || marker.authoritative
4874 || !crate::ulid::is_ulid(&marker.brain)
4875 || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4876 {
4877 return false;
4878 }
4879 store
4880 .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4881 .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4882}
4883
4884fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4885 let mut bytes = serde_json::to_vec_pretty(&json!({
4886 "v": 1,
4887 "kind": "link.md-scoped-view",
4888 "authoritative": false,
4889 "brain": head.brain_id,
4890 "view_revision": head.view_revision,
4891 "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4892 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4893 "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4894 "visible_files": files,
4895 "projection_sha256": scoped_projection_sha256(&head.brain_id),
4896 }))
4897 .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4898 bytes.push(b'\n');
4899 Ok(bytes)
4900}
4901
4902fn refresh_scoped_view_marker(
4903 store: &Store,
4904 head: &V2VerifiedHead,
4905 files: usize,
4906) -> LinkResult<()> {
4907 if head.view_kind == "scoped" {
4908 store.write_atomic(
4909 Path::new(".dbmd/view.json"),
4910 &scoped_view_metadata(head, files)?,
4911 )?;
4912 }
4913 Ok(())
4914}
4915
4916fn ensure_v2_view_compatible(
4917 head: &V2VerifiedHead,
4918 baseline: Option<&V2SyncBaseline>,
4919) -> LinkResult<()> {
4920 let Some(baseline) = baseline else {
4921 return Ok(());
4922 };
4923 match (
4924 baseline.view_kind.as_deref(),
4925 baseline.view_revision.as_deref(),
4926 ) {
4927 (None, None) if head.view_kind == "full" => Ok(()),
4928 (Some(kind), Some(revision))
4929 if kind == head.view_kind && revision == head.view_revision =>
4930 {
4931 Ok(())
4932 }
4933 _ => Err(LinkError::ScopedViewChanged),
4934 }
4935}
4936
4937fn ensure_established_v2_checkout_opened(
4938 head: &V2VerifiedHead,
4939 baseline: Option<&V2SyncBaseline>,
4940 opened: bool,
4941) -> LinkResult<()> {
4942 if baseline.is_none() || opened {
4943 return Ok(());
4944 }
4945 if head.view_kind == "scoped" {
4946 return Err(LinkError::ScopedProjectionModified);
4947 }
4948 Err(LinkError::InvalidPack {
4949 message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4950 })
4951}
4952
4953fn remove_scoped_projection(
4954 head: &V2VerifiedHead,
4955 baseline: Option<&V2SyncBaseline>,
4956 view: &mut V2LocalView,
4957) -> LinkResult<()> {
4958 if head.view_kind != "scoped" {
4959 return Ok(());
4960 }
4961 let expected = scoped_projection_sha256(&head.brain_id);
4962 if baseline
4963 .and_then(|state| state.projection_sha256.as_deref())
4964 .is_some_and(|pinned| pinned != expected)
4965 {
4966 return Err(LinkError::ScopedViewChanged);
4967 }
4968 if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4969 return Err(LinkError::ScopedProjectionModified);
4970 }
4971 view.riding.remove("DB.md");
4972 view.eligibility.remove("DB.md");
4973 Ok(())
4974}
4975
4976fn local_view_for_v2_push(
4977 store: &Store,
4978 head: &V2VerifiedHead,
4979 baseline: Option<&V2SyncBaseline>,
4980 carried: Option<V2LocalView>,
4981) -> LinkResult<V2LocalView> {
4982 match carried {
4983 Some(view) => Ok(view),
4988 None => {
4989 let mut view = v2_local_files(store)?;
4990 remove_scoped_projection(head, baseline, &mut view)?;
4991 Ok(view)
4992 }
4993 }
4994}
4995
4996fn files_for_v2_view(
4997 head: &V2VerifiedHead,
4998 mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4999) -> std::collections::BTreeMap<String, V2BaselineFile> {
5000 if head.view_kind == "scoped" {
5001 files.remove("DB.md");
5005 }
5006 files
5007}
5008
5009fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
5010 let baseline: V2SyncBaseline =
5011 serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
5012 if baseline.v != 2
5013 || baseline.origin != normalized_origin(&cfg.hub)?
5014 || baseline.brain != brain
5015 || baseline
5016 .commit_hash
5017 .as_deref()
5018 .is_some_and(|hash| !is_sha256(hash))
5019 || baseline
5020 .content_root
5021 .as_deref()
5022 .is_some_and(|hash| !is_sha256(hash))
5023 || baseline
5024 .asset_root
5025 .as_deref()
5026 .is_some_and(|hash| !is_sha256(hash))
5027 || baseline
5028 .local_policy_digest
5029 .as_deref()
5030 .is_some_and(|hash| !is_sha256(hash))
5031 || baseline
5032 .view_kind
5033 .as_deref()
5034 .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
5035 || baseline
5036 .view_revision
5037 .as_deref()
5038 .is_some_and(|hash| !is_sha256(hash))
5039 || baseline
5040 .control_revision
5041 .as_deref()
5042 .is_some_and(|hash| !is_sha256(hash))
5043 || baseline
5044 .projection_sha256
5045 .as_deref()
5046 .is_some_and(|hash| !is_sha256(hash))
5047 || (baseline.view_kind.as_deref() == Some("scoped")
5048 && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
5049 || baseline.files.len() > MAX_PUSH_FILES
5050 || baseline.assets.len() > MAX_PUSH_FILES
5051 || baseline.local_eligibility.len() > MAX_PUSH_FILES
5052 || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
5053 || baseline.files.iter().any(|(path, file)| {
5054 crate::linkmd_v2::normalize_path(path).is_err()
5055 || !is_sha256(&file.sha256)
5056 || file.bytes > MAX_STORE_BYTES
5057 })
5058 || baseline.assets.iter().any(|(path, asset)| {
5059 crate::linkmd_v2::normalize_path(path).is_err()
5060 || !is_sha256(&asset.blob_sha256)
5061 || !is_sha256(&asset.leaf_hash)
5062 || asset.bytes > MAX_ASSET_BYTES
5063 || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
5064 || asset.wrappers.is_empty()
5065 || asset
5066 .wrappers
5067 .iter()
5068 .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
5069 })
5070 || baseline
5071 .local_eligibility
5072 .keys()
5073 .chain(baseline.remote_copy_remains.keys())
5074 .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
5075 || baseline
5076 .remote_copy_remains
5077 .values()
5078 .any(|hash| !is_sha256(hash))
5079 || baseline
5080 .checkout_id
5081 .as_deref()
5082 .is_some_and(|checkout_id| !is_sha256(checkout_id))
5083 {
5084 return Err(invalid_feed("v2 sync baseline failed validation"));
5085 }
5086 Ok(baseline)
5087}
5088
5089#[cfg(unix)]
5090fn load_v2_baseline(
5091 cfg: &HubConfig,
5092 brain: &str,
5093 checkout: &Path,
5094) -> LinkResult<Option<V2SyncBaseline>> {
5095 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5096 let directory = open_trust_dir(cfg)?;
5097 let name_string = v2_baseline_name(cfg, brain, checkout)?;
5098 let _lock = lock_trust_name(&directory, &name_string)?;
5099 let name = c_name(name_string.as_bytes(), &name_string)?;
5100 let fd = unsafe {
5101 libc::openat(
5102 directory.as_raw_fd(),
5103 name.as_ptr(),
5104 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5105 )
5106 };
5107 if fd < 0 {
5108 let error = std::io::Error::last_os_error();
5109 return if error.kind() == std::io::ErrorKind::NotFound {
5110 Ok(None)
5111 } else {
5112 Err(LinkError::UnsafePath { path: name_string })
5113 };
5114 }
5115 let file = unsafe { std::fs::File::from_raw_fd(fd) };
5116 let mut bytes = Vec::new();
5117 file.take(MAX_FEED_RESPONSE_BYTES + 1)
5118 .read_to_end(&mut bytes)?;
5119 if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
5120 return Err(invalid_feed("v2 sync baseline is oversized"));
5121 }
5122 Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
5123}
5124
5125#[cfg(windows)]
5126fn load_v2_baseline(
5127 cfg: &HubConfig,
5128 brain: &str,
5129 checkout: &Path,
5130) -> LinkResult<Option<V2SyncBaseline>> {
5131 let directory = open_trust_dir(cfg)?;
5132 let name = v2_baseline_name(cfg, brain, checkout)?;
5133 let _lock = lock_trust_name(&directory, &name)?;
5134 let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
5135 match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
5136 Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5137 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5138 Err(_) => Err(LinkError::UnsafePath { path: name }),
5139 }
5140}
5141
5142#[cfg(not(any(unix, windows)))]
5143fn load_v2_baseline(
5144 _cfg: &HubConfig,
5145 _brain: &str,
5146 _checkout: &Path,
5147) -> LinkResult<Option<V2SyncBaseline>> {
5148 Err(LinkError::UnsupportedPlatform {
5149 operation: "verified link.md v2 baseline",
5150 })
5151}
5152
5153#[cfg(unix)]
5154fn save_v2_baseline(
5155 cfg: &HubConfig,
5156 brain: &str,
5157 checkout: &Path,
5158 baseline: &V2SyncBaseline,
5159) -> LinkResult<()> {
5160 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5161 let directory = open_trust_dir(cfg)?;
5162 let name_string = v2_baseline_name(cfg, brain, checkout)?;
5163 let _lock = lock_trust_name(&directory, &name_string)?;
5164 let name = c_name(name_string.as_bytes(), &name_string)?;
5165 let mut bytes = serde_json::to_vec(baseline)
5166 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5167 bytes.push(b'\n');
5168 let temp_string = format!(
5169 ".{name_string}.tmp.{}-{}",
5170 std::process::id(),
5171 std::time::SystemTime::now()
5172 .duration_since(std::time::UNIX_EPOCH)
5173 .unwrap_or_default()
5174 .as_nanos()
5175 );
5176 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5177 let fd = unsafe {
5178 libc::openat(
5179 directory.as_raw_fd(),
5180 temp.as_ptr(),
5181 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5182 0o600,
5183 )
5184 };
5185 if fd < 0 {
5186 return Err(std::io::Error::last_os_error().into());
5187 }
5188 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5189 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5190 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5191 return Err(error.into());
5192 }
5193 drop(file);
5194 if unsafe {
5195 libc::renameat(
5196 directory.as_raw_fd(),
5197 temp.as_ptr(),
5198 directory.as_raw_fd(),
5199 name.as_ptr(),
5200 )
5201 } != 0
5202 {
5203 let error = std::io::Error::last_os_error();
5204 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5205 return Err(error.into());
5206 }
5207 directory.sync_all()?;
5208 Ok(())
5209}
5210
5211#[cfg(windows)]
5212fn save_v2_baseline(
5213 cfg: &HubConfig,
5214 brain: &str,
5215 checkout: &Path,
5216 baseline: &V2SyncBaseline,
5217) -> LinkResult<()> {
5218 let directory = open_trust_dir(cfg)?;
5219 let name = v2_baseline_name(cfg, brain, checkout)?;
5220 let _lock = lock_trust_name(&directory, &name)?;
5221 let mut bytes = serde_json::to_vec(baseline)
5222 .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5223 bytes.push(b'\n');
5224 crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5225 Ok(())
5226}
5227
5228#[cfg(not(any(unix, windows)))]
5229fn save_v2_baseline(
5230 _cfg: &HubConfig,
5231 _brain: &str,
5232 _checkout: &Path,
5233 _baseline: &V2SyncBaseline,
5234) -> LinkResult<()> {
5235 Err(LinkError::UnsupportedPlatform {
5236 operation: "verified link.md v2 baseline",
5237 })
5238}
5239
5240fn v2_baseline_from_head(
5241 cfg: &HubConfig,
5242 head: &V2VerifiedHead,
5243 files: std::collections::BTreeMap<String, V2BaselineFile>,
5244 assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5245 local: Option<&V2LocalView>,
5246 checkout_id: Option<&str>,
5247) -> LinkResult<V2SyncBaseline> {
5248 let mut local_eligibility = local
5249 .map(|view| view.eligibility.clone())
5250 .unwrap_or_default();
5251 if let Some(view) = local {
5252 for path in files.keys() {
5253 local_eligibility
5254 .entry(path.clone())
5255 .or_insert_with(|| !view.policy.keeps_home(path));
5256 }
5257 }
5258 let remote_copy_remains = local_eligibility
5259 .iter()
5260 .filter(|(_, riding)| !**riding)
5261 .filter_map(|(path, _)| {
5262 files
5263 .get(path)
5264 .map(|file| (path.clone(), file.sha256.clone()))
5265 })
5266 .collect();
5267 Ok(V2SyncBaseline {
5268 v: 2,
5269 origin: normalized_origin(&cfg.hub)?,
5270 brain: head.brain_id.clone(),
5271 checkout_id: Some(v2_checkout_id(checkout_id)?),
5272 head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5273 commit_hash: head
5274 .pointer
5275 .as_ref()
5276 .map(|pointer| pointer.commit_hash.clone()),
5277 content_root: head
5278 .pointer
5279 .as_ref()
5280 .and_then(|pointer| pointer.content_root.clone()),
5281 asset_root: head
5282 .pointer
5283 .as_ref()
5284 .and_then(|pointer| pointer.asset_root.clone()),
5285 assets,
5286 view_kind: Some(head.view_kind.clone()),
5287 view_revision: Some(head.view_revision.clone()),
5288 control_revision: Some(head.control_revision.clone()),
5289 projection_sha256: (head.view_kind == "scoped")
5290 .then(|| scoped_projection_sha256(&head.brain_id)),
5291 files,
5292 local_policy_digest: local.map(|view| view.policy.digest.clone()),
5293 local_eligibility,
5294 remote_copy_remains,
5295 })
5296}
5297
5298fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5299 let policy = crate::linkmd_sync_policy::load(store)
5300 .map_err(|message| LinkError::InvalidPack { message })?;
5301 let asset_paths = crate::assets::read_manifest(store)
5302 .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5303 .into_iter()
5304 .map(|asset| asset.path)
5305 .collect::<std::collections::BTreeSet<_>>();
5306 let mut result = std::collections::BTreeMap::new();
5307 let mut eligibility = std::collections::BTreeMap::new();
5308 let mut riding_links = Vec::<(String, Vec<String>)>::new();
5309 let mut total = 0_u64;
5310 let mut paths = vec![PathBuf::from("DB.md")];
5311 paths.extend(store.walk()?);
5312 for relative in paths {
5313 let path = relative.to_string_lossy().replace('\\', "/");
5314 if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5316 continue;
5317 }
5318 if asset_paths.contains(&path) {
5319 continue;
5320 }
5321 crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5322 path: error.to_string(),
5323 })?;
5324 let riding = !policy.keeps_home(&path);
5325 eligibility.insert(path.clone(), riding);
5326 if !riding {
5327 continue;
5328 }
5329 let remaining = MAX_STORE_BYTES.saturating_sub(total);
5330 let bytes = store.read_bounded(&relative, remaining)?;
5331 total = total
5332 .checked_add(bytes.len() as u64)
5333 .ok_or_else(|| LinkError::PushTooLarge {
5334 detail: "v2 local byte count overflow".to_string(),
5335 })?;
5336 if total > MAX_STORE_BYTES {
5337 return Err(LinkError::PushTooLarge {
5338 detail: format!("{total} uncompressed bytes"),
5339 });
5340 }
5341 if std::str::from_utf8(&bytes).is_err() {
5342 return Err(LinkError::NotUtf8 { path });
5343 }
5344 let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5345 riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
5346 result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
5347 }
5348 let kept_home = eligibility
5349 .iter()
5350 .filter(|(_, riding)| !**riding)
5351 .map(|(path, _)| path.clone())
5352 .collect::<std::collections::BTreeSet<_>>();
5353 let mut withheld_links = riding_links
5354 .into_iter()
5355 .flat_map(|(source, targets)| {
5356 let kept_home = &kept_home;
5357 let policy = &policy;
5358 targets.into_iter().filter_map(move |target| {
5359 let target = format!("{target}.md");
5360 (kept_home.contains(&target) || policy.keeps_home(&target)).then_some(
5370 V2WithheldLink {
5371 source: source.clone(),
5372 target,
5373 },
5374 )
5375 })
5376 })
5377 .collect::<Vec<_>>();
5378 withheld_links.sort();
5379 withheld_links.dedup();
5380 Ok(V2LocalView {
5381 riding: result,
5382 eligibility,
5383 policy,
5384 withheld_links,
5385 })
5386}
5387
5388#[derive(Debug, Clone, Deserialize)]
5389struct V2DownloadItem {
5390 path: String,
5391 sha256: String,
5392 bytes: u64,
5393 url: String,
5394 method: String,
5395}
5396
5397#[derive(Debug, Deserialize)]
5398struct V2DownloadWindow {
5399 v: u8,
5400 commit: String,
5401 downloads: Vec<V2DownloadItem>,
5402}
5403
5404#[derive(Debug, Deserialize)]
5405struct V2BulkStreamHeader {
5406 v: u8,
5407 path: String,
5408 sha256: String,
5409 bytes: u64,
5410}
5411
5412fn parse_v2_bulk_stream(
5413 bytes: &[u8],
5414 expected: &[(&String, &V2BaselineFile)],
5415) -> LinkResult<Vec<(String, Vec<u8>)>> {
5416 if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5417 return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5418 }
5419 let mut cursor = V2_BULK_STREAM_MAGIC.len();
5420 let mut result = Vec::with_capacity(expected.len());
5421 for (expected_path, expected_file) in expected {
5422 let length_bytes = bytes
5423 .get(cursor..cursor + 4)
5424 .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5425 cursor += 4;
5426 let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5427 if header_len == 0 || header_len > 4 * 1024 {
5428 return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5429 }
5430 let header_bytes = bytes
5431 .get(cursor..cursor + header_len)
5432 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5433 cursor += header_len;
5434 let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5435 .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5436 if header.v != 2
5437 || &header.path != *expected_path
5438 || header.sha256 != expected_file.sha256
5439 || header.bytes != expected_file.bytes
5440 || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5441 {
5442 return Err(invalid_feed(
5443 "v2 bulk stream frame differs from its proven manifest entry",
5444 ));
5445 }
5446 let body_len = usize::try_from(header.bytes)
5447 .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5448 let body = bytes
5449 .get(cursor..cursor + body_len)
5450 .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5451 cursor += body_len;
5452 if content_sha256(body) != header.sha256 {
5453 return Err(invalid_feed(
5454 "v2 bulk stream file differs from its proven manifest entry",
5455 ));
5456 }
5457 result.push((header.path, body.to_vec()));
5458 }
5459 if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5460 return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5461 }
5462 cursor += 4;
5463 if cursor != bytes.len() {
5464 return Err(invalid_feed("v2 bulk stream carries trailing data"));
5465 }
5466 Ok(result)
5467}
5468
5469fn download_v2_bulk_stream(
5470 cfg: &HubConfig,
5471 brain: &str,
5472 pointer: &V2PointerBody,
5473 pending: &[(&String, &V2BaselineFile)],
5474) -> LinkResult<Vec<(String, Vec<u8>)>> {
5475 let claims = pending
5476 .iter()
5477 .map(|(path, file)| {
5478 Ok(json!({
5479 "path": path,
5480 "sha256": file.sha256,
5481 "bytes": file.bytes,
5482 "proof": file.proof.as_ref().ok_or_else(|| {
5483 invalid_feed("v2 manifest omitted a bulk-stream proof")
5484 })?,
5485 }))
5486 })
5487 .collect::<LinkResult<Vec<_>>>()?;
5488 let raw = request_raw_retryable_read(
5489 cfg,
5490 "POST",
5491 &format!("/api/hub/brains/{brain}/v2/stream"),
5492 Some(&json!({
5493 "commit": pointer.commit_hash,
5494 "files": claims,
5495 })),
5496 Auth::Required,
5497 V2_BULK_STREAM_RESPONSE_BYTES,
5498 )?;
5499 let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5500 parse_v2_bulk_stream(&body, pending)
5501}
5502
5503fn request_capped_retryable_read(
5504 cfg: &HubConfig,
5505 method: &str,
5506 path: &str,
5507 body: Option<&Value>,
5508 auth: Auth,
5509 max_response_bytes: u64,
5510) -> LinkResult<HubResponse> {
5511 let raw = request_raw_retryable_read(cfg, method, path, body, auth, max_response_bytes)?;
5512 let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
5513 Ok(HubResponse {
5514 status: raw.status,
5515 body: parsed,
5516 })
5517}
5518
5519fn prepare_v2_downloads(
5520 cfg: &HubConfig,
5521 brain: &str,
5522 pointer: &V2PointerBody,
5523 pending: &[(&String, &V2BaselineFile)],
5524) -> LinkResult<Vec<V2DownloadItem>> {
5525 let mut result = Vec::with_capacity(pending.len());
5526 for chunk in pending.chunks(128) {
5527 let claims = chunk
5528 .iter()
5529 .map(|(path, file)| {
5530 Ok(json!({
5531 "path": path,
5532 "sha256": file.sha256,
5533 "bytes": file.bytes,
5534 "proof": file.proof.as_ref().ok_or_else(|| {
5535 invalid_feed("v2 manifest omitted a download proof")
5536 })?,
5537 }))
5538 })
5539 .collect::<LinkResult<Vec<_>>>()?;
5540 let value = ensure_ok(
5541 request_capped_retryable_read(
5542 cfg,
5543 "POST",
5544 &format!("/api/hub/brains/{brain}/v2/downloads"),
5545 Some(&json!({
5546 "commit": pointer.commit_hash,
5547 "files": claims,
5548 })),
5549 Auth::Required,
5550 MAX_FEED_RESPONSE_BYTES,
5551 )?,
5552 "prepare v2 blob downloads",
5553 )?;
5554 let window: V2DownloadWindow = serde_json::from_value(value)
5555 .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5556 if window.v != 2
5557 || window.commit != pointer.commit_hash
5558 || window.downloads.len() != chunk.len()
5559 {
5560 return Err(invalid_feed(
5561 "v2 download window is not bound to the requested files",
5562 ));
5563 }
5564 let mut by_path = window
5565 .downloads
5566 .into_iter()
5567 .map(|item| (item.path.clone(), item))
5568 .collect::<std::collections::BTreeMap<_, _>>();
5569 if by_path.len() != chunk.len() {
5570 return Err(invalid_feed("v2 download window repeats a path"));
5571 }
5572 for (path, file) in chunk {
5573 let item = by_path
5574 .remove(*path)
5575 .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5576 if item.method != "GET"
5577 || item.sha256 != file.sha256
5578 || item.bytes != file.bytes
5579 || item.url.is_empty()
5580 {
5581 return Err(invalid_feed(
5582 "v2 download capability differs from its proven file",
5583 ));
5584 }
5585 result.push(item);
5586 }
5587 }
5588 Ok(result)
5589}
5590
5591fn prepare_v2_asset_downloads(
5592 cfg: &HubConfig,
5593 brain: &str,
5594 pointer: &V2PointerBody,
5595 pending: &[(&String, &V2BaselineAsset)],
5596) -> LinkResult<Vec<V2DownloadItem>> {
5597 let mut result = Vec::with_capacity(pending.len());
5598 for chunk in pending.chunks(128) {
5599 let claims = chunk
5600 .iter()
5601 .map(|(path, asset)| {
5602 json!({
5603 "path": path,
5604 "sha256": asset.blob_sha256,
5605 "bytes": asset.bytes,
5606 "leaf_hash": asset.leaf_hash,
5607 })
5608 })
5609 .collect::<Vec<_>>();
5610 let value = ensure_ok(
5611 request_capped_retryable_read(
5612 cfg,
5613 "POST",
5614 &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5615 Some(&json!({
5616 "commit": pointer.commit_hash,
5617 "assets": claims,
5618 })),
5619 Auth::Required,
5620 MAX_FEED_RESPONSE_BYTES,
5621 )?,
5622 "prepare v2 asset downloads",
5623 )?;
5624 let window: V2DownloadWindow = serde_json::from_value(value)
5625 .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5626 if window.v != 2
5627 || window.commit != pointer.commit_hash
5628 || window.downloads.len() != chunk.len()
5629 {
5630 return Err(invalid_feed(
5631 "v2 asset download window is not bound to the requested assets",
5632 ));
5633 }
5634 let mut by_path = window
5635 .downloads
5636 .into_iter()
5637 .map(|item| (item.path.clone(), item))
5638 .collect::<std::collections::BTreeMap<_, _>>();
5639 if by_path.len() != chunk.len() {
5640 return Err(invalid_feed("v2 asset download window repeats a path"));
5641 }
5642 for (path, asset) in chunk {
5643 let item = by_path
5644 .remove(*path)
5645 .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5646 if item.method != "GET"
5647 || item.sha256 != asset.blob_sha256
5648 || item.bytes != asset.bytes
5649 || item.url.is_empty()
5650 {
5651 return Err(invalid_feed(
5652 "v2 asset download capability differs from its signed leaf",
5653 ));
5654 }
5655 result.push(item);
5656 }
5657 }
5658 Ok(result)
5659}
5660
5661#[cfg(any(unix, windows))]
5662fn stage_v2_asset_download_window(
5663 cfg: &HubConfig,
5664 brain: &str,
5665 pointer: &V2PointerBody,
5666 cache_dir: &Path,
5667 pending: &[(&String, &V2BaselineAsset)],
5668) -> LinkResult<Vec<V2StagedFile>> {
5669 if pending.is_empty() {
5670 return Ok(Vec::new());
5671 }
5672 if pending.len() > V2_DOWNLOAD_CAPABILITY_FILES {
5673 return Err(invalid_feed("v2 asset capability window is oversized"));
5674 }
5675
5676 let mut last_error = None;
5677 for retry_delay in V2_DOWNLOAD_CAPABILITY_BACKOFF_MS
5678 .iter()
5679 .copied()
5680 .map(Some)
5681 .chain(std::iter::once(None))
5682 {
5683 let downloads = prepare_v2_asset_downloads(cfg, brain, pointer, pending)?;
5688 let mut unique = std::collections::BTreeMap::<String, V2DownloadItem>::new();
5689 for item in downloads {
5690 match unique.get(&item.sha256) {
5691 Some(prior) if prior.bytes != item.bytes => {
5692 return Err(invalid_feed(
5693 "one v2 asset hash has conflicting byte lengths",
5694 ));
5695 }
5696 Some(_) => {}
5697 None => {
5698 unique.insert(item.sha256.clone(), item);
5699 }
5700 }
5701 }
5702 let downloads = unique.into_values().collect::<Vec<_>>();
5703 let next = std::sync::atomic::AtomicUsize::new(0);
5704 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5705 let mut results = std::iter::repeat_with(|| None)
5706 .take(downloads.len())
5707 .collect::<Vec<Option<LinkResult<PathBuf>>>>();
5708 std::thread::scope(|scope| {
5709 let (sender, receiver) = std::sync::mpsc::channel();
5710 for _ in 0..worker_count {
5711 let sender = sender.clone();
5712 let downloads = &downloads;
5713 let next = &next;
5714 scope.spawn(move || loop {
5715 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5716 let Some(item) = downloads.get(index) else {
5717 break;
5718 };
5719 let result = download_presigned_to_cache(
5720 cfg,
5721 &item.url,
5722 cache_dir,
5723 &item.sha256,
5724 item.bytes,
5725 );
5726 if sender.send((index, result)).is_err() {
5727 break;
5728 }
5729 });
5730 }
5731 drop(sender);
5732 for (index, result) in receiver {
5733 results[index] = Some(result);
5734 }
5735 });
5736
5737 let mut failed = None;
5738 for result in results {
5739 match result {
5740 Some(Ok(_)) => {}
5741 Some(Err(error)) if failed.is_none() => failed = Some(error),
5742 Some(Err(_)) => {}
5743 None if failed.is_none() => {
5744 failed = Some(LinkError::Transport {
5745 hub: cfg.hub.clone(),
5746 message: "a bounded v2 asset worker stopped before reporting its result"
5747 .to_string(),
5748 });
5749 }
5750 None => {}
5751 }
5752 }
5753 if let Some(error) = failed {
5754 last_error = Some(error);
5755 if let Some(milliseconds) = retry_delay {
5756 std::thread::sleep(std::time::Duration::from_millis(milliseconds));
5757 continue;
5758 }
5759 break;
5760 }
5761
5762 return pending
5763 .iter()
5764 .map(|(path, asset)| {
5765 let source = cache_dir.join(&asset.blob_sha256);
5766 if !cached_blob_is_exact(&source, &asset.blob_sha256, asset.bytes)? {
5767 return Err(invalid_feed(
5768 "v2 asset download cache omitted a proven blob",
5769 ));
5770 }
5771 Ok(V2StagedFile {
5772 path: (*path).clone(),
5773 source,
5774 sha256: asset.blob_sha256.clone(),
5775 bytes: asset.bytes,
5776 })
5777 })
5778 .collect();
5779 }
5780 Err(last_error
5781 .unwrap_or_else(|| invalid_feed("v2 asset capability window made no download attempt")))
5782}
5783
5784fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5785 let bytes = get_presigned(cfg, &item.url)?;
5786 if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5787 return Err(invalid_feed("v2 blob differs from its proven path entry"));
5788 }
5789 Ok(bytes)
5790}
5791
5792#[derive(Debug, Clone)]
5793struct V2StagedFile {
5794 path: String,
5795 source: PathBuf,
5796 sha256: String,
5797 bytes: u64,
5798}
5799
5800#[cfg(unix)]
5801fn v2_download_cache_dir(
5802 cfg: &HubConfig,
5803 brain: &str,
5804 pointer: &V2PointerBody,
5805) -> LinkResult<PathBuf> {
5806 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5807}
5808
5809#[cfg(unix)]
5810fn v2_download_cache_dir_for(
5811 cfg: &HubConfig,
5812 brain: &str,
5813 transaction: &str,
5814) -> LinkResult<PathBuf> {
5815 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5816 return Err(invalid_feed("v2 download cache address is invalid"));
5817 }
5818 let path = cfg
5819 .state_dir
5820 .join("downloads")
5821 .join(brain)
5822 .join(transaction);
5823 let directory = open_or_create_dir_nofollow(&path)?;
5824 use std::os::fd::AsRawFd as _;
5825 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5826 return Err(std::io::Error::last_os_error().into());
5827 }
5828 directory.sync_all()?;
5829 Ok(path)
5830}
5831
5832#[cfg(windows)]
5833fn v2_download_cache_dir(
5834 cfg: &HubConfig,
5835 brain: &str,
5836 pointer: &V2PointerBody,
5837) -> LinkResult<PathBuf> {
5838 v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5839}
5840
5841#[cfg(windows)]
5842fn v2_download_cache_dir_for(
5843 cfg: &HubConfig,
5844 brain: &str,
5845 transaction: &str,
5846) -> LinkResult<PathBuf> {
5847 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5848 return Err(invalid_feed("v2 download cache address is invalid"));
5849 }
5850 let path = cfg
5851 .state_dir
5852 .join("downloads")
5853 .join(brain)
5854 .join(transaction);
5855 crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5856 crate::fsx::open_directory_nofollow(&path)?;
5857 Ok(path)
5858}
5859
5860#[cfg(unix)]
5861fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5862 use std::os::fd::AsRawFd as _;
5863 let parent = cfg.state_dir.join("downloads").join(brain);
5864 let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5865 return;
5866 };
5867 let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5868 return;
5869 };
5870 let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5871 let _ = directory.sync_all();
5872}
5873
5874#[cfg(windows)]
5875fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5876 if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5877 return;
5878 }
5879 let parent = cfg.state_dir.join("downloads").join(brain);
5880 let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5881 return;
5882 };
5883 let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5884}
5885
5886#[cfg(not(any(unix, windows)))]
5887fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5888
5889#[cfg(not(any(unix, windows)))]
5890fn v2_download_cache_dir_for(
5891 _cfg: &HubConfig,
5892 _brain: &str,
5893 _transaction: &str,
5894) -> LinkResult<PathBuf> {
5895 Err(LinkError::UnsupportedPlatform {
5896 operation: "resumable v2 download staging",
5897 })
5898}
5899
5900#[cfg(any(unix, windows))]
5901fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5902 let file = match crate::fsx::open_regular_nofollow(path) {
5903 Ok(file) => file,
5904 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5905 Err(error) => return Err(error.into()),
5906 };
5907 if file.metadata()?.len() != bytes {
5908 return Ok(false);
5909 }
5910 Ok(content_sha256_reader(file)? == sha256)
5911}
5912
5913#[cfg(any(unix, windows))]
5914fn cache_v2_blob_bytes(
5915 cache_dir: &Path,
5916 sha256: &str,
5917 expected_bytes: u64,
5918 bytes: &[u8],
5919) -> LinkResult<PathBuf> {
5920 if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5921 return Err(invalid_feed("v2 cached blob differs from its declaration"));
5922 }
5923 let path = cache_dir.join(sha256);
5924 if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5925 crate::fsx::write_atomic(&path, bytes)?;
5926 }
5927 Ok(path)
5928}
5929
5930#[cfg(not(any(unix, windows)))]
5931fn cache_v2_blob_bytes(
5932 _cache_dir: &Path,
5933 _sha256: &str,
5934 _expected_bytes: u64,
5935 _bytes: &[u8],
5936) -> LinkResult<PathBuf> {
5937 Err(LinkError::UnsupportedPlatform {
5938 operation: "resumable v2 download staging",
5939 })
5940}
5941
5942#[cfg(unix)]
5943fn download_presigned_to_cache(
5944 cfg: &HubConfig,
5945 url: &str,
5946 cache_dir: &Path,
5947 sha256: &str,
5948 expected_bytes: u64,
5949) -> LinkResult<PathBuf> {
5950 use std::os::fd::{AsRawFd as _, FromRawFd as _};
5951
5952 let target = cache_dir.join(sha256);
5953 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5954 return Ok(target);
5955 }
5956 let directory = open_existing_dir_nofollow(cache_dir)?;
5957 let mut nonce = [0_u8; 16];
5958 ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5959 .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5960 let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5961 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5962 let fd = unsafe {
5963 libc::openat(
5964 directory.as_raw_fd(),
5965 temp.as_ptr(),
5966 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5967 0o600,
5968 )
5969 };
5970 if fd < 0 {
5971 return Err(std::io::Error::last_os_error().into());
5972 }
5973 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5974 let response = match presigned_agent(cfg, url)?.get(url).call() {
5975 Ok(response) => response,
5976 Err(ureq::Error::Status(_, response)) => {
5977 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5978 return Err(LinkError::Http {
5979 what: "v2 direct download",
5980 status: response.status(),
5981 message: "object store rejected the download".to_string(),
5982 code: None,
5983 details: None,
5984 });
5985 }
5986 Err(ureq::Error::Transport(error)) => {
5987 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5988 return Err(LinkError::Transport {
5989 hub: cfg.hub.clone(),
5990 message: error.to_string(),
5991 });
5992 }
5993 };
5994 let mut reader = response
5995 .into_reader()
5996 .take(expected_bytes.saturating_add(1));
5997 let mut digest = Sha256::new();
5998 let mut total = 0_u64;
5999 let mut buffer = [0_u8; 64 * 1024];
6000 let write_result = (|| -> LinkResult<()> {
6005 loop {
6006 let read = reader
6007 .read(&mut buffer)
6008 .map_err(|error| LinkError::Transport {
6009 hub: cfg.hub.clone(),
6010 message: error.to_string(),
6011 })?;
6012 if read == 0 {
6013 break;
6014 }
6015 total = total.saturating_add(read as u64);
6016 digest.update(&buffer[..read]);
6017 output.write_all(&buffer[..read])?;
6018 }
6019 output.sync_all().map_err(LinkError::from)
6020 })();
6021 if let Err(error) = write_result {
6022 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6023 return Err(error);
6024 }
6025 drop(output);
6026 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6027 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6028 return Err(invalid_feed(
6029 "v2 direct download failed integrity verification",
6030 ));
6031 }
6032 let target_name = c_name(sha256.as_bytes(), sha256)?;
6033 if unsafe {
6036 libc::renameat(
6037 directory.as_raw_fd(),
6038 temp.as_ptr(),
6039 directory.as_raw_fd(),
6040 target_name.as_ptr(),
6041 )
6042 } != 0
6043 {
6044 let error = std::io::Error::last_os_error();
6045 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6046 return Err(error.into());
6047 }
6048 directory.sync_all()?;
6049 Ok(target)
6050}
6051
6052#[cfg(windows)]
6053fn download_presigned_to_cache(
6054 cfg: &HubConfig,
6055 url: &str,
6056 cache_dir: &Path,
6057 sha256: &str,
6058 expected_bytes: u64,
6059) -> LinkResult<PathBuf> {
6060 use std::fs::OpenOptions;
6061
6062 let target = cache_dir.join(sha256);
6063 if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6064 return Ok(target);
6065 }
6066 let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
6070 let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
6071 let mut output = OpenOptions::new()
6072 .write(true)
6073 .create_new(true)
6074 .open(&temp)?;
6075 let response = match presigned_agent(cfg, url)?.get(url).call() {
6076 Ok(response) => response,
6077 Err(ureq::Error::Status(_, response)) => {
6078 let _ = std::fs::remove_file(&temp);
6079 return Err(LinkError::Http {
6080 what: "v2 direct download",
6081 status: response.status(),
6082 message: "object store rejected the download".to_string(),
6083 code: None,
6084 details: None,
6085 });
6086 }
6087 Err(ureq::Error::Transport(error)) => {
6088 let _ = std::fs::remove_file(&temp);
6089 return Err(LinkError::Transport {
6090 hub: cfg.hub.clone(),
6091 message: error.to_string(),
6092 });
6093 }
6094 };
6095 let mut reader = response
6096 .into_reader()
6097 .take(expected_bytes.saturating_add(1));
6098 let mut digest = Sha256::new();
6099 let mut total = 0_u64;
6100 let mut buffer = [0_u8; 64 * 1024];
6101 let copied = (|| -> LinkResult<()> {
6103 loop {
6104 let read = reader
6105 .read(&mut buffer)
6106 .map_err(|error| LinkError::Transport {
6107 hub: cfg.hub.clone(),
6108 message: error.to_string(),
6109 })?;
6110 if read == 0 {
6111 break;
6112 }
6113 total = total.saturating_add(read as u64);
6114 digest.update(&buffer[..read]);
6115 output.write_all(&buffer[..read])?;
6116 }
6117 output.sync_all()?;
6118 Ok(())
6119 })();
6120 if let Err(error) = copied {
6121 let _ = std::fs::remove_file(&temp);
6122 return Err(error);
6123 }
6124 drop(output);
6125 if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6126 let _ = std::fs::remove_file(&temp);
6127 return Err(invalid_feed(
6128 "v2 direct download failed integrity verification",
6129 ));
6130 }
6131 if target.exists() {
6132 std::fs::remove_file(&target)?;
6133 }
6134 if let Err(error) = std::fs::rename(&temp, &target) {
6135 let _ = std::fs::remove_file(&temp);
6136 return Err(error.into());
6137 }
6138 Ok(target)
6139}
6140
6141#[cfg(not(any(unix, windows)))]
6142fn download_presigned_to_cache(
6143 _cfg: &HubConfig,
6144 _url: &str,
6145 _cache_dir: &Path,
6146 _sha256: &str,
6147 _expected_bytes: u64,
6148) -> LinkResult<PathBuf> {
6149 Err(LinkError::UnsupportedPlatform {
6150 operation: "resumable v2 download staging",
6151 })
6152}
6153
6154fn download_v2_blobs(
6155 cfg: &HubConfig,
6156 brain: &str,
6157 pointer: &V2PointerBody,
6158 pending: Vec<(&String, &V2BaselineFile)>,
6159) -> LinkResult<Vec<(String, Vec<u8>)>> {
6160 if pending.is_empty() {
6161 return Ok(Vec::new());
6162 }
6163 let expected_order = pending
6164 .iter()
6165 .map(|(path, _)| (*path).clone())
6166 .collect::<Vec<_>>();
6167 let mut streamed = std::collections::BTreeMap::new();
6168 let mut direct = Vec::new();
6169 let mut window = Vec::new();
6170 let mut window_bytes = 0_u64;
6171 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6172 window_bytes: &mut u64,
6173 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
6174 -> LinkResult<()> {
6175 if window.is_empty() {
6176 return Ok(());
6177 }
6178 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
6179 if streamed.insert(path, bytes).is_some() {
6180 return Err(invalid_feed("v2 bulk streams repeated a path"));
6181 }
6182 }
6183 window.clear();
6184 *window_bytes = 0;
6185 Ok(())
6186 };
6187 for &(path, file) in &pending {
6188 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6189 flush(&mut window, &mut window_bytes, &mut streamed)?;
6190 direct.push((path, file));
6191 continue;
6192 }
6193 if window.len() == V2_BULK_STREAM_FILES
6194 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6195 {
6196 flush(&mut window, &mut window_bytes, &mut streamed)?;
6197 }
6198 window.push((path, file));
6199 window_bytes += file.bytes;
6200 }
6201 flush(&mut window, &mut window_bytes, &mut streamed)?;
6202
6203 let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
6204 let next = std::sync::atomic::AtomicUsize::new(0);
6205 let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6206 let mut results = std::iter::repeat_with(|| None)
6207 .take(downloads.len())
6208 .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
6209 std::thread::scope(|scope| {
6210 let (sender, receiver) = std::sync::mpsc::channel();
6211 for _ in 0..worker_count {
6212 let sender = sender.clone();
6213 let downloads = &downloads;
6214 let next = &next;
6215 scope.spawn(move || loop {
6216 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6217 let Some(item) = downloads.get(index) else {
6218 break;
6219 };
6220 let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
6221 if sender.send((index, result)).is_err() {
6222 break;
6223 }
6224 });
6225 }
6226 drop(sender);
6227 for (index, result) in receiver {
6228 results[index] = Some(result);
6229 }
6230 });
6231 for result in results.into_iter().map(|result| {
6232 result.ok_or_else(|| LinkError::Transport {
6233 hub: cfg.hub.clone(),
6234 message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
6235 })?
6236 }) {
6237 let (path, bytes) = result?;
6238 if streamed.insert(path, bytes).is_some() {
6239 return Err(invalid_feed("v2 download lanes repeated a path"));
6240 }
6241 }
6242 expected_order
6243 .into_iter()
6244 .map(|path| {
6245 streamed
6246 .remove(&path)
6247 .map(|bytes| (path, bytes))
6248 .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
6249 })
6250 .collect()
6251}
6252
6253#[cfg(any(unix, windows))]
6257fn stage_v2_blobs(
6258 cfg: &HubConfig,
6259 brain: &str,
6260 pointer: &V2PointerBody,
6261 pending: Vec<(&String, &V2BaselineFile)>,
6262) -> LinkResult<Vec<V2StagedFile>> {
6263 let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6264 let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6265 let mut direct = Vec::new();
6266 let mut window = Vec::new();
6267 let mut window_bytes = 0_u64;
6268 let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6269 window_bytes: &mut u64,
6270 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
6271 -> LinkResult<()> {
6272 if window.is_empty() {
6273 return Ok(());
6274 }
6275 let missing = window
6276 .iter()
6277 .filter_map(|(path, file)| {
6278 let target = cache_dir.join(&file.sha256);
6279 match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6280 Ok(true) => {
6281 staged.insert(
6282 (*path).clone(),
6283 V2StagedFile {
6284 path: (*path).clone(),
6285 source: target,
6286 sha256: file.sha256.clone(),
6287 bytes: file.bytes,
6288 },
6289 );
6290 None
6291 }
6292 Ok(false) => Some(Ok((*path, *file))),
6293 Err(error) => Some(Err(error)),
6294 }
6295 })
6296 .collect::<LinkResult<Vec<_>>>()?;
6297 if !missing.is_empty() {
6298 for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
6299 let file = missing
6300 .iter()
6301 .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
6302 .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
6303 let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
6304 staged.insert(
6305 path.clone(),
6306 V2StagedFile {
6307 path,
6308 source,
6309 sha256: file.sha256.clone(),
6310 bytes: file.bytes,
6311 },
6312 );
6313 }
6314 }
6315 window.clear();
6316 *window_bytes = 0;
6317 Ok(())
6318 };
6319 for &(path, file) in &pending {
6320 if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6321 flush(&mut window, &mut window_bytes, &mut staged)?;
6322 direct.push((path, file));
6323 continue;
6324 }
6325 if window.len() == V2_BULK_STREAM_FILES
6326 || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6327 {
6328 flush(&mut window, &mut window_bytes, &mut staged)?;
6329 }
6330 window.push((path, file));
6331 window_bytes += file.bytes;
6332 }
6333 flush(&mut window, &mut window_bytes, &mut staged)?;
6334 for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6335 let source =
6336 download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6337 staged.insert(
6338 item.path.clone(),
6339 V2StagedFile {
6340 path: item.path,
6341 source,
6342 sha256: item.sha256,
6343 bytes: item.bytes,
6344 },
6345 );
6346 }
6347 pending
6348 .into_iter()
6349 .map(|(path, _)| {
6350 staged
6351 .remove(path)
6352 .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6353 })
6354 .collect()
6355}
6356
6357#[cfg(not(any(unix, windows)))]
6358fn stage_v2_blobs(
6359 _cfg: &HubConfig,
6360 _brain: &str,
6361 _pointer: &V2PointerBody,
6362 _pending: Vec<(&String, &V2BaselineFile)>,
6363) -> LinkResult<Vec<V2StagedFile>> {
6364 Err(LinkError::UnsupportedPlatform {
6365 operation: "resumable v2 download staging",
6366 })
6367}
6368
6369const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6370const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6371const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6372
6373#[derive(Debug, Clone, Deserialize, Serialize)]
6374struct V2ConflictCoordinate {
6375 sha256: Option<String>,
6376 bytes: Option<u64>,
6377 file: Option<String>,
6378}
6379
6380#[derive(Debug, Clone, Deserialize, Serialize)]
6381struct V2ConflictFile {
6382 path: String,
6383 base: V2ConflictCoordinate,
6384 local: V2ConflictCoordinate,
6385 remote: V2ConflictCoordinate,
6386}
6387
6388#[derive(Debug, Clone, Deserialize, Serialize)]
6389struct V2ConflictPlan {
6390 v: u8,
6391 class: String,
6392 bundle: String,
6393 brain: String,
6394 origin: String,
6395 created_unix: u64,
6396 expires_unix: u64,
6397 base_seq: Option<u64>,
6398 base_commit: Option<String>,
6399 remote_seq: u64,
6400 remote_commit: Option<String>,
6401 remote_content_root: Option<String>,
6402 view_kind: String,
6403 view_revision: String,
6404 files: Vec<V2ConflictFile>,
6405}
6406
6407fn v2_take_remote_selection(
6408 files: &[V2ConflictFile],
6409 current: &std::collections::BTreeMap<String, V2BaselineFile>,
6410) -> LinkResult<(
6411 std::collections::BTreeMap<String, V2BaselineFile>,
6412 Vec<String>,
6413)> {
6414 let mut selected = std::collections::BTreeMap::new();
6415 let mut deleted = Vec::new();
6416 for file in files {
6417 match (&file.remote.sha256, file.remote.bytes) {
6418 (Some(sha256), Some(bytes)) => {
6419 let proven = current.get(&file.path).ok_or_else(|| {
6420 invalid_feed("conflict remote coordinate disappeared from the exact head")
6421 })?;
6422 if proven.sha256 != *sha256 || proven.bytes != bytes {
6423 return Err(invalid_feed(
6424 "conflict remote coordinate differs from the exact head",
6425 ));
6426 }
6427 if selected.insert(file.path.clone(), proven.clone()).is_some() {
6428 return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6429 }
6430 }
6431 (None, None) => {
6432 if current.contains_key(&file.path) {
6433 return Err(invalid_feed(
6434 "conflict remote deletion differs from the exact head",
6435 ));
6436 }
6437 deleted.push(file.path.clone());
6438 }
6439 _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6440 }
6441 }
6442 Ok((selected, deleted))
6443}
6444
6445fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6446 PathBuf::from(".dbmd")
6447 .join("conflicts")
6448 .join(bundle)
6449 .join(suffix)
6450}
6451
6452fn read_historical_conflict_blob(
6453 cfg: &HubConfig,
6454 brain: &str,
6455 baseline: &V2SyncBaseline,
6456 path: &str,
6457 file: &V2BaselineFile,
6458) -> LinkResult<Option<Vec<u8>>> {
6459 let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6460 return Ok(None);
6461 };
6462 if seq == 0 {
6463 return Ok(None);
6464 }
6465 let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6466 let endpoint = format!(
6467 "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6468 file.sha256
6469 );
6470 let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
6471 if raw.status == 404 || raw.status == 403 {
6472 return Ok(None);
6473 }
6474 let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6475 if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6476 return Err(invalid_feed(
6477 "v2 conflict base failed integrity verification",
6478 ));
6479 }
6480 Ok(Some(bytes))
6481}
6482
6483fn create_v2_conflict_bundle(
6486 cfg: &HubConfig,
6487 store: &Store,
6488 head: &V2VerifiedHead,
6489 baseline: Option<&V2SyncBaseline>,
6490 local: &std::collections::BTreeMap<String, (String, u64)>,
6491 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6492 paths: &[String],
6493) -> LinkResult<(String, Vec<String>)> {
6494 let conflicts_root = Path::new(".dbmd/conflicts");
6495 store.create_dir_all(conflicts_root)?;
6496 let completed = store
6497 .directory_names(conflicts_root)?
6498 .into_iter()
6499 .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6500 .count();
6501 if completed >= V2_CONFLICT_BUNDLE_MAX {
6502 return Err(LinkError::InvalidPack {
6503 message: format!(
6504 "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6505 ),
6506 });
6507 }
6508
6509 let mut selected_paths = Vec::new();
6513 let mut selected_remote_bytes = 0_u64;
6514 for path in paths {
6515 let bytes = remote.get(path).map_or(0, |file| file.bytes);
6516 if !selected_paths.is_empty()
6517 && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6518 {
6519 break;
6520 }
6521 selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6522 selected_paths.push(path.clone());
6523 if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6524 break;
6525 }
6526 }
6527 if selected_paths.is_empty() {
6528 return Err(invalid_feed("content conflict set is empty"));
6529 }
6530 let bundle = crate::ulid::mint();
6531 let bundle_root = v2_conflict_relative(&bundle, "");
6532 store.create_dir_all(&bundle_root.join("files"))?;
6533 let pointer = head.pointer.as_ref();
6534 let remote_bytes = match pointer {
6535 Some(pointer) => download_v2_blobs(
6536 cfg,
6537 &head.brain_id,
6538 pointer,
6539 selected_paths
6540 .iter()
6541 .filter_map(|path| {
6542 remote
6543 .get(path)
6544 .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6545 .map(|file| (path, file))
6546 })
6547 .collect(),
6548 )?
6549 .into_iter()
6550 .collect::<std::collections::BTreeMap<_, _>>(),
6551 None => std::collections::BTreeMap::new(),
6552 };
6553
6554 let mut files = Vec::with_capacity(selected_paths.len());
6555 for (index, path) in selected_paths.iter().enumerate() {
6556 let base_file = baseline.and_then(|state| state.files.get(path));
6557 let base_bytes = match (baseline, base_file) {
6558 (Some(state), Some(file)) => {
6559 read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
6560 }
6561 _ => None,
6562 };
6563 let local_file = local.get(path);
6564 let remote_file = remote.get(path);
6565 let remote_content = remote_bytes.get(path);
6566 let prefix = format!("files/{index:04}");
6567 let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6568 let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6569 let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6570 if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6571 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6572 }
6573 if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6574 let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6575 if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6576 return Err(LinkError::InvalidPack {
6577 message: format!("local conflict path `{path}` changed while bundling"),
6578 });
6579 }
6580 store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6581 }
6582 if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6583 store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6584 }
6585 files.push(V2ConflictFile {
6586 path: path.clone(),
6587 base: V2ConflictCoordinate {
6588 sha256: base_file.map(|file| file.sha256.clone()),
6589 bytes: base_file.map(|file| file.bytes),
6590 file: base_name,
6591 },
6592 local: V2ConflictCoordinate {
6593 sha256: local_file.map(|(sha256, _)| sha256.clone()),
6594 bytes: local_file.map(|(_, bytes)| *bytes),
6595 file: local_name,
6596 },
6597 remote: V2ConflictCoordinate {
6598 sha256: remote_file.map(|file| file.sha256.clone()),
6599 bytes: remote_file.map(|file| file.bytes),
6600 file: remote_name,
6601 },
6602 });
6603 }
6604 let now = SystemTime::now()
6605 .duration_since(UNIX_EPOCH)
6606 .unwrap_or_default()
6607 .as_secs();
6608 let plan = V2ConflictPlan {
6609 v: 2,
6610 class: "content_resolution_required".to_string(),
6611 bundle: bundle.clone(),
6612 brain: head.brain_id.clone(),
6613 origin: normalized_origin(&cfg.hub)?,
6614 created_unix: now,
6615 expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6616 base_seq: baseline.and_then(|state| state.head_seq),
6617 base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6618 remote_seq: pointer.map_or(0, |value| value.seq),
6619 remote_commit: pointer.map(|value| value.commit_hash.clone()),
6620 remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6621 view_kind: head.view_kind.clone(),
6622 view_revision: head.view_revision.clone(),
6623 files,
6624 };
6625 let mut bytes = serde_json::to_vec_pretty(&plan)
6626 .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6627 bytes.push(b'\n');
6628 store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6629 Ok((bundle, selected_paths))
6630}
6631
6632fn v2_sync_pull_with_resolution(
6633 cfg: &HubConfig,
6634 requested_brain: &str,
6635 expected_head: V2VerifiedHead,
6636 out: Option<&Path>,
6637 take_remote: Option<&std::collections::BTreeSet<String>>,
6638) -> LinkResult<V2PulledSnapshot> {
6639 let dest = out
6640 .map(Path::to_path_buf)
6641 .unwrap_or_else(|| PathBuf::from(requested_brain));
6642 let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6643 recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6644 let head = v2_verified_head(cfg, requested_brain)?
6645 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6646 if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6647 return Err(LinkError::RemoteAdvancedDuringSync);
6648 }
6649 let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6650 ensure_v2_view_compatible(&head, baseline.as_ref())?;
6651 let (remote, remote_assets) = match baseline
6652 .as_ref()
6653 .filter(|state| v2_baseline_matches_head(&head, state))
6654 {
6655 Some(state) => (state.files.clone(), state.assets.clone()),
6656 None => (
6657 files_for_v2_view(
6658 &head,
6659 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6660 ),
6661 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6662 ),
6663 };
6664 let local_store = Store::open_strict(&dest).ok();
6665 ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6670 let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6671 if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6672 return Err(LinkError::ScopedViewChanged);
6673 }
6674 if let Some(view) = local_view.as_mut() {
6675 remove_scoped_projection(&head, baseline.as_ref(), view)?;
6676 }
6677 let empty_local = std::collections::BTreeMap::new();
6678 let local = local_view
6679 .as_ref()
6680 .map_or(&empty_local, |view| &view.riding);
6681 let kept_home = |path: &str| {
6682 local_view
6683 .as_ref()
6684 .is_some_and(|view| view.policy.keeps_home(path))
6685 };
6686 let empty_base = std::collections::BTreeMap::new();
6687 let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6688 let empty_base_assets = std::collections::BTreeMap::new();
6689 let base_assets = baseline
6690 .as_ref()
6691 .map_or(&empty_base_assets, |state| &state.assets);
6692 let mut local_assets = local_store
6693 .as_ref()
6694 .map(v2_local_asset_records)
6695 .transpose()?
6696 .unwrap_or_default();
6697 let mut content_merge = merge_v2_pulled_records(
6698 base,
6699 &remote,
6700 local,
6701 |file, _| (file.sha256.clone(), file.bytes),
6702 |file, _| (file.sha256.clone(), file.bytes),
6703 kept_home,
6704 );
6705 if let Some(selected) = take_remote {
6706 for path in selected {
6707 if let Some(position) = content_merge
6708 .conflicts
6709 .iter()
6710 .position(|conflict| conflict == path)
6711 {
6712 content_merge.conflicts.remove(position);
6713 content_merge.accept_remote.insert(path.clone());
6714 match remote.get(path) {
6715 Some(file) => {
6716 content_merge
6717 .records
6718 .insert(path.clone(), (file.sha256.clone(), file.bytes));
6719 }
6720 None => {
6721 content_merge.records.remove(path);
6722 }
6723 }
6724 } else if !content_merge.accept_remote.contains(path) {
6725 return Err(LinkError::InvalidPack {
6726 message: format!(
6727 "take-remote path `{path}` is no longer at its conflict coordinate"
6728 ),
6729 });
6730 }
6731 }
6732 }
6733 if !content_merge.conflicts.is_empty() {
6734 let mut conflicts = content_merge.conflicts.clone();
6735 conflicts.truncate(100);
6736 if let Some(store) = local_store.as_ref() {
6737 let (bundle, paths) = create_v2_conflict_bundle(
6738 cfg,
6739 store,
6740 &head,
6741 baseline.as_ref(),
6742 local,
6743 &remote,
6744 &conflicts,
6745 )?;
6746 return Err(LinkError::ConflictBundle { bundle, paths });
6747 }
6748 return Err(LinkError::Conflict { paths: conflicts });
6749 }
6750 let asset_merge = merge_v2_pulled_records(
6751 base_assets,
6752 &remote_assets,
6753 &local_assets,
6754 v2_asset_record,
6755 v2_asset_record,
6756 |_| false,
6757 );
6758 if !asset_merge.conflicts.is_empty() {
6759 let mut conflicts = asset_merge.conflicts.clone();
6760 conflicts.truncate(100);
6761 return Err(LinkError::Conflict { paths: conflicts });
6762 }
6763 let pointer = head.pointer.as_ref();
6764 let cache_transaction = pointer.map_or_else(
6765 || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6766 |value| value.commit_hash.clone(),
6767 );
6768 let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6769 let mut changed = match pointer {
6770 Some(pointer) => stage_v2_blobs(
6771 cfg,
6772 &head.brain_id,
6773 pointer,
6774 remote
6775 .iter()
6776 .filter(|(path, file)| {
6777 content_merge.accept_remote.contains(*path)
6778 && local.get(*path).map(|value| value.0.as_str())
6779 != Some(file.sha256.as_str())
6780 })
6781 .collect(),
6782 )?,
6783 None => Vec::new(),
6784 };
6785 let mut deleted = content_merge
6786 .accept_remote
6787 .iter()
6788 .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6789 .cloned()
6790 .collect::<Vec<_>>();
6791 if local_assets != asset_merge.records {
6792 if asset_merge.records.is_empty() {
6793 deleted.push("assets.jsonl".to_string());
6794 } else {
6795 let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6796 let sha256 = content_sha256(&bytes);
6797 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6798 changed.push(V2StagedFile {
6799 path: "assets.jsonl".to_string(),
6800 source,
6801 sha256,
6802 bytes: bytes.len() as u64,
6803 });
6804 }
6805 }
6806 if let Some(pointer) = pointer {
6807 let mut pending_assets = Vec::new();
6808 for (path, asset) in &remote_assets {
6809 if asset.disposition != "hosted"
6810 || kept_home(path)
6811 || !asset_merge.accept_remote.contains(path)
6812 {
6813 continue;
6814 }
6815 let already_current = local_store.as_ref().is_some_and(|store| {
6816 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6817 && store
6818 .read_bounded(Path::new(path), asset.bytes)
6819 .ok()
6820 .is_some_and(|bytes| {
6821 bytes.len() as u64 == asset.bytes
6822 && content_sha256(&bytes) == asset.blob_sha256
6823 })
6824 });
6825 if !already_current {
6826 pending_assets.push((path, asset));
6827 }
6828 }
6829 let mut window = Vec::new();
6830 let mut window_bytes = 0_u64;
6831 let flush = |window: &mut Vec<(&String, &V2BaselineAsset)>,
6832 window_bytes: &mut u64,
6833 changed: &mut Vec<V2StagedFile>|
6834 -> LinkResult<()> {
6835 changed.extend(stage_v2_asset_download_window(
6836 cfg,
6837 &head.brain_id,
6838 pointer,
6839 &cache_dir,
6840 window,
6841 )?);
6842 window.clear();
6843 *window_bytes = 0;
6844 Ok(())
6845 };
6846 for item @ (_, asset) in pending_assets {
6847 if !window.is_empty()
6848 && (window.len() == V2_DOWNLOAD_CAPABILITY_FILES
6849 || window_bytes.saturating_add(asset.bytes) > V2_DOWNLOAD_CAPABILITY_BYTES)
6850 {
6851 flush(&mut window, &mut window_bytes, &mut changed)?;
6852 }
6853 window.push(item);
6854 window_bytes = window_bytes.saturating_add(asset.bytes);
6855 if window_bytes >= V2_DOWNLOAD_CAPABILITY_BYTES {
6856 flush(&mut window, &mut window_bytes, &mut changed)?;
6857 }
6858 }
6859 flush(&mut window, &mut window_bytes, &mut changed)?;
6860 }
6861 for (path, prior) in base_assets {
6862 if remote_assets.contains_key(path)
6863 || kept_home(path)
6864 || !asset_merge.accept_remote.contains(path)
6865 {
6866 continue;
6867 }
6868 let unchanged = local_store.as_ref().is_some_and(|store| {
6869 matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6870 && store
6871 .read_bounded(Path::new(path), prior.bytes)
6872 .ok()
6873 .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6874 });
6875 if unchanged {
6876 deleted.push(path.clone());
6877 }
6878 }
6879 let extra_local = content_merge
6880 .records
6881 .keys()
6882 .filter(|path| !remote.contains_key(*path))
6883 .cloned()
6884 .collect::<Vec<_>>();
6885 if head.view_kind == "scoped" {
6886 for (path, bytes) in [
6887 ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6888 (
6889 ".dbmd/view.json".to_string(),
6890 scoped_view_metadata(&head, remote.len())?,
6891 ),
6892 ] {
6893 let sha256 = content_sha256(&bytes);
6894 let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6895 changed.push(V2StagedFile {
6896 path,
6897 source,
6898 sha256,
6899 bytes: bytes.len() as u64,
6900 });
6901 }
6902 }
6903 let install_changed = !changed.is_empty() || !deleted.is_empty();
6904 install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6905 let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6906 let installed_store =
6907 Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6908 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6909 })?;
6910 let installed_local = if install_changed {
6911 let mut scanned = v2_local_files(&installed_store)?;
6912 remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6913 scanned
6914 } else {
6915 local_view
6916 .take()
6917 .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6918 };
6919 if installed_local.riding != content_merge.records {
6920 return Err(LinkError::InvalidPack {
6921 message: "local content changed while installing the v2 pull".to_string(),
6922 });
6923 }
6924 let installed_assets = if install_changed {
6925 v2_local_asset_records(&installed_store)?
6926 } else {
6927 std::mem::take(&mut local_assets)
6928 };
6929 if installed_assets != asset_merge.records {
6930 return Err(LinkError::InvalidPack {
6931 message: "local assets changed while installing the v2 pull".to_string(),
6932 });
6933 }
6934 let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6935 installed_local.policy.keeps_home(path)
6936 })
6937 || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6938 let final_head = v2_verified_head(cfg, requested_brain)?
6939 .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6940 if !same_v2_head(&head, &final_head) {
6941 return Err(LinkError::RemoteAdvancedDuringSync);
6942 }
6943 accept_v2_head(cfg, &final_head)?;
6944 save_v2_baseline(
6945 cfg,
6946 &head.brain_id,
6947 &dest,
6948 &v2_baseline_from_head(
6949 cfg,
6950 &head,
6951 remote.clone(),
6952 remote_assets.clone(),
6953 Some(&installed_local),
6954 baseline
6955 .as_ref()
6956 .and_then(|current| current.checkout_id.as_deref()),
6957 )?,
6958 )?;
6959 complete_v2_pull(&dest)?;
6960 Ok((local_dirty, installed_local, installed_assets))
6961 })();
6962 let (local_dirty, installed_local, installed_assets) = match finalized {
6963 Ok(value) => value,
6964 Err(error) => {
6965 if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6966 return Err(LinkError::InvalidPack {
6967 message: format!("{error}; durable pull recovery also failed: {recovery}"),
6968 });
6969 }
6970 return Err(error);
6971 }
6972 };
6973 cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6974 let report = PullReport {
6975 brain: head.brain_id.clone(),
6976 slug: requested_brain.to_string(),
6977 head_seq: pointer.map_or(0, |value| value.seq),
6978 files: remote.len() + remote_assets.len(),
6979 dest: dest.to_string_lossy().into_owned(),
6980 extra_local,
6981 sync_status: if local_dirty {
6982 "local_dirty_after_install".to_string()
6983 } else {
6984 "synced".to_string()
6985 },
6986 };
6987 Ok(V2PulledSnapshot {
6988 report,
6989 head,
6990 files: remote,
6991 assets: remote_assets,
6992 local: installed_local,
6993 local_assets: installed_assets,
6994 })
6995}
6996
6997fn v2_sync_pull(
6998 cfg: &HubConfig,
6999 requested_brain: &str,
7000 head: V2VerifiedHead,
7001 out: Option<&Path>,
7002) -> LinkResult<PullReport> {
7003 Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
7004}
7005
7006fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
7007 match remote {
7008 Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
7009 None => json!({ "kind": "absent" }),
7010 }
7011}
7012
7013fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
7014 match remote {
7015 Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
7016 None => json!({ "kind": "absent" }),
7017 }
7018}
7019
7020fn v2_content_withdrawal_operation(
7021 store: &Store,
7022 local_view: &V2LocalView,
7023 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7024 path: &str,
7025 reason: &str,
7026) -> LinkResult<Value> {
7027 if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
7028 || path == "DB.md"
7029 {
7030 return Err(LinkError::InvalidPack {
7031 message: format!("content withdrawal path `{path}` is not a record or source"),
7032 });
7033 }
7034 if !local_view.policy.keeps_home(path)
7035 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7036 {
7037 return Err(LinkError::InvalidPack {
7038 message: format!(
7039 "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7040 ),
7041 });
7042 }
7043 let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
7044 message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
7045 })?;
7046 verify_v2_upload_source(store, path, ¤t.sha256, current.bytes)?;
7047 Ok(json!({
7048 "op": "withdraw_from_hosting",
7049 "path": path,
7050 "expected": { "kind": "blob", "hash": current.sha256 },
7051 "reason": reason,
7052 }))
7053}
7054
7055fn v2_asset_withdrawal_operation(
7056 store: &Store,
7057 local_view: &V2LocalView,
7058 path: &str,
7059 local: &crate::AssetRecord,
7060 current: &V2BaselineAsset,
7061 reason: &str,
7062) -> LinkResult<Value> {
7063 if !local_view.policy.keeps_home(path)
7064 || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7065 {
7066 return Err(LinkError::InvalidPack {
7067 message: format!(
7068 "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7069 ),
7070 });
7071 }
7072 verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
7073 if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
7074 return Err(LinkError::InvalidPack {
7075 message: format!(
7076 "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
7077 ),
7078 });
7079 }
7080 Ok(json!({
7081 "op": "asset_withdraw",
7082 "path": path,
7083 "expected": v2_asset_expected(Some(current)),
7084 "reason": reason,
7085 }))
7086}
7087
7088fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
7095 let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
7096 let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
7097 for (index, operation) in operations.iter().enumerate() {
7098 match operation.get("op").and_then(Value::as_str) {
7099 Some("delete") => {
7100 let Some(path) = operation.get("path").and_then(Value::as_str) else {
7101 continue;
7102 };
7103 let Some(hash) = operation
7104 .get("expected")
7105 .and_then(|value| value.get("hash"))
7106 .and_then(Value::as_str)
7107 else {
7108 continue;
7109 };
7110 if path.starts_with("sources/") {
7111 deletes
7112 .entry(hash.to_string())
7113 .or_default()
7114 .push((index, path.to_string()));
7115 }
7116 }
7117 Some("put") => {
7118 let Some(path) = operation.get("path").and_then(Value::as_str) else {
7119 continue;
7120 };
7121 let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
7122 continue;
7123 };
7124 let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
7125 continue;
7126 };
7127 let destination_absent = operation
7128 .get("expected")
7129 .and_then(|value| value.get("kind"))
7130 .and_then(Value::as_str)
7131 == Some("absent");
7132 if path.starts_with("sources/") && destination_absent {
7133 puts.entry(hash.to_string()).or_default().push((
7134 index,
7135 path.to_string(),
7136 bytes,
7137 ));
7138 }
7139 }
7140 _ => {}
7141 }
7142 }
7143 let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
7144 let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
7145 for (hash, source) in deletes {
7146 let Some(destination) = puts.get(&hash) else {
7147 continue;
7148 };
7149 if source.len() != 1 || destination.len() != 1 {
7150 continue;
7151 }
7152 let (delete_index, from) = &source[0];
7153 let (put_index, to, bytes) = &destination[0];
7154 if from == to {
7155 continue;
7156 }
7157 rename_at.insert(
7158 *delete_index,
7159 json!({
7160 "op": "rename",
7161 "from": from,
7162 "to": to,
7163 "expected_from": { "kind": "blob", "hash": hash },
7164 "expected_to": { "kind": "absent" },
7165 "blob": hash,
7166 "bytes": bytes,
7167 }),
7168 );
7169 consumed_puts.insert(*put_index);
7170 }
7171 operations
7172 .into_iter()
7173 .enumerate()
7174 .filter_map(|(index, operation)| {
7175 if let Some(rename) = rename_at.remove(&index) {
7176 Some(rename)
7177 } else if consumed_puts.contains(&index) {
7178 None
7179 } else {
7180 Some(operation)
7181 }
7182 })
7183 .collect()
7184}
7185
7186fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
7187 json!({
7188 "blob_sha256": record.sha256,
7189 "bytes": record.bytes,
7190 "media_type": record.media_type,
7191 "wrappers": record.wrappers,
7192 "required": record.required,
7193 "disposition": disposition,
7194 })
7195}
7196
7197fn apply_generated_v2_operations(
7201 operations: &[Value],
7202 local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
7203 candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
7204 candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
7205) -> LinkResult<bool> {
7206 let mut asset_changed = false;
7207 for operation in operations {
7208 match operation.get("op").and_then(Value::as_str) {
7209 Some("put") => {
7210 let path = operation
7211 .get("path")
7212 .and_then(Value::as_str)
7213 .ok_or_else(|| invalid_feed("v2 put has no path"))?;
7214 let sha256 = operation
7215 .get("blob")
7216 .and_then(Value::as_str)
7217 .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
7218 let bytes = operation
7219 .get("bytes")
7220 .and_then(Value::as_u64)
7221 .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
7222 candidate.insert(
7223 path.to_string(),
7224 V2BaselineFile {
7225 sha256: sha256.to_string(),
7226 bytes,
7227 proof: None,
7228 },
7229 );
7230 }
7231 Some("rename") => {
7232 let from = operation
7233 .get("from")
7234 .and_then(Value::as_str)
7235 .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
7236 let to = operation
7237 .get("to")
7238 .and_then(Value::as_str)
7239 .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
7240 let sha256 = operation
7241 .get("blob")
7242 .and_then(Value::as_str)
7243 .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
7244 let bytes = operation
7245 .get("bytes")
7246 .and_then(Value::as_u64)
7247 .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
7248 let expected_from = operation
7249 .get("expected_from")
7250 .and_then(|expected| expected.get("hash"))
7251 .and_then(Value::as_str);
7252 let expected_to_absent = operation
7253 .get("expected_to")
7254 .and_then(|expected| expected.get("kind"))
7255 .and_then(Value::as_str)
7256 == Some("absent");
7257 if from == to
7258 || !from.starts_with("sources/")
7259 || !to.starts_with("sources/")
7260 || expected_from != Some(sha256)
7261 || !expected_to_absent
7262 || candidate.contains_key(to)
7263 {
7264 return Err(invalid_feed("generated v2 source rename is malformed"));
7265 }
7266 let source = candidate
7267 .remove(from)
7268 .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
7269 if source.sha256 != sha256 || source.bytes != bytes {
7270 return Err(invalid_feed(
7271 "v2 rename source differs from its exact-byte claim",
7272 ));
7273 }
7274 candidate.insert(
7275 to.to_string(),
7276 V2BaselineFile {
7277 sha256: sha256.to_string(),
7278 bytes,
7279 proof: None,
7280 },
7281 );
7282 }
7283 Some("delete" | "withdraw_from_hosting") => {
7284 let path = operation
7285 .get("path")
7286 .and_then(Value::as_str)
7287 .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7288 candidate.remove(path);
7289 }
7290 Some("asset_delete") => {
7291 let path = operation
7292 .get("path")
7293 .and_then(Value::as_str)
7294 .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7295 candidate_assets.remove(path);
7296 asset_changed = true;
7297 }
7298 Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7299 let path = operation
7300 .get("path")
7301 .and_then(Value::as_str)
7302 .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7303 let record = local_assets
7304 .get(path)
7305 .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7306 let disposition =
7307 if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7308 "withheld"
7309 } else {
7310 operation
7311 .get("asset")
7312 .and_then(|asset| asset.get("disposition"))
7313 .and_then(Value::as_str)
7314 .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7315 };
7316 candidate_assets.insert(
7317 path.to_string(),
7318 V2BaselineAsset {
7319 blob_sha256: record.sha256.clone(),
7320 bytes: record.bytes,
7321 media_type: record.media_type.clone(),
7322 wrappers: record.wrappers.clone(),
7323 required: record.required,
7324 disposition: disposition.to_string(),
7325 leaf_hash: String::new(),
7328 },
7329 );
7330 asset_changed = true;
7331 }
7332 _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7333 }
7334 }
7335 Ok(asset_changed)
7336}
7337
7338fn v2_riding_matches_remote(
7339 local: &std::collections::BTreeMap<String, (String, u64)>,
7340 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7341 keeps_home: impl Fn(&str) -> bool,
7342) -> bool {
7343 remote.iter().all(|(path, file)| {
7344 keeps_home(path)
7345 || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7346 }) && local.iter().all(|(path, (hash, _))| {
7347 remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7348 })
7349}
7350
7351fn v2_initial_content_conflicts(
7352 local: &std::collections::BTreeMap<String, (String, u64)>,
7353 remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7354 resolving: bool,
7355) -> Vec<String> {
7356 if resolving {
7357 return Vec::new();
7365 }
7366 remote
7367 .iter()
7368 .filter(|(path, file)| {
7369 local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7370 })
7371 .map(|(path, _)| path.clone())
7372 .collect()
7373}
7374
7375fn v2_resolution_allows_path(
7376 resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7377 path: &str,
7378 remote_present: bool,
7379) -> bool {
7380 resolution.is_none_or(|allowed| allowed.contains_key(path) || !remote_present)
7381}
7382
7383#[derive(Debug, Clone)]
7384struct V2ResolutionOverride {
7385 expected_remote: Option<String>,
7386 selected_local: Option<String>,
7387}
7388
7389#[derive(Debug, Clone)]
7390struct V2UploadSource {
7391 path: String,
7392 bytes: u64,
7393}
7394
7395struct V2SyncPushOptions<'a> {
7396 resume_local_policy: bool,
7397 bulk_confirmation: Option<&'a V2BulkConfirmation>,
7398 resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7399 pulled: Option<V2PulledSnapshot>,
7400 withdrawal_paths: &'a [String],
7401 withdrawal_reason: Option<&'a str>,
7402}
7403
7404fn verify_v2_upload_source(
7405 store: &Store,
7406 path: &str,
7407 sha256: &str,
7408 expected_bytes: u64,
7409) -> LinkResult<()> {
7410 let file = store.open_regular(Path::new(path))?;
7411 if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7412 return Err(LinkError::InvalidPack {
7413 message: format!("local path `{path}` changed during sync planning"),
7414 });
7415 }
7416 Ok(())
7417}
7418
7419struct V2PendingUpload<'a> {
7422 url: String,
7423 headers: Value,
7424 sha256: String,
7425 source: &'a V2UploadSource,
7426}
7427
7428const V2_UPLOAD_CONCURRENCY: usize = 16;
7435
7436fn upload_v2_batch_concurrently(
7440 cfg: &HubConfig,
7441 store: &Store,
7442 pending: &[V2PendingUpload<'_>],
7443) -> LinkResult<()> {
7444 if pending.is_empty() {
7445 return Ok(());
7446 }
7447 let urls = pending
7448 .iter()
7449 .map(|task| task.url.as_str())
7450 .collect::<Vec<_>>();
7451 let shared = shared_staging_agent(cfg, &urls);
7452 if pending.len() == 1 {
7453 let task = &pending[0];
7454 put_presigned_source(
7455 cfg,
7456 &task.url,
7457 &task.headers,
7458 store,
7459 task.source,
7460 shared.as_ref(),
7461 )?;
7462 return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7463 }
7464 let next = std::sync::atomic::AtomicUsize::new(0);
7465 let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7466 let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7467 std::thread::scope(|scope| {
7468 for _ in 0..workers {
7469 scope.spawn(|| loop {
7470 if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7471 return;
7472 }
7473 let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7474 let Some(task) = pending.get(index) else {
7475 return;
7476 };
7477 let outcome = put_presigned_source(
7478 cfg,
7479 &task.url,
7480 &task.headers,
7481 store,
7482 task.source,
7483 shared.as_ref(),
7484 )
7485 .and_then(|()| {
7486 verify_v2_upload_source(
7487 store,
7488 &task.source.path,
7489 &task.sha256,
7490 task.source.bytes,
7491 )
7492 });
7493 if let Err(error) = outcome {
7494 if let Ok(mut guard) = failure.lock() {
7495 guard.get_or_insert(error);
7496 }
7497 return;
7498 }
7499 });
7500 }
7501 });
7502 match failure.into_inner() {
7503 Ok(Some(error)) => Err(error),
7504 Ok(None) => Ok(()),
7505 Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7506 }
7507}
7508
7509fn put_presigned_source(
7510 cfg: &HubConfig,
7511 raw: &str,
7512 headers: &Value,
7513 store: &Store,
7514 source: &V2UploadSource,
7515 shared: Option<&ureq::Agent>,
7516) -> LinkResult<()> {
7517 put_presigned_source_with_budget(
7518 cfg,
7519 raw,
7520 headers,
7521 store,
7522 source,
7523 shared,
7524 std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
7525 )
7526}
7527
7528fn put_presigned_source_with_budget(
7529 cfg: &HubConfig,
7530 raw: &str,
7531 headers: &Value,
7532 store: &Store,
7533 source: &V2UploadSource,
7534 shared: Option<&ureq::Agent>,
7535 total_budget: std::time::Duration,
7536) -> LinkResult<()> {
7537 let owned = match shared {
7540 Some(_) => {
7541 checked_presigned_url(cfg, raw)?;
7542 None
7543 }
7544 None => Some(presigned_agent(cfg, raw)?),
7545 };
7546 let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7547 let deadline = std::time::Instant::now()
7548 .checked_add(total_budget)
7549 .ok_or_else(upload_deadline_error)?;
7550 let mut attempt = 0;
7551 let result = loop {
7552 let file = store.open_regular(Path::new(&source.path))?;
7553 if file.metadata()?.len() != source.bytes {
7554 return Err(LinkError::InvalidPack {
7555 message: format!("local path `{}` changed before upload", source.path),
7556 });
7557 }
7558 let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
7563 let mut has_content_length = false;
7564 if let Some(map) = headers.as_object() {
7565 for (name, value) in map {
7566 if let Some(value) = value.as_str() {
7567 has_content_length |= name.eq_ignore_ascii_case("content-length");
7568 req = req.set(name, value);
7569 }
7570 }
7571 }
7572 if !has_content_length {
7573 req = req.set("Content-Length", &source.bytes.to_string());
7574 }
7575 match req.send(file) {
7576 Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
7582 attempt += 1;
7583 }
7584 Err(ureq::Error::Status(status, _))
7590 if status != 412
7591 && is_retryable_upload_status(status)
7592 && wait_for_upload_retry(deadline, attempt) =>
7593 {
7594 attempt += 1;
7595 }
7596 result => break result,
7597 }
7598 };
7599 match result {
7600 Ok(response) if (200..300).contains(&response.status()) => {
7601 drain_presigned_response(response);
7602 Ok(())
7603 }
7604 Ok(response) => {
7605 let status = response.status();
7610 let detail = response
7611 .into_string()
7612 .ok()
7613 .map(|body| body.chars().take(400).collect::<String>())
7614 .filter(|body| !body.trim().is_empty());
7615 Err(LinkError::Http {
7616 what: "v2 changed-byte upload",
7617 status,
7618 message: match detail {
7619 Some(body) => format!(
7620 "object store rejected the upload of `{}`: {}",
7621 source.path,
7622 body.replace('\n', " ")
7623 ),
7624 None => format!("object store rejected the upload of `{}`", source.path),
7625 },
7626 code: None,
7627 details: None,
7628 })
7629 }
7630 Err(error) => match error {
7631 ureq::Error::Status(412, _) => Ok(()),
7632 ureq::Error::Status(_, response) => {
7633 let status = response.status();
7634 let detail = response
7635 .into_string()
7636 .ok()
7637 .map(|body| body.chars().take(400).collect::<String>())
7638 .filter(|body| !body.trim().is_empty());
7639 Err(LinkError::Http {
7640 what: "v2 changed-byte upload",
7641 status,
7642 message: match detail {
7643 Some(body) => format!(
7644 "object store rejected the upload of `{}`: {}",
7645 source.path,
7646 body.replace('\n', " ")
7647 ),
7648 None => {
7649 format!("object store rejected the upload of `{}`", source.path)
7650 }
7651 },
7652 code: None,
7653 details: None,
7654 })
7655 }
7656 ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
7657 },
7658 }
7659}
7660
7661fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
7665 if body.get("operations").is_some() {
7666 return body.clone();
7667 }
7668 let mut value = body.clone();
7669 if let Some(map) = value.as_object_mut() {
7670 map.remove("staged_change");
7671 map.insert("operations".to_string(), Value::Array(operations.to_vec()));
7672 }
7673 value
7674}
7675
7676fn reserve_upload_window(
7680 cfg: &HubConfig,
7681 path: &str,
7682 body: &Value,
7683 what: &'static str,
7684) -> LinkResult<Value> {
7685 let mut attempt = 0;
7686 loop {
7687 let pause = |attempt: usize| {
7688 std::thread::sleep(std::time::Duration::from_millis(
7689 RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
7690 ));
7691 };
7692 match request(cfg, "POST", path, Some(body), Auth::Required) {
7693 Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
7698 pause(attempt);
7699 attempt += 1;
7700 }
7701 Err(error) => return Err(error),
7702 Ok(response) => {
7703 if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
7704 pause(attempt);
7705 attempt += 1;
7706 continue;
7707 }
7708 return ensure_ok(response, what);
7709 }
7710 }
7711 }
7712}
7713
7714fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
7718 let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
7719 .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
7720 if bytes.len() > MAX_STAGED_CHANGE_BYTES {
7721 return Err(LinkError::PushTooLarge {
7722 detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
7723 });
7724 }
7725 Ok(bytes)
7726}
7727
7728fn stage_v2_change(
7738 cfg: &HubConfig,
7739 requested_brain: &str,
7740 operations: &[Value],
7741 blobs: Value,
7742) -> LinkResult<Value> {
7743 let bytes = v2_change_manifest(operations, blobs)?;
7744 let sha256 = content_sha256(&bytes);
7745 let reserved = reserve_upload_window(
7746 cfg,
7747 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7748 &json!({
7749 "blobs": [{
7750 "sha256": sha256,
7751 "bytes": bytes.len(),
7752 "kind": "staged_change",
7753 }],
7754 }),
7755 "stage the v2 change",
7756 )?;
7757 let items = reserved
7758 .get("uploads")
7759 .and_then(Value::as_array)
7760 .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
7761 let [item] = items.as_slice() else {
7762 return Err(invalid_feed(
7763 "v2 change staging response changed the requested set",
7764 ));
7765 };
7766 let reservation_id = item
7767 .get("reservation_id")
7768 .and_then(Value::as_str)
7769 .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
7770 if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
7771 || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
7772 || !crate::ulid::is_ulid(reservation_id)
7773 {
7774 return Err(invalid_feed("v2 change staging item is inconsistent"));
7775 }
7776 match item.get("status").and_then(Value::as_str) {
7777 Some("upload") => put_presigned(
7778 cfg,
7779 item.get("url")
7780 .and_then(Value::as_str)
7781 .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
7782 item.get("headers").unwrap_or(&Value::Null),
7783 &bytes,
7784 )?,
7785 Some("already_present") => {}
7786 _ => return Err(invalid_feed("v2 change staging has an unknown status")),
7787 }
7788 Ok(json!({
7789 "sha256": sha256,
7790 "bytes": bytes.len(),
7791 "reservation_id": reservation_id,
7792 }))
7793}
7794
7795fn stage_oversized_v2_change(
7799 cfg: &HubConfig,
7800 requested_brain: &str,
7801 operations: &[Value],
7802 body: &mut Value,
7803) -> LinkResult<()> {
7804 if body.to_string().len() <= MAX_PUSH_BYTES {
7805 return Ok(());
7806 }
7807 let staged = stage_v2_change(
7808 cfg,
7809 requested_brain,
7810 operations,
7811 body.get("blobs")
7812 .cloned()
7813 .unwrap_or(Value::Array(Vec::new())),
7814 )?;
7815 let map = body
7816 .as_object_mut()
7817 .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
7818 map.remove("operations");
7819 map.remove("blobs");
7820 map.insert("staged_change".to_string(), staged);
7821 Ok(())
7822}
7823
7824fn v2_sync_push(
7825 cfg: &HubConfig,
7826 requested_brain: &str,
7827 store: &Store,
7828 head: V2VerifiedHead,
7829 options: V2SyncPushOptions<'_>,
7830) -> LinkResult<Value> {
7831 let V2SyncPushOptions {
7832 resume_local_policy,
7833 bulk_confirmation,
7834 resolution,
7835 pulled,
7836 withdrawal_paths,
7837 withdrawal_reason,
7838 } = options;
7839 let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
7840 let head = v2_verified_head(cfg, requested_brain)?
7841 .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7842 let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
7843 ensure_v2_view_compatible(&head, baseline.as_ref())?;
7844 let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
7845 match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
7846 Some(snapshot) => (
7847 snapshot.files,
7848 snapshot.assets,
7849 Some(snapshot.local),
7850 Some(snapshot.local_assets),
7851 ),
7852 None => match baseline
7853 .as_ref()
7854 .filter(|state| v2_baseline_matches_head(&head, state))
7855 {
7856 Some(state) => (state.files.clone(), state.assets.clone(), None, None),
7857 None => (
7858 files_for_v2_view(
7859 &head,
7860 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7861 ),
7862 v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7863 None,
7864 None,
7865 ),
7866 },
7867 };
7868 if head.view_kind == "scoped" && baseline.is_none() {
7869 return Err(LinkError::ScopedViewChanged);
7870 }
7871 let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
7872 let local = &local_view.riding;
7873 let local_assets = match carried_local_assets {
7874 Some(assets) => assets,
7875 None => v2_local_asset_records(store)?,
7876 };
7877 if withdrawal_paths.len() > MAX_PUSH_FILES {
7878 return Err(LinkError::PushTooLarge {
7879 detail: "too many explicit withdrawal paths".to_string(),
7880 });
7881 }
7882 let withdrawal_reason = if withdrawal_paths.is_empty() {
7883 None
7884 } else {
7885 let reason = withdrawal_reason
7886 .map(str::trim)
7887 .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
7888 .ok_or_else(|| LinkError::InvalidPack {
7889 message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
7890 })?;
7891 Some(reason)
7892 };
7893 let mut withdrawals = withdrawal_paths
7894 .iter()
7895 .map(|path| {
7896 crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
7897 path: error.to_string(),
7898 })
7899 })
7900 .collect::<LinkResult<Vec<_>>>()?;
7901 withdrawals.sort();
7902 withdrawals.dedup();
7903 if withdrawals.len() != withdrawal_paths.len() {
7904 return Err(LinkError::InvalidPack {
7905 message: "explicit withdrawal paths must be unique".to_string(),
7906 });
7907 }
7908 let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
7909 let mut consumed_withdrawals = BTreeSet::new();
7910 if let Some(previous) = baseline.as_ref() {
7911 if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
7912 && !resume_local_policy
7913 {
7914 let mut newly_eligible = previous
7915 .local_eligibility
7916 .iter()
7917 .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7918 .map(|(path, _)| path.clone())
7919 .collect::<Vec<_>>();
7920 if !newly_eligible.is_empty() {
7921 newly_eligible.truncate(100);
7922 return Err(LinkError::LocalPolicyTransition {
7923 paths: newly_eligible,
7924 });
7925 }
7926 }
7927 }
7928 let base = match baseline.as_ref() {
7929 Some(state) => &state.files,
7930 None if remote.is_empty() => &remote,
7931 None => {
7932 let mut conflicts = v2_initial_content_conflicts(local, &remote, resolution.is_some());
7933 if !conflicts.is_empty() {
7934 conflicts.truncate(100);
7935 let (bundle, paths) =
7936 create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7937 return Err(LinkError::ConflictBundle { bundle, paths });
7938 }
7939 &remote
7940 }
7941 };
7942 let all_paths = base
7943 .keys()
7944 .chain(remote.keys())
7945 .chain(local.keys())
7946 .cloned()
7947 .collect::<std::collections::BTreeSet<_>>();
7948 let mut conflicts = Vec::new();
7949 let mut operations = Vec::new();
7950 let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7951 for path in all_paths {
7952 let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7953 let remote_file = remote.get(&path);
7954 let remote_hash = remote_file.map(|file| file.sha256.as_str());
7955 let local_file = local.get(&path);
7956 let local_hash = local_file.map(|file| file.0.as_str());
7957 if local_hash == base_hash {
7958 continue;
7959 }
7960 if !v2_resolution_allows_path(resolution, &path, remote_file.is_some()) {
7961 continue;
7962 }
7963 if local_view.policy.keeps_home(&path) {
7964 continue;
7967 }
7968 if remote_hash != base_hash && local_hash != remote_hash {
7969 let explicitly_resolved = resolution
7970 .and_then(|allowed| allowed.get(&path))
7971 .is_some_and(|selected| {
7972 selected.expected_remote.as_deref() == remote_hash
7973 && selected.selected_local.as_deref() == local_hash
7974 });
7975 if !explicitly_resolved {
7976 conflicts.push(path);
7977 continue;
7978 }
7979 }
7980 match local_file {
7981 Some((sha256, byte_count)) => {
7982 verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7983 operations.push(json!({
7984 "op": "put",
7985 "path": path,
7986 "expected": v2_expected(remote_file),
7987 "blob": sha256,
7988 "bytes": byte_count,
7989 }));
7990 upload_sources
7991 .entry(sha256.clone())
7992 .or_insert_with(|| V2UploadSource {
7993 path: path.clone(),
7994 bytes: *byte_count,
7995 });
7996 }
7997 None => {
7998 let Some(current) = remote_file else {
7999 continue;
8000 };
8001 operations.push(json!({
8002 "op": "delete",
8003 "path": path,
8004 "expected": { "kind": "blob", "hash": current.sha256 },
8005 }));
8006 }
8007 }
8008 }
8009 operations = infer_exact_source_promotions(operations);
8010 for path in &withdrawals {
8011 if local_assets.contains_key(path) {
8012 continue;
8013 }
8014 operations.push(v2_content_withdrawal_operation(
8015 store,
8016 &local_view,
8017 &remote,
8018 path,
8019 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8020 )?);
8021 consumed_withdrawals.insert(path.clone());
8022 }
8023 if !conflicts.is_empty() {
8024 conflicts.truncate(100);
8025 let (bundle, paths) = create_v2_conflict_bundle(
8026 cfg,
8027 store,
8028 &head,
8029 baseline.as_ref(),
8030 local,
8031 &remote,
8032 &conflicts,
8033 )?;
8034 return Err(LinkError::ConflictBundle { bundle, paths });
8035 }
8036 let base_assets = match baseline.as_ref() {
8037 Some(state) => &state.assets,
8038 None if remote_assets.is_empty() => &remote_assets,
8039 None => {
8040 let mismatched = remote_assets.iter().any(|(path, remote)| {
8041 local_assets.get(path) != Some(&v2_asset_record(remote, path))
8042 }) || local_assets.len() != remote_assets.len();
8043 if mismatched {
8044 return Err(LinkError::Conflict {
8045 paths: vec!["assets.jsonl".to_string()],
8046 });
8047 }
8048 &remote_assets
8049 }
8050 };
8051 let asset_paths = base_assets
8052 .keys()
8053 .chain(remote_assets.keys())
8054 .chain(local_assets.keys())
8055 .cloned()
8056 .collect::<std::collections::BTreeSet<_>>();
8057 let mut asset_policy_transitions = Vec::new();
8058 for path in asset_paths {
8059 let base_record = base_assets
8060 .get(&path)
8061 .map(|asset| v2_asset_record(asset, &path));
8062 let remote = remote_assets.get(&path);
8063 let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
8064 let local_record = local_assets.get(&path);
8065 if withdrawal_set.contains(&path) {
8066 let record = local_record.ok_or_else(|| LinkError::InvalidPack {
8067 message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
8068 })?;
8069 let current = remote.ok_or_else(|| LinkError::InvalidPack {
8070 message: format!(
8071 "asset withdrawal path `{path}` has no readable hosted coordinate"
8072 ),
8073 })?;
8074 operations.push(v2_asset_withdrawal_operation(
8075 store,
8076 &local_view,
8077 &path,
8078 record,
8079 current,
8080 withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8081 )?);
8082 consumed_withdrawals.insert(path.clone());
8083 continue;
8084 }
8085 let mut raw_present = false;
8086 let mut disposition = "withheld";
8087 let mut resumes_hosting = false;
8088 if let Some(record) = local_record {
8089 crate::linkmd_v2::normalize_path(&record.path)
8090 .map_err(|error| invalid_feed(error.to_string()))?;
8091 let kept_home = local_view.policy.keeps_home(&path);
8092 raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
8093 disposition = if kept_home || !raw_present {
8094 "withheld"
8095 } else {
8096 "hosted"
8097 };
8098 let inherits_withheld_absence = v2_asset_inherits_withheld_absence(
8099 base_assets.get(&path),
8100 base_record.as_ref(),
8101 local_record,
8102 raw_present,
8103 );
8104 if !raw_present && record.required && !kept_home && !inherits_withheld_absence {
8105 return Err(LinkError::InvalidPack {
8106 message: format!("required asset {path} is missing"),
8107 });
8108 }
8109 resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
8110 }
8111 if local_record == base_record.as_ref() && !resumes_hosting {
8112 continue;
8113 }
8114 if remote_record != base_record && local_record != remote_record.as_ref() {
8115 conflicts.push(path);
8116 continue;
8117 }
8118 let Some(record) = local_record else {
8119 if let Some(remote) = remote {
8120 operations.push(json!({
8121 "op": "asset_delete",
8122 "path": path,
8123 "expected": v2_asset_expected(Some(remote)),
8124 }));
8125 }
8126 continue;
8127 };
8128 let raw = if raw_present {
8129 verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
8130 Some(())
8131 } else {
8132 None
8133 };
8134 let op = if resumes_hosting {
8135 if !resume_local_policy {
8136 asset_policy_transitions.push(path);
8137 continue;
8138 }
8139 "asset_resume"
8140 } else {
8141 "asset_put"
8142 };
8143 operations.push(json!({
8144 "op": op,
8145 "path": path,
8146 "expected": v2_asset_expected(remote),
8147 "asset": v2_asset_value(record, disposition),
8148 }));
8149 if disposition == "hosted" {
8150 raw.expect("hosted asset was checked present");
8151 upload_sources
8152 .entry(record.sha256.clone())
8153 .or_insert_with(|| V2UploadSource {
8154 path: path.clone(),
8155 bytes: record.bytes,
8156 });
8157 }
8158 }
8159 if consumed_withdrawals != withdrawal_set {
8160 let missing = withdrawal_set
8161 .difference(&consumed_withdrawals)
8162 .next()
8163 .expect("different withdrawal sets have one member");
8164 return Err(LinkError::InvalidPack {
8165 message: format!(
8166 "withdrawal path `{missing}` is not a readable content or asset coordinate"
8167 ),
8168 });
8169 }
8170 if !conflicts.is_empty() {
8171 conflicts.truncate(100);
8172 return Err(LinkError::Conflict { paths: conflicts });
8173 }
8174 if !asset_policy_transitions.is_empty() {
8175 asset_policy_transitions.truncate(100);
8176 return Err(LinkError::LocalPolicyTransition {
8177 paths: asset_policy_transitions,
8178 });
8179 }
8180 let touched_sources = operations
8181 .iter()
8182 .filter_map(
8183 |operation| match operation.get("op").and_then(Value::as_str) {
8184 Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
8185 Some("rename") => operation.get("to").and_then(Value::as_str),
8186 _ => None,
8187 },
8188 )
8189 .collect::<std::collections::BTreeSet<_>>();
8190 let withheld_links = local_view
8191 .withheld_links
8192 .iter()
8193 .filter(|link| touched_sources.contains(link.source.as_str()))
8194 .collect::<Vec<_>>();
8195 let checkout_pseudonym = v2_checkout_id(
8196 baseline
8197 .as_ref()
8198 .and_then(|current| current.checkout_id.as_deref()),
8199 )?;
8200 let checkout_id = if withheld_links.is_empty() {
8201 None
8202 } else {
8203 Some(checkout_pseudonym.clone())
8204 };
8205 if operations.is_empty() {
8206 let final_head = v2_verified_head(cfg, requested_brain)?
8207 .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
8208 if !same_v2_head(&head, &final_head) {
8209 return Err(LinkError::RemoteAdvancedDuringSync);
8210 }
8211 let mut final_local = v2_local_files(store)?;
8212 remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8213 let final_assets = v2_local_asset_records(store)?;
8214 let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
8215 let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
8216 final_local.policy.keeps_home(path)
8217 }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
8218 let next = v2_baseline_from_head(
8219 cfg,
8220 &head,
8221 remote,
8222 remote_assets,
8223 Some(&final_local),
8224 Some(&checkout_pseudonym),
8225 )?;
8226 let split_count = next.remote_copy_remains.len();
8227 accept_v2_head(cfg, &final_head)?;
8228 if !local_changed && !remote_ahead {
8229 refresh_scoped_view_marker(store, &head, next.files.len())?;
8230 save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8231 }
8232 return Ok(json!({
8233 "v": 2,
8234 "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
8235 "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
8236 "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8237 "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8238 "local_policy": {
8239 "remote_copy_remains": split_count,
8240 },
8241 }));
8242 }
8243 let includes_contract = operations
8244 .iter()
8245 .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
8246 let rebase = if head.pointer.is_none() || includes_contract {
8247 "strict"
8248 } else {
8249 "disjoint"
8250 };
8251 let base_value = head.pointer.as_ref().map(|pointer| {
8252 json!({
8253 "seq": pointer.seq,
8254 "commit_hash": pointer.commit_hash,
8255 "content_root": pointer.content_root,
8256 "asset_root": pointer.asset_root,
8257 })
8258 });
8259 let entropy = format!(
8263 "{}\0{}\0{}\0{}\0{}\0{}",
8264 normalized_origin(&cfg.hub)?,
8265 head.brain_id,
8266 serde_json::to_string(&base_value).unwrap_or_default(),
8267 serde_json::to_string(&operations).unwrap_or_default(),
8268 serde_json::to_string(&withheld_links).unwrap_or_default(),
8269 checkout_id.as_deref().unwrap_or("")
8270 );
8271 let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
8272 let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
8273 total
8274 .checked_add(source.bytes)
8275 .ok_or_else(|| LinkError::PushTooLarge {
8276 detail: "v2 changed-byte total overflow".to_string(),
8277 })
8278 })?;
8279 let inline = changed_bytes <= 3 * 1024 * 1024;
8280 let inline_blobs = if inline {
8281 upload_sources
8282 .iter()
8283 .map(|(sha256, source)| {
8284 let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
8285 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
8286 return Err(LinkError::InvalidPack {
8287 message: format!("local path `{}` changed before upload", source.path),
8288 });
8289 }
8290 Ok(json!({
8291 "sha256": sha256,
8292 "bytes": source.bytes,
8293 "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
8294 }))
8295 })
8296 .collect::<LinkResult<Vec<_>>>()?
8297 } else {
8298 Vec::new()
8299 };
8300 let mut body = json!({
8301 "mutation_id": mutation_id,
8302 "base": base_value,
8303 "rebase": rebase,
8304 "reason": "dbmd sync",
8305 "operations": operations,
8306 "blobs": inline_blobs,
8307 });
8308 if !withheld_links.is_empty() {
8309 body["withheld_links"] = serde_json::to_value(&withheld_links)
8310 .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
8311 body["checkout_id"] =
8312 Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
8313 }
8314 if let Some(confirmation) = bulk_confirmation {
8315 if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
8316 return Err(LinkError::InvalidPack {
8317 message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
8318 .to_string(),
8319 });
8320 }
8321 body["rebase"] = Value::String("strict".to_string());
8325 body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8326 body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8327 }
8328 if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8329 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8330 for operation in &operations {
8331 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8332 return Err(invalid_feed("v2 upload operation has no kind"));
8333 };
8334 let hash = match kind {
8335 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
8336 "asset_put" | "asset_resume" => operation
8337 .get("asset")
8338 .and_then(|asset| asset.get("blob_sha256"))
8339 .and_then(Value::as_str),
8340 _ => None,
8341 };
8342 let Some(hash) = hash else { continue };
8343 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8344 if kind == "rename" {
8345 for field in ["from", "to"] {
8346 coordinates.insert(
8347 operation
8348 .get(field)
8349 .and_then(Value::as_str)
8350 .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8351 .to_string(),
8352 );
8353 }
8354 } else {
8355 let path = operation
8356 .get("path")
8357 .and_then(Value::as_str)
8358 .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8359 coordinates.insert(if kind.starts_with("asset_") {
8360 format!("assets/{path}")
8361 } else {
8362 path.to_string()
8363 });
8364 }
8365 }
8366 let declarations = upload_sources
8367 .iter()
8368 .map(|(sha256, source)| {
8369 json!({
8370 "sha256": sha256,
8371 "bytes": source.bytes,
8372 "coordinates": coordinates_by_hash
8373 .get(sha256)
8374 .into_iter()
8375 .flatten()
8376 .collect::<Vec<_>>(),
8377 })
8378 })
8379 .collect::<Vec<_>>();
8380 let mut references = Vec::with_capacity(upload_sources.len());
8381 let mut seen = std::collections::BTreeSet::new();
8382 let mut reserved_count = 0usize;
8383 let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8384 for batch in batch_upload_declarations(declarations) {
8388 let batch_len = batch.len();
8389 let reserved = reserve_upload_window(
8390 cfg,
8391 &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8392 &json!({ "blobs": batch }),
8393 "prepare v2 changed-byte uploads",
8394 )?;
8395 let items = reserved
8396 .get("uploads")
8397 .and_then(Value::as_array)
8398 .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8399 if items.len() != batch_len {
8400 return Err(invalid_feed(
8401 "v2 upload reservation response changed the requested set",
8402 ));
8403 }
8404 reserved_count += items.len();
8405 for item in items {
8406 let sha256 = item
8407 .get("sha256")
8408 .and_then(Value::as_str)
8409 .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8410 let source = upload_sources
8411 .get(sha256)
8412 .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8413 let declared_bytes = item
8414 .get("bytes")
8415 .and_then(Value::as_u64)
8416 .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8417 let reservation_id = item
8418 .get("reservation_id")
8419 .and_then(Value::as_str)
8420 .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8421 let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8422 invalid_feed("v2 upload reservation has no coordinate binding")
8423 })?;
8424 let returned_coordinates = item
8425 .get("coordinates")
8426 .and_then(Value::as_array)
8427 .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8428 if declared_bytes != source.bytes
8429 || !crate::ulid::is_ulid(reservation_id)
8430 || !seen.insert(sha256.to_string())
8431 || returned_coordinates.len() != expected_coordinates.len()
8432 || returned_coordinates
8433 .iter()
8434 .zip(expected_coordinates)
8435 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8436 {
8437 return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8438 }
8439 match item.get("status").and_then(Value::as_str) {
8440 Some("upload") => {
8441 let url = item
8442 .get("url")
8443 .and_then(Value::as_str)
8444 .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8445 pending_uploads.push(V2PendingUpload {
8446 url: url.to_string(),
8447 headers: item.get("headers").cloned().unwrap_or(Value::Null),
8448 sha256: sha256.to_string(),
8449 source,
8450 });
8451 }
8452 Some("already_present") => {}
8453 _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
8454 }
8455 references.push(json!({
8456 "sha256": sha256,
8457 "bytes": source.bytes,
8458 "reservation_id": reservation_id,
8459 }));
8460 }
8461 upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
8467 pending_uploads.clear();
8468 }
8469 if reserved_count != upload_sources.len() {
8470 return Err(invalid_feed(
8471 "v2 upload reservation response changed the requested set",
8472 ));
8473 }
8474 body["blobs"] = Value::Array(references);
8475 }
8476 stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
8477 let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
8478 let mut candidate_hub_signer: Option<String> = None;
8479 let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8480 let bulk_preview_required = !(200..300).contains(&response.status)
8481 && response.body.as_ref().is_some_and(|value| {
8482 value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
8483 || value
8484 .get("details")
8485 .and_then(|details| details.get("code"))
8486 .and_then(Value::as_str)
8487 == Some("bulk_preview_required")
8488 });
8489 if bulk_preview_required && bulk_confirmation.is_none() {
8490 body["rebase"] = Value::String("strict".to_string());
8491 body["preview_only"] = Value::Bool(true);
8492 let preview = ensure_ok(
8493 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
8494 "v2 bulk preview",
8495 )?;
8496 let preview_code = preview.get("code").and_then(Value::as_str);
8497 let required = preview.get("required").and_then(Value::as_bool);
8498 if preview.get("v").and_then(Value::as_u64) != Some(2)
8499 || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
8500 || !matches!(
8501 preview_code,
8502 Some("bulk_preview_created" | "bulk_preview_not_required")
8503 )
8504 || required.is_none()
8505 {
8506 return Err(invalid_feed(
8507 "bulk preview response is not bound to the requested mutation",
8508 ));
8509 }
8510 if required == Some(true) {
8511 let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
8512 let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
8513 if preview_code != Some("bulk_preview_created")
8514 || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
8515 || preview_digest.is_none_or(|value| !is_sha256(value))
8516 || preview.get("expires_at").and_then(Value::as_str).is_none()
8517 || !preview.get("impact").is_some_and(Value::is_object)
8518 {
8519 return Err(invalid_feed("bulk preview receipt is malformed"));
8520 }
8521 return Err(LinkError::BulkPreviewRequired { preview });
8522 }
8523 if preview_code != Some("bulk_preview_not_required") {
8524 return Err(invalid_feed("bulk preview requirement is inconsistent"));
8525 }
8526 body.as_object_mut()
8529 .expect("v2 commit request is an object")
8530 .remove("preview_only");
8531 response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8532 }
8533 let mut result = ensure_ok(response, "v2 sync push")?;
8534 if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
8535 if let Some(object) = result.as_object_mut() {
8536 object.insert(
8537 "sync_status".to_string(),
8538 Value::String("proposal_pending".to_string()),
8539 );
8540 }
8541 return Ok(result);
8542 }
8543 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
8544 let request_id = result
8545 .get("request_id")
8546 .and_then(Value::as_str)
8547 .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
8548 .to_string();
8549 let challenge = result
8550 .get("signing_challenge")
8551 .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
8552 let mut expected_candidate = remote.clone();
8553 let mut expected_candidate_assets = remote_assets.clone();
8554 apply_generated_v2_operations(
8555 &operations,
8556 &local_assets,
8557 &mut expected_candidate,
8558 &mut expected_candidate_assets,
8559 )?;
8560 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
8561 cfg,
8562 &head,
8563 &expected_candidate,
8564 &expected_candidate_assets,
8565 &mutation_id,
8566 &v2_signed_request_view(&body, &operations),
8567 challenge,
8568 )?;
8569 body["signing_challenge_id"] = Value::String(challenge_id);
8570 body["signature_base64url"] = Value::String(signature);
8571 candidate_hub_signer = Some(actor_signer);
8572 result = ensure_ok(
8573 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
8574 "v2 self-custody commit",
8575 )?;
8576 }
8577 let refreshed = v2_verified_head(cfg, requested_brain)?
8578 .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
8579 if candidate_hub_signer
8580 .as_ref()
8581 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
8582 {
8583 return Err(invalid_feed(
8584 "self-custody actor signer differs from the committed hub pointer signer",
8585 ));
8586 }
8587 let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
8588 if refreshed
8589 .pointer
8590 .as_ref()
8591 .map(|pointer| pointer.commit_hash.as_str())
8592 != accepted_hash
8593 {
8594 return Err(LinkError::RemoteAdvancedDuringSync);
8595 }
8596 ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
8597 let rebased = result
8598 .get("rebased")
8599 .and_then(Value::as_bool)
8600 .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
8601 let (refreshed_files, refreshed_assets) = if rebased {
8602 (
8603 files_for_v2_view(
8604 &refreshed,
8605 v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8606 ),
8607 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8608 )
8609 } else {
8610 let asset_changed = apply_generated_v2_operations(
8611 &operations,
8612 &local_assets,
8613 &mut remote,
8614 &mut remote_assets,
8615 )?;
8616 let assets = if asset_changed {
8617 v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
8620 } else {
8621 remote_assets
8622 };
8623 (remote, assets)
8624 };
8625 let mut final_local = v2_local_files(store)?;
8626 remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
8627 let final_assets = v2_local_asset_records(store)?;
8628 let local_dirty = final_local.riding != local_view.riding
8629 || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
8630 final_local.policy.keeps_home(path)
8631 })
8632 || final_assets != local_assets
8633 || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
8634 let next = v2_baseline_from_head(
8635 cfg,
8636 &refreshed,
8637 refreshed_files,
8638 refreshed_assets,
8639 Some(&final_local),
8640 Some(&checkout_pseudonym),
8641 )?;
8642 let split_count = next.remote_copy_remains.len();
8643 accept_v2_head(cfg, &refreshed)?;
8644 if !local_dirty {
8645 refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
8646 save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
8647 }
8648 if let Some(object) = result.as_object_mut() {
8649 object.insert(
8650 "local_policy".to_string(),
8651 json!({ "remote_copy_remains": split_count }),
8652 );
8653 object.insert(
8654 "sync_status".to_string(),
8655 Value::String(if local_dirty {
8656 "remote_committed_local_dirty".to_string()
8657 } else {
8658 "synced".to_string()
8659 }),
8660 );
8661 }
8662 Ok(result)
8663}
8664
8665pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
8668 sync_push_incremental_with_policy(cfg, brain, store, false)
8669}
8670
8671pub fn sync_push_incremental_with_policy(
8674 cfg: &HubConfig,
8675 brain: &str,
8676 store: &Store,
8677 resume_local_policy: bool,
8678) -> LinkResult<Value> {
8679 sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
8680}
8681
8682pub fn sync_push_incremental_with_options(
8685 cfg: &HubConfig,
8686 brain: &str,
8687 store: &Store,
8688 resume_local_policy: bool,
8689 bulk_confirmation: Option<&V2BulkConfirmation>,
8690) -> LinkResult<Value> {
8691 sync_push_incremental_with_controls(
8692 cfg,
8693 brain,
8694 store,
8695 resume_local_policy,
8696 bulk_confirmation,
8697 &[],
8698 None,
8699 )
8700}
8701
8702pub fn sync_push_incremental_with_controls(
8704 cfg: &HubConfig,
8705 brain: &str,
8706 store: &Store,
8707 resume_local_policy: bool,
8708 bulk_confirmation: Option<&V2BulkConfirmation>,
8709 withdrawal_paths: &[String],
8710 withdrawal_reason: Option<&str>,
8711) -> LinkResult<Value> {
8712 require_safe_ref(brain)?;
8713 if let Some(head) = v2_verified_head(cfg, brain)? {
8714 return v2_sync_push(
8715 cfg,
8716 brain,
8717 store,
8718 head,
8719 V2SyncPushOptions {
8720 resume_local_policy,
8721 bulk_confirmation,
8722 resolution: None,
8723 pulled: None,
8724 withdrawal_paths,
8725 withdrawal_reason,
8726 },
8727 );
8728 }
8729 if !withdrawal_paths.is_empty() {
8730 return Err(LinkError::InvalidPack {
8731 message: "explicit withdrawal requires a link.md v2 brain".to_string(),
8732 });
8733 }
8734 legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
8735}
8736
8737pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
8741 require_safe_ref(brain)?;
8742 Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
8743}
8744
8745#[cfg(windows)]
8746fn legacy_sync_push_incremental(
8747 _cfg: &HubConfig,
8748 _brain: &str,
8749 _store: &Store,
8750 _resume_local_policy: bool,
8751 _bulk_confirmation: Option<&V2BulkConfirmation>,
8752) -> LinkResult<Value> {
8753 Err(LinkError::UnsupportedPlatform {
8754 operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
8755 })
8756}
8757
8758#[cfg(not(windows))]
8759fn legacy_sync_push_incremental(
8760 cfg: &HubConfig,
8761 brain: &str,
8762 store: &Store,
8763 resume_local_policy: bool,
8764 bulk_confirmation: Option<&V2BulkConfirmation>,
8765) -> LinkResult<Value> {
8766 if resume_local_policy || bulk_confirmation.is_some() {
8767 return Err(LinkError::InvalidPack {
8768 message: "v2 sync options require a link.md v2 brain".to_string(),
8769 });
8770 }
8771 let files = collect_push_files(store)?;
8772 sync_push(cfg, brain, &files)
8773}
8774
8775#[derive(Debug, Clone)]
8777pub enum V2ConflictChoice {
8778 KeepLocal,
8779 TakeRemote,
8780 From(PathBuf),
8781}
8782
8783fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
8784 if !crate::ulid::is_ulid(bundle) {
8785 return Err(LinkError::InvalidPack {
8786 message: "conflict bundle must be a lowercase ULID".to_string(),
8787 });
8788 }
8789 let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
8790 let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
8791 .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
8792 if plan.v != 2
8793 || plan.class != "content_resolution_required"
8794 || plan.bundle != bundle
8795 || !crate::ulid::is_ulid(&plan.brain)
8796 || plan.files.is_empty()
8797 || plan.files.len() > 100
8798 || plan.files.iter().any(|file| {
8799 crate::linkmd_v2::normalize_path(&file.path).is_err()
8800 || [&file.base, &file.local, &file.remote]
8801 .into_iter()
8802 .any(|coordinate| {
8803 coordinate
8804 .sha256
8805 .as_deref()
8806 .is_some_and(|hash| !is_sha256(hash))
8807 || coordinate.file.as_deref().is_some_and(|name| {
8808 name.starts_with('/')
8809 || name
8810 .split('/')
8811 .any(|part| part.is_empty() || part == "." || part == "..")
8812 })
8813 })
8814 })
8815 {
8816 return Err(invalid_feed("private conflict plan failed validation"));
8817 }
8818 Ok(plan)
8819}
8820
8821pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
8826 require_hardened_filesystem("private conflict maintenance")?;
8827 if all && !prune {
8828 return Err(LinkError::InvalidPack {
8829 message: "discarding all conflict bundles requires prune=true".to_string(),
8830 });
8831 }
8832 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8833 message: format!("conflict checkout is not a valid db.md store: {error}"),
8834 })?;
8835 let _transaction = store.transaction()?;
8836 let root = Path::new(".dbmd/conflicts");
8837 let names = match store.directory_names(root) {
8838 Ok(names) => names,
8839 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8840 Err(error) => return Err(error.into()),
8841 };
8842 let now = SystemTime::now()
8843 .duration_since(UNIX_EPOCH)
8844 .unwrap_or_default()
8845 .as_secs();
8846 let mut bundles = Vec::new();
8847 let mut pruned = 0_u64;
8848 for name in names {
8849 let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
8850 continue;
8851 };
8852 let plan_path = v2_conflict_relative(bundle, "plan.json");
8853 let plan_exists = store.regular_file_exists(&plan_path)?;
8854 let expired = if plan_exists {
8855 match load_v2_conflict_plan(&store, bundle) {
8856 Ok(plan) => plan.expires_unix < now,
8857 Err(error) if all => {
8858 let _ = error;
8859 true
8860 }
8861 Err(error) => return Err(error),
8862 }
8863 } else {
8864 true
8865 };
8866 if prune && (all || expired) {
8867 store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8868 pruned += 1;
8869 continue;
8870 }
8871 bundles.push(json!({
8872 "bundle": bundle,
8873 "complete": plan_exists,
8874 "expired": expired,
8875 }));
8876 }
8877 Ok(json!({
8878 "v": 2,
8879 "class": "private_conflict_state",
8880 "bundles": bundles.len(),
8881 "pruned": pruned,
8882 "items": bundles,
8883 }))
8884}
8885
8886pub fn sync_resolve_conflict(
8890 cfg: &HubConfig,
8891 checkout: &Path,
8892 bundle: &str,
8893 choice: V2ConflictChoice,
8894 bulk_confirmation: Option<&V2BulkConfirmation>,
8895) -> LinkResult<Value> {
8896 require_hardened_filesystem("conflict resolution")?;
8897 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8898 message: format!("conflict checkout is not a valid db.md store: {error}"),
8899 })?;
8900 let plan = load_v2_conflict_plan(&store, bundle)?;
8901 if plan.origin != normalized_origin(&cfg.hub)? {
8902 return Err(invalid_feed(
8903 "conflict bundle belongs to another hub origin",
8904 ));
8905 }
8906 let now = SystemTime::now()
8907 .duration_since(UNIX_EPOCH)
8908 .unwrap_or_default()
8909 .as_secs();
8910 if now > plan.expires_unix {
8911 return Err(LinkError::InvalidPack {
8912 message: "conflict bundle expired; rerun sync to obtain current coordinates"
8913 .to_string(),
8914 });
8915 }
8916 let head = v2_verified_head(cfg, &plan.brain)?
8917 .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8918 let pointer = head.pointer.as_ref();
8919 if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8920 || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8921 || pointer.and_then(|value| value.content_root.as_deref())
8922 != plan.remote_content_root.as_deref()
8923 || head.view_kind != plan.view_kind
8924 || head.view_revision != plan.view_revision
8925 {
8926 return Err(LinkError::RemoteAdvancedDuringSync);
8927 }
8928
8929 for file in &plan.files {
8931 let actual = match store.regular_file_exists(Path::new(&file.path))? {
8932 true => Some(content_sha256(&store.read_bounded(
8933 Path::new(&file.path),
8934 file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8935 )?)),
8936 false => None,
8937 };
8938 if actual.as_deref() != file.local.sha256.as_deref() {
8939 return Err(LinkError::InvalidPack {
8940 message: format!(
8941 "local conflict path `{}` changed after the bundle was created",
8942 file.path
8943 ),
8944 });
8945 }
8946 }
8947
8948 let from_source = match &choice {
8949 V2ConflictChoice::From(source) => Some(source.clone()),
8950 _ => None,
8951 };
8952 let result = match choice {
8953 V2ConflictChoice::TakeRemote => {
8954 if bulk_confirmation.is_some() {
8955 return Err(LinkError::InvalidPack {
8956 message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8957 });
8958 }
8959 let current_remote =
8963 files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8964 let _ = v2_take_remote_selection(&plan.files, ¤t_remote)?;
8965 let selected = plan
8966 .files
8967 .iter()
8968 .map(|file| file.path.clone())
8969 .collect::<std::collections::BTreeSet<_>>();
8970 serde_json::to_value(
8971 v2_sync_pull_with_resolution(
8972 cfg,
8973 &plan.brain,
8974 head,
8975 Some(checkout),
8976 Some(&selected),
8977 )?
8978 .report,
8979 )
8980 .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
8981 }
8982 V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
8983 if let Some(source) = from_source.as_ref() {
8984 if plan.files.len() != 1 {
8985 return Err(LinkError::InvalidPack {
8986 message: "--from requires a bundle with exactly one conflict".to_string(),
8987 });
8988 }
8989 let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
8990 if std::str::from_utf8(&candidate).is_err() {
8991 return Err(LinkError::NotUtf8 {
8992 path: source.display().to_string(),
8993 });
8994 }
8995 store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
8996 }
8997 let refreshed_store =
8998 Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8999 message: format!("resolved checkout is not a valid db.md store: {error}"),
9000 })?;
9001 let mut overrides = std::collections::BTreeMap::new();
9002 for file in &plan.files {
9003 let selected_local = match refreshed_store
9004 .regular_file_exists(Path::new(&file.path))?
9005 {
9006 true => Some(content_sha256(
9007 &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
9008 )),
9009 false => None,
9010 };
9011 overrides.insert(
9012 file.path.clone(),
9013 V2ResolutionOverride {
9014 expected_remote: file.remote.sha256.clone(),
9015 selected_local,
9016 },
9017 );
9018 }
9019 v2_sync_push(
9020 cfg,
9021 &plan.brain,
9022 &refreshed_store,
9023 head,
9024 V2SyncPushOptions {
9025 resume_local_policy: true,
9026 bulk_confirmation,
9027 resolution: Some(&overrides),
9028 pulled: None,
9029 withdrawal_paths: &[],
9030 withdrawal_reason: None,
9031 },
9032 )?
9033 }
9034 };
9035
9036 if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9037 let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9038 message: format!("resolved checkout is not a valid db.md store: {error}"),
9039 })?;
9040 installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9041 }
9042 Ok(json!({
9043 "v": 2,
9044 "class": "auto_converged",
9045 "bundle": bundle,
9046 "receipt": result,
9047 }))
9048}
9049
9050pub fn sync_converge(
9061 cfg: &HubConfig,
9062 brain: &str,
9063 checkout: &Path,
9064 resume_local_policy: bool,
9065) -> LinkResult<Value> {
9066 sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
9067}
9068
9069pub fn sync_converge_with_options(
9071 cfg: &HubConfig,
9072 brain: &str,
9073 checkout: &Path,
9074 resume_local_policy: bool,
9075 bulk_confirmation: Option<&V2BulkConfirmation>,
9076) -> LinkResult<Value> {
9077 sync_converge_with_controls(
9078 cfg,
9079 brain,
9080 checkout,
9081 resume_local_policy,
9082 bulk_confirmation,
9083 &[],
9084 None,
9085 )
9086}
9087
9088pub fn sync_converge_with_controls(
9090 cfg: &HubConfig,
9091 brain: &str,
9092 checkout: &Path,
9093 resume_local_policy: bool,
9094 bulk_confirmation: Option<&V2BulkConfirmation>,
9095 withdrawal_paths: &[String],
9096 withdrawal_reason: Option<&str>,
9097) -> LinkResult<Value> {
9098 require_hardened_filesystem("bidirectional sync")?;
9099 require_safe_ref(brain)?;
9100 let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
9101 message:
9102 "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
9103 .to_string(),
9104 })?;
9105 let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
9106 let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9107 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9108 })?;
9109 let _transaction = store.transaction()?;
9110 let pulled_report = pulled.report.clone();
9111 let pulled_head = pulled.head.clone();
9112 let mut result = v2_sync_push(
9113 cfg,
9114 brain,
9115 &store,
9116 pulled_head,
9117 V2SyncPushOptions {
9118 resume_local_policy,
9119 bulk_confirmation,
9120 resolution: None,
9121 pulled: Some(pulled),
9122 withdrawal_paths,
9123 withdrawal_reason,
9124 },
9125 )?;
9126 if let Some(object) = result.as_object_mut() {
9127 object.insert("pulled_files".to_string(), json!(pulled_report.files));
9128 object.insert("checkout".to_string(), Value::String(pulled_report.dest));
9129 object.insert(
9130 "mode".to_string(),
9131 Value::String("bidirectional".to_string()),
9132 );
9133 }
9134 Ok(result)
9135}
9136
9137pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9143 require_hardened_filesystem("sync pull")?;
9144 require_safe_ref(brain)?;
9145 if let Some(head) = v2_verified_head(cfg, brain)? {
9146 return v2_sync_pull(cfg, brain, head, out);
9147 }
9148 legacy_sync_pull(cfg, brain, out)
9149}
9150
9151#[cfg(windows)]
9152fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
9153 Err(LinkError::UnsupportedPlatform {
9154 operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
9155 })
9156}
9157
9158#[cfg(not(windows))]
9159fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9160 let remote = verified_remote_head(cfg, brain, false)?;
9161 if !remote.head.verified {
9162 return Err(invalid_feed(
9163 "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
9164 ));
9165 }
9166 let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
9167 let path = format!(
9168 "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
9169 remote.head.seq
9170 );
9171 let body = ensure_ok(
9172 request(cfg, "GET", &path, None, Auth::Required)?,
9173 "sync pull",
9174 )?;
9175 if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
9176 || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
9177 {
9178 return Err(invalid_feed(
9179 "export response is not bound to the verified snapshot token",
9180 ));
9181 }
9182
9183 let remote_slug = body
9184 .get("slug")
9185 .and_then(Value::as_str)
9186 .filter(|slug| is_safe_slug(slug));
9187 let slug = remote_slug
9188 .or_else(|| is_safe_slug(brain).then_some(brain))
9189 .unwrap_or("brain")
9190 .to_string();
9191 let brain_id = body
9192 .get("brain")
9193 .and_then(Value::as_str)
9194 .unwrap_or(&remote.head.brain)
9195 .to_string();
9196 if brain_id != remote.head.brain {
9197 return Err(invalid_feed(
9198 "export response names a different brain than the verified head",
9199 ));
9200 }
9201 let head_seq = remote.head.seq;
9202 let dest: PathBuf = match out {
9203 Some(p) => p.to_path_buf(),
9204 None => PathBuf::from(&slug),
9205 };
9206 let entries = if head_seq == 0 {
9207 let files = body
9208 .get("files")
9209 .and_then(Value::as_array)
9210 .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
9211 if !files.is_empty() || body.get("url").is_some() {
9212 return Err(invalid_feed(
9213 "empty signed feed cannot authorize non-empty exported content",
9214 ));
9215 }
9216 Vec::new()
9217 } else {
9218 let signed_head = remote
9219 .head_entry
9220 .as_ref()
9221 .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
9222 let expected = &signed_head.entry.pack_sha256;
9223 if !is_sha256(expected) {
9224 return Err(invalid_feed(
9225 "signed head carries an invalid snapshot pack digest",
9226 ));
9227 }
9228 if let Some(url) = body.get("url").and_then(Value::as_str) {
9229 if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
9230 return Err(invalid_feed(
9231 "export pack digest does not match the signed head entry",
9232 ));
9233 }
9234 let bytes = get_presigned(cfg, url)?;
9235 let actual = format!("{:x}", Sha256::digest(&bytes));
9236 if actual != *expected {
9237 return Err(LinkError::InvalidPack {
9238 message: "downloaded pack does not match the signed snapshot digest"
9239 .to_string(),
9240 });
9241 }
9242 let entries = parse_store_pack(bytes)?;
9243 if signed_head.entry.kind == "push" {
9244 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9245 }
9246 entries
9247 } else {
9248 if signed_head.entry.kind != "push" {
9249 return Err(invalid_feed(
9250 "delta snapshots must export the exact signed pack",
9251 ));
9252 }
9253 let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
9254 invalid_feed("verified snapshot export carried neither a pack nor files")
9255 })?;
9256 let mut entries = Vec::with_capacity(files.len());
9257 for file in files {
9258 let path = file
9259 .get("path")
9260 .and_then(Value::as_str)
9261 .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
9262 let content = file
9263 .get("content")
9264 .and_then(Value::as_str)
9265 .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
9266 entries.push((path.to_string(), content.as_bytes().to_vec()));
9267 }
9268 verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9269 entries
9270 }
9271 };
9272
9273 let mut seen = std::collections::HashSet::new();
9275 for (path, _) in &entries {
9276 if !safe_store_rel_path(path) {
9277 return Err(LinkError::UnsafePath { path: path.clone() });
9278 }
9279 if !seen.insert(path) {
9280 return Err(LinkError::InvalidPack {
9281 message: format!("duplicate path `{path}`"),
9282 });
9283 }
9284 }
9285 let pulled: std::collections::BTreeSet<&str> =
9288 entries.iter().map(|(p, _)| p.as_str()).collect();
9289 let mut extra_local = Vec::new();
9290 if let Ok(store) = Store::open(&dest) {
9291 if let Ok(walked) = store.walk() {
9292 for rel in walked {
9293 let rel_str = rel.to_string_lossy().replace('\\', "/");
9294 if !pulled.contains(rel_str.as_str()) {
9295 extra_local.push(rel_str);
9296 }
9297 }
9298 }
9299 }
9300 #[cfg(unix)]
9301 install_pulled_snapshot(&dest, &entries)?;
9302
9303 Ok(PullReport {
9304 brain: brain_id,
9305 slug,
9306 head_seq,
9307 files: entries.len(),
9308 dest: dest.to_string_lossy().into_owned(),
9309 extra_local,
9310 sync_status: "synced".to_string(),
9311 })
9312}
9313
9314#[cfg(unix)]
9315fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
9316 std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
9317 path: display.to_string(),
9318 })
9319}
9320
9321#[cfg(unix)]
9322fn open_dir_at(
9323 parent: std::os::fd::RawFd,
9324 name: &std::ffi::CStr,
9325 display: &str,
9326) -> LinkResult<std::fs::File> {
9327 use std::os::fd::FromRawFd as _;
9328 let fd = unsafe {
9329 libc::openat(
9330 parent,
9331 name.as_ptr(),
9332 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9333 )
9334 };
9335 if fd < 0 {
9336 return Err(LinkError::UnsafePath {
9337 path: display.to_string(),
9338 });
9339 }
9340 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
9341}
9342
9343#[cfg(unix)]
9347fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
9348 use std::os::fd::AsRawFd as _;
9349
9350 #[cfg(target_os = "macos")]
9354 let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
9355 .into_iter()
9356 .find_map(|(alias, real)| {
9357 path.strip_prefix(alias)
9358 .ok()
9359 .map(|rest| Path::new(real).join(rest))
9360 })
9361 .unwrap_or_else(|| path.to_path_buf());
9362 #[cfg(not(target_os = "macos"))]
9363 let normalized = path.to_path_buf();
9364
9365 let start = if normalized.is_absolute() {
9366 std::fs::File::open("/")?
9367 } else {
9368 std::fs::File::open(".")?
9369 };
9370 let mut directory = start;
9371 for component in normalized.components() {
9372 use std::path::Component;
9373 let name = match component {
9374 Component::RootDir | Component::CurDir => continue,
9375 Component::Normal(name) => name,
9376 Component::ParentDir | Component::Prefix(_) => {
9377 return Err(LinkError::UnsafePath {
9378 path: path.display().to_string(),
9379 });
9380 }
9381 };
9382 use std::os::unix::ffi::OsStrExt as _;
9383 let name = c_name(name.as_bytes(), &path.display().to_string())?;
9384 if create {
9385 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9386 if made != 0 {
9387 let error = std::io::Error::last_os_error();
9388 if error.raw_os_error() != Some(libc::EEXIST) {
9389 return Err(error.into());
9390 }
9391 }
9392 }
9393 directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
9394 }
9395 Ok(directory)
9396}
9397
9398#[cfg(unix)]
9399fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9400 open_dir_path_nofollow(path, true)
9401}
9402
9403#[cfg(unix)]
9404fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9405 open_dir_path_nofollow(path, false)
9406}
9407
9408#[cfg(unix)]
9409fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
9410 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9411 let result =
9412 unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
9413 if result == 0 {
9414 return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
9415 }
9416 let error = std::io::Error::last_os_error();
9417 if error.kind() == std::io::ErrorKind::NotFound {
9418 Ok(None)
9419 } else {
9420 Err(error.into())
9421 }
9422}
9423
9424#[cfg(unix)]
9425fn create_dir_exclusive_at(
9426 parent: std::os::fd::RawFd,
9427 name: &std::ffi::CStr,
9428 display: &str,
9429) -> LinkResult<std::fs::File> {
9430 let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
9431 if made != 0 {
9432 return Err(LinkError::UnsafePath {
9433 path: display.to_string(),
9434 });
9435 }
9436 open_dir_at(parent, name, display)
9437}
9438
9439#[cfg(unix)]
9440fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
9441 use std::os::fd::AsRawFd as _;
9442
9443 let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
9444 if duplicate < 0 {
9445 return Err(std::io::Error::last_os_error().into());
9446 }
9447 let stream = unsafe { libc::fdopendir(duplicate) };
9448 if stream.is_null() {
9449 let error = std::io::Error::last_os_error();
9450 unsafe {
9451 libc::close(duplicate);
9452 }
9453 return Err(error.into());
9454 }
9455 let mut names = Vec::new();
9456 loop {
9457 let entry = unsafe { libc::readdir(stream) };
9458 if entry.is_null() {
9459 break;
9460 }
9461 let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
9462 if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
9463 names.push(raw.to_owned());
9464 }
9465 }
9466 if unsafe { libc::closedir(stream) } != 0 {
9467 return Err(std::io::Error::last_os_error().into());
9468 }
9469 Ok(names)
9470}
9471
9472#[cfg(unix)]
9475fn remove_tree_at(
9476 parent: std::os::fd::RawFd,
9477 name: &std::ffi::CStr,
9478 display: &str,
9479) -> LinkResult<()> {
9480 use std::os::fd::AsRawFd as _;
9481
9482 match entry_is_dir_at(parent, name)? {
9483 None => return Ok(()),
9484 Some(false) => {
9485 if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
9486 return Err(std::io::Error::last_os_error().into());
9487 }
9488 }
9489 Some(true) => {
9490 let directory = open_dir_at(parent, name, display)?;
9491 for child in directory_entry_names(&directory)? {
9492 let child_display =
9493 format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
9494 remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
9495 }
9496 drop(directory);
9497 if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
9498 return Err(std::io::Error::last_os_error().into());
9499 }
9500 }
9501 }
9502 Ok(())
9503}
9504
9505#[cfg(unix)]
9509fn clone_tree_contents(
9510 source: &std::fs::File,
9511 destination: &std::fs::File,
9512 display: &str,
9513) -> LinkResult<()> {
9514 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9515
9516 for name in directory_entry_names(source)? {
9517 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9518 let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9519 if unsafe {
9520 libc::fstatat(
9521 source.as_raw_fd(),
9522 name.as_ptr(),
9523 &mut stat,
9524 libc::AT_SYMLINK_NOFOLLOW,
9525 )
9526 } != 0
9527 {
9528 return Err(std::io::Error::last_os_error().into());
9529 }
9530 match stat.st_mode & libc::S_IFMT {
9531 libc::S_IFDIR => {
9532 if unsafe {
9533 libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
9534 } != 0
9535 {
9536 return Err(std::io::Error::last_os_error().into());
9537 }
9538 let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
9539 let destination_child =
9540 open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
9541 clone_tree_contents(&source_child, &destination_child, &child_display)?;
9542 destination_child.sync_all()?;
9543 }
9544 libc::S_IFREG => {
9545 let source_fd = unsafe {
9546 libc::openat(
9547 source.as_raw_fd(),
9548 name.as_ptr(),
9549 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9550 )
9551 };
9552 if source_fd < 0 {
9553 return Err(std::io::Error::last_os_error().into());
9554 }
9555 let destination_fd = unsafe {
9556 libc::openat(
9557 destination.as_raw_fd(),
9558 name.as_ptr(),
9559 libc::O_WRONLY
9560 | libc::O_CREAT
9561 | libc::O_EXCL
9562 | libc::O_CLOEXEC
9563 | libc::O_NOFOLLOW,
9564 (stat.st_mode & 0o777) as libc::c_uint,
9565 )
9566 };
9567 if destination_fd < 0 {
9568 unsafe {
9569 libc::close(source_fd);
9570 }
9571 return Err(std::io::Error::last_os_error().into());
9572 }
9573 let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
9574 let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
9575 std::io::copy(&mut input, &mut output)?;
9576 output.sync_all()?;
9577 }
9578 libc::S_IFLNK => {
9579 let mut target = vec![0_u8; 4097];
9580 let length = unsafe {
9581 libc::readlinkat(
9582 source.as_raw_fd(),
9583 name.as_ptr(),
9584 target.as_mut_ptr().cast(),
9585 target.len(),
9586 )
9587 };
9588 if length < 0 || length as usize >= target.len() {
9589 return Err(LinkError::UnsafePath {
9590 path: child_display,
9591 });
9592 }
9593 target.truncate(length as usize);
9594 let target = c_name(&target, &child_display)?;
9595 if unsafe {
9596 libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
9597 } != 0
9598 {
9599 return Err(std::io::Error::last_os_error().into());
9600 }
9601 }
9602 _ => {
9603 return Err(LinkError::UnsafePath {
9604 path: child_display,
9605 });
9606 }
9607 }
9608 }
9609 destination.sync_all()?;
9610 Ok(())
9611}
9612
9613#[cfg(target_os = "linux")]
9614fn install_stage_at(
9615 parent: std::os::fd::RawFd,
9616 stage: &std::ffi::CStr,
9617 dest: &std::ffi::CStr,
9618 dest_exists: bool,
9619) -> LinkResult<()> {
9620 let flags = if dest_exists {
9621 libc::RENAME_EXCHANGE
9622 } else {
9623 libc::RENAME_NOREPLACE
9624 };
9625 let result = unsafe {
9629 libc::syscall(
9630 libc::SYS_renameat2,
9631 parent,
9632 stage.as_ptr(),
9633 parent,
9634 dest.as_ptr(),
9635 flags,
9636 )
9637 };
9638 if result == 0 {
9639 Ok(())
9640 } else {
9641 Err(std::io::Error::last_os_error().into())
9642 }
9643}
9644
9645#[cfg(target_os = "macos")]
9646fn install_stage_at(
9647 parent: std::os::fd::RawFd,
9648 stage: &std::ffi::CStr,
9649 dest: &std::ffi::CStr,
9650 dest_exists: bool,
9651) -> LinkResult<()> {
9652 let flags = if dest_exists {
9653 libc::RENAME_SWAP
9654 } else {
9655 libc::RENAME_EXCL
9656 };
9657 let result =
9658 unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
9659 if result == 0 {
9660 Ok(())
9661 } else {
9662 Err(std::io::Error::last_os_error().into())
9663 }
9664}
9665
9666#[cfg(unix)]
9667fn write_pull_entries_beneath_dir(
9668 root: &std::fs::File,
9669 entries: &[(String, Vec<u8>)],
9670) -> LinkResult<()> {
9671 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9672
9673 for (path, content) in entries {
9674 let components: Vec<&str> = path.split('/').collect();
9675 let (leaf, parents) = components
9676 .split_last()
9677 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9678 let mut directory = root.try_clone()?;
9679 for component in parents {
9680 let name = c_name(component.as_bytes(), path)?;
9681 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9682 if made != 0 {
9683 let error = std::io::Error::last_os_error();
9684 if error.raw_os_error() != Some(libc::EEXIST) {
9685 return Err(error.into());
9686 }
9687 }
9688 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9689 }
9690
9691 let leaf_name = c_name(leaf.as_bytes(), path)?;
9692 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9693 let inspected = unsafe {
9694 libc::fstatat(
9695 directory.as_raw_fd(),
9696 leaf_name.as_ptr(),
9697 &mut existing,
9698 libc::AT_SYMLINK_NOFOLLOW,
9699 )
9700 };
9701 if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
9702 return Err(LinkError::UnsafePath { path: path.clone() });
9703 }
9704
9705 let nonce = std::time::SystemTime::now()
9706 .duration_since(std::time::UNIX_EPOCH)
9707 .unwrap_or_default()
9708 .as_nanos();
9709 let temp_name = format!(
9710 ".dbmd-pull-{}-{nonce}-{}",
9711 std::process::id(),
9712 content_sha256(format!("{path}\0{}", content.len()).as_bytes())
9713 );
9714 let temp = c_name(temp_name.as_bytes(), path)?;
9715 let fd = unsafe {
9716 libc::openat(
9717 directory.as_raw_fd(),
9718 temp.as_ptr(),
9719 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9720 0o600,
9721 )
9722 };
9723 if fd < 0 {
9724 return Err(std::io::Error::last_os_error().into());
9725 }
9726 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9727 if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
9728 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9729 return Err(error.into());
9730 }
9731 drop(file);
9732 let renamed = unsafe {
9733 libc::renameat(
9734 directory.as_raw_fd(),
9735 temp.as_ptr(),
9736 directory.as_raw_fd(),
9737 leaf_name.as_ptr(),
9738 )
9739 };
9740 if renamed != 0 {
9741 let error = std::io::Error::last_os_error();
9742 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9743 return Err(error.into());
9744 }
9745 directory.sync_all()?;
9746 }
9747 root.sync_all()?;
9748 Ok(())
9749}
9750
9751#[cfg(unix)]
9752fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9753 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9754
9755 let path = &entry.path;
9756 let components: Vec<&str> = path.split('/').collect();
9757 let (leaf, parents) = components
9758 .split_last()
9759 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9760 let mut directory = root.try_clone()?;
9761 for component in parents {
9762 let name = c_name(component.as_bytes(), path)?;
9763 let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9764 if made != 0 {
9765 let error = std::io::Error::last_os_error();
9766 if error.raw_os_error() != Some(libc::EEXIST) {
9767 return Err(error.into());
9768 }
9769 }
9770 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9771 }
9772 let leaf_name = c_name(leaf.as_bytes(), path)?;
9773 let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9774 if unsafe {
9775 libc::fstatat(
9776 directory.as_raw_fd(),
9777 leaf_name.as_ptr(),
9778 &mut existing,
9779 libc::AT_SYMLINK_NOFOLLOW,
9780 )
9781 } == 0
9782 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
9783 {
9784 return Err(LinkError::UnsafePath { path: path.clone() });
9785 }
9786 let nonce = SystemTime::now()
9787 .duration_since(UNIX_EPOCH)
9788 .unwrap_or_default()
9789 .as_nanos();
9790 let temp_name = format!(
9791 ".dbmd-pull-{}-{nonce}-{}",
9792 std::process::id(),
9793 content_sha256(path.as_bytes())
9794 );
9795 let temp = c_name(temp_name.as_bytes(), path)?;
9796 let fd = unsafe {
9797 libc::openat(
9798 directory.as_raw_fd(),
9799 temp.as_ptr(),
9800 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9801 0o600,
9802 )
9803 };
9804 if fd < 0 {
9805 return Err(std::io::Error::last_os_error().into());
9806 }
9807 let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
9808 let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
9809 let mut digest = Sha256::new();
9810 let mut total = 0_u64;
9811 let mut buffer = [0_u8; 64 * 1024];
9812 let copied = (|| -> std::io::Result<()> {
9813 loop {
9814 let read = input.read(&mut buffer)?;
9815 if read == 0 {
9816 break;
9817 }
9818 total = total.saturating_add(read as u64);
9819 if total > entry.bytes {
9820 return Err(std::io::Error::new(
9821 std::io::ErrorKind::InvalidData,
9822 "staged sync source grew beyond its verified length",
9823 ));
9824 }
9825 digest.update(&buffer[..read]);
9826 output.write_all(&buffer[..read])?;
9827 }
9828 Ok(())
9829 })();
9830 if let Err(error) = copied {
9831 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9832 return Err(error.into());
9833 }
9834 drop(output);
9835 if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
9836 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9837 return Err(invalid_feed(
9838 "private staged sync source failed final integrity verification",
9839 ));
9840 }
9841 if unsafe {
9842 libc::renameat(
9843 directory.as_raw_fd(),
9844 temp.as_ptr(),
9845 directory.as_raw_fd(),
9846 leaf_name.as_ptr(),
9847 )
9848 } != 0
9849 {
9850 let error = std::io::Error::last_os_error();
9851 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9852 return Err(error.into());
9853 }
9854 Ok(())
9855}
9856
9857#[cfg(unix)]
9858fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9859 use std::os::fd::{AsRawFd as _, FromRawFd as _};
9860
9861 let path = &entry.path;
9862 let components: Vec<&str> = path.split('/').collect();
9863 let (leaf, parents) = components
9864 .split_last()
9865 .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9866 let mut directory = root.try_clone()?;
9867 for component in parents {
9868 directory = open_dir_at(
9869 directory.as_raw_fd(),
9870 &c_name(component.as_bytes(), path)?,
9871 path,
9872 )?;
9873 }
9874 let leaf = c_name(leaf.as_bytes(), path)?;
9875 let fd = unsafe {
9876 libc::openat(
9877 directory.as_raw_fd(),
9878 leaf.as_ptr(),
9879 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9880 )
9881 };
9882 if fd < 0 {
9883 return Err(std::io::Error::last_os_error().into());
9884 }
9885 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9886 if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
9887 return Err(invalid_feed(
9888 "private pull stage changed before its durability barrier",
9889 ));
9890 }
9891 file.sync_all()?;
9892 Ok(())
9893}
9894
9895#[cfg(unix)]
9896fn run_pull_source_workers(
9897 root: &std::fs::File,
9898 entries: &[V2StagedFile],
9899 operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
9900) -> LinkResult<()> {
9901 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9902
9903 let next = AtomicUsize::new(0);
9904 let failed = AtomicBool::new(false);
9905 let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
9906 let mut first_error = None;
9907 std::thread::scope(|scope| {
9908 let (sender, receiver) = std::sync::mpsc::channel();
9909 for _ in 0..worker_count {
9910 let sender = sender.clone();
9911 let next = &next;
9912 let failed = &failed;
9913 scope.spawn(move || {
9914 while !failed.load(Ordering::Acquire) {
9915 let index = next.fetch_add(1, Ordering::Relaxed);
9916 let Some(entry) = entries.get(index) else {
9917 break;
9918 };
9919 let result = operation(root, entry);
9920 if result.is_err() {
9921 failed.store(true, Ordering::Release);
9922 }
9923 if sender.send(result).is_err() {
9924 break;
9925 }
9926 }
9927 });
9928 }
9929 drop(sender);
9930 for result in receiver {
9931 if let Err(error) = result {
9932 if first_error.is_none() {
9933 first_error = Some(error);
9934 }
9935 }
9936 }
9937 });
9938 if let Some(error) = first_error {
9939 return Err(error);
9940 }
9941 if next.load(Ordering::Relaxed) < entries.len() {
9942 return Err(invalid_feed(
9943 "a bounded pull worker stopped before reporting every file",
9944 ));
9945 }
9946 Ok(())
9947}
9948
9949#[cfg(unix)]
9950fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9951 use std::os::fd::AsRawFd as _;
9952
9953 for name in directory_entry_names(root)? {
9954 if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9955 let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9956 let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9957 sync_pull_directory_tree(&child, &child_display)?;
9958 }
9959 }
9960 root.sync_all()?;
9961 Ok(())
9962}
9963
9964#[cfg(unix)]
9965fn write_pull_sources_beneath_dir(
9966 root: &std::fs::File,
9967 entries: &[V2StagedFile],
9968) -> LinkResult<()> {
9969 run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
9976 run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
9977 sync_pull_directory_tree(root, "v2 pull stage")
9978}
9979
9980#[cfg(unix)]
9981fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
9982 use std::os::fd::AsRawFd as _;
9983 for path in paths {
9984 if !safe_store_rel_path(path) {
9985 return Err(LinkError::UnsafePath { path: path.clone() });
9986 }
9987 let components = path.split('/').collect::<Vec<_>>();
9988 let Some((leaf, parents)) = components.split_last() else {
9989 return Err(LinkError::UnsafePath { path: path.clone() });
9990 };
9991 let mut directory = root.try_clone()?;
9992 let mut missing = false;
9993 for component in parents {
9994 let name = c_name(component.as_bytes(), path)?;
9995 match entry_is_dir_at(directory.as_raw_fd(), &name)? {
9996 None => {
9997 missing = true;
9998 break;
9999 }
10000 Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
10001 Some(true) => {
10002 directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10003 }
10004 }
10005 }
10006 if missing {
10007 continue;
10008 }
10009 let leaf = c_name(leaf.as_bytes(), path)?;
10010 match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
10011 None => {}
10012 Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
10013 Some(false) => {
10014 if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
10015 return Err(std::io::Error::last_os_error().into());
10016 }
10017 directory.sync_all()?;
10018 }
10019 }
10020 }
10021 Ok(())
10022}
10023
10024#[cfg(unix)]
10025fn install_pulled_delta(
10026 dest: &Path,
10027 entries: &[(String, Vec<u8>)],
10028 deleted: &[String],
10029 rebuild_indexes: bool,
10030) -> LinkResult<()> {
10031 use ring::rand::SecureRandom as _;
10032 use std::os::fd::AsRawFd as _;
10033 use std::os::unix::ffi::OsStrExt as _;
10034
10035 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10036 let name = dest
10037 .file_name()
10038 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10039 .ok_or_else(|| LinkError::UnsafePath {
10040 path: dest.display().to_string(),
10041 })?;
10042 let parent_dir = open_or_create_dir_nofollow(parent)?;
10043 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10044 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10045 None => false,
10046 Some(true) => true,
10047 Some(false) => {
10048 return Err(LinkError::UnsafePath {
10049 path: dest.display().to_string(),
10050 });
10051 }
10052 };
10053
10054 let mut nonce = [0_u8; 16];
10055 ring::rand::SystemRandom::new()
10056 .fill(&mut nonce)
10057 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10058 let stage_label = format!(
10059 ".{}.dbmd-pull-stage-{}",
10060 name.to_string_lossy(),
10061 URL_SAFE_NO_PAD.encode(nonce)
10062 );
10063 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10064 let stage_dir = create_dir_exclusive_at(
10065 parent_dir.as_raw_fd(),
10066 &stage_name,
10067 &dest.display().to_string(),
10068 )?;
10069
10070 let prepared = (|| -> LinkResult<()> {
10071 if dest_exists {
10072 let live = open_dir_at(
10073 parent_dir.as_raw_fd(),
10074 &dest_name,
10075 &dest.display().to_string(),
10076 )?;
10077 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10078 }
10079 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10080 write_pull_entries_beneath_dir(&stage_dir, entries)?;
10081 if rebuild_indexes {
10082 let stage_store =
10083 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10084 .map_err(|error| LinkError::InvalidPack {
10085 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10086 })?;
10087 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10088 LinkError::InvalidPack {
10089 message: format!("could not materialize v2 local catalogs: {error}"),
10090 }
10091 })?;
10092 }
10093 stage_dir.sync_all()?;
10094 Ok(())
10095 })();
10096 if let Err(error) = prepared {
10097 let _ = remove_tree_at(
10098 parent_dir.as_raw_fd(),
10099 &stage_name,
10100 &dest.display().to_string(),
10101 );
10102 return Err(error);
10103 }
10104
10105 if let Err(error) =
10106 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10107 {
10108 let _ = remove_tree_at(
10109 parent_dir.as_raw_fd(),
10110 &stage_name,
10111 &dest.display().to_string(),
10112 );
10113 return Err(error);
10114 }
10115 parent_dir.sync_all()?;
10116 if dest_exists {
10117 let _ = remove_tree_at(
10121 parent_dir.as_raw_fd(),
10122 &stage_name,
10123 &dest.display().to_string(),
10124 );
10125 let _ = parent_dir.sync_all();
10126 }
10127 Ok(())
10128}
10129
10130#[cfg(unix)]
10131fn install_pulled_delta_sources(
10132 dest: &Path,
10133 entries: &[V2StagedFile],
10134 deleted: &[String],
10135 rebuild_indexes: bool,
10136 _previous: Option<&V2SyncBaseline>,
10137 _next: &V2VerifiedHead,
10138) -> LinkResult<()> {
10139 use ring::rand::SecureRandom as _;
10140 use std::os::fd::AsRawFd as _;
10141 use std::os::unix::ffi::OsStrExt as _;
10142
10143 if let Ok(store) = Store::open_strict(dest) {
10147 return install_established_v2_delta(
10148 store,
10149 entries,
10150 deleted,
10151 rebuild_indexes,
10152 _previous,
10153 _next,
10154 );
10155 }
10156
10157 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10158 let name = dest
10159 .file_name()
10160 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10161 .ok_or_else(|| LinkError::UnsafePath {
10162 path: dest.display().to_string(),
10163 })?;
10164 let parent_dir = open_or_create_dir_nofollow(parent)?;
10165 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10166 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10167 None => false,
10168 Some(true) => true,
10169 Some(false) => {
10170 return Err(LinkError::UnsafePath {
10171 path: dest.display().to_string(),
10172 })
10173 }
10174 };
10175 let mut nonce = [0_u8; 16];
10176 ring::rand::SystemRandom::new()
10177 .fill(&mut nonce)
10178 .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10179 let stage_label = format!(
10180 ".{}.dbmd-pull-stage-{}",
10181 name.to_string_lossy(),
10182 URL_SAFE_NO_PAD.encode(nonce)
10183 );
10184 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10185 let stage_dir = create_dir_exclusive_at(
10186 parent_dir.as_raw_fd(),
10187 &stage_name,
10188 &dest.display().to_string(),
10189 )?;
10190 let prepared = (|| -> LinkResult<()> {
10191 if dest_exists {
10192 let live = open_dir_at(
10193 parent_dir.as_raw_fd(),
10194 &dest_name,
10195 &dest.display().to_string(),
10196 )?;
10197 clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10198 }
10199 remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10200 write_pull_sources_beneath_dir(&stage_dir, entries)?;
10201 if rebuild_indexes {
10202 let stage_store =
10203 Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10204 .map_err(|error| LinkError::InvalidPack {
10205 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10206 })?;
10207 crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10208 LinkError::InvalidPack {
10209 message: format!("could not materialize v2 local catalogs: {error}"),
10210 }
10211 })?;
10212 }
10213 stage_dir.sync_all()?;
10214 Ok(())
10215 })();
10216 if let Err(error) = prepared {
10217 let _ = remove_tree_at(
10218 parent_dir.as_raw_fd(),
10219 &stage_name,
10220 &dest.display().to_string(),
10221 );
10222 return Err(error);
10223 }
10224 if let Err(error) =
10225 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10226 {
10227 let _ = remove_tree_at(
10228 parent_dir.as_raw_fd(),
10229 &stage_name,
10230 &dest.display().to_string(),
10231 );
10232 return Err(error);
10233 }
10234 parent_dir.sync_all()?;
10235 if dest_exists {
10236 let _ = remove_tree_at(
10237 parent_dir.as_raw_fd(),
10238 &stage_name,
10239 &dest.display().to_string(),
10240 );
10241 let _ = parent_dir.sync_all();
10242 }
10243 Ok(())
10244}
10245
10246#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10247struct V2PullCoordinate {
10248 head_seq: Option<u64>,
10249 commit_hash: Option<String>,
10250 view_kind: Option<String>,
10251 view_revision: Option<String>,
10252}
10253
10254#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10255struct V2PullFileCoordinate {
10256 sha256: String,
10257 bytes: u64,
10258}
10259
10260#[derive(Debug, Clone, Deserialize, Serialize)]
10261struct V2PullJournalEntry {
10262 path: String,
10263 old: Option<V2PullFileCoordinate>,
10264 new: Option<V2PullFileCoordinate>,
10265 backup: Option<String>,
10266}
10267
10268#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10269#[serde(rename_all = "snake_case")]
10270enum V2PullPhase {
10271 Preparing,
10272 Ready,
10273}
10274
10275#[derive(Debug, Clone, Deserialize, Serialize)]
10276struct V2PullJournal {
10277 v: u8,
10278 phase: V2PullPhase,
10279 brain: String,
10280 previous: V2PullCoordinate,
10281 next: V2PullCoordinate,
10282 backup_dir: String,
10283 entries: Vec<V2PullJournalEntry>,
10284}
10285
10286const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
10287
10288fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
10289 V2PullCoordinate {
10290 head_seq: baseline.and_then(|value| value.head_seq),
10291 commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
10292 view_kind: baseline.and_then(|value| value.view_kind.clone()),
10293 view_revision: baseline.and_then(|value| value.view_revision.clone()),
10294 }
10295}
10296
10297fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
10298 V2PullCoordinate {
10299 head_seq: head.pointer.as_ref().map(|value| value.seq),
10300 commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
10301 view_kind: Some(head.view_kind.clone()),
10302 view_revision: Some(head.view_revision.clone()),
10303 }
10304}
10305
10306fn v2_pull_file_coordinate(
10307 store: &Store,
10308 path: &str,
10309 limit: u64,
10310) -> LinkResult<Option<V2PullFileCoordinate>> {
10311 let file = match store.open_regular(Path::new(path)) {
10312 Ok(file) => file,
10313 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10314 Err(error) => return Err(error.into()),
10315 };
10316 let bytes = file.metadata()?.len();
10317 if bytes > limit || bytes > MAX_STORE_BYTES {
10318 return Err(invalid_feed(
10319 "pull transaction file exceeds its declared bound",
10320 ));
10321 }
10322 Ok(Some(V2PullFileCoordinate {
10323 sha256: content_sha256_reader(file)?,
10324 bytes,
10325 }))
10326}
10327
10328fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
10329 let mut bytes = serde_json::to_vec_pretty(journal)
10330 .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
10331 bytes.push(b'\n');
10332 Ok(bytes)
10333}
10334
10335fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
10336 let backup_prefix = ".dbmd/pull-backup-";
10337 let suffix = journal
10338 .backup_dir
10339 .strip_prefix(backup_prefix)
10340 .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
10341 let mut paths = std::collections::BTreeSet::new();
10342 if journal.v != 1
10343 || !crate::ulid::is_ulid(&journal.brain)
10344 || !crate::ulid::is_ulid(suffix)
10345 || journal.entries.is_empty()
10346 || journal.entries.len() > MAX_PUSH_FILES + 4
10347 || journal.previous == journal.next
10348 {
10349 return Err(invalid_feed("v2 pull journal failed validation"));
10350 }
10351 for (index, entry) in journal.entries.iter().enumerate() {
10352 if !safe_store_rel_path(&entry.path)
10353 || entry.path == V2_PULL_JOURNAL
10354 || entry.path.starts_with(backup_prefix)
10355 || !paths.insert(entry.path.clone())
10356 || (entry.old.is_none() && entry.new.is_none())
10357 || entry
10358 .old
10359 .iter()
10360 .chain(entry.new.iter())
10361 .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
10362 || entry.backup.as_deref()
10363 != entry
10364 .old
10365 .as_ref()
10366 .map(|_| format!("{index:08x}"))
10367 .as_deref()
10368 {
10369 return Err(invalid_feed("v2 pull journal entry failed validation"));
10370 }
10371 }
10372 Ok(())
10373}
10374
10375fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
10376 #[cfg(unix)]
10377 {
10378 use std::os::unix::fs::PermissionsExt as _;
10379 match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
10380 Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
10381 return Err(invalid_feed(
10382 "v2 pull journal is accessible to group/other; set mode 0600",
10383 ));
10384 }
10385 Ok(_) => {}
10386 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10387 Err(error) => return Err(error.into()),
10388 }
10389 }
10390 let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
10391 Ok(bytes) => bytes,
10392 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10393 Err(error) => return Err(error.into()),
10394 };
10395 let journal: V2PullJournal =
10396 serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
10397 validate_v2_pull_journal(&journal)?;
10398 Ok(Some(journal))
10399}
10400
10401fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10402 match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
10406 Ok(()) => {}
10407 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
10408 Err(error) => return Err(error.into()),
10409 }
10410 match store.remove_private_tree(Path::new(&journal.backup_dir)) {
10411 Ok(()) => Ok(()),
10412 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
10413 Err(error) => Err(error.into()),
10414 }
10415}
10416
10417fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
10418 let names = match store.directory_names(Path::new(".dbmd")) {
10419 Ok(names) => names,
10420 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
10421 Err(error) => return Err(error.into()),
10422 };
10423 for name in names {
10424 let Some(name) = name.to_str() else {
10425 continue;
10426 };
10427 let Some(suffix) = name.strip_prefix("pull-backup-") else {
10428 continue;
10429 };
10430 if crate::ulid::is_ulid(suffix) {
10431 store.remove_private_tree(&Path::new(".dbmd").join(name))?;
10432 }
10433 }
10434 Ok(())
10435}
10436
10437fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10438 for entry in &journal.entries {
10440 let limit = entry
10441 .old
10442 .as_ref()
10443 .into_iter()
10444 .chain(entry.new.iter())
10445 .map(|value| value.bytes)
10446 .max()
10447 .unwrap_or(0);
10448 let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
10449 if current != entry.old && current != entry.new {
10450 return Err(LinkError::InvalidPack {
10451 message: format!(
10452 "cannot recover interrupted pull because `{}` changed afterward",
10453 entry.path
10454 ),
10455 });
10456 }
10457 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10458 let path = Path::new(&journal.backup_dir).join(backup);
10459 let file = store.open_regular(&path)?;
10460 if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
10461 return Err(invalid_feed("v2 pull recovery backup failed verification"));
10462 }
10463 }
10464 }
10465 for entry in journal.entries.iter().rev() {
10466 match (&entry.old, &entry.backup) {
10467 (Some(old), Some(backup)) => {
10468 let bytes =
10469 store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
10470 store.write_atomic(Path::new(&entry.path), &bytes)?;
10471 }
10472 (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
10473 store.remove_file(Path::new(&entry.path))?;
10474 }
10475 (None, None) => {}
10476 _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
10477 }
10478 }
10479 crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
10480 message: format!("could not rebuild catalogs after pull recovery: {error}"),
10481 })?;
10482 cleanup_v2_pull_journal(store, journal)
10483}
10484
10485fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
10486 let Ok(store) = Store::open_strict(dest) else {
10487 return Ok(());
10488 };
10489 if let Some(journal) = load_v2_pull_journal(&store)? {
10490 if journal.brain != brain {
10491 return Err(invalid_feed("v2 pull journal belongs to another brain"));
10492 }
10493 if journal.phase == V2PullPhase::Preparing {
10494 cleanup_v2_pull_journal(&store, &journal)?;
10495 } else {
10496 let baseline = load_v2_baseline(cfg, brain, dest)?;
10497 let current = v2_pull_baseline_coordinate(baseline.as_ref());
10498 if current == journal.next {
10499 cleanup_v2_pull_journal(&store, &journal)?;
10500 } else {
10501 if current != journal.previous {
10502 return Err(invalid_feed(
10503 "cannot recover interrupted pull because its baseline changed afterward",
10504 ));
10505 }
10506 rollback_v2_pull(&store, &journal)?;
10507 }
10508 }
10509 }
10510 prune_orphan_v2_pull_backups(&store)
10515}
10516
10517fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
10518 let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
10519 message: format!("installed v2 checkout is not a valid db.md store: {error}"),
10520 })?;
10521 if let Some(journal) = load_v2_pull_journal(&store)? {
10522 cleanup_v2_pull_journal(&store, &journal)?;
10523 }
10524 Ok(())
10525}
10526
10527#[cfg(windows)]
10528fn install_windows_initial_sources(
10529 dest: &Path,
10530 entries: &[V2StagedFile],
10531 rebuild_indexes: bool,
10532) -> LinkResult<()> {
10533 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10534 let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
10535 path: dest.display().to_string(),
10536 })?;
10537 let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
10538 if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
10539 return Err(LinkError::UnsafePath {
10540 path: dest.display().to_string(),
10541 });
10542 }
10543 let stage_name = format!(
10544 ".{}.dbmd-pull-stage-{}",
10545 name.to_string_lossy(),
10546 crate::ulid::mint()
10547 );
10548 let stage_path = parent.join(&stage_name);
10549 let stage_capability =
10550 crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
10551 let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
10552 let prepared = (|| -> LinkResult<()> {
10553 for entry in entries {
10554 let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
10555 if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
10556 return Err(invalid_feed(
10557 "private staged sync source failed final integrity verification",
10558 ));
10559 }
10560 stage.write_atomic(Path::new(&entry.path), &bytes)?;
10561 }
10562 let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
10563 .map_err(|error| LinkError::InvalidPack {
10564 message: format!("v2 staging tree is not a valid db.md store: {error}"),
10565 })?;
10566 if rebuild_indexes {
10567 crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
10568 message: format!("could not materialize v2 local catalogs: {error}"),
10569 })?;
10570 }
10571 Ok(())
10572 })();
10573 if let Err(error) = prepared {
10574 let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
10575 return Err(error);
10576 }
10577 crate::fsx::rename_directory_beneath(
10578 &parent_capability,
10579 Path::new(&stage_name),
10580 Path::new(name),
10581 )?;
10582 Ok(())
10583}
10584
10585fn install_established_v2_delta(
10586 store: Store,
10587 entries: &[V2StagedFile],
10588 deleted: &[String],
10589 rebuild_indexes: bool,
10590 previous: Option<&V2SyncBaseline>,
10591 next: &V2VerifiedHead,
10592) -> LinkResult<()> {
10593 if load_v2_pull_journal(&store)?.is_some() {
10594 return Err(invalid_feed(
10595 "an interrupted pull must be recovered before installing",
10596 ));
10597 }
10598 let mut sources = std::collections::BTreeMap::new();
10599 for entry in entries {
10600 if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
10601 return Err(invalid_feed("pull mutation repeats a path"));
10602 }
10603 }
10604 let mut paths = sources.keys().cloned().collect::<Vec<_>>();
10605 paths.extend(deleted.iter().cloned());
10606 paths.sort();
10607 paths.dedup();
10608 if paths.is_empty() {
10609 return Ok(());
10610 }
10611 let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
10612 let mut journal = V2PullJournal {
10613 v: 1,
10614 phase: V2PullPhase::Preparing,
10615 brain: next.brain_id.clone(),
10616 previous: v2_pull_baseline_coordinate(previous),
10617 next: v2_pull_head_coordinate(next),
10618 backup_dir: backup_dir.clone(),
10619 entries: Vec::with_capacity(paths.len()),
10620 };
10621 for path in &paths {
10622 let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
10623 let new = sources.get(path).map(|entry| V2PullFileCoordinate {
10624 sha256: entry.sha256.clone(),
10625 bytes: entry.bytes,
10626 });
10627 if old == new {
10628 continue;
10629 }
10630 let index = journal.entries.len();
10631 journal.entries.push(V2PullJournalEntry {
10632 path: path.clone(),
10633 backup: old.as_ref().map(|_| format!("{index:08x}")),
10634 old,
10635 new,
10636 });
10637 }
10638 if journal.entries.is_empty() {
10639 return Ok(());
10640 }
10641 let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
10642 entry
10643 .old
10644 .as_ref()
10645 .map_or(Some(total), |old| total.checked_add(old.bytes))
10646 });
10647 if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
10648 return Err(LinkError::InvalidPack {
10649 message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
10650 });
10651 }
10652 validate_v2_pull_journal(&journal)?;
10653 store.write_private_atomic_new(
10654 Path::new(V2_PULL_JOURNAL),
10655 &v2_pull_journal_bytes(&journal)?,
10656 )?;
10657 let prepared = (|| -> LinkResult<()> {
10658 store.create_private_dir_all(Path::new(&backup_dir))?;
10659 for entry in &journal.entries {
10660 if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10661 let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
10662 if content_sha256(&bytes) != old.sha256 {
10663 return Err(invalid_feed("live pull source changed during backup"));
10664 }
10665 store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
10666 }
10667 }
10668 journal.phase = V2PullPhase::Ready;
10669 store.write_private_atomic(
10670 Path::new(V2_PULL_JOURNAL),
10671 &v2_pull_journal_bytes(&journal)?,
10672 )?;
10673 Ok(())
10674 })();
10675 if let Err(error) = prepared {
10676 let cleanup = cleanup_v2_pull_journal(&store, &journal);
10677 return match cleanup {
10678 Ok(()) => Err(error),
10679 Err(cleanup) => Err(LinkError::InvalidPack {
10680 message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
10681 }),
10682 };
10683 }
10684 let installed = (|| -> LinkResult<()> {
10685 for entry in &journal.entries {
10686 if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
10687 return Err(LinkError::InvalidPack {
10688 message: format!("local path `{}` changed during pull", entry.path),
10689 });
10690 }
10691 if let Some(source) = sources.get(&entry.path) {
10692 let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
10693 if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
10694 return Err(invalid_feed(
10695 "private staged sync source failed final integrity verification",
10696 ));
10697 }
10698 store.write_atomic(Path::new(&entry.path), &bytes)?;
10699 } else if entry.old.is_some() {
10700 store.remove_file(Path::new(&entry.path))?;
10701 }
10702 }
10703 if rebuild_indexes {
10704 crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
10705 message: format!("could not materialize v2 local catalogs: {error}"),
10706 })?;
10707 }
10708 Ok(())
10709 })();
10710 if let Err(error) = installed {
10711 return match rollback_v2_pull(&store, &journal) {
10712 Ok(()) => Err(error),
10713 Err(rollback) => Err(LinkError::InvalidPack {
10714 message: format!("{error}; durable pull rollback also failed: {rollback}"),
10715 }),
10716 };
10717 }
10718 Ok(())
10719}
10720
10721#[cfg(windows)]
10722fn install_pulled_delta_sources(
10723 dest: &Path,
10724 entries: &[V2StagedFile],
10725 deleted: &[String],
10726 rebuild_indexes: bool,
10727 previous: Option<&V2SyncBaseline>,
10728 next: &V2VerifiedHead,
10729) -> LinkResult<()> {
10730 match Store::open_strict(dest) {
10731 Ok(store) => {
10732 install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
10733 }
10734 Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
10735 }
10736}
10737
10738#[cfg(not(any(unix, windows)))]
10739fn install_pulled_delta_sources(
10740 _dest: &Path,
10741 _entries: &[V2StagedFile],
10742 _deleted: &[String],
10743 _rebuild_indexes: bool,
10744 _previous: Option<&V2SyncBaseline>,
10745 _next: &V2VerifiedHead,
10746) -> LinkResult<()> {
10747 Err(LinkError::UnsupportedPlatform {
10748 operation: "atomic v2 pull install",
10749 })
10750}
10751
10752#[cfg(unix)]
10753fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
10754 install_pulled_delta(dest, entries, &[], false)
10755}
10756
10757#[cfg(not(windows))]
10758fn is_safe_slug(slug: &str) -> bool {
10759 !slug.is_empty()
10760 && slug.len() <= 63
10761 && !slug.starts_with('-')
10762 && !slug.ends_with('-')
10763 && slug
10764 .bytes()
10765 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
10766}
10767
10768fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
10769 Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
10770}
10771
10772fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
10773 Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
10774}
10775
10776fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
10777 Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
10778}
10779
10780fn preflight_zip_central_directory(
10781 bytes: &[u8],
10782 offset: usize,
10783 size: usize,
10784 count: u64,
10785) -> LinkResult<()> {
10786 const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
10787 let end = offset
10788 .checked_add(size)
10789 .filter(|end| *end <= bytes.len())
10790 .ok_or_else(|| LinkError::InvalidPack {
10791 message: "ZIP central directory is out of bounds".to_string(),
10792 })?;
10793 let mut cursor = offset;
10794 for _ in 0..count {
10795 if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
10796 return Err(LinkError::InvalidPack {
10797 message: "ZIP central directory entry count is inconsistent".to_string(),
10798 });
10799 }
10800 if le_u16(bytes, cursor + 34) != Some(0) {
10801 return Err(LinkError::InvalidPack {
10802 message: "multi-disk ZIP archives are not supported".to_string(),
10803 });
10804 }
10805 let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
10806 total.checked_add(le_u16(bytes, cursor + at)? as usize)
10807 });
10808 cursor = cursor
10809 .checked_add(46)
10810 .and_then(|fixed| fixed.checked_add(variable?))
10811 .filter(|cursor| *cursor <= end)
10812 .ok_or_else(|| LinkError::InvalidPack {
10813 message: "ZIP central directory entry is truncated".to_string(),
10814 })?;
10815 }
10816 if cursor != end {
10817 return Err(LinkError::InvalidPack {
10818 message: "ZIP central directory size is inconsistent".to_string(),
10819 });
10820 }
10821 Ok(())
10822}
10823
10824fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
10828 const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
10829 const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
10830 const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
10831 let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
10832 let eocd = bytes[search_start..]
10833 .windows(4)
10834 .rposition(|window| window == EOCD_SIG)
10835 .map(|offset| search_start + offset)
10836 .ok_or_else(|| LinkError::InvalidPack {
10837 message: "ZIP has no end-of-central-directory record".to_string(),
10838 })?;
10839 let invalid_end = || LinkError::InvalidPack {
10840 message: "ZIP has an invalid end-of-central-directory structure".to_string(),
10841 };
10842 let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
10843 if eocd
10844 .checked_add(22)
10845 .and_then(|end| end.checked_add(comment_len))
10846 != Some(bytes.len())
10847 {
10848 return Err(invalid_end());
10852 }
10853 let disk = le_u16(bytes, eocd + 4);
10854 let central_disk = le_u16(bytes, eocd + 6);
10855 if disk != Some(0) || central_disk != Some(0) {
10856 return Err(LinkError::InvalidPack {
10857 message: "multi-disk ZIP archives are not supported".to_string(),
10858 });
10859 }
10860 let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
10861 let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
10862 if entries_on_disk != ordinary {
10863 return Err(LinkError::InvalidPack {
10864 message: "multi-disk ZIP archives are not supported".to_string(),
10865 });
10866 }
10867 let zip64_locator = eocd
10868 .checked_sub(20)
10869 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
10870 let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
10871 let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
10872 let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
10873 if central_offset
10874 .checked_add(central_size)
10875 .filter(|end| *end == eocd)
10876 .is_none()
10877 {
10878 return Err(invalid_end());
10879 }
10880 (ordinary as u64, central_offset, central_size)
10881 } else {
10882 let Some(locator) = zip64_locator else {
10883 return Err(invalid_end());
10884 };
10885 if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
10886 return Err(LinkError::InvalidPack {
10887 message: "multi-disk ZIP64 archives are not supported".to_string(),
10888 });
10889 }
10890 let record = le_u64(bytes, locator + 8)
10891 .and_then(|offset| usize::try_from(offset).ok())
10892 .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
10893 .ok_or_else(|| LinkError::InvalidPack {
10894 message: "ZIP64 archive has an invalid end record".to_string(),
10895 })?;
10896 let record_size = le_u64(bytes, record + 4)
10897 .and_then(|size| usize::try_from(size).ok())
10898 .filter(|size| *size >= 44)
10899 .ok_or_else(invalid_end)?;
10900 if record
10901 .checked_add(12)
10902 .and_then(|end| end.checked_add(record_size))
10903 != Some(locator)
10904 || le_u32(bytes, record + 16) != Some(0)
10905 || le_u32(bytes, record + 20) != Some(0)
10906 {
10907 return Err(invalid_end());
10908 }
10909 let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
10910 let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
10911 let central_size = le_u64(bytes, record + 40)
10912 .and_then(|size| usize::try_from(size).ok())
10913 .ok_or_else(invalid_end)?;
10914 let central_offset = le_u64(bytes, record + 48)
10915 .and_then(|offset| usize::try_from(offset).ok())
10916 .ok_or_else(invalid_end)?;
10917 if zip64_on_disk != zip64_total
10918 || central_offset
10919 .checked_add(central_size)
10920 .filter(|end| *end == record)
10921 .is_none()
10922 {
10923 return Err(invalid_end());
10924 }
10925 let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10926 let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10927 if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10928 || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10929 {
10930 return Err(invalid_end());
10931 }
10932 (zip64_total, central_offset, central_size)
10933 };
10934 if count == 0 || count > max_entries as u64 {
10935 return Err(LinkError::InvalidPack {
10936 message: format!("invalid file count {count}"),
10937 });
10938 }
10939 preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10940 Ok(())
10941}
10942
10943fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10944 preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10945 let mut archive =
10946 zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10947 message: format!("ZIP parse failed: {err}"),
10948 })?;
10949 if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10950 return Err(LinkError::InvalidPack {
10951 message: format!("invalid file count {}", archive.len()),
10952 });
10953 }
10954 let mut total = 0u64;
10955 let mut seen = std::collections::HashSet::new();
10956 let mut entries = Vec::with_capacity(archive.len());
10957 for index in 0..archive.len() {
10958 let mut file = archive
10959 .by_index(index)
10960 .map_err(|err| LinkError::InvalidPack {
10961 message: format!("ZIP entry failed: {err}"),
10962 })?;
10963 if file.is_dir() {
10964 continue;
10965 }
10966 let path = file.name().to_string();
10967 if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10968 return Err(LinkError::UnsafePath { path });
10969 }
10970 if file
10971 .unix_mode()
10972 .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
10973 {
10974 return Err(LinkError::InvalidPack {
10975 message: format!("non-file entry `{path}`"),
10976 });
10977 }
10978 if !seen.insert(path.clone()) {
10979 return Err(LinkError::InvalidPack {
10980 message: format!("duplicate path `{path}`"),
10981 });
10982 }
10983 let remaining = MAX_STORE_BYTES.saturating_sub(total);
10984 if file.size() > remaining {
10985 return Err(LinkError::InvalidPack {
10986 message: "expanded content exceeds the 512 MB limit".to_string(),
10987 });
10988 }
10989 let mut content = Vec::new();
10990 (&mut file)
10991 .take(remaining + 1)
10992 .read_to_end(&mut content)
10993 .map_err(|err| LinkError::InvalidPack {
10994 message: format!("could not decompress `{path}`: {err}"),
10995 })?;
10996 if content.len() as u64 > remaining {
10997 return Err(LinkError::InvalidPack {
10998 message: "expanded content exceeds the 512 MB limit".to_string(),
10999 });
11000 }
11001 if content.len() as u64 != file.size() {
11002 return Err(LinkError::InvalidPack {
11003 message: format!("length mismatch for `{path}`"),
11004 });
11005 }
11006 total += content.len() as u64;
11007 entries.push((path, content));
11008 }
11009 if entries.is_empty() {
11010 return Err(LinkError::InvalidPack {
11011 message: "pack contains no files".to_string(),
11012 });
11013 }
11014 Ok(entries)
11015}
11016
11017fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
11018 let mut expected = std::collections::BTreeMap::new();
11019 for file in signed {
11020 if !safe_store_rel_path(&file.path) {
11021 return Err(LinkError::UnsafePath {
11022 path: file.path.clone(),
11023 });
11024 }
11025 if !is_sha256(&file.sha256)
11026 || expected
11027 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11028 .is_some()
11029 {
11030 return Err(invalid_feed(
11031 "signed snapshot manifest contains an invalid or duplicate file",
11032 ));
11033 }
11034 }
11035 if expected.len() != entries.len() {
11036 return Err(invalid_feed(
11037 "downloaded pack file set differs from the signed snapshot manifest",
11038 ));
11039 }
11040 for (path, bytes) in entries {
11041 let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
11042 return Err(invalid_feed(format!(
11043 "downloaded pack contains unsigned path `{path}`"
11044 )));
11045 };
11046 if *declared_bytes != bytes.len() as u64
11047 || *sha256 != format!("{:x}", Sha256::digest(bytes))
11048 {
11049 return Err(invalid_feed(format!(
11050 "downloaded file `{path}` differs from its signed manifest"
11051 )));
11052 }
11053 }
11054 Ok(())
11055}
11056
11057pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
11064 require_hardened_filesystem("sync push")?;
11065 preflight_push_ownership(store)?;
11066 let mut out: Vec<(String, String)> = Vec::new();
11067 let mut total = 0u64;
11068
11069 let mut read_text = |rel: &str| -> LinkResult<String> {
11070 let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
11071 total = total
11072 .checked_add(bytes.len() as u64)
11073 .ok_or_else(|| LinkError::PushTooLarge {
11074 detail: "uncompressed byte count overflow".to_string(),
11075 })?;
11076 if total > MAX_STORE_BYTES {
11077 return Err(LinkError::PushTooLarge {
11078 detail: format!("{total} uncompressed bytes"),
11079 });
11080 }
11081 String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
11082 path: rel.to_string(),
11083 })
11084 };
11085
11086 out.push(("DB.md".to_string(), read_text("DB.md")?));
11087 if store
11088 .regular_file_exists(Path::new("assets.jsonl"))
11089 .unwrap_or(false)
11090 {
11091 out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
11092 }
11093
11094 for rel in store.walk()? {
11095 let rel_str = rel.to_string_lossy().replace('\\', "/");
11096 if !safe_store_rel_path(&rel_str) {
11097 return Err(LinkError::UnsafePath { path: rel_str });
11100 }
11101 let content = read_text(&rel_str)?;
11102 out.push((rel_str, content));
11103 }
11104
11105 out.sort_by(|a, b| a.0.cmp(&b.0));
11106 Ok(out)
11107}
11108
11109fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
11113 if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
11114 return Err(LinkError::from(std::io::Error::new(
11115 std::io::ErrorKind::PermissionDenied,
11116 format!("cannot push: nested db.md store at {}", nested.display()),
11117 )));
11118 }
11119
11120 if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
11121 return Err(LinkError::from(std::io::Error::new(
11122 std::io::ErrorKind::PermissionDenied,
11123 format!(
11124 "cannot push: {} is a symlink outside the store ownership model",
11125 symlink.display()
11126 ),
11127 )));
11128 }
11129 Ok(())
11130}
11131
11132pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
11138 require_safe_ref(brain)?;
11139 let remote = verified_remote_head(cfg, brain, false)?;
11140 if files.len() > MAX_PUSH_FILES {
11141 return Err(LinkError::PushTooLarge {
11142 detail: format!("{} files", files.len()),
11143 });
11144 }
11145 let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
11146 if raw_total > MAX_STORE_BYTES {
11147 return Err(LinkError::PushTooLarge {
11148 detail: format!("{raw_total} uncompressed bytes"),
11149 });
11150 }
11151
11152 if cfg.brain_key.is_none() {
11156 let body = json!({
11157 "files": files
11158 .iter()
11159 .map(|(p, c)| json!({ "path": p, "content": c }))
11160 .collect::<Vec<_>>(),
11161 });
11162 if body.to_string().len() <= MAX_PUSH_BYTES {
11163 let path = format!("/api/hub/brains/{brain}/push");
11164 let pushed = ensure_ok(
11165 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11166 "sync push",
11167 )?;
11168 return Ok(pushed);
11169 }
11170 }
11171
11172 let pack = build_store_pack(files)?;
11173 if pack.len() as u64 > MAX_PACK_BYTES {
11174 return Err(LinkError::PushTooLarge {
11175 detail: format!("{} pack bytes", pack.len()),
11176 });
11177 }
11178 let sha256 = format!("{:x}", Sha256::digest(&pack));
11179 let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
11180 if let Some(key) = &cfg.brain_key {
11181 if !remote.head.verified {
11182 return Err(invalid_feed(
11183 "self-custody push requires a fully verified, unscoped feed head",
11184 ));
11185 }
11186 let identity = remote
11187 .identity
11188 .as_ref()
11189 .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
11190 let current_multikey = format!("ed25519:{}", identity.fingerprint);
11191 if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
11192 return Err(invalid_feed(
11193 "configured brain key is not the verified current brain identity",
11194 ));
11195 }
11196 let next_seq = remote
11199 .head
11200 .seq
11201 .checked_add(1)
11202 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
11203 let mut manifest: Vec<WireFeedFile> = files
11204 .iter()
11205 .map(|(path, content)| WireFeedFile {
11206 path: path.clone(),
11207 sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
11208 bytes: content.len() as u64,
11209 })
11210 .collect();
11211 manifest.sort_by(|a, b| a.path.cmp(&b.path));
11212 let ts = crate::now()
11213 .with_timezone(&chrono::Utc)
11214 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
11215 .to_string();
11216 let entry = self_custody_entry(
11217 key,
11218 next_seq,
11219 ts,
11220 &sha256,
11221 &manifest,
11222 remote.head.feed_hash.as_deref(),
11223 )?;
11224 meta["entry"] = Value::String(entry);
11225 }
11226 let presigned = ensure_ok(
11227 request(
11228 cfg,
11229 "POST",
11230 &format!("/api/hub/brains/{brain}/packs/presign"),
11231 Some(&meta),
11232 Auth::Required,
11233 )?,
11234 "prepare pack upload",
11235 )?;
11236 let url = presigned
11237 .get("url")
11238 .and_then(Value::as_str)
11239 .ok_or_else(|| LinkError::InvalidPack {
11240 message: "the hub returned no upload URL".to_string(),
11241 })?;
11242 put_presigned(
11243 cfg,
11244 url,
11245 presigned.get("headers").unwrap_or(&Value::Null),
11246 &pack,
11247 )?;
11248 let committed = ensure_ok(
11249 request(
11250 cfg,
11251 "POST",
11252 &format!("/api/hub/brains/{brain}/packs/commit"),
11253 Some(&meta),
11254 Auth::Required,
11255 )?,
11256 "commit pack",
11257 )?;
11258 Ok(committed)
11259}
11260
11261fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
11262 const LOCAL_HEADER: u32 = 0x0403_4b50;
11263 const CENTRAL_HEADER: u32 = 0x0201_4b50;
11264 const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
11265 const VERSION_20: u16 = 20;
11266 const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
11267 const UTF8_FLAG: u16 = 1 << 11;
11268 const STORED: u16 = 0;
11269 const DOS_TIME_MIDNIGHT: u16 = 0;
11270 const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
11271 const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
11272
11273 struct CentralEntry<'a> {
11274 name: &'a [u8],
11275 crc32: u32,
11276 size: u32,
11277 local_offset: u32,
11278 }
11279
11280 fn push_u16(out: &mut Vec<u8>, value: u16) {
11281 out.extend_from_slice(&value.to_le_bytes());
11282 }
11283
11284 fn push_u32(out: &mut Vec<u8>, value: u32) {
11285 out.extend_from_slice(&value.to_le_bytes());
11286 }
11287
11288 if files.is_empty() {
11289 return Err(LinkError::InvalidPack {
11290 message: "cannot create an empty snapshot pack".to_string(),
11291 });
11292 }
11293 if files.len() > u16::MAX as usize {
11294 return Err(LinkError::PushTooLarge {
11295 detail: format!(
11296 "{} files (canonical ZIP32 packs cap at {})",
11297 files.len(),
11298 u16::MAX
11299 ),
11300 });
11301 }
11302
11303 let mut sorted: Vec<_> = files.iter().collect();
11304 sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
11305 let mut previous: Option<&str> = None;
11306 for (path, content) in &sorted {
11307 if !safe_store_rel_path(path) {
11308 return Err(LinkError::UnsafePath {
11309 path: (*path).clone(),
11310 });
11311 }
11312 if previous == Some(path.as_str()) {
11313 return Err(LinkError::InvalidPack {
11314 message: format!("duplicate path `{path}`"),
11315 });
11316 }
11317 previous = Some(path.as_str());
11318 u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
11319 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11320 })?;
11321 }
11322
11323 let mut out = Vec::new();
11324 let mut central = Vec::with_capacity(sorted.len());
11325 for (path, content) in sorted {
11326 let name = path.as_bytes();
11327 let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
11328 message: format!("ZIP entry name is too long: `{path}`"),
11329 })?;
11330 let bytes = content.as_bytes();
11331 let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
11332 detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11333 })?;
11334 let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11335 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11336 })?;
11337 let crc32 = crc32fast::hash(bytes);
11338
11339 push_u32(&mut out, LOCAL_HEADER);
11342 push_u16(&mut out, VERSION_20);
11343 push_u16(&mut out, UTF8_FLAG);
11344 push_u16(&mut out, STORED);
11345 push_u16(&mut out, DOS_TIME_MIDNIGHT);
11346 push_u16(&mut out, DOS_DATE_1980_01_01);
11347 push_u32(&mut out, crc32);
11348 push_u32(&mut out, size);
11349 push_u32(&mut out, size);
11350 push_u16(&mut out, name_len);
11351 push_u16(&mut out, 0); out.extend_from_slice(name);
11353 out.extend_from_slice(bytes);
11354
11355 central.push(CentralEntry {
11356 name,
11357 crc32,
11358 size,
11359 local_offset,
11360 });
11361 }
11362
11363 let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11364 detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11365 })?;
11366 for entry in ¢ral {
11367 push_u32(&mut out, CENTRAL_HEADER);
11368 push_u16(&mut out, MADE_BY_UNIX_20);
11369 push_u16(&mut out, VERSION_20);
11370 push_u16(&mut out, UTF8_FLAG);
11371 push_u16(&mut out, STORED);
11372 push_u16(&mut out, DOS_TIME_MIDNIGHT);
11373 push_u16(&mut out, DOS_DATE_1980_01_01);
11374 push_u32(&mut out, entry.crc32);
11375 push_u32(&mut out, entry.size);
11376 push_u32(&mut out, entry.size);
11377 push_u16(&mut out, entry.name.len() as u16);
11378 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);
11383 push_u32(&mut out, entry.local_offset);
11384 out.extend_from_slice(entry.name);
11385 }
11386 let central_size = u32::try_from(out.len())
11387 .ok()
11388 .and_then(|end| end.checked_sub(central_offset))
11389 .ok_or_else(|| LinkError::PushTooLarge {
11390 detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
11391 })?;
11392 let entry_count = central.len() as u16;
11393
11394 push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
11395 push_u16(&mut out, 0); push_u16(&mut out, 0); push_u16(&mut out, entry_count);
11398 push_u16(&mut out, entry_count);
11399 push_u32(&mut out, central_size);
11400 push_u32(&mut out, central_offset);
11401 push_u16(&mut out, 0); if out.len() > u32::MAX as usize {
11404 return Err(LinkError::PushTooLarge {
11405 detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
11406 });
11407 }
11408 Ok(out)
11409}
11410
11411#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11417pub enum Capability {
11418 Read,
11420 Write,
11422}
11423
11424impl Capability {
11425 pub fn as_str(self) -> &'static str {
11427 match self {
11428 Capability::Read => "read",
11429 Capability::Write => "write",
11430 }
11431 }
11432}
11433
11434pub fn grant_issue(
11440 cfg: &HubConfig,
11441 brain: &str,
11442 grantee: &str,
11443 can: Capability,
11444 scope: Option<&str>,
11445 until: Option<&str>,
11446) -> LinkResult<Value> {
11447 require_safe_ref(brain)?;
11448 let is_key_grantee = URL_SAFE_NO_PAD
11453 .decode(grantee)
11454 .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
11455 .unwrap_or(false);
11456 if let Some(head) = v2_verified_head(cfg, brain)? {
11457 if is_key_grantee {
11458 let scope = scope.unwrap_or("");
11459 let preset = match can {
11460 Capability::Read => "viewer",
11461 Capability::Write => "editor",
11462 };
11463 let entropy = format!(
11464 "{}\0{}\0{}\0{}\0{}\0{}\0{}",
11465 normalized_origin(&cfg.hub)?,
11466 head.brain_id,
11467 head.control_revision,
11468 grantee,
11469 preset,
11470 scope,
11471 until.unwrap_or("")
11472 );
11473 let mut body = json!({
11474 "context": "external",
11475 "expected_control_revision": head.control_revision,
11476 "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
11477 "preset": preset,
11478 "principal_kind": "key",
11479 "public_key": grantee,
11480 "scope": scope,
11481 "scope_kind": "prefix",
11482 });
11483 if let Some(value) = until {
11484 body["expires_at"] = json!(value);
11485 }
11486 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11487 let response = ensure_ok(
11488 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11489 "v2 grant issue",
11490 )?;
11491 let expected_fingerprint = identity_fingerprint(grantee)?;
11492 if response.get("v").and_then(Value::as_u64) != Some(2)
11493 || response
11494 .get("id")
11495 .and_then(Value::as_str)
11496 .is_none_or(|id| !crate::ulid::is_ulid(id))
11497 || response.get("principal_kind").and_then(Value::as_str) != Some("key")
11498 || response.get("principal_id").and_then(Value::as_str)
11499 != Some(expected_fingerprint.as_str())
11500 || response
11501 .get("control_revision")
11502 .and_then(Value::as_str)
11503 .is_none_or(|value| !is_sha256(value))
11504 {
11505 return Err(invalid_feed(
11506 "v2 grant issue response is not authority-bound",
11507 ));
11508 }
11509 return Ok(response);
11510 }
11511 let mut body = json!({ "email": grantee, "capability": can.as_str() });
11517 if let Some(value) = scope {
11518 body["scopePrefix"] = json!(value);
11519 }
11520 if let Some(value) = until {
11521 body["expiresAt"] = json!(value);
11522 }
11523 let path = format!("/api/hub/brains/{}/grants", head.brain_id);
11524 return ensure_ok(
11525 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11526 "account grant issue",
11527 );
11528 }
11529 let _ = verified_remote_head(cfg, brain, false)?;
11530 let mut body = if is_key_grantee {
11531 json!({ "keySpki": grantee, "capability": can.as_str() })
11532 } else {
11533 json!({ "email": grantee, "capability": can.as_str() })
11534 };
11535 if let Some(s) = scope {
11536 body["scopePrefix"] = json!(s);
11537 }
11538 if let Some(u) = until {
11539 body["expiresAt"] = json!(u);
11540 }
11541 let path = format!("/api/hub/brains/{brain}/grants");
11542 ensure_ok(
11543 request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11544 "grant issue",
11545 )
11546}
11547
11548pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
11550 require_safe_ref(brain)?;
11551 if let Some(head) = v2_verified_head(cfg, brain)? {
11552 let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11553 let response = ensure_ok(
11554 request(cfg, "GET", &path, None, Auth::Required)?,
11555 "v2 grant list",
11556 )?;
11557 if response.get("v").and_then(Value::as_u64) != Some(2)
11558 || response.get("control_revision").and_then(Value::as_str)
11559 != Some(head.control_revision.as_str())
11560 || !response.get("grants").is_some_and(Value::is_array)
11561 {
11562 return Err(invalid_feed(
11563 "v2 grant list is not bound to the verified authority",
11564 ));
11565 }
11566 return Ok(response);
11567 }
11568 let _ = verified_remote_head(cfg, brain, false)?;
11569 let path = format!("/api/hub/brains/{brain}/grants");
11570 ensure_ok(
11571 request(cfg, "GET", &path, None, Auth::Required)?,
11572 "grant list",
11573 )
11574}
11575
11576pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
11579 require_safe_ref(brain)?;
11580 require_safe_grant_id(grant_id)?;
11581 if let Some(head) = v2_verified_head(cfg, brain)? {
11582 let entropy = format!(
11583 "{}\0{}\0{}\0{}",
11584 normalized_origin(&cfg.hub)?,
11585 head.brain_id,
11586 head.control_revision,
11587 grant_id
11588 );
11589 let body = json!({
11590 "expected_control_revision": head.control_revision,
11591 "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
11592 });
11593 let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
11594 let response = ensure_ok(
11595 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11596 "v2 grant revoke",
11597 )?;
11598 if response.get("v").and_then(Value::as_u64) != Some(2)
11599 || response.get("id").and_then(Value::as_str) != Some(grant_id)
11600 || response.get("revoked").and_then(Value::as_bool) != Some(true)
11601 || response
11602 .get("control_revision")
11603 .and_then(Value::as_str)
11604 .is_none_or(|value| !is_sha256(value))
11605 {
11606 return Err(invalid_feed(
11607 "v2 grant revocation response is not authority-bound",
11608 ));
11609 }
11610 return Ok(response);
11611 }
11612 let _ = verified_remote_head(cfg, brain, false)?;
11613 let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
11614 ensure_ok(
11615 request(cfg, "DELETE", &path, None, Auth::Required)?,
11616 "grant revoke",
11617 )
11618}
11619
11620#[derive(Debug)]
11625struct VerifiedV2Proposal {
11626 value: Value,
11627 changes: Value,
11628 blobs: Vec<(String, u64, String)>,
11629}
11630
11631fn require_proposal_id(id: &str) -> LinkResult<()> {
11632 if crate::ulid::is_ulid(id) {
11633 Ok(())
11634 } else {
11635 Err(invalid_feed("proposal id is not a lowercase ULID"))
11636 }
11637}
11638
11639fn verified_v2_proposal(
11640 cfg: &HubConfig,
11641 head: &V2VerifiedHead,
11642 proposal_id: &str,
11643) -> LinkResult<VerifiedV2Proposal> {
11644 require_proposal_id(proposal_id)?;
11645 if head.view_kind != "full" {
11646 return Err(invalid_feed(
11647 "proposal review requires a full readable view",
11648 ));
11649 }
11650 let path = format!(
11651 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11652 head.brain_id
11653 );
11654 let value = ensure_ok(
11655 request_capped(
11656 cfg,
11657 "GET",
11658 &path,
11659 None,
11660 Auth::Required,
11661 MAX_FEED_RESPONSE_BYTES,
11662 )?,
11663 "v2 proposal",
11664 )?;
11665 verify_v2_proposal_value(head, proposal_id, value)
11666}
11667
11668fn verify_v2_proposal_value(
11669 head: &V2VerifiedHead,
11670 proposal_id: &str,
11671 value: Value,
11672) -> LinkResult<VerifiedV2Proposal> {
11673 if value.get("v").and_then(Value::as_u64) != Some(2) {
11674 return Err(invalid_feed("proposal response has an invalid version"));
11675 }
11676 let proposal = value
11677 .get("proposal")
11678 .and_then(Value::as_object)
11679 .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
11680 if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
11681 return Err(invalid_feed("proposal response changed its id"));
11682 }
11683 let payload_hash = proposal
11684 .get("payload_sha256")
11685 .and_then(Value::as_str)
11686 .filter(|hash| is_sha256(hash))
11687 .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
11688 let clear_hash = proposal
11689 .get("clear_sha256")
11690 .and_then(Value::as_str)
11691 .filter(|hash| is_sha256(hash))
11692 .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
11693 let submission_hash = proposal
11694 .get("submission_claim_sha256")
11695 .and_then(Value::as_str)
11696 .filter(|hash| is_sha256(hash))
11697 .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
11698 let submission = STANDARD
11699 .decode(
11700 proposal
11701 .get("submission_claim_base64")
11702 .and_then(Value::as_str)
11703 .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
11704 )
11705 .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
11706 let submission_value: Value = serde_json::from_slice(&submission)
11707 .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
11708 if crate::linkmd_v2::canonical_bytes(&submission_value)
11709 .map_err(|error| invalid_feed(error.to_string()))?
11710 != submission
11711 || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
11712 .map_err(|error| invalid_feed(error.to_string()))?
11713 != submission_hash
11714 {
11715 return Err(invalid_feed(
11716 "proposal submission claim is not canonical or addressed",
11717 ));
11718 }
11719 let envelope = submission_value
11720 .as_object()
11721 .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
11722 let claim = envelope
11723 .get("claim")
11724 .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
11725 let claim_object = claim
11726 .as_object()
11727 .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
11728 let actor_root = claim_object
11729 .get("actor_root")
11730 .and_then(Value::as_object)
11731 .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
11732 let public_key = envelope
11733 .get("public_key")
11734 .and_then(Value::as_str)
11735 .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
11736 let fingerprint = envelope
11737 .get("fingerprint")
11738 .and_then(Value::as_str)
11739 .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
11740 let signature = envelope
11741 .get("sig")
11742 .and_then(Value::as_str)
11743 .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
11744 let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
11745 .map_err(|error| invalid_feed(error.to_string()))?;
11746 let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
11747 let signer = format!("{fingerprint}:{public_key}");
11748 let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
11749 let grants = actor_root.get("grants").and_then(Value::as_array);
11750 let grants_are_canonical = grants.is_some_and(|items| {
11751 let mut prior: Option<&str> = None;
11752 items.iter().all(|item| {
11753 let Some(grant) = item.as_str() else {
11754 return false;
11755 };
11756 if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
11757 return false;
11758 }
11759 prior = Some(grant);
11760 true
11761 })
11762 });
11763 let optional_actor_field = |name: &str| {
11764 actor_root.get(name).is_some_and(|value| {
11765 value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
11766 })
11767 };
11768 let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
11769 if claim_object.get("v").and_then(Value::as_u64) != Some(2)
11770 || format!("{:x}", Sha256::digest(&der)) != fingerprint
11771 || head
11772 .trust
11773 .hub_signer
11774 .as_ref()
11775 .is_some_and(|known| known != &signer)
11776 || !matches!(
11777 actor_class,
11778 Some(
11779 "user"
11780 | "owned_agent"
11781 | "foreign_key"
11782 | "curation"
11783 | "inbox"
11784 | "restore"
11785 | "migration"
11786 | "operator_recovery"
11787 )
11788 )
11789 || actor_root
11790 .get("principal")
11791 .and_then(Value::as_str)
11792 .is_none_or(|value| value.is_empty())
11793 || actor_root
11794 .get("credential")
11795 .and_then(Value::as_str)
11796 .is_none_or(|value| value.is_empty())
11797 || !optional_actor_field("organization")
11798 || !optional_actor_field("role")
11799 || !grants_are_canonical
11800 || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
11801 || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11802 || !claim_object
11803 .get("mutation_id")
11804 .and_then(Value::as_str)
11805 .is_some_and(|value| {
11806 !value.is_empty()
11807 && value.len() <= 128
11808 && value.chars().enumerate().all(|(index, char)| {
11809 char.is_ascii_alphanumeric()
11810 || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
11811 })
11812 })
11813 || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
11814 || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
11815 || !claim_object
11816 .get("control_revision")
11817 .and_then(Value::as_str)
11818 .is_some_and(is_sha256)
11819 || submitted_at.is_none_or(|value| {
11820 chrono::DateTime::parse_from_rfc3339(value).is_err()
11821 || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
11822 })
11823 || !proposal
11824 .get("state")
11825 .and_then(Value::as_str)
11826 .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
11827 || proposal
11828 .get("expires_at")
11829 .and_then(Value::as_str)
11830 .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
11831 || proposal
11832 .get("proposer")
11833 .and_then(Value::as_object)
11834 .and_then(|value| value.get("class"))
11835 .and_then(Value::as_str)
11836 != actor_class
11837 {
11838 return Err(invalid_feed(
11839 "proposal submission claim does not bind the verified proposal",
11840 ));
11841 }
11842 let changes_b64 = proposal
11843 .get("changes_base64")
11844 .and_then(Value::as_str)
11845 .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
11846 let changes_bytes = STANDARD
11847 .decode(changes_b64)
11848 .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
11849 let changes: Value = serde_json::from_slice(&changes_bytes)
11850 .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
11851 if crate::linkmd_v2::canonical_bytes(&changes)
11852 .map_err(|error| invalid_feed(error.to_string()))?
11853 != changes_bytes
11854 || changes.get("v").and_then(Value::as_u64) != Some(2)
11855 || !changes.get("operations").is_some_and(Value::is_array)
11856 {
11857 return Err(invalid_feed("proposal changeset is not canonical v2"));
11858 }
11859 let blob_values = proposal
11860 .get("blobs")
11861 .and_then(Value::as_array)
11862 .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
11863 let mut blobs = Vec::with_capacity(blob_values.len());
11864 let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
11865 let mut prior_hash: Option<String> = None;
11866 for item in blob_values {
11867 let hash = item
11868 .get("sha256")
11869 .and_then(Value::as_str)
11870 .filter(|hash| is_sha256(hash))
11871 .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
11872 let bytes = item
11873 .get("bytes")
11874 .and_then(Value::as_u64)
11875 .filter(|bytes| *bytes <= MAX_STORE_BYTES)
11876 .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
11877 if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
11878 return Err(invalid_feed(
11879 "proposal blob declarations are not unique and sorted",
11880 ));
11881 }
11882 prior_hash = Some(hash.to_string());
11883 let endpoint = item
11884 .get("endpoint")
11885 .and_then(Value::as_str)
11886 .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
11887 let expected_endpoint = format!(
11888 "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
11889 head.brain_id
11890 );
11891 if endpoint != expected_endpoint {
11892 return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
11893 }
11894 descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
11895 blobs.push((hash.to_string(), bytes, endpoint.to_string()));
11896 }
11897 let descriptor = json!({
11898 "base": proposal.get("base").cloned().unwrap_or(Value::Null),
11899 "blobs": descriptor_blobs,
11900 "changes_base64": changes_b64,
11901 "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
11902 "v": 2,
11903 });
11904 let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
11905 .map_err(|error| invalid_feed(error.to_string()))?;
11906 if content_sha256(&descriptor_bytes) != clear_hash {
11907 return Err(invalid_feed(
11908 "proposal clear payload differs from its signed submission claim",
11909 ));
11910 }
11911 Ok(VerifiedV2Proposal {
11912 value,
11913 changes,
11914 blobs,
11915 })
11916}
11917
11918pub fn proposal_list(
11919 cfg: &HubConfig,
11920 brain: &str,
11921 state: &str,
11922 after: Option<&str>,
11923 limit: usize,
11924) -> LinkResult<Value> {
11925 require_safe_ref(brain)?;
11926 if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11927 return Err(invalid_feed("proposal state is invalid"));
11928 }
11929 if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11930 return Err(invalid_feed("proposal cursor is invalid"));
11931 }
11932 let head = v2_verified_head(cfg, brain)?
11933 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11934 let path = format!(
11935 "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11936 head.brain_id,
11937 limit.clamp(1, 100),
11938 after.map_or_else(String::new, |value| format!("&after={value}"))
11939 );
11940 ensure_ok(
11941 request_capped(
11942 cfg,
11943 "GET",
11944 &path,
11945 None,
11946 Auth::Required,
11947 MAX_FEED_RESPONSE_BYTES,
11948 )?,
11949 "v2 proposal list",
11950 )
11951}
11952
11953pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11954 require_safe_ref(brain)?;
11955 let head = v2_verified_head(cfg, brain)?
11956 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11957 Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11958}
11959
11960pub fn proposal_reject(
11961 cfg: &HubConfig,
11962 brain: &str,
11963 proposal_id: &str,
11964 mutation_id: &str,
11965 reason: &str,
11966) -> LinkResult<Value> {
11967 require_safe_ref(brain)?;
11968 require_proposal_id(proposal_id)?;
11969 let head = v2_verified_head(cfg, brain)?
11970 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11971 let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
11972 let body = json!({
11973 "mutation_id": mutation_id,
11974 "control_revision": head.control_revision,
11975 "reason": reason,
11976 });
11977 let path = format!(
11978 "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11979 head.brain_id
11980 );
11981 ensure_ok(
11982 request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11983 "v2 proposal rejection",
11984 )
11985}
11986
11987pub fn proposal_accept_exact(
11988 cfg: &HubConfig,
11989 brain: &str,
11990 proposal_id: &str,
11991 mutation_id: &str,
11992 reason: &str,
11993) -> LinkResult<Value> {
11994 require_safe_ref(brain)?;
11995 require_proposal_id(proposal_id)?;
11996 let head = v2_verified_head(cfg, brain)?
11997 .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11998 let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
11999 let operations = proposal
12000 .changes
12001 .get("operations")
12002 .and_then(Value::as_array)
12003 .cloned()
12004 .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
12005 if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
12006 return Err(invalid_feed("proposal operation count is invalid"));
12007 }
12008 let mut downloaded = std::collections::BTreeMap::new();
12009 for (hash, bytes, endpoint) in &proposal.blobs {
12010 let body = ensure_raw_ok(
12011 request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
12012 "v2 proposal blob",
12013 )?;
12014 if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
12015 return Err(invalid_feed("proposal blob does not match its declaration"));
12016 }
12017 downloaded.insert(hash.clone(), body);
12018 }
12019 let remote = files_for_v2_view(
12020 &head,
12021 v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
12022 );
12023 let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
12024 let mut expected_candidate = remote.clone();
12025 let mut expected_candidate_assets = remote_assets;
12026 for operation in &operations {
12027 let op = operation
12028 .get("op")
12029 .and_then(Value::as_str)
12030 .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
12031 match op {
12032 "put" | "restore" => {
12033 let path = operation
12034 .get("path")
12035 .and_then(Value::as_str)
12036 .ok_or_else(|| invalid_feed("proposal write has no path"))?;
12037 crate::linkmd_v2::normalize_path(path)
12038 .map_err(|error| invalid_feed(error.to_string()))?;
12039 let hash = operation
12040 .get("blob")
12041 .and_then(Value::as_str)
12042 .filter(|hash| is_sha256(hash))
12043 .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
12044 let bytes = operation
12045 .get("bytes")
12046 .and_then(Value::as_u64)
12047 .ok_or_else(|| invalid_feed("proposal write has no size"))?;
12048 expected_candidate.insert(
12049 path.to_string(),
12050 V2BaselineFile {
12051 sha256: hash.to_string(),
12052 bytes,
12053 proof: None,
12054 },
12055 );
12056 }
12057 "delete" | "withdraw_from_hosting" => {
12058 let path = operation
12059 .get("path")
12060 .and_then(Value::as_str)
12061 .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
12062 crate::linkmd_v2::normalize_path(path)
12063 .map_err(|error| invalid_feed(error.to_string()))?;
12064 expected_candidate.remove(path);
12065 }
12066 "rename" => {
12067 let from = operation
12068 .get("from")
12069 .and_then(Value::as_str)
12070 .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
12071 let to = operation
12072 .get("to")
12073 .and_then(Value::as_str)
12074 .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
12075 crate::linkmd_v2::normalize_path(from)
12076 .and_then(|_| crate::linkmd_v2::normalize_path(to))
12077 .map_err(|error| invalid_feed(error.to_string()))?;
12078 let hash = operation
12079 .get("blob")
12080 .and_then(Value::as_str)
12081 .filter(|hash| is_sha256(hash))
12082 .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
12083 let bytes = operation
12084 .get("bytes")
12085 .and_then(Value::as_u64)
12086 .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
12087 expected_candidate.remove(from);
12088 expected_candidate.insert(
12089 to.to_string(),
12090 V2BaselineFile {
12091 sha256: hash.to_string(),
12092 bytes,
12093 proof: None,
12094 },
12095 );
12096 }
12097 "asset_delete" => {
12098 let path = operation
12099 .get("path")
12100 .and_then(Value::as_str)
12101 .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
12102 expected_candidate_assets.remove(path);
12103 }
12104 "asset_withdraw" => {
12105 let path = operation
12106 .get("path")
12107 .and_then(Value::as_str)
12108 .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
12109 let asset = expected_candidate_assets
12110 .get_mut(path)
12111 .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
12112 asset.disposition = "withheld".to_string();
12113 asset.leaf_hash.clear();
12114 }
12115 "asset_put" | "asset_resume" => {
12116 let path = operation
12117 .get("path")
12118 .and_then(Value::as_str)
12119 .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
12120 let asset = operation
12121 .get("asset")
12122 .and_then(Value::as_object)
12123 .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
12124 let blob_sha256 = asset
12125 .get("blob_sha256")
12126 .and_then(Value::as_str)
12127 .filter(|hash| is_sha256(hash))
12128 .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12129 let bytes = asset
12130 .get("bytes")
12131 .and_then(Value::as_u64)
12132 .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12133 let media_type = asset
12134 .get("media_type")
12135 .and_then(Value::as_str)
12136 .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12137 let wrappers = asset
12138 .get("wrappers")
12139 .and_then(Value::as_array)
12140 .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12141 .iter()
12142 .map(|wrapper| {
12143 wrapper
12144 .as_str()
12145 .map(str::to_string)
12146 .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12147 })
12148 .collect::<LinkResult<Vec<_>>>()?;
12149 let required = asset
12150 .get("required")
12151 .and_then(Value::as_bool)
12152 .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12153 let disposition = asset
12154 .get("disposition")
12155 .and_then(Value::as_str)
12156 .filter(|value| matches!(*value, "hosted" | "withheld"))
12157 .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
12158 expected_candidate_assets.insert(
12159 path.to_string(),
12160 V2BaselineAsset {
12161 blob_sha256: blob_sha256.to_string(),
12162 bytes,
12163 media_type: media_type.to_string(),
12164 wrappers,
12165 required,
12166 disposition: disposition.to_string(),
12167 leaf_hash: String::new(),
12168 },
12169 );
12170 }
12171 _ => return Err(invalid_feed("proposal operation kind is unsupported")),
12172 }
12173 }
12174 let base = head.pointer.as_ref().map(|pointer| {
12175 json!({
12176 "seq": pointer.seq,
12177 "commit_hash": pointer.commit_hash,
12178 "content_root": pointer.content_root,
12179 "asset_root": pointer.asset_root,
12180 })
12181 });
12182 let mut body = json!({
12183 "mutation_id": mutation_id,
12184 "base": base,
12185 "rebase": "strict",
12186 "reason": reason,
12187 "operations": operations,
12188 "blobs": downloaded
12189 .iter()
12190 .map(|(sha256, bytes)| json!({
12191 "sha256": sha256,
12192 "bytes": bytes.len(),
12193 "content_base64": STANDARD.encode(bytes),
12194 }))
12195 .collect::<Vec<_>>(),
12196 "proposal_id": proposal_id,
12197 "proposal_mode": "exact",
12198 });
12199 let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
12200 total
12201 .checked_add(bytes.len())
12202 .ok_or_else(|| LinkError::PushTooLarge {
12203 detail: "proposal changed-byte total overflow".to_string(),
12204 })
12205 })?;
12206 if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
12207 let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12208 for operation in &operations {
12209 let Some(kind) = operation.get("op").and_then(Value::as_str) else {
12210 return Err(invalid_feed("proposal upload operation has no kind"));
12211 };
12212 let hash = match kind {
12213 "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
12214 "asset_put" | "asset_resume" => operation
12215 .get("asset")
12216 .and_then(|asset| asset.get("blob_sha256"))
12217 .and_then(Value::as_str),
12218 _ => None,
12219 };
12220 let Some(hash) = hash else { continue };
12221 let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
12222 if kind == "rename" {
12223 for field in ["from", "to"] {
12224 coordinates.insert(
12225 operation
12226 .get(field)
12227 .and_then(Value::as_str)
12228 .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
12229 .to_string(),
12230 );
12231 }
12232 } else {
12233 let path = operation
12234 .get("path")
12235 .and_then(Value::as_str)
12236 .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
12237 coordinates.insert(if kind.starts_with("asset_") {
12238 format!("assets/{path}")
12239 } else {
12240 path.to_string()
12241 });
12242 }
12243 }
12244 let declarations = downloaded
12245 .iter()
12246 .map(|(sha256, bytes)| {
12247 json!({
12248 "sha256": sha256,
12249 "bytes": bytes.len(),
12250 "coordinates": coordinates_by_hash
12251 .get(sha256)
12252 .into_iter()
12253 .flatten()
12254 .collect::<Vec<_>>(),
12255 })
12256 })
12257 .collect::<Vec<_>>();
12258 let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
12259 for batch in batch_upload_declarations(declarations) {
12260 let reserved = reserve_upload_window(
12261 cfg,
12262 &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
12263 &json!({ "blobs": batch }),
12264 "prepare proposal blob transport",
12265 )?;
12266 let reserved_items = reserved
12267 .get("uploads")
12268 .and_then(Value::as_array)
12269 .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
12270 items.extend(reserved_items.iter().cloned());
12271 }
12272 if items.len() != downloaded.len() {
12273 return Err(invalid_feed("proposal upload reservation changed the set"));
12274 }
12275 let mut references = Vec::with_capacity(items.len());
12276 for item in items {
12277 let hash = item
12278 .get("sha256")
12279 .and_then(Value::as_str)
12280 .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
12281 let bytes = downloaded
12282 .get(hash)
12283 .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
12284 let reservation_id = item
12285 .get("reservation_id")
12286 .and_then(Value::as_str)
12287 .filter(|id| crate::ulid::is_ulid(id))
12288 .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
12289 let expected_coordinates = coordinates_by_hash
12290 .get(hash)
12291 .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
12292 let returned_coordinates = item
12293 .get("coordinates")
12294 .and_then(Value::as_array)
12295 .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
12296 if returned_coordinates.len() != expected_coordinates.len()
12297 || returned_coordinates
12298 .iter()
12299 .zip(expected_coordinates)
12300 .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
12301 {
12302 return Err(invalid_feed(
12303 "proposal upload reservation changed its coordinates",
12304 ));
12305 }
12306 match item.get("status").and_then(Value::as_str) {
12307 Some("upload") => put_presigned(
12308 cfg,
12309 item.get("url")
12310 .and_then(Value::as_str)
12311 .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
12312 item.get("headers").unwrap_or(&Value::Null),
12313 bytes,
12314 )?,
12315 Some("already_present") => {}
12316 _ => return Err(invalid_feed("proposal upload status is invalid")),
12317 }
12318 references.push(json!({
12319 "sha256": hash,
12320 "bytes": bytes.len(),
12321 "reservation_id": reservation_id,
12322 }));
12323 }
12324 body["blobs"] = Value::Array(references);
12325 }
12326 stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
12330 let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
12331 let mut result = ensure_ok(
12332 request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
12333 "exact proposal acceptance",
12334 )?;
12335 let mut candidate_hub_signer = None;
12336 if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
12337 let request_id = result
12338 .get("request_id")
12339 .and_then(Value::as_str)
12340 .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
12341 .to_string();
12342 let challenge = result
12343 .get("signing_challenge")
12344 .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
12345 let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
12346 cfg,
12347 &head,
12348 &expected_candidate,
12349 &expected_candidate_assets,
12350 mutation_id,
12351 &v2_signed_request_view(&body, &operations),
12352 challenge,
12353 )?;
12354 body["signing_challenge_id"] = Value::String(challenge_id);
12355 body["signature_base64url"] = Value::String(signature);
12356 candidate_hub_signer = Some(actor_signer);
12357 result = ensure_ok(
12358 request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
12359 "signed exact proposal acceptance",
12360 )?;
12361 }
12362 let refreshed = v2_verified_head(cfg, brain)?
12363 .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
12364 if candidate_hub_signer
12365 .as_ref()
12366 .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
12367 || refreshed
12368 .pointer
12369 .as_ref()
12370 .map(|pointer| pointer.commit_hash.as_str())
12371 != result.get("commit_hash").and_then(Value::as_str)
12372 || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12373 || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
12374 {
12375 return Err(LinkError::RemoteAdvancedDuringSync);
12376 }
12377 accept_v2_head(cfg, &refreshed)?;
12378 Ok(result)
12379}
12380
12381pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
12392 require_valid_handle(handle)?;
12393 if body.len() as u64 > MAX_PROPOSE_BYTES {
12394 return Err(LinkError::ProposeTooLarge {
12395 bytes: body.len() as u64,
12396 });
12397 }
12398 let payload = json!({ "app": app, "body": body });
12399 let (path, auth) = if crate::ulid::is_ulid(handle) {
12404 (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
12405 } else {
12406 (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
12407 };
12408 ensure_ok(
12409 request(cfg, "POST", &path, Some(&payload), auth)?,
12410 "propose",
12411 )
12412}
12413
12414#[derive(Debug, serde::Serialize)]
12420pub struct Head {
12421 pub brain: String,
12423 pub seq: u64,
12425 #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
12427 pub updated_at: Option<String>,
12428 #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
12430 pub feed_hash: Option<String>,
12431 pub verified: bool,
12434}
12435
12436struct BoundedVecVisitor<T, const MAX: usize> {
12437 label: &'static str,
12438 marker: std::marker::PhantomData<T>,
12439}
12440
12441impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
12442where
12443 T: Deserialize<'de>,
12444{
12445 type Value = Vec<T>;
12446
12447 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12448 write!(formatter, "at most {MAX} {}", self.label)
12449 }
12450
12451 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
12452 where
12453 A: serde::de::SeqAccess<'de>,
12454 {
12455 if sequence.size_hint().is_some_and(|size| size > MAX) {
12456 return Err(serde::de::Error::custom(format!(
12457 "{} exceeds the {MAX}-item limit",
12458 self.label
12459 )));
12460 }
12461 let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
12462 while let Some(value) = sequence.next_element()? {
12463 if values.len() == MAX {
12464 return Err(serde::de::Error::custom(format!(
12465 "{} exceeds the {MAX}-item limit",
12466 self.label
12467 )));
12468 }
12469 values.push(value);
12470 }
12471 Ok(values)
12472 }
12473}
12474
12475fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
12476 deserializer: D,
12477 label: &'static str,
12478) -> Result<Vec<T>, D::Error>
12479where
12480 D: serde::Deserializer<'de>,
12481 T: Deserialize<'de>,
12482{
12483 deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
12484 label,
12485 marker: std::marker::PhantomData,
12486 })
12487}
12488
12489fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
12490where
12491 D: serde::Deserializer<'de>,
12492{
12493 deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
12494}
12495
12496fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12497where
12498 D: serde::Deserializer<'de>,
12499{
12500 deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
12501}
12502
12503fn deserialize_previous_identities<'de, D>(
12504 deserializer: D,
12505) -> Result<Vec<PreviousIdentity>, D::Error>
12506where
12507 D: serde::Deserializer<'de>,
12508{
12509 deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
12510 deserializer,
12511 "previous identities",
12512 )
12513}
12514
12515fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12516where
12517 D: serde::Deserializer<'de>,
12518{
12519 deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
12520 deserializer,
12521 "rotation statements",
12522 )
12523}
12524
12525fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
12526where
12527 D: serde::Deserializer<'de>,
12528{
12529 deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
12530}
12531
12532#[derive(Debug, Clone, Deserialize, Serialize)]
12533struct FeedFile {
12534 path: String,
12535 sha256: String,
12536 bytes: u64,
12537}
12538
12539#[cfg(test)]
12540#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12541enum V1DisclosureError {
12542 DuplicateFile,
12543 DuplicateRemoved,
12544 PushManifestMismatch,
12545 EditMissingChange,
12546 EditFalseFile,
12547 RemovedMismatch,
12548}
12549
12550#[cfg(test)]
12554fn verify_v1_manifest_disclosure(
12555 kind: &str,
12556 previous: &[FeedFile],
12557 resulting: &[FeedFile],
12558 files: &[FeedFile],
12559 removed: &[String],
12560) -> Result<(), V1DisclosureError> {
12561 fn as_map(
12562 files: &[FeedFile],
12563 ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
12564 let mut result = std::collections::BTreeMap::new();
12565 for file in files {
12566 if result
12567 .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
12568 .is_some()
12569 {
12570 return Err(V1DisclosureError::DuplicateFile);
12571 }
12572 }
12573 Ok(result)
12574 }
12575 let previous = as_map(previous)?;
12576 let resulting = as_map(resulting)?;
12577 let disclosed = as_map(files)?;
12578 let removed_set: std::collections::BTreeSet<&str> =
12579 removed.iter().map(String::as_str).collect();
12580 if removed_set.len() != removed.len() {
12581 return Err(V1DisclosureError::DuplicateRemoved);
12582 }
12583 let expected_removed: std::collections::BTreeSet<&str> = previous
12584 .keys()
12585 .copied()
12586 .filter(|path| !resulting.contains_key(path))
12587 .collect();
12588 if removed_set != expected_removed {
12589 return Err(V1DisclosureError::RemovedMismatch);
12590 }
12591 if kind == "push" {
12592 return if disclosed == resulting {
12593 Ok(())
12594 } else {
12595 Err(V1DisclosureError::PushManifestMismatch)
12596 };
12597 }
12598 if kind != "edit" {
12599 return Err(V1DisclosureError::EditFalseFile);
12600 }
12601 if disclosed
12602 .iter()
12603 .any(|(path, value)| resulting.get(path) != Some(value))
12604 {
12605 return Err(V1DisclosureError::EditFalseFile);
12606 }
12607 for (path, value) in &resulting {
12608 if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
12609 return Err(V1DisclosureError::EditMissingChange);
12610 }
12611 }
12612 Ok(())
12613}
12614
12615#[derive(Debug, Clone, Deserialize, Serialize)]
12616struct FeedEntry {
12617 v: u8,
12618 seq: u64,
12619 ts: String,
12620 brain: String,
12621 public_key: String,
12622 kind: String,
12623 op: String,
12624 pack_sha256: String,
12625 #[serde(deserialize_with = "deserialize_feed_files")]
12626 files: Vec<FeedFile>,
12627 #[serde(deserialize_with = "deserialize_removed_paths")]
12628 removed: Vec<String>,
12629 prev_entry_hash: Option<String>,
12630 sig: String,
12631}
12632
12633#[derive(Serialize)]
12634struct UnsignedFeedEntry<'a> {
12635 v: u8,
12636 seq: u64,
12637 ts: &'a str,
12638 brain: &'a str,
12639 public_key: &'a str,
12640 kind: &'a str,
12641 op: &'a str,
12642 pack_sha256: &'a str,
12643 files: &'a [FeedFile],
12644 removed: &'a [String],
12645 prev_entry_hash: &'a Option<String>,
12646}
12647
12648#[derive(Debug, Clone, Deserialize, Serialize)]
12649struct FeedItem {
12650 hash: String,
12651 entry: FeedEntry,
12652}
12653
12654#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12655struct FeedIdentity {
12656 fingerprint: String,
12657 #[serde(rename = "publicKeySpki")]
12658 public_key_spki: String,
12659 #[serde(default, deserialize_with = "deserialize_previous_identities")]
12663 previous: Vec<PreviousIdentity>,
12664 #[serde(default, deserialize_with = "deserialize_rotations")]
12667 rotations: Vec<String>,
12668}
12669
12670#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12671struct PreviousIdentity {
12672 fingerprint: String,
12673 #[serde(rename = "publicKeySpki")]
12674 public_key_spki: String,
12675}
12676
12677#[derive(Debug, Deserialize)]
12678struct FeedResponse {
12679 #[serde(rename = "headSeq")]
12680 head_seq: u64,
12681 #[serde(rename = "feedHash")]
12682 feed_hash: Option<String>,
12683 identity: Option<FeedIdentity>,
12684 #[serde(deserialize_with = "deserialize_feed_items")]
12685 entries: Vec<FeedItem>,
12686 #[serde(rename = "scopeLimited")]
12687 scope_limited: bool,
12688}
12689
12690#[derive(Debug, Deserialize, Serialize)]
12691#[serde(deny_unknown_fields)]
12692struct RotationStatement {
12693 v: u8,
12694 op: String,
12695 brain: String,
12696 public_key: String,
12697 new_brain: String,
12698 new_public_key: String,
12699 prior_head_seq: u64,
12700 prior_feed_hash: Option<String>,
12701 ts: String,
12702 sig: String,
12703}
12704
12705#[derive(Debug, Clone, Deserialize, Serialize)]
12706struct TrustState {
12707 v: u8,
12708 origin: String,
12709 #[serde(default)]
12713 requested: String,
12714 brain: String,
12716 #[serde(default, skip_serializing_if = "Option::is_none")]
12719 home: Option<String>,
12720 anchor: String,
12721 current: String,
12722 #[serde(rename = "headSeq")]
12723 head_seq: u64,
12724 #[serde(rename = "feedHash")]
12725 feed_hash: Option<String>,
12726 #[serde(default)]
12730 rotations: Vec<String>,
12731 #[serde(default, skip_serializing_if = "Option::is_none")]
12734 hub_signer: Option<String>,
12735 #[serde(default, skip_serializing_if = "Option::is_none")]
12738 protocol_profile: Option<String>,
12739}
12740
12741fn accepted_as_v2(state: &TrustState) -> bool {
12742 state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
12743}
12744
12745fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
12746 let directory = open_trust_dir(cfg)?;
12747 if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
12748 return Ok(true);
12749 }
12750 let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
12751 return Ok(false);
12752 };
12753 Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
12754}
12755
12756#[derive(Debug, Clone, Deserialize, Serialize)]
12757struct AliasBinding {
12758 v: u8,
12759 origin: String,
12760 requested: String,
12761 brain: String,
12762 #[serde(default, skip_serializing_if = "Option::is_none")]
12763 home: Option<String>,
12764}
12765
12766struct VerifiedRemote {
12767 head: Head,
12768 identity: Option<FeedIdentity>,
12769 head_entry: Option<FeedItem>,
12770 entries: Vec<FeedItem>,
12772 anchor: Option<String>,
12773}
12774
12775fn invalid_feed(message: impl Into<String>) -> LinkError {
12776 LinkError::InvalidFeed {
12777 message: message.into(),
12778 }
12779}
12780
12781fn is_sha256(value: &str) -> bool {
12782 value.len() == 64
12783 && value
12784 .bytes()
12785 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
12786}
12787
12788fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
12789 let der = URL_SAFE_NO_PAD
12790 .decode(public_key_spki)
12791 .map_err(|_| invalid_feed("identity public key is not base64url"))?;
12792 if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
12793 return Err(invalid_feed(
12794 "identity public key is not a valid Ed25519 SPKI",
12795 ));
12796 }
12797 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
12798}
12799
12800fn verify_identity_chain(
12804 identity: &FeedIdentity,
12805 pinned: Option<&TrustState>,
12806) -> LinkResult<String> {
12807 if identity.previous.len() > MAX_IDENTITY_ROTATIONS
12808 || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
12809 {
12810 return Err(invalid_feed(
12811 "identity rotation history exceeds the client cap",
12812 ));
12813 }
12814 if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
12815 return Err(invalid_feed(
12816 "current identity fingerprint does not match its public key",
12817 ));
12818 }
12819 for previous in &identity.previous {
12820 if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
12821 return Err(invalid_feed(
12822 "previous identity fingerprint does not match its public key",
12823 ));
12824 }
12825 }
12826 if identity.rotations.len() != identity.previous.len() {
12827 return Err(invalid_feed(
12828 "identity history is missing an old-key-signed rotation statement",
12829 ));
12830 }
12831
12832 let mut chain: Vec<(&str, &str)> = identity
12836 .previous
12837 .iter()
12838 .rev()
12839 .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
12840 .collect();
12841 chain.push((&identity.fingerprint, &identity.public_key_spki));
12842
12843 for (index, raw) in identity.rotations.iter().enumerate() {
12844 let statement: RotationStatement = serde_json::from_str(raw)
12845 .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
12846 let (old_fingerprint, old_spki) = chain[index];
12847 let (new_fingerprint, new_spki) = chain[index + 1];
12848 if statement.v != 1
12849 || statement.op != "rotate"
12850 || statement.brain != format!("ed25519:{old_fingerprint}")
12851 || statement.public_key != old_spki
12852 || statement.new_brain != format!("ed25519:{new_fingerprint}")
12853 || statement.new_public_key != new_spki
12854 || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
12855 || (statement.prior_head_seq > 0
12856 && statement
12857 .prior_feed_hash
12858 .as_deref()
12859 .is_none_or(|hash| !is_sha256(hash)))
12860 {
12861 return Err(invalid_feed(
12862 "rotation statement does not connect adjacent identities",
12863 ));
12864 }
12865 let unsigned = serde_json::to_string(&UnsignedRotation {
12866 v: statement.v,
12867 op: &statement.op,
12868 brain: &statement.brain,
12869 public_key: &statement.public_key,
12870 new_brain: &statement.new_brain,
12871 new_public_key: &statement.new_public_key,
12872 prior_head_seq: statement.prior_head_seq,
12873 prior_feed_hash: statement.prior_feed_hash.as_deref(),
12874 ts: statement.ts.clone(),
12875 })
12876 .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
12877 let exact = format!(
12878 "{},\"sig\":\"{}\"}}",
12879 &unsigned[..unsigned.len() - 1],
12880 statement.sig
12881 );
12882 if exact != *raw {
12883 return Err(invalid_feed(
12884 "rotation statement is not in normative serialization",
12885 ));
12886 }
12887 let der = URL_SAFE_NO_PAD
12888 .decode(old_spki)
12889 .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
12890 let signature = URL_SAFE_NO_PAD
12891 .decode(&statement.sig)
12892 .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
12893 UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
12894 .verify(unsigned.as_bytes(), &signature)
12895 .map_err(|_| invalid_feed("rotation signature verification failed"))?;
12896 if index > 0 {
12897 let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
12898 .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
12899 if statement.prior_head_seq < prior.prior_head_seq {
12900 return Err(invalid_feed("rotation feed boundaries move backward"));
12901 }
12902 }
12903 }
12904
12905 let anchor = format!("ed25519:{}", chain[0].0);
12906 let current = format!("ed25519:{}", identity.fingerprint);
12907 if let Some(pin) = pinned {
12908 if pin.anchor != anchor {
12909 return Err(invalid_feed(
12910 "served identity chain does not descend from the pinned anchor",
12911 ));
12912 }
12913 if !chain
12914 .iter()
12915 .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12916 {
12917 return Err(invalid_feed(
12918 "served identity chain forked away from the last pinned identity",
12919 ));
12920 }
12921 if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
12922 return Err(invalid_feed("served identity discarded its rotation chain"));
12923 }
12924 if pin.v >= 2
12925 && (identity.rotations.len() < pin.rotations.len()
12926 || identity.rotations[..pin.rotations.len()] != pin.rotations)
12927 {
12928 return Err(invalid_feed(
12929 "served identity rewrote the locally accepted rotation history",
12930 ));
12931 }
12932 }
12933 Ok(anchor)
12934}
12935
12936fn verify_rotation_feed_boundaries(
12937 identity: &FeedIdentity,
12938 pinned: Option<&TrustState>,
12939 observed: &[FeedItem],
12940 advertised_seq: u64,
12941) -> LinkResult<()> {
12942 let mut chain: Vec<String> = identity
12943 .previous
12944 .iter()
12945 .rev()
12946 .map(|previous| format!("ed25519:{}", previous.fingerprint))
12947 .collect();
12948 chain.push(format!("ed25519:{}", identity.fingerprint));
12949 let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
12950
12951 for (index, raw) in identity.rotations.iter().enumerate() {
12952 let rotation: RotationStatement = serde_json::from_str(raw)
12953 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12954 if rotation.prior_head_seq > advertised_seq {
12955 return Err(invalid_feed(
12956 "rotation claims a feed boundary beyond the advertised head",
12957 ));
12958 }
12959 if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
12960 if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
12961 return Err(invalid_feed(
12962 "newly disclosed rotation predates the local feed checkpoint",
12963 ));
12964 }
12965 }
12966 let actual = if rotation.prior_head_seq == 0 {
12967 None
12968 } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
12969 pinned.and_then(|pin| pin.feed_hash.as_deref())
12970 } else {
12971 observed
12972 .iter()
12973 .find(|item| item.entry.seq == rotation.prior_head_seq)
12974 .map(|item| item.hash.as_str())
12975 };
12976 if let Some(actual) = actual {
12977 if rotation.prior_feed_hash.as_deref() != Some(actual) {
12978 return Err(invalid_feed(
12979 "rotation statement does not commit the verified feed boundary",
12980 ));
12981 }
12982 } else if rotation.prior_head_seq == 0 {
12983 } else if pinned.is_some_and(|pin| {
12986 pinned_index.is_some_and(|pin_index| index >= pin_index)
12987 || rotation.prior_head_seq >= pin.head_seq
12988 }) {
12989 return Err(invalid_feed(
12990 "rotation feed boundary was not present in the verified chain",
12991 ));
12992 }
12993 }
12994 Ok(())
12995}
12996
12997fn reject_retired_signer_after_checkpoint(
13002 identity: &FeedIdentity,
13003 pinned: Option<&TrustState>,
13004 item: &FeedItem,
13005) -> LinkResult<()> {
13006 let Some(pin) = pinned else {
13007 return Ok(());
13008 };
13009 if item.entry.seq <= pin.head_seq {
13010 return Ok(());
13011 }
13012 let mut chain: Vec<String> = identity
13013 .previous
13014 .iter()
13015 .rev()
13016 .map(|previous| format!("ed25519:{}", previous.fingerprint))
13017 .collect();
13018 chain.push(format!("ed25519:{}", identity.fingerprint));
13019 let pinned_index = chain
13020 .iter()
13021 .position(|key| key == &pin.current)
13022 .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
13023 let signer_index = chain
13024 .iter()
13025 .position(|key| key == &item.entry.brain)
13026 .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
13027 if signer_index < pinned_index {
13028 return Err(invalid_feed(
13029 "a retired identity attempted to sign after the local checkpoint",
13030 ));
13031 }
13032 Ok(())
13033}
13034
13035fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
13036 let origin = normalized_origin(&cfg.hub)?;
13037 let key = format!(
13038 "{:x}",
13039 Sha256::digest(format!("{origin}\0{brain}").as_bytes())
13040 );
13041 Ok(format!("{key}.json"))
13042}
13043
13044fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
13045 let origin = normalized_origin(&cfg.hub)?;
13046 let key = format!(
13047 "{:x}",
13048 Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
13049 );
13050 Ok(format!("alias-{key}.json"))
13051}
13052
13053#[cfg(any(unix, windows))]
13054struct TrustLock {
13055 _file: std::fs::File,
13056}
13057
13058#[cfg(unix)]
13059fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13060 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13061
13062 let lock_string = format!(".{state_name}.lock");
13063 let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
13064 let fd = unsafe {
13065 libc::openat(
13066 directory.as_raw_fd(),
13067 lock_name.as_ptr(),
13068 libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13069 0o600,
13070 )
13071 };
13072 if fd < 0 {
13073 return Err(std::io::Error::last_os_error().into());
13074 }
13075 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13076 if !file.metadata()?.is_file() {
13077 return Err(LinkError::UnsafePath { path: lock_string });
13078 }
13079 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
13080 return Err(std::io::Error::last_os_error().into());
13081 }
13082 Ok(TrustLock { _file: file })
13083}
13084
13085#[cfg(windows)]
13086fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13087 let lock_name = format!(".{state_name}.lock");
13088 let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
13089 Ok(TrustLock { _file: file })
13090}
13091
13092#[cfg(any(unix, windows))]
13093fn lock_trust_many(
13094 cfg: &HubConfig,
13095 directory: &std::fs::File,
13096 refs: &[&str],
13097) -> LinkResult<Vec<TrustLock>> {
13098 let mut names = refs
13099 .iter()
13100 .map(|reference| trust_file_name(cfg, reference))
13101 .collect::<LinkResult<Vec<_>>>()?;
13102 names.sort();
13103 names.dedup();
13104 names
13105 .iter()
13106 .map(|name| lock_trust_name(directory, name))
13107 .collect()
13108}
13109
13110#[cfg(not(any(unix, windows)))]
13111fn lock_trust_many(
13112 _cfg: &HubConfig,
13113 _directory: &TrustDirectory,
13114 _refs: &[&str],
13115) -> LinkResult<Vec<()>> {
13116 Err(LinkError::UnsupportedPlatform {
13117 operation: "verified link.md state",
13118 })
13119}
13120
13121#[cfg(any(unix, windows))]
13122type TrustDirectory = std::fs::File;
13123
13124#[cfg(not(any(unix, windows)))]
13125struct TrustDirectory;
13126
13127#[cfg(unix)]
13128fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13129 use std::os::fd::AsRawFd as _;
13130
13131 let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
13132 if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
13133 return Err(std::io::Error::last_os_error().into());
13134 }
13135 directory.sync_all()?;
13136 Ok(directory)
13137}
13138
13139#[cfg(windows)]
13140fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13141 let marker = cfg.state_dir.join("trust").join(".directory");
13142 crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
13143 Ok(crate::fsx::open_directory_nofollow(
13144 marker.parent().expect("trust marker has a parent"),
13145 )?)
13146}
13147
13148#[cfg(not(any(unix, windows)))]
13149fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13150 Err(LinkError::UnsupportedPlatform {
13151 operation: "verified link.md state",
13152 })
13153}
13154
13155#[cfg(unix)]
13156fn load_trust_in(
13157 cfg: &HubConfig,
13158 directory: &TrustDirectory,
13159 requested: &str,
13160) -> LinkResult<Option<TrustState>> {
13161 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13162
13163 let name_string = trust_file_name(cfg, requested)?;
13164 let name = c_name(name_string.as_bytes(), &name_string)?;
13165 let fd = unsafe {
13166 libc::openat(
13167 directory.as_raw_fd(),
13168 name.as_ptr(),
13169 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13170 )
13171 };
13172 if fd < 0 {
13173 let error = std::io::Error::last_os_error();
13174 if error.kind() == std::io::ErrorKind::NotFound {
13175 return Ok(None);
13176 }
13177 return Err(LinkError::UnsafePath { path: name_string });
13178 }
13179 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13180 if !file.metadata()?.is_file() {
13181 return Err(LinkError::UnsafePath { path: name_string });
13182 }
13183 let mut bytes = Vec::new();
13184 file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
13185 if bytes.len() > 1024 * 1024 {
13186 return Err(invalid_feed("local identity/feed checkpoint is oversized"));
13187 }
13188 let mut state: TrustState = serde_json::from_slice(&bytes)
13189 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13190 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13191 return Err(invalid_feed(
13192 "local identity/feed checkpoint does not match this hub and brain",
13193 ));
13194 }
13195 if state.v == 1 {
13196 if state.brain != requested {
13200 return Err(invalid_feed(
13201 "legacy checkpoint is not bound to the requested brain id",
13202 ));
13203 }
13204 state.requested = requested.to_string();
13205 } else if state.requested != requested {
13206 return Err(invalid_feed(
13207 "local identity/feed checkpoint is bound to a different requested ref",
13208 ));
13209 }
13210 Ok(Some(state))
13211}
13212
13213#[cfg(windows)]
13214fn load_trust_in(
13215 cfg: &HubConfig,
13216 directory: &TrustDirectory,
13217 requested: &str,
13218) -> LinkResult<Option<TrustState>> {
13219 let name = trust_file_name(cfg, requested)?;
13220 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13221 let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
13222 Ok(bytes) => bytes,
13223 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13224 Err(_) => return Err(LinkError::UnsafePath { path: name }),
13225 };
13226 let mut state: TrustState = serde_json::from_slice(&bytes)
13227 .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13228 if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13229 return Err(invalid_feed(
13230 "local identity/feed checkpoint does not match this hub and brain",
13231 ));
13232 }
13233 if state.v == 1 {
13234 if state.brain != requested {
13235 return Err(invalid_feed(
13236 "legacy checkpoint is not bound to the requested brain id",
13237 ));
13238 }
13239 state.requested = requested.to_string();
13240 } else if state.requested != requested {
13241 return Err(invalid_feed(
13242 "local identity/feed checkpoint is bound to a different requested ref",
13243 ));
13244 }
13245 Ok(Some(state))
13246}
13247
13248#[cfg(not(any(unix, windows)))]
13249fn load_trust_in(
13250 _cfg: &HubConfig,
13251 _directory: &TrustDirectory,
13252 _brain: &str,
13253) -> LinkResult<Option<TrustState>> {
13254 Err(LinkError::UnsupportedPlatform {
13255 operation: "verified link.md state",
13256 })
13257}
13258
13259#[cfg(all(test, any(unix, windows)))]
13260fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
13261 let directory = open_trust_dir(cfg)?;
13262 load_trust_in(cfg, &directory, requested)
13263}
13264
13265#[cfg(unix)]
13266fn save_trust_in(
13267 cfg: &HubConfig,
13268 directory: &TrustDirectory,
13269 state: &TrustState,
13270) -> LinkResult<()> {
13271 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13272
13273 let name_string = trust_file_name(cfg, &state.requested)?;
13274 let name = c_name(name_string.as_bytes(), &name_string)?;
13275 let mut bytes = serde_json::to_vec(state)
13276 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13277 bytes.push(b'\n');
13278
13279 let nonce = std::time::SystemTime::now()
13280 .duration_since(std::time::UNIX_EPOCH)
13281 .unwrap_or_default()
13282 .as_nanos();
13283 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13284 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13285 let fd = unsafe {
13286 libc::openat(
13287 directory.as_raw_fd(),
13288 temp.as_ptr(),
13289 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13290 0o600,
13291 )
13292 };
13293 if fd < 0 {
13294 return Err(std::io::Error::last_os_error().into());
13295 }
13296 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13297 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13298 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13299 return Err(error.into());
13300 }
13301 drop(file);
13302 if unsafe {
13303 libc::renameat(
13304 directory.as_raw_fd(),
13305 temp.as_ptr(),
13306 directory.as_raw_fd(),
13307 name.as_ptr(),
13308 )
13309 } != 0
13310 {
13311 let error = std::io::Error::last_os_error();
13312 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13313 return Err(error.into());
13314 }
13315 directory.sync_all()?;
13316 Ok(())
13317}
13318
13319#[cfg(windows)]
13320fn save_trust_in(
13321 cfg: &HubConfig,
13322 directory: &TrustDirectory,
13323 state: &TrustState,
13324) -> LinkResult<()> {
13325 let name = trust_file_name(cfg, &state.requested)?;
13326 let mut bytes = serde_json::to_vec(state)
13327 .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13328 bytes.push(b'\n');
13329 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13330 Ok(())
13331}
13332
13333#[cfg(not(any(unix, windows)))]
13334fn save_trust_in(
13335 _cfg: &HubConfig,
13336 _directory: &TrustDirectory,
13337 _state: &TrustState,
13338) -> LinkResult<()> {
13339 Err(LinkError::UnsupportedPlatform {
13340 operation: "verified link.md state",
13341 })
13342}
13343
13344#[cfg(unix)]
13345fn load_alias_in(
13346 cfg: &HubConfig,
13347 directory: &TrustDirectory,
13348 requested: &str,
13349) -> LinkResult<Option<AliasBinding>> {
13350 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13351
13352 let name_string = alias_file_name(cfg, requested)?;
13353 let name = c_name(name_string.as_bytes(), &name_string)?;
13354 let fd = unsafe {
13355 libc::openat(
13356 directory.as_raw_fd(),
13357 name.as_ptr(),
13358 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13359 )
13360 };
13361 if fd < 0 {
13362 let error = std::io::Error::last_os_error();
13363 if error.kind() == std::io::ErrorKind::NotFound {
13364 return Ok(None);
13365 }
13366 return Err(LinkError::UnsafePath { path: name_string });
13367 }
13368 let file = unsafe { std::fs::File::from_raw_fd(fd) };
13369 if !file.metadata()?.is_file() {
13370 return Err(LinkError::UnsafePath { path: name_string });
13371 }
13372 let mut bytes = Vec::new();
13373 file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
13374 if bytes.len() > 64 * 1024 {
13375 return Err(invalid_feed("local alias binding is oversized"));
13376 }
13377 let alias: AliasBinding = serde_json::from_slice(&bytes)
13378 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13379 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13380 {
13381 return Err(invalid_feed(
13382 "local alias binding does not match this hub and requested ref",
13383 ));
13384 }
13385 Ok(Some(alias))
13386}
13387
13388#[cfg(windows)]
13389fn load_alias_in(
13390 cfg: &HubConfig,
13391 directory: &TrustDirectory,
13392 requested: &str,
13393) -> LinkResult<Option<AliasBinding>> {
13394 let name = alias_file_name(cfg, requested)?;
13395 let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13396 let bytes = match reader.read(Path::new(&name), 64 * 1024) {
13397 Ok(bytes) => bytes,
13398 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13399 Err(_) => return Err(LinkError::UnsafePath { path: name }),
13400 };
13401 let alias: AliasBinding = serde_json::from_slice(&bytes)
13402 .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13403 if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13404 {
13405 return Err(invalid_feed(
13406 "local alias binding does not match this hub and requested ref",
13407 ));
13408 }
13409 Ok(Some(alias))
13410}
13411
13412#[cfg(not(any(unix, windows)))]
13413fn load_alias_in(
13414 _cfg: &HubConfig,
13415 _directory: &TrustDirectory,
13416 _requested: &str,
13417) -> LinkResult<Option<AliasBinding>> {
13418 Err(LinkError::UnsupportedPlatform {
13419 operation: "verified link.md state",
13420 })
13421}
13422
13423#[cfg(unix)]
13424fn save_alias_in(
13425 cfg: &HubConfig,
13426 directory: &TrustDirectory,
13427 alias: &AliasBinding,
13428) -> LinkResult<()> {
13429 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13430
13431 let name_string = alias_file_name(cfg, &alias.requested)?;
13432 let name = c_name(name_string.as_bytes(), &name_string)?;
13433 let mut bytes = serde_json::to_vec(alias)
13434 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13435 bytes.push(b'\n');
13436 let nonce = std::time::SystemTime::now()
13437 .duration_since(std::time::UNIX_EPOCH)
13438 .unwrap_or_default()
13439 .as_nanos();
13440 let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13441 let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13442 let fd = unsafe {
13443 libc::openat(
13444 directory.as_raw_fd(),
13445 temp.as_ptr(),
13446 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13447 0o600,
13448 )
13449 };
13450 if fd < 0 {
13451 return Err(std::io::Error::last_os_error().into());
13452 }
13453 let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13454 if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13455 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13456 return Err(error.into());
13457 }
13458 drop(file);
13459 if unsafe {
13460 libc::renameat(
13461 directory.as_raw_fd(),
13462 temp.as_ptr(),
13463 directory.as_raw_fd(),
13464 name.as_ptr(),
13465 )
13466 } != 0
13467 {
13468 let error = std::io::Error::last_os_error();
13469 let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13470 return Err(error.into());
13471 }
13472 directory.sync_all()?;
13473 Ok(())
13474}
13475
13476#[cfg(windows)]
13477fn save_alias_in(
13478 cfg: &HubConfig,
13479 directory: &TrustDirectory,
13480 alias: &AliasBinding,
13481) -> LinkResult<()> {
13482 let name = alias_file_name(cfg, &alias.requested)?;
13483 let mut bytes = serde_json::to_vec(alias)
13484 .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13485 bytes.push(b'\n');
13486 crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13487 Ok(())
13488}
13489
13490#[cfg(not(any(unix, windows)))]
13491fn save_alias_in(
13492 _cfg: &HubConfig,
13493 _directory: &TrustDirectory,
13494 _alias: &AliasBinding,
13495) -> LinkResult<()> {
13496 Err(LinkError::UnsupportedPlatform {
13497 operation: "verified link.md state",
13498 })
13499}
13500
13501fn load_canonical_pin(
13506 cfg: &HubConfig,
13507 directory: &TrustDirectory,
13508 requested: &str,
13509 resolved_brain: &str,
13510) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
13511 let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
13512 if requested == resolved_brain {
13513 return Ok((canonical, None));
13514 }
13515
13516 let mut alias = load_alias_in(cfg, directory, requested)?;
13517 if let Some(binding) = &alias {
13518 if binding.brain != resolved_brain {
13519 return Err(LinkError::AliasRebindRequired {
13520 alias: requested.to_string(),
13521 from: binding.brain.clone(),
13522 to: resolved_brain.to_string(),
13523 });
13524 }
13525 return Ok((canonical, alias));
13526 }
13527
13528 if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
13532 if legacy.brain != resolved_brain {
13533 return Err(invalid_feed(
13534 "legacy alias checkpoint names a different canonical brain",
13535 ));
13536 }
13537 if let Some(existing) = &canonical {
13538 if existing.brain != legacy.brain
13539 || existing.anchor != legacy.anchor
13540 || existing.current != legacy.current
13541 || existing.head_seq != legacy.head_seq
13542 || existing.feed_hash != legacy.feed_hash
13543 || existing.rotations != legacy.rotations
13544 {
13545 return Err(invalid_feed(
13546 "legacy alias checkpoint conflicts with the canonical checkpoint",
13547 ));
13548 }
13549 } else {
13550 let mut promoted = legacy.clone();
13551 promoted.requested = resolved_brain.to_string();
13552 promoted.home = None;
13553 save_trust_in(cfg, directory, &promoted)?;
13554 canonical = Some(promoted);
13555 }
13556 alias = Some(AliasBinding {
13557 v: 1,
13558 origin: normalized_origin(&cfg.hub)?,
13559 requested: requested.to_string(),
13560 brain: resolved_brain.to_string(),
13561 home: legacy.home,
13562 });
13563 save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
13564 }
13565 Ok((canonical, alias))
13566}
13567
13568pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
13573 require_hardened_filesystem("verified alias rebind")?;
13574 require_safe_ref(alias)?;
13575 require_safe_ref(from)?;
13576 require_safe_ref(to)?;
13577 if crate::ulid::is_ulid(alias)
13578 || !crate::ulid::is_ulid(from)
13579 || !crate::ulid::is_ulid(to)
13580 || from == to
13581 {
13582 return Err(LinkError::InvalidPack {
13583 message:
13584 "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
13585 .to_string(),
13586 });
13587 }
13588
13589 let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
13590 message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
13591 })?;
13592 accept_v2_head(cfg, &verified)?;
13593
13594 let alias_response = ensure_ok(
13595 request(
13596 cfg,
13597 "GET",
13598 &format!("/api/hub/brains/{alias}/v2/head"),
13599 None,
13600 Auth::Required,
13601 )?,
13602 "resolve alias for explicit rebind",
13603 )?;
13604 let resolved: V2HeadResponse = serde_json::from_value(alias_response)
13605 .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
13606 if resolved.v != 2 || resolved.brain_id != to {
13607 return Err(LinkError::RemoteAdvancedDuringSync);
13608 }
13609
13610 let directory = open_trust_dir(cfg)?;
13611 let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
13612 let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
13613 message: "the requested alias has no existing local binding to replace".to_string(),
13614 })?;
13615 if binding.brain != from {
13616 return Err(LinkError::AliasRebindRequired {
13617 alias: alias.to_string(),
13618 from: binding.brain,
13619 to: to.to_string(),
13620 });
13621 }
13622 save_alias_in(
13623 cfg,
13624 &directory,
13625 &AliasBinding {
13626 v: 1,
13627 origin: normalized_origin(&cfg.hub)?,
13628 requested: alias.to_string(),
13629 brain: to.to_string(),
13630 home: binding.home,
13631 },
13632 )?;
13633 Ok(json!({
13634 "v": 2,
13635 "alias": alias,
13636 "from": from,
13637 "to": to,
13638 "outcome": "alias_rebound",
13639 }))
13640}
13641
13642fn save_canonical_pin_and_alias(
13643 cfg: &HubConfig,
13644 directory: &TrustDirectory,
13645 requested: &str,
13646 resolved_brain: &str,
13647 mut state: TrustState,
13648 existing_alias: Option<&AliasBinding>,
13649) -> LinkResult<()> {
13650 state.requested = resolved_brain.to_string();
13651 state.brain = resolved_brain.to_string();
13652 state.home = None;
13653 save_trust_in(cfg, directory, &state)?;
13654 if requested != resolved_brain {
13655 save_alias_in(
13656 cfg,
13657 directory,
13658 &AliasBinding {
13659 v: 1,
13660 origin: normalized_origin(&cfg.hub)?,
13661 requested: requested.to_string(),
13662 brain: resolved_brain.to_string(),
13663 home: existing_alias.and_then(|alias| alias.home.clone()),
13664 },
13665 )?;
13666 }
13667 Ok(())
13668}
13669
13670fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
13671 const ED25519_SPKI_PREFIX: &[u8] = &[
13672 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13673 ];
13674 let entry = &item.entry;
13675 let public_der = URL_SAFE_NO_PAD
13676 .decode(&entry.public_key)
13677 .map_err(|_| invalid_feed("public key is not base64url"))?;
13678 if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
13679 || !public_der.starts_with(ED25519_SPKI_PREFIX)
13680 {
13681 return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
13682 }
13683 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
13684 if entry.brain != format!("ed25519:{fingerprint}") {
13685 return Err(invalid_feed(
13686 "brain fingerprint does not match its public key",
13687 ));
13688 }
13689 let _ = verify_identity_chain(identity, None)?;
13691 let mut chain: Vec<(&str, &str)> = identity
13692 .previous
13693 .iter()
13694 .rev()
13695 .map(|previous| {
13696 (
13697 previous.fingerprint.as_str(),
13698 previous.public_key_spki.as_str(),
13699 )
13700 })
13701 .collect();
13702 chain.push((&identity.fingerprint, &identity.public_key_spki));
13703 let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
13704 *known_fingerprint == fingerprint && *spki == entry.public_key
13705 });
13706 let Some(signer_index) = signer_index else {
13707 return Err(invalid_feed(
13708 "entry signer is not this brain's identity (current or rotated-from)",
13709 ));
13710 };
13711 let lower_boundary = if signer_index == 0 {
13712 None
13713 } else {
13714 let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
13715 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13716 Some(prior.prior_head_seq)
13717 };
13718 let upper_boundary = if signer_index == identity.rotations.len() {
13719 None
13720 } else {
13721 let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
13722 .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13723 Some(next.prior_head_seq)
13724 };
13725 if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
13726 || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
13727 {
13728 return Err(invalid_feed(
13729 "entry signer is outside its authenticated rotation epoch",
13730 ));
13731 }
13732 let unsigned = UnsignedFeedEntry {
13733 v: entry.v,
13734 seq: entry.seq,
13735 ts: &entry.ts,
13736 brain: &entry.brain,
13737 public_key: &entry.public_key,
13738 kind: &entry.kind,
13739 op: &entry.op,
13740 pack_sha256: &entry.pack_sha256,
13741 files: &entry.files,
13742 removed: &entry.removed,
13743 prev_entry_hash: &entry.prev_entry_hash,
13744 };
13745 let message =
13746 serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
13747 let signature = URL_SAFE_NO_PAD
13748 .decode(&entry.sig)
13749 .map_err(|_| invalid_feed("signature is not base64url"))?;
13750 UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
13751 .verify(&message, &signature)
13752 .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
13753
13754 let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
13755 exact.push(b'\n');
13756 let actual_hash = format!("{:x}", Sha256::digest(&exact));
13757 if actual_hash != item.hash {
13758 return Err(invalid_feed("entry SHA-256 does not match"));
13759 }
13760 Ok(())
13761}
13762
13763#[derive(Serialize)]
13769struct UnsignedRotation<'a> {
13770 v: u8,
13771 op: &'a str,
13772 brain: &'a str,
13773 public_key: &'a str,
13774 new_brain: &'a str,
13775 new_public_key: &'a str,
13776 prior_head_seq: u64,
13777 prior_feed_hash: Option<&'a str>,
13778 ts: String,
13779}
13780
13781#[derive(Debug, Deserialize, Serialize)]
13786#[serde(deny_unknown_fields)]
13787struct RotationJournal {
13788 v: u8,
13789 origin: String,
13790 brain: String,
13791 old_brain: String,
13792 new_brain: String,
13793 prior_head_seq: u64,
13794 prior_feed_hash: Option<String>,
13795 statement: String,
13796}
13797
13798fn rotation_journal_path(key_path: &Path) -> PathBuf {
13799 let mut path = key_path.as_os_str().to_os_string();
13800 path.push(".rotation.json");
13801 PathBuf::from(path)
13802}
13803
13804fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
13805 #[cfg(unix)]
13806 let file = {
13807 use std::os::fd::{AsRawFd as _, FromRawFd as _};
13808 use std::os::unix::ffi::OsStrExt as _;
13809 let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13810 .map_err(|error| {
13811 bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
13812 })?;
13813 let leaf_name = path
13814 .file_name()
13815 .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
13816 let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
13817 let fd = unsafe {
13818 libc::openat(
13819 parent.as_raw_fd(),
13820 leaf.as_ptr(),
13821 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13822 )
13823 };
13824 if fd < 0 {
13825 return Err(bad_agent_key(
13826 "the rotation journal must be an existing regular file without symlink ancestors",
13827 ));
13828 }
13829 unsafe { std::fs::File::from_raw_fd(fd) }
13830 };
13831 #[cfg(not(unix))]
13832 let file = std::fs::File::open(path)
13833 .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
13834 let metadata = file
13835 .metadata()
13836 .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
13837 if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
13838 return Err(bad_agent_key(
13839 "the rotation journal must be a bounded regular file",
13840 ));
13841 }
13842 #[cfg(unix)]
13843 {
13844 use std::os::unix::fs::PermissionsExt as _;
13845 if metadata.permissions().mode() & 0o077 != 0 {
13846 return Err(bad_agent_key(
13847 "the rotation journal is accessible to group/other; set mode 0600",
13848 ));
13849 }
13850 }
13851 serde_json::from_reader(file)
13852 .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
13853}
13854
13855fn remove_rotation_journal(path: &Path) {
13856 #[cfg(unix)]
13857 {
13858 use std::os::fd::AsRawFd as _;
13859 use std::os::unix::ffi::OsStrExt as _;
13860 let Ok(parent) =
13861 open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13862 else {
13863 return;
13864 };
13865 let Some(leaf_name) = path.file_name() else {
13866 return;
13867 };
13868 let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
13869 return;
13870 };
13871 if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
13872 let _ = parent.sync_all();
13873 }
13874 }
13875 #[cfg(not(unix))]
13876 {
13877 let _ = std::fs::remove_file(path);
13878 }
13879}
13880
13881fn validate_rotation_journal(
13882 journal: &RotationJournal,
13883 cfg: &HubConfig,
13884 canonical_brain: &str,
13885 old_key: &AgentSigningKey,
13886 new_key: &AgentSigningKey,
13887 head: &Head,
13888) -> LinkResult<()> {
13889 if journal.v != 1
13890 || journal.origin != normalized_origin(&cfg.hub)?
13891 || journal.brain != canonical_brain
13892 || journal.old_brain != old_key.multikey
13893 || journal.new_brain != new_key.multikey
13894 || journal.prior_head_seq != head.seq
13895 || journal.prior_feed_hash != head.feed_hash
13896 {
13897 return Err(invalid_feed(
13898 "rotation journal does not match the verified key and feed boundary",
13899 ));
13900 }
13901 let statement: RotationStatement = serde_json::from_str(&journal.statement)
13902 .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
13903 if statement.prior_head_seq != journal.prior_head_seq
13904 || statement.prior_feed_hash != journal.prior_feed_hash
13905 || statement.brain != old_key.multikey
13906 || statement.public_key != old_key.public_key_spki
13907 || statement.new_brain != new_key.multikey
13908 || statement.new_public_key != new_key.public_key_spki
13909 {
13910 return Err(invalid_feed(
13911 "rotation journal statement does not match its durable intent",
13912 ));
13913 }
13914 let identity = FeedIdentity {
13915 fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13916 public_key_spki: new_key.public_key_spki.clone(),
13917 previous: vec![PreviousIdentity {
13918 fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
13919 public_key_spki: old_key.public_key_spki.clone(),
13920 }],
13921 rotations: vec![journal.statement.clone()],
13922 };
13923 verify_identity_chain(&identity, None)?;
13924 Ok(())
13925}
13926
13927#[derive(Debug, Serialize)]
13929pub struct RotationReport {
13930 pub brain: String,
13932 pub multikey: String,
13934 #[serde(rename = "keyFile")]
13936 pub key_file: String,
13937 pub previous: Vec<String>,
13939}
13940
13941pub fn rotate_brain_key(
13947 cfg: &HubConfig,
13948 brain: &str,
13949 old_key: &AgentSigningKey,
13950 out: &Path,
13951) -> LinkResult<RotationReport> {
13952 require_hardened_filesystem("key rotation")?;
13953 require_safe_ref(brain)?;
13954 let new_key = if out.exists() {
13958 load_signing_key(out)?
13959 } else {
13960 let rng = ring::rand::SystemRandom::new();
13961 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
13962 .map_err(|_| bad_agent_key("key generation failed"))?;
13963 let pair = agent_keypair(pkcs8.as_ref())?;
13964 let (public_key_spki, multikey) = public_identity_for(&pair);
13965 write_secret_new(
13966 out,
13967 format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
13968 )?;
13969 AgentSigningKey {
13970 pkcs8: pkcs8.as_ref().to_vec(),
13971 multikey,
13972 public_key_spki,
13973 }
13974 };
13975 let new_spki = new_key.public_key_spki.clone();
13976 let new_multikey = new_key.multikey.clone();
13977 let journal_path = rotation_journal_path(out);
13978 let before_v2 = v2_verified_head(cfg, brain)?;
13979 let (canonical_brain, served_identity, observed_head, v2_profile) =
13980 if let Some(head) = before_v2 {
13981 let observed = Head {
13982 brain: head.brain_id.clone(),
13983 seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13984 updated_at: head
13985 .pointer
13986 .as_ref()
13987 .map(|pointer| pointer.signed_at.clone()),
13988 feed_hash: head
13989 .pointer
13990 .as_ref()
13991 .map(|pointer| pointer.feed_hash.clone()),
13992 verified: true,
13993 };
13994 let identity = v2_identity(&head.identity);
13995 let canonical = head.brain_id.clone();
13996 accept_v2_head(cfg, &head)?;
13997 (canonical, identity, observed, true)
13998 } else {
13999 let remote = verified_remote_head(cfg, brain, false)?;
14000 let identity = remote
14001 .identity
14002 .clone()
14003 .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
14004 (remote.head.brain.clone(), identity, remote.head, false)
14005 };
14006 let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
14007 let already_rotated = served_multikey == new_multikey;
14008 if already_rotated && !journal_path.exists() {
14013 remove_rotation_journal(&journal_path);
14014 return Ok(RotationReport {
14015 brain: brain.to_string(),
14016 multikey: new_multikey,
14017 key_file: out.display().to_string(),
14018 previous: served_identity
14019 .previous
14020 .iter()
14021 .map(|identity| format!("ed25519:{}", identity.fingerprint))
14022 .collect(),
14023 });
14024 }
14025 if !already_rotated && served_multikey != old_key.multikey {
14026 return Err(invalid_feed(
14027 "the supplied old key is not the brain's verified current identity",
14028 ));
14029 }
14030
14031 let journal = if journal_path.exists() {
14032 read_rotation_journal(&journal_path)?
14033 } else {
14034 let ts = crate::now()
14035 .with_timezone(&chrono::Utc)
14036 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
14037 .to_string();
14038 let unsigned = serde_json::to_string(&UnsignedRotation {
14039 v: 1,
14040 op: "rotate",
14041 brain: &old_key.multikey,
14042 public_key: &old_key.public_key_spki,
14043 new_brain: &new_multikey,
14044 new_public_key: &new_spki,
14045 prior_head_seq: observed_head.seq,
14046 prior_feed_hash: observed_head.feed_hash.as_deref(),
14047 ts,
14048 })
14049 .expect("serialize rotation");
14050 let old_pair = agent_keypair(&old_key.pkcs8)?;
14051 let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14052 let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
14053 let journal = RotationJournal {
14054 v: 1,
14055 origin: normalized_origin(&cfg.hub)?,
14056 brain: canonical_brain.clone(),
14057 old_brain: old_key.multikey.clone(),
14058 new_brain: new_multikey.clone(),
14059 prior_head_seq: observed_head.seq,
14060 prior_feed_hash: observed_head.feed_hash.clone(),
14061 statement,
14062 };
14063 let mut exact = serde_json::to_vec(&journal)
14064 .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
14065 exact.push(b'\n');
14066 if write_secret_new(&journal_path, &exact).is_err() {
14067 read_rotation_journal(&journal_path)?
14070 } else {
14071 journal
14072 }
14073 };
14074 validate_rotation_journal(
14075 &journal,
14076 cfg,
14077 &canonical_brain,
14078 old_key,
14079 &new_key,
14080 &observed_head,
14081 )?;
14082
14083 let body = json!({ "statement": journal.statement });
14084 let path = format!("/api/hub/brains/{brain}/rotate");
14085 let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
14086 let attempted_failure = match attempted {
14087 Ok(response) if (200..300).contains(&response.status) => None,
14088 Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
14089 Err(error) => Some(error),
14090 };
14091
14092 let identity = if v2_profile {
14096 match v2_verified_head(cfg, brain) {
14097 Ok(Some(after)) => {
14098 let identity = v2_identity(&after.identity);
14099 accept_v2_head(cfg, &after)?;
14100 identity
14101 }
14102 Ok(None) => {
14103 return Err(attempted_failure.unwrap_or_else(|| {
14104 invalid_feed("rotated v2 brain no longer serves a v2 head")
14105 }));
14106 }
14107 Err(error) => return Err(attempted_failure.unwrap_or(error)),
14108 }
14109 } else {
14110 match verified_remote_head(cfg, brain, false) {
14111 Ok(after) => after
14112 .identity
14113 .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
14114 Err(error) => return Err(attempted_failure.unwrap_or(error)),
14115 }
14116 };
14117 if format!("ed25519:{}", identity.fingerprint) != new_multikey
14118 || identity.public_key_spki != new_spki
14119 {
14120 return Err(attempted_failure.unwrap_or_else(|| {
14121 invalid_feed("hub acknowledged rotation without committing the verified new identity")
14122 }));
14123 }
14124 if v2_profile {
14125 if let Some(error) = attempted_failure {
14126 return Err(error);
14131 }
14132 }
14133 let previous = identity
14134 .previous
14135 .iter()
14136 .map(|prior| format!("ed25519:{}", prior.fingerprint))
14137 .collect();
14138 remove_rotation_journal(&journal_path);
14139
14140 Ok(RotationReport {
14141 brain: brain.to_string(),
14142 multikey: new_multikey,
14143 key_file: out.display().to_string(),
14144 previous,
14145 })
14146}
14147
14148#[derive(Debug, Serialize)]
14154pub struct MirrorReport {
14155 pub brain: String,
14157 #[serde(rename = "headSeq")]
14159 pub head_seq: u64,
14160 #[serde(rename = "feedHash")]
14162 pub feed_hash: Option<String>,
14163 pub entries: u64,
14165 pub pinned: String,
14167 pub files: usize,
14169}
14170
14171pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
14173
14174#[derive(Debug)]
14176pub struct VerifiedMirrorMaterial {
14177 pub brain: String,
14178 pub head_seq: u64,
14179 pub feed_hash: Option<String>,
14180 pub identity: serde_json::Value,
14181 pub entries: Vec<(u64, String, String)>,
14183 pub pack_sha256: Option<String>,
14184}
14185
14186#[derive(Deserialize)]
14187#[serde(deny_unknown_fields)]
14188struct StoredMirrorHead {
14189 brain: String,
14190 #[serde(rename = "headSeq")]
14191 head_seq: u64,
14192 #[serde(rename = "feedHash")]
14193 feed_hash: Option<String>,
14194}
14195
14196pub fn verify_mirror_material(
14199 head_bytes: &[u8],
14200 identity_bytes: &[u8],
14201 feed_bytes: &[Vec<u8>],
14202 snapshot_pack: Option<&[u8]>,
14203 expected_anchor: &str,
14204) -> LinkResult<VerifiedMirrorMaterial> {
14205 let snapshot_hash = snapshot_pack
14206 .filter(|pack| !pack.is_empty())
14207 .map(content_sha256);
14208 verify_mirror_material_with_pack_hash(
14209 head_bytes,
14210 identity_bytes,
14211 feed_bytes,
14212 snapshot_hash.as_deref(),
14213 expected_anchor,
14214 )
14215}
14216
14217pub fn verify_mirror_material_with_pack_hash(
14221 head_bytes: &[u8],
14222 identity_bytes: &[u8],
14223 feed_bytes: &[Vec<u8>],
14224 snapshot_pack_sha256: Option<&str>,
14225 expected_anchor: &str,
14226) -> LinkResult<VerifiedMirrorMaterial> {
14227 let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
14228 .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
14229 require_safe_ref(&head.brain)?;
14230 if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
14231 return Err(invalid_feed(
14232 "stored mirror feed count does not match its bounded head sequence",
14233 ));
14234 }
14235 let aggregate = feed_bytes
14236 .iter()
14237 .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
14238 .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
14239 if aggregate > MAX_FEED_REPLAY_BYTES {
14240 return Err(invalid_feed(
14241 "stored mirror feed metadata exceeds the aggregate limit",
14242 ));
14243 }
14244 let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
14245 .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
14246 let anchor = verify_identity_chain(&identity, None)?;
14247 if anchor != expected_anchor {
14248 return Err(invalid_feed(
14249 "stored mirror identity does not descend from the explicitly trusted anchor",
14250 ));
14251 }
14252
14253 let mut entries = Vec::with_capacity(feed_bytes.len());
14254 let mut items = Vec::with_capacity(feed_bytes.len());
14255 let mut previous_hash = None;
14256 let mut pack_sha256 = None;
14257 for (index, bytes) in feed_bytes.iter().enumerate() {
14258 let exact = bytes
14259 .strip_suffix(b"\n")
14260 .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
14261 if exact.ends_with(b"\n") {
14262 return Err(invalid_feed("stored feed entry has extra trailing bytes"));
14263 }
14264 let entry: FeedEntry = serde_json::from_slice(exact)
14265 .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
14266 let expected_seq = index as u64 + 1;
14267 if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
14268 return Err(invalid_feed(
14269 "stored mirror feed is not contiguous and hash-chained",
14270 ));
14271 }
14272 let canonical = serde_json::to_vec(&entry)
14273 .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
14274 if canonical != exact {
14275 return Err(invalid_feed(
14276 "stored feed entry is not in normative serialization",
14277 ));
14278 }
14279 let hash = content_sha256(bytes);
14280 let item = FeedItem {
14281 hash: hash.clone(),
14282 entry,
14283 };
14284 verify_feed_item(&item, &identity)?;
14285 previous_hash = Some(hash.clone());
14286 if expected_seq == head.head_seq {
14287 pack_sha256 = Some(item.entry.pack_sha256.clone());
14288 }
14289 entries.push((
14290 expected_seq,
14291 std::str::from_utf8(exact)
14292 .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
14293 .to_string(),
14294 hash,
14295 ));
14296 items.push(item);
14297 }
14298 if previous_hash != head.feed_hash {
14299 return Err(invalid_feed(
14300 "stored mirror feed does not converge on its advertised head",
14301 ));
14302 }
14303 verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
14304 match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
14305 (0, None, None) => {}
14306 (_, Some(actual), Some(expected)) if actual == expected => {}
14307 _ => {
14308 return Err(LinkError::InvalidPack {
14309 message: "stored snapshot pack does not match the signed head digest".to_string(),
14310 });
14311 }
14312 }
14313 let identity_value = serde_json::to_value(&identity)
14314 .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
14315 Ok(VerifiedMirrorMaterial {
14316 brain: head.brain,
14317 head_seq: head.head_seq,
14318 feed_hash: head.feed_hash,
14319 identity: identity_value,
14320 entries,
14321 pack_sha256,
14322 })
14323}
14324
14325pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
14328 format!(
14329 "{:x}",
14330 Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
14331 )
14332}
14333
14334pub fn content_sha256(bytes: &[u8]) -> String {
14337 format!("{:x}", Sha256::digest(bytes))
14338}
14339
14340pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
14342 let mut digest = Sha256::new();
14343 let mut buffer = [0u8; 64 * 1024];
14344 loop {
14345 let read = reader.read(&mut buffer)?;
14346 if read == 0 {
14347 break;
14348 }
14349 digest.update(&buffer[..read]);
14350 }
14351 Ok(format!("{:x}", digest.finalize()))
14352}
14353
14354#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
14362pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
14363 require_hardened_filesystem("mirror")?;
14364 require_safe_ref(brain)?;
14365 #[cfg(windows)]
14366 {
14367 let _ = (cfg, dest);
14368 return Err(LinkError::UnsupportedPlatform {
14369 operation: "atomic whole-mirror replacement on Windows",
14370 });
14371 }
14372 let parent = dest.parent().unwrap_or_else(|| Path::new("."));
14373 let name = dest
14374 .file_name()
14375 .and_then(|name| name.to_str())
14376 .filter(|name| !name.is_empty() && *name != "." && *name != "..")
14377 .ok_or_else(|| LinkError::UnsafePath {
14378 path: dest.display().to_string(),
14379 })?;
14380 #[cfg(unix)]
14381 let parent_dir = open_or_create_dir_nofollow(parent)?;
14382 #[cfg(unix)]
14383 use std::os::fd::AsRawFd as _;
14384 #[cfg(unix)]
14385 let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
14386 #[cfg(unix)]
14387 let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
14388 None => false,
14389 Some(true) => true,
14390 Some(false) => {
14391 return Err(LinkError::UnsafePath {
14392 path: dest.display().to_string(),
14393 });
14394 }
14395 };
14396
14397 #[cfg(unix)]
14400 let legacy_backup_name = c_name(
14401 format!(".{name}.dbmd-backup").as_bytes(),
14402 &dest.display().to_string(),
14403 )?;
14404 #[cfg(unix)]
14405 if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
14406 return Err(LinkError::UnsafePath {
14407 path: parent
14408 .join(format!(".{name}.dbmd-backup"))
14409 .display()
14410 .to_string(),
14411 });
14412 }
14413
14414 let nonce = std::time::SystemTime::now()
14415 .duration_since(std::time::UNIX_EPOCH)
14416 .unwrap_or_default()
14417 .as_nanos();
14418 let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
14419 #[cfg(unix)]
14420 let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
14421 #[cfg(unix)]
14422 let stage_dir = create_dir_exclusive_at(
14423 parent_dir.as_raw_fd(),
14424 &stage_name,
14425 &dest.display().to_string(),
14426 )?;
14427
14428 let assembled = (|| -> LinkResult<MirrorReport> {
14429 let remote = verified_remote_head(cfg, brain, true)?;
14430 let brain_id = remote.head.brain.clone();
14431 let identity = remote
14432 .identity
14433 .as_ref()
14434 .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
14435 let anchor = remote
14436 .anchor
14437 .clone()
14438 .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
14439 let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
14440 let snapshot_entries = parse_store_pack(pack.clone())?;
14441 let snapshot_count = snapshot_entries.len();
14442 let mut staged_entries = snapshot_entries;
14443 staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
14444 for item in &remote.entries {
14445 let mut exact = serde_json::to_vec(&item.entry)
14446 .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
14447 exact.push(b'\n');
14448 if format!("{:x}", Sha256::digest(&exact)) != item.hash {
14449 return Err(invalid_feed(
14450 "serialized mirror entry differs from its verified hash",
14451 ));
14452 }
14453 staged_entries.push((
14454 format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
14455 exact,
14456 ));
14457 }
14458 let mut identity_bytes = serde_json::to_vec(identity)
14459 .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
14460 identity_bytes.push(b'\n');
14461 staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
14462 let mut head_bytes = serde_json::to_vec(&json!({
14463 "brain": brain_id,
14464 "headSeq": remote.head.seq,
14465 "feedHash": remote.head.feed_hash,
14466 }))
14467 .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
14468 head_bytes.push(b'\n');
14469 staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
14470 staged_entries.push((
14471 CONFIG_REL_PATH.to_string(),
14472 format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
14473 ));
14474 #[cfg(unix)]
14475 write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
14476
14477 Ok(MirrorReport {
14478 brain: brain_id,
14479 head_seq: remote.head.seq,
14480 feed_hash: remote.head.feed_hash,
14481 entries: remote.entries.len() as u64,
14482 pinned: anchor,
14483 files: snapshot_count,
14484 })
14485 })();
14486
14487 let report = match assembled {
14488 Ok(report) => report,
14489 Err(error) => {
14490 #[cfg(unix)]
14491 let _ = remove_tree_at(
14492 parent_dir.as_raw_fd(),
14493 &stage_name,
14494 &dest.display().to_string(),
14495 );
14496 return Err(error);
14497 }
14498 };
14499
14500 #[cfg(unix)]
14501 if let Err(error) =
14502 install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
14503 {
14504 let _ = remove_tree_at(
14505 parent_dir.as_raw_fd(),
14506 &stage_name,
14507 &dest.display().to_string(),
14508 );
14509 return Err(error);
14510 }
14511 #[cfg(unix)]
14514 if dest_exists {
14515 remove_tree_at(
14516 parent_dir.as_raw_fd(),
14517 &stage_name,
14518 &dest.display().to_string(),
14519 )?;
14520 }
14521 #[cfg(unix)]
14522 parent_dir.sync_all()?;
14523 Ok(report)
14524}
14525
14526fn verified_remote_head(
14527 cfg: &HubConfig,
14528 brain: &str,
14529 require_full_chain: bool,
14530) -> LinkResult<VerifiedRemote> {
14531 require_hardened_filesystem("verified link.md state")?;
14532 require_safe_ref(brain)?;
14533 let trust_directory = open_trust_dir(cfg)?;
14537 let path = format!("/api/hub/brains/{brain}");
14538 let body = ensure_ok(
14539 request(cfg, "GET", &path, None, Auth::Required)?,
14540 "subscribe",
14541 )?;
14542 let resolved_brain = body
14543 .get("id")
14544 .and_then(Value::as_str)
14545 .filter(|id| crate::ulid::is_ulid(id))
14546 .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
14547 .to_string();
14548 if crate::ulid::is_ulid(brain) && resolved_brain != brain {
14549 return Err(invalid_feed(
14550 "brain card id differs from the explicitly requested brain id",
14551 ));
14552 }
14553 let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
14558 let (pinned, alias_binding) =
14559 load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
14560 let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
14561 let advertised_hash = body
14562 .get("feedHash")
14563 .and_then(Value::as_str)
14564 .map(str::to_string);
14565 let updated_at = body
14566 .get("updatedAt")
14567 .and_then(Value::as_str)
14568 .map(str::to_string);
14569 if let Some(pin) = &pinned {
14570 if seq < pin.head_seq {
14571 return Err(invalid_feed(format!(
14572 "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
14573 pin.head_seq
14574 )));
14575 }
14576 if seq == pin.head_seq && advertised_hash != pin.feed_hash {
14577 return Err(invalid_feed(
14578 "feed equivocation: the checkpoint sequence now has a different hash",
14579 ));
14580 }
14581 }
14582 if seq == 0 {
14583 if advertised_hash.is_some() {
14584 return Err(invalid_feed("an empty feed advertised a head hash"));
14585 }
14586 let identity: FeedIdentity = serde_json::from_value(
14587 body.get("identity")
14588 .cloned()
14589 .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
14590 )
14591 .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
14592 let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
14593 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
14598 save_canonical_pin_and_alias(
14599 cfg,
14600 &trust_directory,
14601 brain,
14602 &resolved_brain,
14603 TrustState {
14604 v: 2,
14605 origin: normalized_origin(&cfg.hub)?,
14606 requested: resolved_brain.clone(),
14607 brain: resolved_brain.clone(),
14608 home: None,
14609 anchor: anchor.clone(),
14610 current: format!("ed25519:{}", identity.fingerprint),
14611 head_seq: 0,
14612 feed_hash: None,
14613 rotations: identity.rotations.clone(),
14614 hub_signer: None,
14615 protocol_profile: None,
14616 },
14617 alias_binding.as_ref(),
14618 )?;
14619 return Ok(VerifiedRemote {
14620 head: Head {
14621 brain: resolved_brain,
14622 seq,
14623 updated_at,
14624 feed_hash: None,
14625 verified: true,
14626 },
14627 identity: Some(identity),
14628 head_entry: None,
14629 entries: Vec::new(),
14630 anchor: Some(anchor),
14631 });
14632 }
14633 if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
14634 return Err(invalid_feed(
14635 "non-empty feed did not advertise a valid SHA-256 head",
14636 ));
14637 }
14638
14639 let replay_head_only = !require_full_chain
14643 && pinned
14644 .as_ref()
14645 .is_none_or(|checkpoint| checkpoint.head_seq == seq);
14646 let mut after = if replay_head_only {
14647 seq - 1
14648 } else if require_full_chain || pinned.is_none() {
14649 0
14650 } else {
14651 pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
14652 };
14653 let mut expected_seq = after + 1;
14654 let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
14655 None
14656 } else {
14657 pinned
14658 .as_ref()
14659 .and_then(|checkpoint| checkpoint.feed_hash.clone())
14660 };
14661 let mut identity: Option<FeedIdentity> = None;
14662 let mut anchor: Option<String> = None;
14663 let mut head_entry: Option<FeedItem> = None;
14664 let mut all_entries = Vec::new();
14665 let mut observed_entries = Vec::new();
14666 let replay_count = seq
14667 .checked_sub(after)
14668 .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
14669 if replay_count > MAX_FEED_REPLAY_ENTRIES {
14670 return Err(invalid_feed(format!(
14671 "feed replay requires {replay_count} entries, over the client cap"
14672 )));
14673 }
14674 let mut replay_bytes = 0u64;
14675
14676 loop {
14677 let feed_bytes = ensure_raw_ok(
14678 request_raw(
14679 cfg,
14680 "GET",
14681 &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
14682 None,
14683 Auth::Required,
14684 MAX_FEED_RESPONSE_BYTES,
14685 )?,
14686 "subscribe feed",
14687 )?;
14688 let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
14689 .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
14690 if feed.head_seq != seq || feed.feed_hash != advertised_hash {
14691 return Err(invalid_feed("brain card and feed head disagree"));
14692 }
14693 if feed.entries.len() > FEED_PAGE_LIMIT {
14694 return Err(invalid_feed("feed page exceeds the requested entry limit"));
14695 }
14696 if feed.scope_limited {
14697 if require_full_chain {
14698 return Err(invalid_feed(
14699 "path-scoped grants cannot verify a full snapshot chain",
14700 ));
14701 }
14702 return Ok(VerifiedRemote {
14703 head: Head {
14704 brain: resolved_brain,
14705 seq,
14706 updated_at,
14707 feed_hash: advertised_hash,
14708 verified: false,
14709 },
14710 identity: None,
14711 head_entry: None,
14712 entries: Vec::new(),
14713 anchor: None,
14714 });
14715 }
14716 let page_identity = feed
14717 .identity
14718 .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14719 let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
14720 if identity
14721 .as_ref()
14722 .is_some_and(|existing| existing != &page_identity)
14723 {
14724 return Err(invalid_feed("identity changed while reading the feed"));
14725 }
14726 if anchor
14727 .as_ref()
14728 .is_some_and(|existing| existing != &page_anchor)
14729 {
14730 return Err(invalid_feed(
14731 "identity anchor changed while reading the feed",
14732 ));
14733 }
14734 identity = Some(page_identity.clone());
14735 if anchor.is_none() {
14736 anchor = Some(page_anchor);
14737 }
14738 if feed.entries.is_empty() {
14739 return Err(invalid_feed("feed page was empty before the signed head"));
14740 }
14741
14742 for item in feed.entries {
14743 if item.entry.seq != expected_seq {
14744 return Err(invalid_feed(format!(
14745 "expected entry {expected_seq}, feed served {}",
14746 item.entry.seq
14747 )));
14748 }
14749 if item.entry.seq > seq {
14750 return Err(invalid_feed("feed advanced past the card snapshot"));
14751 }
14752 if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
14753 return Err(invalid_feed(format!(
14754 "entry {} does not chain to the local checkpoint",
14755 item.entry.seq
14756 )));
14757 }
14758 verify_feed_item(&item, &page_identity)?;
14759 reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
14760 replay_bytes = replay_bytes.saturating_add(
14761 serde_json::to_vec(&item)
14762 .map_err(|_| invalid_feed("could not size feed entry"))?
14763 .len() as u64,
14764 );
14765 if replay_bytes > MAX_FEED_REPLAY_BYTES {
14766 return Err(invalid_feed("feed replay metadata exceeds the client cap"));
14767 }
14768 previous_hash = Some(item.hash.clone());
14769 after = item.entry.seq;
14770 expected_seq = expected_seq
14771 .checked_add(1)
14772 .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
14773 if require_full_chain {
14774 all_entries.push(item.clone());
14775 }
14776 observed_entries.push(item.clone());
14777 head_entry = Some(item);
14778 }
14779 if after == seq {
14780 break;
14781 }
14782 }
14783
14784 if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
14785 return Err(invalid_feed(
14786 "verified chain does not converge on the advertised head",
14787 ));
14788 }
14789 let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14790 let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
14791 verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
14792 save_canonical_pin_and_alias(
14793 cfg,
14794 &trust_directory,
14795 brain,
14796 &resolved_brain,
14797 TrustState {
14798 v: 2,
14799 origin: normalized_origin(&cfg.hub)?,
14800 requested: resolved_brain.clone(),
14801 brain: resolved_brain.clone(),
14802 home: None,
14803 anchor: anchor.clone(),
14804 current: format!("ed25519:{}", identity.fingerprint),
14805 head_seq: seq,
14806 feed_hash: advertised_hash.clone(),
14807 rotations: identity.rotations.clone(),
14808 hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
14809 protocol_profile: pinned
14810 .as_ref()
14811 .and_then(|state| state.protocol_profile.clone()),
14812 },
14813 alias_binding.as_ref(),
14814 )?;
14815 Ok(VerifiedRemote {
14816 head: Head {
14817 brain: resolved_brain,
14818 seq,
14819 updated_at,
14820 feed_hash: advertised_hash,
14821 verified: true,
14822 },
14823 identity: Some(identity),
14824 head_entry,
14825 entries: all_entries,
14826 anchor: Some(anchor),
14827 })
14828}
14829
14830pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
14835 if let Some(verified) = v2_verified_head(cfg, brain)? {
14836 let observation = Head {
14837 brain: verified.brain_id.clone(),
14838 seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14839 updated_at: verified
14840 .pointer
14841 .as_ref()
14842 .map(|pointer| pointer.signed_at.clone()),
14843 feed_hash: verified
14844 .pointer
14845 .as_ref()
14846 .map(|pointer| pointer.feed_hash.clone()),
14847 verified: true,
14848 };
14849 accept_v2_head(cfg, &verified)?;
14850 return Ok(observation);
14851 }
14852 Ok(verified_remote_head(cfg, brain, false)?.head)
14853}
14854
14855#[cfg(test)]
14856mod tests {
14857 use super::*;
14858
14859 const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
14860
14861 fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
14862 json!({
14863 "sha256": "a".repeat(64),
14864 "bytes": 10,
14865 "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
14866 })
14867 }
14868
14869 #[test]
14870 fn upload_reservations_batch_by_count_and_by_size() {
14871 let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
14875 let batches = batch_upload_declarations(declarations.clone());
14876
14877 assert!(batches.len() > 1, "5,000 blobs must not ride one request");
14878 for batch in &batches {
14879 assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
14880 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14881 .expect("batch serializes")
14882 .len();
14883 assert!(
14884 bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
14885 "batch body {bytes} exceeds the reservation budget"
14886 );
14887 }
14888 let flattened: Vec<Value> = batches.into_iter().flatten().collect();
14889 assert_eq!(
14890 flattened, declarations,
14891 "batching must preserve the set and order"
14892 );
14893 }
14894
14895 #[test]
14896 fn only_load_shaped_hub_answers_are_worth_asking_again() {
14897 for status in [408, 429, 500, 502, 503, 504] {
14902 assert!(is_retryable_hub_status(status), "{status} is load-shaped");
14903 }
14904 for status in [400, 401, 403, 404, 409, 413, 422] {
14905 assert!(
14906 !is_retryable_hub_status(status),
14907 "{status} states something about the request"
14908 );
14909 }
14910 let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
14912 assert!(total >= 60_000, "backoff totals only {total}ms");
14913 }
14914
14915 #[test]
14916 fn a_batch_shares_a_connection_only_within_one_authority() {
14917 let cfg = HubConfig {
14922 hub: "https://www.sevrahq.com".to_string(),
14923 key: Some("k".to_string()),
14924 agent_key: None,
14925 brain_key: None,
14926 state_dir: PathBuf::from("."),
14927 store_selected: false,
14928 };
14929 assert!(shared_staging_agent(&cfg, &[]).is_none());
14930 assert!(
14931 shared_staging_agent(
14932 &cfg,
14933 &[
14934 "https://one.example.com/a?sig=1",
14935 "https://two.example.com/b?sig=2",
14936 ]
14937 )
14938 .is_none(),
14939 "two authorities must not share a pinned pool"
14940 );
14941 assert!(
14942 shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
14943 "an unsafe object-store URL must not produce an agent"
14944 );
14945 assert!(
14946 shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
14947 "credentials in the URL must not produce an agent"
14948 );
14949 }
14950
14951 #[test]
14952 fn a_staged_change_states_only_operations_and_blobs() {
14953 let operations = vec![json!({
14957 "op": "put",
14958 "path": "records/a.md",
14959 "blob": "a".repeat(64),
14960 "bytes": 3,
14961 })];
14962 let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
14963 let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
14964 let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
14965 let keys: Vec<&str> = parsed
14966 .as_object()
14967 .expect("manifest is an object")
14968 .keys()
14969 .map(String::as_str)
14970 .collect();
14971 assert_eq!(keys, ["blobs", "operations"]);
14972 assert_eq!(parsed["operations"], Value::Array(operations));
14973 assert_eq!(parsed["blobs"], blobs);
14974 }
14975
14976 #[test]
14977 fn a_staged_push_signs_the_change_not_the_transport() {
14978 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14983 let staged = json!({
14984 "mutation_id": "dbmd-1",
14985 "rebase": "strict",
14986 "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
14987 });
14988 let view = v2_signed_request_view(&staged, &operations);
14989 assert_eq!(view["operations"], Value::Array(operations.clone()));
14990 assert!(view.get("staged_change").is_none());
14991 assert_eq!(view["mutation_id"], staged["mutation_id"]);
14992
14993 let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
14994 assert_eq!(v2_signed_request_view(&inline, &[]), inline);
14995 }
14996
14997 #[test]
14998 fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
14999 let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
15000 let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
15001 .expect_err("an oversized change must not be staged");
15002 assert!(
15003 matches!(error, LinkError::PushTooLarge { .. }),
15004 "expected a size refusal, got {error:?}"
15005 );
15006 }
15007
15008 #[test]
15009 fn a_push_that_fits_the_request_is_left_inline() {
15010 let cfg = HubConfig {
15014 hub: "http://127.0.0.1:9".to_string(),
15015 key: Some("k".to_string()),
15016 agent_key: None,
15017 brain_key: None,
15018 state_dir: PathBuf::from("."),
15019 store_selected: false,
15020 };
15021 let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15022 let mut body = json!({
15023 "mutation_id": "dbmd-1",
15024 "operations": operations,
15025 "blobs": [],
15026 });
15027 stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
15028 assert!(body.get("staged_change").is_none());
15029 assert_eq!(body["operations"], Value::Array(operations));
15030 }
15031
15032 #[test]
15033 fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
15034 let declarations: Vec<Value> = (0..2_000)
15038 .map(|index| {
15039 json!({
15040 "sha256": "a".repeat(64),
15041 "bytes": 10,
15042 "coordinates": (0..24)
15043 .map(|slot| format!(
15044 "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
15045 ))
15046 .collect::<Vec<_>>(),
15047 })
15048 })
15049 .collect();
15050 let batches = batch_upload_declarations(declarations);
15051 assert!(
15052 batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
15053 "wide coordinate sets must bound the batch by size"
15054 );
15055 for batch in &batches {
15056 let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15057 .expect("batch serializes")
15058 .len();
15059 assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
15060 }
15061 }
15062
15063 #[test]
15064 fn a_small_push_still_rides_exactly_one_request() {
15065 let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
15066 assert_eq!(batch_upload_declarations(declarations).len(), 1);
15067 assert!(batch_upload_declarations(Vec::new()).is_empty());
15068 }
15069
15070 #[test]
15071 fn exact_source_move_becomes_one_provenance_preserving_rename() {
15072 let hash = "a".repeat(64);
15073 let operations = vec![
15074 json!({
15075 "op": "put",
15076 "path": "sources/curated/item.md",
15077 "expected": { "kind": "absent" },
15078 "blob": hash,
15079 "bytes": 19,
15080 }),
15081 json!({
15082 "op": "delete",
15083 "path": "sources/inbox/item.md",
15084 "expected": { "kind": "blob", "hash": hash },
15085 }),
15086 ];
15087
15088 assert_eq!(
15089 infer_exact_source_promotions(operations),
15090 vec![json!({
15091 "op": "rename",
15092 "from": "sources/inbox/item.md",
15093 "to": "sources/curated/item.md",
15094 "expected_from": { "kind": "blob", "hash": hash },
15095 "expected_to": { "kind": "absent" },
15096 "blob": hash,
15097 "bytes": 19,
15098 })]
15099 );
15100 }
15101
15102 #[test]
15103 fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
15104 let hash = "b".repeat(64);
15105 let operations = vec![
15106 json!({
15107 "op": "delete",
15108 "path": "sources/inbox/a.md",
15109 "expected": { "kind": "blob", "hash": hash },
15110 }),
15111 json!({
15112 "op": "delete",
15113 "path": "sources/inbox/b.md",
15114 "expected": { "kind": "blob", "hash": hash },
15115 }),
15116 json!({
15117 "op": "put",
15118 "path": "sources/curated/item.md",
15119 "expected": { "kind": "absent" },
15120 "blob": hash,
15121 "bytes": 19,
15122 }),
15123 ];
15124
15125 assert_eq!(
15126 infer_exact_source_promotions(operations.clone()),
15127 operations,
15128 "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
15129 );
15130 }
15131
15132 #[test]
15133 fn accepted_source_promotion_advances_the_local_baseline_exactly() {
15134 let hash = "c".repeat(64);
15135 let mut candidate = std::collections::BTreeMap::from([(
15136 "sources/inbox/item.md".to_string(),
15137 V2BaselineFile {
15138 sha256: hash.clone(),
15139 bytes: 19,
15140 proof: None,
15141 },
15142 )]);
15143 let mut candidate_assets = std::collections::BTreeMap::new();
15144 let operations = vec![
15145 json!({
15146 "op": "rename",
15147 "from": "sources/inbox/item.md",
15148 "to": "sources/curated/item.md",
15149 "expected_from": { "kind": "blob", "hash": hash },
15150 "expected_to": { "kind": "absent" },
15151 "blob": hash,
15152 "bytes": 19,
15153 }),
15154 json!({
15155 "op": "put",
15156 "path": "records/rsvps/item.md",
15157 "expected": { "kind": "absent" },
15158 "blob": "d".repeat(64),
15159 "bytes": 23,
15160 }),
15161 ];
15162
15163 assert!(!apply_generated_v2_operations(
15164 &operations,
15165 &std::collections::BTreeMap::new(),
15166 &mut candidate,
15167 &mut candidate_assets,
15168 )
15169 .unwrap());
15170 assert!(!candidate.contains_key("sources/inbox/item.md"));
15171 assert_eq!(
15172 candidate
15173 .get("sources/curated/item.md")
15174 .map(|file| (&file.sha256, file.bytes)),
15175 Some((&hash, 19))
15176 );
15177 assert_eq!(
15178 candidate
15179 .get("records/rsvps/item.md")
15180 .map(|file| (file.sha256.as_str(), file.bytes)),
15181 Some((
15182 "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
15183 23
15184 ))
15185 );
15186 }
15187
15188 fn merge_fixture(
15189 base: Option<&str>,
15190 remote: Option<&str>,
15191 local: Option<&str>,
15192 keep_local: bool,
15193 ) -> V2PulledMerge<String> {
15194 let map = |value: Option<&str>| {
15195 value
15196 .map(|value| [("records/a.md".to_string(), value.to_string())])
15197 .into_iter()
15198 .flatten()
15199 .collect::<std::collections::BTreeMap<_, _>>()
15200 };
15201 merge_v2_pulled_records(
15202 &map(base),
15203 &map(remote),
15204 &map(local),
15205 |value, _| value.clone(),
15206 |value, _| value.clone(),
15207 |_| keep_local,
15208 )
15209 }
15210
15211 #[test]
15212 fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
15213 let path = "records/a.md".to_string();
15214
15215 let local_add = merge_fixture(None, None, Some("local"), false);
15216 assert_eq!(
15217 local_add.records.get(&path).map(String::as_str),
15218 Some("local")
15219 );
15220 assert!(local_add.accept_remote.is_empty());
15221 assert!(local_add.conflicts.is_empty());
15222
15223 let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
15224 assert_eq!(
15225 local_edit.records.get(&path).map(String::as_str),
15226 Some("local")
15227 );
15228 assert!(local_edit.accept_remote.is_empty());
15229 assert!(local_edit.conflicts.is_empty());
15230
15231 let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
15232 assert!(!local_delete.records.contains_key(&path));
15233 assert!(local_delete.accept_remote.is_empty());
15234 assert!(local_delete.conflicts.is_empty());
15235
15236 let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
15237 assert_eq!(
15238 remote_edit.records.get(&path).map(String::as_str),
15239 Some("remote")
15240 );
15241 assert!(remote_edit.accept_remote.contains(&path));
15242 assert!(remote_edit.conflicts.is_empty());
15243
15244 let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
15245 assert!(!remote_delete.records.contains_key(&path));
15246 assert!(remote_delete.accept_remote.contains(&path));
15247 assert!(remote_delete.conflicts.is_empty());
15248
15249 let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
15250 assert_eq!(
15251 same_edit.records.get(&path).map(String::as_str),
15252 Some("same")
15253 );
15254 assert!(same_edit.accept_remote.contains(&path));
15255 assert!(same_edit.conflicts.is_empty());
15256
15257 let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
15258 assert_eq!(conflict.conflicts, vec![path.clone()]);
15259 assert_eq!(
15260 conflict.records.get(&path).map(String::as_str),
15261 Some("local")
15262 );
15263 assert!(conflict.accept_remote.is_empty());
15264
15265 let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
15266 assert_eq!(
15267 kept_home.records.get(&path).map(String::as_str),
15268 Some("local")
15269 );
15270 assert!(kept_home.accept_remote.is_empty());
15271 assert!(kept_home.conflicts.is_empty());
15272 }
15273
15274 #[test]
15275 fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
15276 let path = "sources/report.pdf";
15277 let record = crate::AssetRecord {
15278 path: path.to_string(),
15279 sha256: "a".repeat(64),
15280 bytes: 42,
15281 media_type: "application/pdf".to_string(),
15282 wrappers: vec!["gzip".to_string()],
15283 required: true,
15284 };
15285 let mut remote = V2BaselineAsset {
15286 blob_sha256: record.sha256.clone(),
15287 bytes: record.bytes,
15288 media_type: record.media_type.clone(),
15289 wrappers: record.wrappers.clone(),
15290 required: record.required,
15291 disposition: "withheld".to_string(),
15292 leaf_hash: "b".repeat(64),
15293 };
15294
15295 assert!(v2_asset_resumes_hosting(
15296 Some(&remote),
15297 path,
15298 &record,
15299 "hosted"
15300 ));
15301 assert!(!v2_asset_resumes_hosting(
15302 Some(&remote),
15303 path,
15304 &record,
15305 "withheld"
15306 ));
15307
15308 remote.disposition = "hosted".to_string();
15309 assert!(!v2_asset_resumes_hosting(
15310 Some(&remote),
15311 path,
15312 &record,
15313 "hosted"
15314 ));
15315
15316 remote.disposition = "withheld".to_string();
15317 remote.blob_sha256 = "c".repeat(64);
15318 assert!(!v2_asset_resumes_hosting(
15319 Some(&remote),
15320 path,
15321 &record,
15322 "hosted"
15323 ));
15324 assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
15325 }
15326
15327 #[test]
15328 fn v2_fresh_clone_preserves_only_exact_inherited_withheld_asset_absence() {
15329 let path = "sources/report.pdf";
15330 let record = crate::AssetRecord {
15331 path: path.to_string(),
15332 sha256: "a".repeat(64),
15333 bytes: 42,
15334 media_type: "application/pdf".to_string(),
15335 wrappers: vec!["records/report.md".to_string()],
15336 required: true,
15337 };
15338 let mut base = V2BaselineAsset {
15339 blob_sha256: record.sha256.clone(),
15340 bytes: record.bytes,
15341 media_type: record.media_type.clone(),
15342 wrappers: record.wrappers.clone(),
15343 required: record.required,
15344 disposition: "withheld".to_string(),
15345 leaf_hash: "b".repeat(64),
15346 };
15347
15348 assert!(v2_asset_inherits_withheld_absence(
15349 Some(&base),
15350 Some(&record),
15351 Some(&record),
15352 false,
15353 ));
15354 assert!(!v2_asset_inherits_withheld_absence(
15355 Some(&base),
15356 Some(&record),
15357 Some(&record),
15358 true,
15359 ));
15360
15361 base.disposition = "hosted".to_string();
15362 assert!(!v2_asset_inherits_withheld_absence(
15363 Some(&base),
15364 Some(&record),
15365 Some(&record),
15366 false,
15367 ));
15368
15369 base.disposition = "withheld".to_string();
15370 let mut changed = record.clone();
15371 changed.bytes += 1;
15372 assert!(!v2_asset_inherits_withheld_absence(
15373 Some(&base),
15374 Some(&record),
15375 Some(&changed),
15376 false,
15377 ));
15378 assert!(!v2_asset_inherits_withheld_absence(
15379 None,
15380 None,
15381 Some(&record),
15382 false,
15383 ));
15384 }
15385
15386 #[test]
15387 fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
15388 let path = "records/team/alpha.md".to_string();
15389 let deleted_path = "records/team/deleted.md".to_string();
15390 let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
15391 sha256,
15392 bytes,
15393 file: None,
15394 };
15395 let files = vec![
15396 V2ConflictFile {
15397 path: path.clone(),
15398 base: coordinate(None, None),
15399 local: coordinate(Some("b".repeat(64)), Some(7)),
15400 remote: coordinate(Some("a".repeat(64)), Some(5)),
15401 },
15402 V2ConflictFile {
15403 path: deleted_path.clone(),
15404 base: coordinate(Some("c".repeat(64)), Some(9)),
15405 local: coordinate(Some("d".repeat(64)), Some(11)),
15406 remote: coordinate(None, None),
15407 },
15408 ];
15409 let proven = V2BaselineFile {
15410 sha256: "a".repeat(64),
15411 bytes: 5,
15412 proof: None,
15413 };
15414 let current = [(path.clone(), proven.clone())]
15415 .into_iter()
15416 .collect::<std::collections::BTreeMap<_, _>>();
15417
15418 let (selected, deleted) = v2_take_remote_selection(&files, ¤t).unwrap();
15419 assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
15420 assert_eq!(deleted, vec![deleted_path.clone()]);
15421
15422 let changed = [(
15423 path.clone(),
15424 V2BaselineFile {
15425 sha256: "e".repeat(64),
15426 bytes: 5,
15427 proof: None,
15428 },
15429 )]
15430 .into_iter()
15431 .collect::<std::collections::BTreeMap<_, _>>();
15432 assert!(v2_take_remote_selection(&files, &changed).is_err());
15433
15434 let resurrected = [
15435 (path, proven),
15436 (
15437 deleted_path,
15438 V2BaselineFile {
15439 sha256: "f".repeat(64),
15440 bytes: 13,
15441 proof: None,
15442 },
15443 ),
15444 ]
15445 .into_iter()
15446 .collect::<std::collections::BTreeMap<_, _>>();
15447 assert!(v2_take_remote_selection(&files, &resurrected).is_err());
15448 }
15449
15450 #[cfg(target_os = "linux")]
15451 #[test]
15452 fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
15453 use std::os::fd::AsRawFd as _;
15454
15455 let sandbox = tempfile::TempDir::new().unwrap();
15456 let parent = std::fs::File::open(sandbox.path()).unwrap();
15457 let stage = std::ffi::CString::new("stage").unwrap();
15458 let destination = std::ffi::CString::new("brain").unwrap();
15459
15460 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15461 std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
15462 install_stage_at(
15463 parent.as_raw_fd(),
15464 stage.as_c_str(),
15465 destination.as_c_str(),
15466 false,
15467 )
15468 .unwrap();
15469 assert!(!sandbox.path().join("stage").exists());
15470 assert_eq!(
15471 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15472 b"created"
15473 );
15474
15475 std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15476 std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
15477 install_stage_at(
15478 parent.as_raw_fd(),
15479 stage.as_c_str(),
15480 destination.as_c_str(),
15481 true,
15482 )
15483 .unwrap();
15484 assert_eq!(
15485 std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15486 b"replacement"
15487 );
15488 assert_eq!(
15489 std::fs::read(sandbox.path().join("stage/value")).unwrap(),
15490 b"created",
15491 "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
15492 );
15493 }
15494
15495 struct SignedRemoteFixture {
15496 card: String,
15497 feed: String,
15498 key: AgentSigningKey,
15499 identity: FeedIdentity,
15500 }
15501
15502 fn signed_remote_fixture() -> SignedRemoteFixture {
15503 let rng = ring::rand::SystemRandom::new();
15504 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15505 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15506 let (public_key, multikey) = public_identity_for(&pair);
15507 let identity = FeedIdentity {
15508 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15509 public_key_spki: public_key.clone(),
15510 previous: Vec::new(),
15511 rotations: Vec::new(),
15512 };
15513 let mut entry = FeedEntry {
15514 v: 1,
15515 seq: 1,
15516 ts: "2026-07-30T12:00:00.000Z".to_string(),
15517 brain: multikey.clone(),
15518 public_key: public_key.clone(),
15519 kind: "push".to_string(),
15520 op: "snapshot".to_string(),
15521 pack_sha256: "a".repeat(64),
15522 files: Vec::new(),
15523 removed: Vec::new(),
15524 prev_entry_hash: None,
15525 sig: String::new(),
15526 };
15527 let unsigned = UnsignedFeedEntry {
15528 v: entry.v,
15529 seq: entry.seq,
15530 ts: &entry.ts,
15531 brain: &entry.brain,
15532 public_key: &entry.public_key,
15533 kind: &entry.kind,
15534 op: &entry.op,
15535 pack_sha256: &entry.pack_sha256,
15536 files: &entry.files,
15537 removed: &entry.removed,
15538 prev_entry_hash: &entry.prev_entry_hash,
15539 };
15540 entry.sig =
15541 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15542 let mut exact = serde_json::to_vec(&entry).unwrap();
15543 exact.push(b'\n');
15544 let hash = content_sha256(&exact);
15545 let card = json!({
15546 "id": TEST_BRAIN_ID,
15547 "headSeq": 1,
15548 "feedHash": hash,
15549 "identity": identity.clone(),
15550 })
15551 .to_string();
15552 let feed = json!({
15553 "headSeq": 1,
15554 "feedHash": hash,
15555 "identity": identity.clone(),
15556 "entries": [{"hash": hash, "entry": entry}],
15557 "scopeLimited": false,
15558 })
15559 .to_string();
15560 SignedRemoteFixture {
15561 card,
15562 feed,
15563 key: AgentSigningKey {
15564 pkcs8: pkcs8.as_ref().to_vec(),
15565 multikey,
15566 public_key_spki: public_key,
15567 },
15568 identity,
15569 }
15570 }
15571
15572 #[test]
15573 fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
15574 let file = |path: &str, byte: char| FeedFile {
15575 path: path.to_string(),
15576 sha256: byte.to_string().repeat(64),
15577 bytes: 1,
15578 };
15579 let a0 = file("records/a.md", 'a');
15580 let a1 = file("records/a.md", 'b');
15581 let stable = file("records/stable.md", 'c');
15582 let added = file("records/added.md", 'd');
15583 let removed_file = file("records/removed.md", 'e');
15584 let previous = vec![a0, stable.clone(), removed_file.clone()];
15585 let resulting = vec![a1.clone(), stable.clone(), added.clone()];
15586 let removed = vec![removed_file.path.clone()];
15587
15588 assert_eq!(
15589 verify_v1_manifest_disclosure(
15590 "edit",
15591 &previous,
15592 &resulting,
15593 &[a1.clone(), added.clone()],
15594 &removed,
15595 ),
15596 Ok(())
15597 );
15598 assert_eq!(
15599 verify_v1_manifest_disclosure(
15600 "edit",
15601 &previous,
15602 &resulting,
15603 &[stable.clone(), added.clone(), a1.clone()],
15604 &removed,
15605 ),
15606 Ok(())
15607 );
15608 assert_eq!(
15609 verify_v1_manifest_disclosure(
15610 "edit",
15611 &previous,
15612 &resulting,
15613 std::slice::from_ref(&added),
15614 &removed,
15615 ),
15616 Err(V1DisclosureError::EditMissingChange)
15617 );
15618 assert_eq!(
15619 verify_v1_manifest_disclosure(
15620 "edit",
15621 &previous,
15622 &resulting,
15623 &[file("records/a.md", 'f'), added.clone()],
15624 &removed,
15625 ),
15626 Err(V1DisclosureError::EditFalseFile)
15627 );
15628 assert_eq!(
15629 verify_v1_manifest_disclosure(
15630 "edit",
15631 &previous,
15632 &resulting,
15633 &[a1.clone(), added.clone()],
15634 &[],
15635 ),
15636 Err(V1DisclosureError::RemovedMismatch)
15637 );
15638 assert_eq!(
15639 verify_v1_manifest_disclosure(
15640 "push",
15641 &previous,
15642 &resulting,
15643 &[added.clone(), stable, a1],
15644 &removed,
15645 ),
15646 Ok(())
15647 );
15648 assert_eq!(
15649 verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
15650 Err(V1DisclosureError::PushManifestMismatch)
15651 );
15652 }
15653
15654 #[test]
15655 fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
15656 let fixture = signed_remote_fixture();
15657 let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
15658 let item = feed["entries"][0].to_string();
15659 let oversized_page = format!(
15660 "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
15661 std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
15662 .collect::<Vec<_>>()
15663 .join(",")
15664 );
15665 assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
15666
15667 let oversized_identity = format!(
15668 "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
15669 std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
15670 .collect::<Vec<_>>()
15671 .join(",")
15672 );
15673 assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
15674
15675 let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
15676 let oversized_entry = format!(
15677 "{{\"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\"}}",
15678 "a".repeat(64),
15679 std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
15680 .collect::<Vec<_>>()
15681 .join(",")
15682 );
15683 assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
15684 }
15685
15686 #[test]
15687 fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
15688 let id = "01arz3ndektsv4rrffq69g5fav";
15689 let digest = "a".repeat(64);
15690 assert_eq!(
15691 V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
15692 V2BulkConfirmation {
15693 id: id.to_string(),
15694 digest,
15695 }
15696 );
15697 for invalid in [
15698 "",
15699 "01arz3ndektsv4rrffq69g5fav",
15700 "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15701 "01arz3ndektsv4rrffq69g5fav:ABCDEF",
15702 "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15703 ] {
15704 assert!(matches!(
15705 V2BulkConfirmation::parse(invalid),
15706 Err(LinkError::InvalidPack { .. })
15707 ));
15708 }
15709 }
15710
15711 fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
15712 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15713 use std::net::TcpListener;
15714
15715 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15716 let url = format!("http://{}", listener.local_addr().unwrap());
15717 let handle = std::thread::spawn(move || {
15718 for (status, body) in responses {
15719 let (stream, _) = listener.accept().unwrap();
15720 let mut reader = BufReader::new(stream);
15721 let mut line = String::new();
15722 reader.read_line(&mut line).unwrap();
15723 let mut content_length = 0usize;
15724 loop {
15725 line.clear();
15726 reader.read_line(&mut line).unwrap();
15727 if line == "\r\n" || line == "\n" || line.is_empty() {
15728 break;
15729 }
15730 if let Some((name, value)) = line.split_once(':') {
15731 if name.eq_ignore_ascii_case("content-length") {
15732 content_length = value.trim().parse().unwrap();
15733 }
15734 }
15735 }
15736 let mut request_body = vec![0_u8; content_length];
15737 reader.read_exact(&mut request_body).unwrap();
15738 let response = format!(
15739 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15740 body.len()
15741 );
15742 reader.get_mut().write_all(response.as_bytes()).unwrap();
15743 }
15744 });
15745 (url, handle)
15746 }
15747
15748 fn routed_json_hub(
15749 requests: usize,
15750 mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
15751 ) -> (String, std::thread::JoinHandle<()>) {
15752 use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15753 use std::net::TcpListener;
15754
15755 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15756 let url = format!("http://{}", listener.local_addr().unwrap());
15757 let handle = std::thread::spawn(move || {
15758 for _ in 0..requests {
15759 let (stream, _) = listener.accept().unwrap();
15760 let mut reader = BufReader::new(stream);
15761 let mut line = String::new();
15762 reader.read_line(&mut line).unwrap();
15763 let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
15764 let mut content_length = 0usize;
15765 loop {
15766 line.clear();
15767 reader.read_line(&mut line).unwrap();
15768 if line == "\r\n" || line == "\n" || line.is_empty() {
15769 break;
15770 }
15771 if let Some((name, value)) = line.split_once(':') {
15772 if name.eq_ignore_ascii_case("content-length") {
15773 content_length = value.trim().parse().unwrap();
15774 }
15775 }
15776 }
15777 let mut request_body = vec![0_u8; content_length];
15778 reader.read_exact(&mut request_body).unwrap();
15779 let (status, body) = respond(&path);
15780 let response = format!(
15781 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15782 body.len()
15783 );
15784 reader.get_mut().write_all(response.as_bytes()).unwrap();
15785 }
15786 });
15787 (url, handle)
15788 }
15789
15790 fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
15791 HubConfig {
15792 hub,
15793 key: Some("test-key".to_string()),
15794 agent_key: None,
15795 brain_key: None,
15796 state_dir,
15797 store_selected: false,
15798 }
15799 }
15800
15801 #[cfg(any(unix, windows))]
15802 #[test]
15803 fn an_expired_asset_capability_is_refreshed_without_losing_cached_progress() {
15804 use std::sync::{Arc, Mutex};
15805
15806 let bytes = b"immutable asset bytes".to_vec();
15807 let sha256 = content_sha256(&bytes);
15808 let commit_hash = "c".repeat(64);
15809 let base_url = Arc::new(Mutex::new(String::new()));
15810 let server_base = Arc::clone(&base_url);
15811 let object_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15812 let server_attempt = Arc::clone(&object_attempt);
15813 let response_bytes = bytes.clone();
15814 let response_sha = sha256.clone();
15815 let response_commit = commit_hash.clone();
15816 let (hub, server) = routed_json_hub(4, move |path| {
15817 if path.contains("/v2/assets/downloads") {
15818 let attempt = server_attempt.load(std::sync::atomic::Ordering::SeqCst) + 1;
15819 let url = format!("{}/asset/{attempt}", server_base.lock().unwrap());
15820 return (
15821 200,
15822 json!({
15823 "v": 2,
15824 "commit": response_commit,
15825 "downloads": [{
15826 "path": "assets/proof.bin",
15827 "sha256": response_sha,
15828 "bytes": response_bytes.len(),
15829 "url": url,
15830 "method": "GET"
15831 }]
15832 })
15833 .to_string(),
15834 );
15835 }
15836 let attempt = server_attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15837 if attempt == 0 {
15838 (403, "{}".to_string())
15839 } else {
15840 (200, String::from_utf8(response_bytes.clone()).unwrap())
15841 }
15842 });
15843 *base_url.lock().unwrap() = hub.clone();
15844
15845 let temp = tempfile::tempdir().unwrap();
15846 let cache = temp.path().join("cache");
15847 std::fs::create_dir(&cache).unwrap();
15848 let cfg = test_hub_config(hub, temp.path().to_path_buf());
15849 let pointer = V2PointerBody {
15850 v: 2,
15851 brain: TEST_BRAIN_ID.to_string(),
15852 seq: 1,
15853 commit_hash,
15854 feed_hash: "f".repeat(64),
15855 content_root: Some("a".repeat(64)),
15856 asset_root: Some("b".repeat(64)),
15857 materializer: "m".repeat(64),
15858 signer_epoch: 1,
15859 control_revision: "d".repeat(64),
15860 backup_preparation: "ready".to_string(),
15861 prior_pointer_hash: None,
15862 signed_at: "2026-08-23T00:00:00Z".to_string(),
15863 };
15864 let path = "assets/proof.bin".to_string();
15865 let asset = V2BaselineAsset {
15866 blob_sha256: sha256.clone(),
15867 bytes: bytes.len() as u64,
15868 media_type: "application/octet-stream".to_string(),
15869 wrappers: Vec::new(),
15870 required: true,
15871 disposition: "hosted".to_string(),
15872 leaf_hash: "e".repeat(64),
15873 };
15874
15875 let staged = stage_v2_asset_download_window(
15876 &cfg,
15877 TEST_BRAIN_ID,
15878 &pointer,
15879 &cache,
15880 &[(&path, &asset)],
15881 )
15882 .expect("a fresh authority-checked capability recovers an expired one");
15883 assert_eq!(staged.len(), 1);
15884 assert_eq!(staged[0].path, path);
15885 assert_eq!(std::fs::read(cache.join(sha256)).unwrap(), bytes);
15886 assert_eq!(object_attempt.load(std::sync::atomic::Ordering::SeqCst), 2);
15887 server.join().unwrap();
15888 }
15889
15890 #[cfg(any(unix, windows))]
15891 #[test]
15892 fn asset_capability_windows_cannot_exceed_the_worker_bound() {
15893 let temp = tempfile::tempdir().unwrap();
15894 let cfg = test_hub_config("http://127.0.0.1:1".to_string(), temp.path().to_path_buf());
15895 let pointer = V2PointerBody {
15896 v: 2,
15897 brain: TEST_BRAIN_ID.to_string(),
15898 seq: 1,
15899 commit_hash: "c".repeat(64),
15900 feed_hash: "f".repeat(64),
15901 content_root: Some("a".repeat(64)),
15902 asset_root: Some("b".repeat(64)),
15903 materializer: "m".repeat(64),
15904 signer_epoch: 1,
15905 control_revision: "d".repeat(64),
15906 backup_preparation: "ready".to_string(),
15907 prior_pointer_hash: None,
15908 signed_at: "2026-08-23T00:00:00Z".to_string(),
15909 };
15910 let paths = (0..=V2_DOWNLOAD_CAPABILITY_FILES)
15911 .map(|index| format!("assets/{index}.bin"))
15912 .collect::<Vec<_>>();
15913 let assets = paths
15914 .iter()
15915 .map(|_| V2BaselineAsset {
15916 blob_sha256: "a".repeat(64),
15917 bytes: 1,
15918 media_type: "application/octet-stream".to_string(),
15919 wrappers: Vec::new(),
15920 required: true,
15921 disposition: "hosted".to_string(),
15922 leaf_hash: "b".repeat(64),
15923 })
15924 .collect::<Vec<_>>();
15925 let pending = paths.iter().zip(&assets).collect::<Vec<_>>();
15926
15927 let error =
15928 stage_v2_asset_download_window(&cfg, TEST_BRAIN_ID, &pointer, temp.path(), &pending)
15929 .expect_err("an oversized window must fail before any network request");
15930 assert!(matches!(error, LinkError::InvalidFeed { .. }));
15931 }
15932
15933 #[test]
15934 fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
15935 use ring::signature::KeyPair as _;
15936
15937 let rng = ring::rand::SystemRandom::new();
15938 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15939 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15940 let (spki, multikey) = public_identity_for(&pair);
15941 let key = AgentSigningKey {
15942 pkcs8: pkcs8.as_ref().to_vec(),
15943 multikey,
15944 public_key_spki: spki,
15945 };
15946 let header = linkmd_sig_header(
15947 &key,
15948 "https://hub-a.example",
15949 "post",
15950 "/api/hub/brains/brain/push?mode=exact",
15951 Some("{\"ok\":true}"),
15952 )
15953 .unwrap();
15954 assert!(header.starts_with("LinkMD-Sig v2,"));
15955 let ts = header
15956 .split(",ts=")
15957 .nth(1)
15958 .unwrap()
15959 .split(',')
15960 .next()
15961 .unwrap();
15962 let signature = URL_SAFE_NO_PAD
15963 .decode(header.rsplit(",sig=").next().unwrap())
15964 .unwrap();
15965 let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
15966 let accepted = format!(
15967 "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15968 );
15969 let replayed = format!(
15970 "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15971 );
15972 let public = pair.public_key().as_ref();
15973 assert!(UnparsedPublicKey::new(&ED25519, public)
15974 .verify(accepted.as_bytes(), &signature)
15975 .is_ok());
15976 assert!(
15977 UnparsedPublicKey::new(&ED25519, public)
15978 .verify(replayed.as_bytes(), &signature)
15979 .is_err(),
15980 "a proof captured at hub A must not authenticate at hub B"
15981 );
15982 }
15983
15984 #[test]
15985 fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
15986 let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15987 let card = json!({
15988 "id": other,
15989 "headSeq": 0,
15990 "identity": signed_remote_fixture().identity,
15991 })
15992 .to_string();
15993 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15994 let state = tempfile::tempdir().unwrap();
15995 let cfg = test_hub_config(hub, state.path().to_path_buf());
15996 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15997 assert!(
15998 error.contains("differs from the explicitly requested"),
15999 "{error}"
16000 );
16001 server.join().unwrap();
16002 }
16003
16004 #[test]
16005 fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
16006 let first = signed_remote_fixture().identity;
16007 let second = signed_remote_fixture().identity;
16008 let card = |identity: FeedIdentity| {
16009 json!({
16010 "id": TEST_BRAIN_ID,
16011 "headSeq": 0,
16012 "identity": identity,
16013 })
16014 .to_string()
16015 };
16016 let (hub, server) = scripted_json_hub(vec![
16017 (404, "{}".to_string()),
16018 (200, card(first)),
16019 (404, "{}".to_string()),
16020 (200, card(second)),
16021 ]);
16022 let state = tempfile::tempdir().unwrap();
16023 let cfg = test_hub_config(hub, state.path().to_path_buf());
16024 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16025 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16026 assert!(
16027 error.contains("pinned anchor") || error.contains("forked away"),
16028 "{error}"
16029 );
16030 server.join().unwrap();
16031 }
16032
16033 #[test]
16034 fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
16035 let old = signed_remote_fixture();
16036 let new = signed_remote_fixture();
16037 let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
16038 let unsigned = serde_json::to_string(&UnsignedRotation {
16039 v: 1,
16040 op: "rotate",
16041 brain: &old.key.multikey,
16042 public_key: &old.key.public_key_spki,
16043 new_brain: &new.key.multikey,
16044 new_public_key: &new.key.public_key_spki,
16045 prior_head_seq: 1,
16046 prior_feed_hash: Some(&"a".repeat(64)),
16047 ts: "2026-07-30T12:00:00.000Z".to_string(),
16048 })
16049 .unwrap();
16050 let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
16051 let rotation = format!(
16052 "{},\"sig\":\"{}\"}}",
16053 &unsigned[..unsigned.len() - 1],
16054 signature
16055 );
16056 let identity = FeedIdentity {
16057 fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
16058 public_key_spki: new.key.public_key_spki,
16059 previous: vec![PreviousIdentity {
16060 fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
16061 public_key_spki: old.key.public_key_spki,
16062 }],
16063 rotations: vec![rotation],
16064 };
16065 let card = json!({
16066 "id": TEST_BRAIN_ID,
16067 "headSeq": 0,
16068 "feedHash": null,
16069 "identity": identity,
16070 })
16071 .to_string();
16072 let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
16073 let state = tempfile::tempdir().unwrap();
16074 let cfg = test_hub_config(hub, state.path().to_path_buf());
16075 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16076 assert!(
16077 error.contains("rotation claims a feed boundary beyond the advertised head"),
16078 "{error}"
16079 );
16080 assert!(
16081 load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
16082 "an inconsistent empty-head identity must not become the TOFU checkpoint"
16083 );
16084 server.join().unwrap();
16085 }
16086
16087 #[test]
16088 fn trust_checkpoint_rejects_a_later_fork() {
16089 let fixture = signed_remote_fixture();
16090 let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
16091 fork["feedHash"] = Value::String("b".repeat(64));
16092 let (hub, server) = scripted_json_hub(vec![
16093 (404, "{}".to_string()),
16094 (200, fixture.card),
16095 (200, fixture.feed),
16096 (404, "{}".to_string()),
16097 (200, fork.to_string()),
16098 ]);
16099 let state = tempfile::tempdir().unwrap();
16100 let cfg = test_hub_config(hub, state.path().to_path_buf());
16101 assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16102 assert!(head(&cfg, TEST_BRAIN_ID).is_err());
16103 server.join().unwrap();
16104 }
16105
16106 #[test]
16107 fn alias_and_canonical_id_share_one_identity_checkpoint() {
16108 let trusted = signed_remote_fixture();
16109 let attacker = signed_remote_fixture();
16110 let (hub, server) = scripted_json_hub(vec![
16111 (404, "{}".to_string()),
16112 (200, trusted.card),
16113 (200, trusted.feed),
16114 (404, "{}".to_string()),
16115 (200, attacker.card),
16116 ]);
16117 let state = tempfile::tempdir().unwrap();
16118 let cfg = test_hub_config(hub, state.path().to_path_buf());
16119 assert!(head(&cfg, "trusted-slug").unwrap().verified);
16120 let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16121 assert!(
16122 error.contains("equivocation")
16123 || error.contains("pinned")
16124 || error.contains("identity"),
16125 "{error}"
16126 );
16127 server.join().unwrap();
16128 }
16129
16130 #[test]
16131 fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
16132 let state = tempfile::tempdir().unwrap();
16133 let cfg = test_hub_config(
16134 "https://hub.example".to_string(),
16135 state.path().to_path_buf(),
16136 );
16137 let directory = open_trust_dir(&cfg).unwrap();
16138 let old = TEST_BRAIN_ID;
16139 let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16140 save_alias_in(
16141 &cfg,
16142 &directory,
16143 &AliasBinding {
16144 v: 1,
16145 origin: normalized_origin(&cfg.hub).unwrap(),
16146 requested: "company-brain".to_string(),
16147 brain: old.to_string(),
16148 home: Some("company-brain".to_string()),
16149 },
16150 )
16151 .unwrap();
16152
16153 let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
16154 assert!(matches!(
16155 error,
16156 LinkError::AliasRebindRequired {
16157 alias,
16158 from,
16159 to
16160 } if alias == "company-brain" && from == old && to == new
16161 ));
16162 let unchanged = load_alias_in(&cfg, &directory, "company-brain")
16163 .unwrap()
16164 .unwrap();
16165 assert_eq!(unchanged.brain, old);
16166 assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
16167 }
16168
16169 #[test]
16170 fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
16171 let alpha = signed_remote_fixture();
16172 let beta = signed_remote_fixture();
16173 let alpha_card = alpha.card.clone();
16174 let alpha_feed = alpha.feed.clone();
16175 let beta_card = beta.card.clone();
16176 let beta_feed = beta.feed.clone();
16177 let (hub, server) = routed_json_hub(5, move |path| {
16178 if path.ends_with("/v2/head") {
16179 (404, "{}".to_string())
16180 } else if path.contains("/alpha/feed?") {
16181 (200, alpha_feed.clone())
16182 } else if path.contains("/beta/feed?") {
16183 (200, beta_feed.clone())
16184 } else if path.ends_with("/alpha") {
16185 (200, alpha_card.clone())
16186 } else if path.ends_with("/beta") {
16187 (200, beta_card.clone())
16188 } else {
16189 (500, r#"{"error":"unexpected path"}"#.to_string())
16190 }
16191 });
16192 let state = tempfile::tempdir().unwrap();
16193 let cfg = test_hub_config(hub, state.path().to_path_buf());
16194 let alpha_cfg = cfg.clone();
16195 let beta_cfg = cfg;
16196 let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
16197 let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
16198 let results = [first.join().unwrap(), second.join().unwrap()];
16199 assert_eq!(
16200 results.iter().filter(|result| result.is_ok()).count(),
16201 1,
16202 "only one alias identity may establish canonical TOFU: {results:?}"
16203 );
16204 assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
16205 server.join().unwrap();
16206 }
16207
16208 #[cfg(unix)]
16209 #[test]
16210 fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
16211 use std::os::unix::fs::symlink;
16212
16213 let fixture = signed_remote_fixture();
16214 let card = json!({
16215 "id": TEST_BRAIN_ID,
16216 "headSeq": 0,
16217 "feedHash": Value::Null,
16218 "identity": fixture.identity,
16219 })
16220 .to_string();
16221 let work = tempfile::tempdir().unwrap();
16222 let outside = tempfile::tempdir().unwrap();
16223 let state = work.path().join("state");
16224 let moved = work.path().join("state-held");
16225 let swap_state = state.clone();
16226 let swap_moved = moved.clone();
16227 let outside_path = outside.path().to_path_buf();
16228 let (hub, server) = routed_json_hub(1, move |_| {
16229 std::fs::rename(&swap_state, &swap_moved).unwrap();
16231 symlink(&outside_path, &swap_state).unwrap();
16232 (200, card.clone())
16233 });
16234 let cfg = test_hub_config(hub, state);
16235
16236 let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
16237 assert_eq!(verified.head.seq, 0);
16238 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
16239 assert!(std::fs::read_dir(moved.join("trust"))
16240 .unwrap()
16241 .flatten()
16242 .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
16243 server.join().unwrap();
16244 }
16245
16246 #[test]
16247 fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
16248 let remote = signed_remote_fixture();
16249 let unrelated = signed_remote_fixture().key;
16250 let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
16251 let state = tempfile::tempdir().unwrap();
16252 let mut cfg = test_hub_config(hub, state.path().to_path_buf());
16253 cfg.brain_key = Some(unrelated);
16254 let error = sync_push(
16255 &cfg,
16256 TEST_BRAIN_ID,
16257 &[("DB.md".to_string(), "signed local content".to_string())],
16258 )
16259 .unwrap_err()
16260 .to_string();
16261 assert!(
16262 error.contains("not the verified current brain identity"),
16263 "{error}"
16264 );
16265 server.join().unwrap();
16266 }
16267
16268 #[test]
16269 fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
16270 let remote = signed_remote_fixture();
16271 let new = signed_remote_fixture().key;
16272 let state = tempfile::tempdir().unwrap();
16273 let new_file = state.path().join("new.key");
16274 std::fs::write(
16275 &new_file,
16276 format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
16277 )
16278 .unwrap();
16279 #[cfg(unix)]
16280 {
16281 use std::os::unix::fs::PermissionsExt as _;
16282 std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
16283 }
16284 let forged = json!({
16285 "brain": TEST_BRAIN_ID,
16286 "identity": {
16287 "fingerprint": new.multikey.trim_start_matches("ed25519:"),
16288 "publicKeySpki": new.public_key_spki,
16289 }
16290 })
16291 .to_string();
16292 let (hub, server) = scripted_json_hub(vec![
16293 (404, "{}".to_string()),
16294 (200, remote.card.clone()),
16295 (200, remote.feed.clone()),
16296 (200, forged),
16297 (200, remote.card),
16298 (200, remote.feed),
16299 ]);
16300 let cfg = test_hub_config(hub, state.path().to_path_buf());
16301 let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
16302 .unwrap_err()
16303 .to_string();
16304 assert!(
16305 error.contains("without committing the verified new identity"),
16306 "{error}"
16307 );
16308 server.join().unwrap();
16309 }
16310
16311 #[test]
16312 fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
16313 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16314 let raw = format!(
16315 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16316 );
16317 let pack = build_store_pack(&[
16318 (
16319 "DB.md".to_string(),
16320 "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
16321 ),
16322 ("records/clients/truth.md".to_string(), raw.clone()),
16323 ])
16324 .unwrap();
16325 let by_id = resolve_from_verified_pack(
16326 "01j5qc3v9k4ym8rwbn2tqe6f7d",
16327 &AddressTarget::Id(record_id.to_string()),
16328 pack.clone(),
16329 )
16330 .unwrap();
16331 assert_eq!(by_id["document"]["summary"], "Signed truth");
16332 assert_eq!(by_id["document"]["body"], "# Signed truth\n");
16333 assert_eq!(
16334 by_id["document"]["contentSha"],
16335 content_sha256(raw.as_bytes())
16336 );
16337
16338 let by_path = resolve_from_verified_pack(
16339 "01j5qc3v9k4ym8rwbn2tqe6f7d",
16340 &AddressTarget::Path("records/clients/truth.md".to_string()),
16341 pack,
16342 )
16343 .unwrap();
16344 assert_eq!(by_path["document"]["id"], record_id);
16345 assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
16346
16347 let wrong_id = resolve_from_verified_record_bytes(
16348 TEST_BRAIN_ID,
16349 &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
16350 "records/clients/truth.md".to_string(),
16351 raw.as_bytes().to_vec(),
16352 )
16353 .unwrap_err()
16354 .to_string();
16355 assert!(wrong_id.contains("id differs"), "{wrong_id}");
16356
16357 let wrong_path = resolve_from_verified_record_bytes(
16358 TEST_BRAIN_ID,
16359 &AddressTarget::Path("records/clients/other.md".to_string()),
16360 "records/clients/truth.md".to_string(),
16361 raw.into_bytes(),
16362 )
16363 .unwrap_err()
16364 .to_string();
16365 assert!(wrong_path.contains("path differs"), "{wrong_path}");
16366 }
16367
16368 #[test]
16369 fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
16370 let path = "records/clients/truth.md";
16371 let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16372 let raw = format!(
16373 "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16374 );
16375 let sha256 = content_sha256(raw.as_bytes());
16376 let mut nonce = 0_u128;
16377 let tree = crate::linkmd_v2::build_content_tree(
16378 &[crate::linkmd_v2::ContentFile {
16379 path: path.to_string(),
16380 blob_hash: sha256.clone(),
16381 bytes: raw.len() as u64,
16382 }],
16383 None,
16384 &mut || {
16385 nonce += 1;
16386 format!("{nonce:032x}")
16387 },
16388 )
16389 .unwrap();
16390 let root = tree.root.clone().unwrap();
16391 let mut directory_root = root.clone();
16392 let mut proof = Vec::new();
16393 for component in path.split('/') {
16394 let inclusion =
16395 crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
16396 let child = match &inclusion {
16397 crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
16398 crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
16399 panic!("fixture path must have an inclusion proof")
16400 }
16401 };
16402 proof.push(json!({
16403 "directory_root": directory_root,
16404 "component": component,
16405 "proof": inclusion,
16406 }));
16407 directory_root = child;
16408 }
16409 let commit_hash = "c".repeat(64);
16410 let pointer = V2PointerBody {
16411 v: 2,
16412 brain: TEST_BRAIN_ID.to_string(),
16413 seq: 1,
16414 commit_hash: commit_hash.clone(),
16415 feed_hash: "f".repeat(64),
16416 content_root: Some(root.clone()),
16417 asset_root: None,
16418 materializer: "dbmd-projection-v1".to_string(),
16419 signer_epoch: 1,
16420 control_revision: "d".repeat(64),
16421 backup_preparation: "e".repeat(64),
16422 prior_pointer_hash: None,
16423 signed_at: "2026-08-21T12:00:00.000Z".to_string(),
16424 };
16425 let manifest = json!({
16426 "v": 2,
16427 "commit": commit_hash,
16428 "content_root": root,
16429 "files": [{
16430 "path": path,
16431 "sha256": sha256,
16432 "bytes": raw.len(),
16433 "proof": proof,
16434 }],
16435 "next_cursor": Value::Null,
16436 })
16437 .to_string();
16438
16439 let path_manifest = manifest.clone();
16440 let (hub, server) = routed_json_hub(1, move |request| {
16441 assert_eq!(
16442 request,
16443 format!(
16444 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
16445 "c".repeat(64)
16446 )
16447 );
16448 (200, path_manifest.clone())
16449 });
16450 let state = tempfile::tempdir().unwrap();
16451 let cfg = test_hub_config(hub, state.path().to_path_buf());
16452 let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
16453 .unwrap()
16454 .unwrap();
16455 assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
16456 assert!(by_path.proof.is_some());
16457 server.join().unwrap();
16458
16459 let (hub, server) = routed_json_hub(1, move |request| {
16460 assert_eq!(
16461 request,
16462 format!(
16463 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
16464 "c".repeat(64)
16465 )
16466 );
16467 (404, r#"{"error":"File not found"}"#.to_string())
16468 });
16469 let state = tempfile::tempdir().unwrap();
16470 let cfg = test_hub_config(hub, state.path().to_path_buf());
16471 assert!(
16472 v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
16473 .unwrap()
16474 .is_none()
16475 );
16476 server.join().unwrap();
16477
16478 let id_manifest = manifest;
16479 let (hub, server) = routed_json_hub(1, move |request| {
16480 assert_eq!(
16481 request,
16482 format!(
16483 "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
16484 "c".repeat(64)
16485 )
16486 );
16487 (200, id_manifest.clone())
16488 });
16489 let state = tempfile::tempdir().unwrap();
16490 let cfg = test_hub_config(hub, state.path().to_path_buf());
16491 let (located_path, by_id) =
16492 v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
16493 assert_eq!(located_path, path);
16494 assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
16495 server.join().unwrap();
16496 }
16497
16498 #[test]
16499 fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
16500 let unsorted = vec![
16501 ("records/a.md".to_string(), "alpha\n".to_string()),
16502 ("DB.md".to_string(), "# db\n".to_string()),
16503 ];
16504 let sorted = vec![
16505 ("DB.md".to_string(), "# db\n".to_string()),
16506 ("records/a.md".to_string(), "alpha\n".to_string()),
16507 ];
16508 let pack = build_store_pack(&unsorted).unwrap();
16509
16510 assert_eq!(pack.len(), 219);
16515 assert_eq!(
16516 content_sha256(&pack),
16517 "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
16518 );
16519 assert_eq!(pack, build_store_pack(&sorted).unwrap());
16520 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
16521 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
16522 assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
16523
16524 assert_eq!(
16525 parse_store_pack(pack).unwrap(),
16526 vec![
16527 ("DB.md".to_string(), b"# db\n".to_vec()),
16528 ("records/a.md".to_string(), b"alpha\n".to_vec()),
16529 ]
16530 );
16531 }
16532
16533 #[test]
16534 fn canonical_store_pack_validates_every_path_before_writing() {
16535 let duplicate = vec![
16536 ("DB.md".to_string(), "first".to_string()),
16537 ("DB.md".to_string(), "second".to_string()),
16538 ];
16539 assert!(build_store_pack(&duplicate)
16540 .unwrap_err()
16541 .to_string()
16542 .contains("duplicate path"));
16543 assert!(matches!(
16544 build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
16545 Err(LinkError::UnsafePath { .. })
16546 ));
16547 }
16548
16549 #[test]
16550 fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
16551 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16552 let mut bytes = vec![0_u8];
16555 let zip64_offset = bytes.len() as u64;
16556 bytes.extend_from_slice(b"PK\x06\x06");
16557 bytes.extend_from_slice(&44_u64.to_le_bytes());
16558 bytes.extend_from_slice(&[0_u8; 12]);
16559 bytes.extend_from_slice(&COUNT.to_le_bytes());
16560 bytes.extend_from_slice(&COUNT.to_le_bytes());
16561 bytes.extend_from_slice(&1_u64.to_le_bytes());
16562 bytes.extend_from_slice(&0_u64.to_le_bytes());
16563 bytes.extend_from_slice(b"PK\x06\x07");
16564 bytes.extend_from_slice(&0_u32.to_le_bytes());
16565 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16566 bytes.extend_from_slice(&1_u32.to_le_bytes());
16567 bytes.extend_from_slice(b"PK\x05\x06");
16568 bytes.extend_from_slice(&0_u16.to_le_bytes());
16569 bytes.extend_from_slice(&0_u16.to_le_bytes());
16570 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16571 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16572 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16573 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16574 bytes.extend_from_slice(&0_u16.to_le_bytes());
16575
16576 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16577 .unwrap_err()
16578 .to_string();
16579 assert!(error.contains("invalid file count"), "{error}");
16580 }
16581
16582 #[test]
16583 fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
16584 const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16585 let mut bytes = vec![0_u8];
16586 let zip64_offset = bytes.len() as u64;
16587 bytes.extend_from_slice(b"PK\x06\x06");
16588 bytes.extend_from_slice(&44_u64.to_le_bytes());
16589 bytes.extend_from_slice(&[0_u8; 12]);
16590 bytes.extend_from_slice(&COUNT.to_le_bytes());
16591 bytes.extend_from_slice(&COUNT.to_le_bytes());
16592 bytes.extend_from_slice(&1_u64.to_le_bytes());
16593 bytes.extend_from_slice(&0_u64.to_le_bytes());
16594 bytes.extend_from_slice(b"PK\x06\x07");
16595 bytes.extend_from_slice(&0_u32.to_le_bytes());
16596 bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16597 bytes.extend_from_slice(&1_u32.to_le_bytes());
16598 bytes.extend_from_slice(b"PK\x05\x06");
16599 bytes.extend_from_slice(&0_u16.to_le_bytes());
16600 bytes.extend_from_slice(&0_u16.to_le_bytes());
16601 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16602 bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16603 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16604 bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16605 bytes.extend_from_slice(&0_u16.to_le_bytes());
16606 let fake_eocd = bytes.len() as u32;
16610 bytes.extend_from_slice(b"PK\x05\x06");
16611 bytes.extend_from_slice(&0_u16.to_le_bytes());
16612 bytes.extend_from_slice(&0_u16.to_le_bytes());
16613 bytes.extend_from_slice(&1_u16.to_le_bytes());
16614 bytes.extend_from_slice(&1_u16.to_le_bytes());
16615 bytes.extend_from_slice(&0_u32.to_le_bytes());
16616 bytes.extend_from_slice(&fake_eocd.to_le_bytes());
16617 bytes.extend_from_slice(&0_u16.to_le_bytes());
16618
16619 let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16620 .unwrap_err()
16621 .to_string();
16622 assert!(error.contains("central directory"), "{error}");
16623 }
16624
16625 #[test]
16626 fn strict_http_status_handling_rejects_redirects_without_panicking() {
16627 let error = ensure_ok(
16628 HubResponse {
16629 status: 302,
16630 body: Some(json!({"redirect": "/elsewhere"})),
16631 },
16632 "mutation",
16633 )
16634 .unwrap_err();
16635 assert!(matches!(error, LinkError::Http { status: 302, .. }));
16636
16637 let error = ensure_raw_ok(
16638 RawHubResponse {
16639 status: 302,
16640 body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
16641 },
16642 "feed",
16643 )
16644 .unwrap_err();
16645 assert!(matches!(error, LinkError::Http { status: 302, .. }));
16646 }
16647
16648 #[cfg(unix)]
16649 #[test]
16650 fn collect_push_files_refuses_external_symlink_and_nested_store() {
16651 use std::os::unix::fs::symlink;
16652
16653 let root = tempfile::tempdir().unwrap();
16654 std::fs::write(
16655 root.path().join("DB.md"),
16656 "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
16657 )
16658 .unwrap();
16659 std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
16660
16661 let external = tempfile::tempdir().unwrap();
16662 let secret = external.path().join("secret.md");
16663 std::fs::write(&secret, "TOP SECRET").unwrap();
16664 symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
16665
16666 let store = Store::open_strict(root.path()).unwrap();
16667 let err = collect_push_files(&store).unwrap_err().to_string();
16668 assert!(err.contains("cannot push"), "{err}");
16669 assert!(
16670 !err.contains("TOP SECRET"),
16671 "external bytes must never leak"
16672 );
16673
16674 std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
16675 let nested = root.path().join("records/nested");
16676 std::fs::create_dir_all(&nested).unwrap();
16677 std::fs::write(
16678 nested.join("DB.md"),
16679 "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
16680 )
16681 .unwrap();
16682 let err = collect_push_files(&store).unwrap_err().to_string();
16683 assert!(err.contains("nested db.md store"), "{err}");
16684 }
16685
16686 #[cfg(unix)]
16687 #[test]
16688 fn remote_push_uses_opened_root_after_path_replacement() {
16689 use std::os::unix::fs::symlink;
16690
16691 let sandbox = tempfile::tempdir().unwrap();
16692 let root = sandbox.path().join("store");
16693 std::fs::create_dir_all(root.join("records/notes")).unwrap();
16694 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16695 std::fs::write(
16696 root.join("records/notes/owned.md"),
16697 "---\ntype: note\nsummary: owned\n---\nowned upload\n",
16698 )
16699 .unwrap();
16700 let store = Store::open_strict(&root).unwrap();
16701 let detached = sandbox.path().join("detached");
16702 std::fs::rename(&root, &detached).unwrap();
16703
16704 let replacement = sandbox.path().join("replacement");
16705 std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
16706 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16707 std::fs::write(
16708 replacement.join("records/notes/secret.md"),
16709 "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
16710 )
16711 .unwrap();
16712 symlink(&replacement, &root).unwrap();
16713
16714 let files = collect_push_files(&store).unwrap();
16715 let wire_text = files
16716 .iter()
16717 .map(|(path, content)| format!("{path}\n{content}"))
16718 .collect::<Vec<_>>()
16719 .join("\n");
16720 assert!(wire_text.contains("owned upload"));
16721 assert!(!wire_text.contains("replacement sentinel"));
16722 assert!(!wire_text.contains("records/notes/secret.md"));
16723
16724 let remote = signed_remote_fixture();
16725 let (hub, server) = scripted_json_hub(vec![
16726 (200, remote.card),
16727 (200, remote.feed),
16728 (200, json!({"ok": true}).to_string()),
16729 ]);
16730 let state = tempfile::tempdir().unwrap();
16731 let cfg = test_hub_config(hub, state.path().to_path_buf());
16732 let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
16733 assert_eq!(pushed, json!({"ok": true}));
16734 server.join().unwrap();
16735 }
16736
16737 #[test]
16738 fn signed_feed_item_verifies_identity_hash_and_signature() {
16739 use ring::rand::SystemRandom;
16740 use ring::signature::{Ed25519KeyPair, KeyPair};
16741
16742 const PREFIX: &[u8] = &[
16743 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
16744 ];
16745 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
16746 let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16747 let mut spki = PREFIX.to_vec();
16748 spki.extend_from_slice(pair.public_key().as_ref());
16749 let public_key = URL_SAFE_NO_PAD.encode(&spki);
16750 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
16751 let mut entry = FeedEntry {
16752 v: 1,
16753 seq: 1,
16754 ts: "2026-07-14T00:00:00.000Z".to_string(),
16755 brain: format!("ed25519:{fingerprint}"),
16756 public_key: public_key.clone(),
16757 kind: "push".to_string(),
16758 op: "snapshot".to_string(),
16759 pack_sha256: "a".repeat(64),
16760 files: vec![FeedFile {
16761 path: "DB.md".to_string(),
16762 sha256: "b".repeat(64),
16763 bytes: 3,
16764 }],
16765 removed: vec![],
16766 prev_entry_hash: None,
16767 sig: String::new(),
16768 };
16769 let unsigned = UnsignedFeedEntry {
16770 v: entry.v,
16771 seq: entry.seq,
16772 ts: &entry.ts,
16773 brain: &entry.brain,
16774 public_key: &entry.public_key,
16775 kind: &entry.kind,
16776 op: &entry.op,
16777 pack_sha256: &entry.pack_sha256,
16778 files: &entry.files,
16779 removed: &entry.removed,
16780 prev_entry_hash: &entry.prev_entry_hash,
16781 };
16782 entry.sig =
16783 URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16784 let mut exact = serde_json::to_vec(&entry).unwrap();
16785 exact.push(b'\n');
16786 let item = FeedItem {
16787 hash: format!("{:x}", Sha256::digest(&exact)),
16788 entry,
16789 };
16790 let identity = FeedIdentity {
16791 fingerprint,
16792 public_key_spki: public_key,
16793 previous: Vec::new(),
16794 rotations: Vec::new(),
16795 };
16796 assert!(verify_feed_item(&item, &identity).is_ok());
16797 let mut tampered = item;
16798 tampered.entry.pack_sha256 = "c".repeat(64);
16799 assert!(verify_feed_item(&tampered, &identity).is_err());
16800 }
16801
16802 #[test]
16803 fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
16804 let rng = ring::rand::SystemRandom::new();
16805 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16806 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16807 let (spki, multikey) = public_identity_for(&pair);
16808 let identity = V2HeadIdentity {
16809 custody: "self".to_string(),
16810 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16811 public_key_spki: spki.clone(),
16812 previous: Vec::new(),
16813 rotations: Vec::new(),
16814 };
16815 let unsigned = json!({
16816 "actor_ref": "a".repeat(64),
16817 "asset_root": Value::Null,
16818 "brain": multikey,
16819 "changes_sha256": "b".repeat(64),
16820 "control_revision": "c".repeat(64),
16821 "materializer": "dbmd-projection-v1",
16822 "op": "changeset",
16823 "parent_asset_root": Value::Null,
16824 "parent_commit": Value::Null,
16825 "parent_root": Value::Null,
16826 "prev_entry_hash": Value::Null,
16827 "public_key": spki,
16828 "seq": 1,
16829 "signer_epoch": 1,
16830 "state_root": "d".repeat(64),
16831 "ts": "2026-08-19T12:00:00.000Z",
16832 "v": 2,
16833 "v1_bridge": {
16834 "feed_hash": "e".repeat(64),
16835 "head_seq": 7,
16836 "pack_sha256": "f".repeat(64),
16837 },
16838 });
16839 let sign_value = |value: Value| {
16840 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16841 let mut object = value.as_object().unwrap().clone();
16842 object.insert(
16843 "sig".to_string(),
16844 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16845 );
16846 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16847 };
16848 assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
16849
16850 let mut extra = unsigned.clone();
16851 extra
16852 .as_object_mut()
16853 .unwrap()
16854 .insert("future".to_string(), Value::Bool(true));
16855 assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
16856
16857 let mut missing = unsigned.clone();
16858 missing.as_object_mut().unwrap().remove("v1_bridge");
16859 assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
16860
16861 let mut invalid_bridge = unsigned;
16862 invalid_bridge.as_object_mut().unwrap().insert(
16863 "v1_bridge".to_string(),
16864 json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
16865 );
16866 assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
16867 }
16868
16869 #[test]
16870 fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
16871 let vector: Value = serde_json::from_str(include_str!(
16872 "../tests/vectors/linkmd-v2-commit-bridge.json"
16873 ))
16874 .unwrap();
16875 let identity_value = vector.get("identity").unwrap();
16876 let identity = V2HeadIdentity {
16877 custody: "self".to_string(),
16878 fingerprint: identity_value
16879 .get("fingerprint")
16880 .and_then(Value::as_str)
16881 .unwrap()
16882 .to_string(),
16883 public_key_spki: identity_value
16884 .get("public_key_spki")
16885 .and_then(Value::as_str)
16886 .unwrap()
16887 .to_string(),
16888 previous: Vec::new(),
16889 rotations: Vec::new(),
16890 };
16891 let private = URL_SAFE_NO_PAD
16892 .decode(
16893 identity_value
16894 .get("private_key_pkcs8")
16895 .and_then(Value::as_str)
16896 .unwrap(),
16897 )
16898 .unwrap();
16899 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
16900 .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
16901 .unwrap();
16902 let base = vector.get("body").unwrap().as_object().unwrap();
16903
16904 for item in vector.get("valid").unwrap().as_array().unwrap() {
16905 let mut body = base.clone();
16906 body.insert(
16907 "v1_bridge".to_string(),
16908 item.get("v1_bridge").unwrap().clone(),
16909 );
16910 body.insert(
16911 "sig".to_string(),
16912 item.get("signature_base64url").unwrap().clone(),
16913 );
16914 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16915 assert!(verified_v2_commit_object(&signed, &identity).is_ok());
16916 assert_eq!(
16917 crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
16918 item.get("commit_hash").and_then(Value::as_str).unwrap()
16919 );
16920 assert_eq!(
16921 format!("{:x}", Sha256::digest(&signed)),
16922 item.get("feed_hash").and_then(Value::as_str).unwrap()
16923 );
16924 }
16925
16926 for item in vector.get("invalid").unwrap().as_array().unwrap() {
16927 let mut body = base.clone();
16928 if let Some(remove) = item.get("remove").and_then(Value::as_array) {
16929 for field in remove {
16930 body.remove(field.as_str().unwrap());
16931 }
16932 }
16933 if let Some(set) = item.get("set").and_then(Value::as_object) {
16934 for (field, value) in set {
16935 body.insert(field.clone(), value.clone());
16936 }
16937 }
16938 let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
16939 body.insert(
16940 "sig".to_string(),
16941 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16942 );
16943 let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16944 assert!(
16945 verified_v2_commit_object(&signed, &identity).is_err(),
16946 "accepted invalid shared vector {}",
16947 item.get("reason").and_then(Value::as_str).unwrap()
16948 );
16949 }
16950 }
16951
16952 #[test]
16953 fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
16954 let vector: Value = serde_json::from_str(include_str!(
16955 "../tests/vectors/linkmd-v2-changeset-withheld.json"
16956 ))
16957 .unwrap();
16958 assert_eq!(
16959 vector.get("profile").and_then(Value::as_str),
16960 Some("link.md-v2-changeset-withheld")
16961 );
16962 let canonical =
16963 crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
16964 let expected = STANDARD
16965 .decode(
16966 vector
16967 .get("canonical_base64")
16968 .and_then(Value::as_str)
16969 .unwrap(),
16970 )
16971 .unwrap();
16972 assert_eq!(canonical, expected);
16973 assert_eq!(
16974 crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
16975 vector.get("domain_hash").and_then(Value::as_str).unwrap()
16976 );
16977 }
16978
16979 #[test]
16980 fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
16981 let remote = signed_remote_fixture();
16982 let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
16983 let legacy_item = legacy.entries.first().unwrap();
16984 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
16985 let body = json!({
16986 "actor_ref": "a".repeat(64),
16987 "asset_root": Value::Null,
16988 "brain": remote.key.multikey,
16989 "changes_sha256": "b".repeat(64),
16990 "control_revision": "c".repeat(64),
16991 "materializer": "dbmd-projection-v1",
16992 "op": "changeset",
16993 "parent_asset_root": Value::Null,
16994 "parent_commit": Value::Null,
16995 "parent_root": Value::Null,
16996 "prev_entry_hash": Value::Null,
16997 "public_key": remote.key.public_key_spki,
16998 "seq": 1,
16999 "signer_epoch": 1,
17000 "state_root": "d".repeat(64),
17001 "ts": "2026-08-19T12:00:00.000Z",
17002 "v": 2,
17003 "v1_bridge": {
17004 "feed_hash": legacy_item.hash,
17005 "head_seq": legacy_item.entry.seq,
17006 "pack_sha256": legacy_item.entry.pack_sha256,
17007 },
17008 });
17009 let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
17010 let mut signed = body.as_object().unwrap().clone();
17011 signed.insert(
17012 "sig".to_string(),
17013 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17014 );
17015 let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
17016 let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
17017 let feed_hash = content_sha256(&raw);
17018 let pointer = V2PointerBody {
17019 v: 2,
17020 brain: TEST_BRAIN_ID.to_string(),
17021 seq: 1,
17022 commit_hash: commit_hash.clone(),
17023 feed_hash: feed_hash.clone(),
17024 content_root: Some("d".repeat(64)),
17025 asset_root: None,
17026 materializer: "dbmd-projection-v1".to_string(),
17027 signer_epoch: 1,
17028 control_revision: "c".repeat(64),
17029 backup_preparation: "e".repeat(64),
17030 prior_pointer_hash: None,
17031 signed_at: "2026-08-19T12:00:00.000Z".to_string(),
17032 };
17033 let v2_page = json!({
17034 "v": 2,
17035 "head_seq": 1,
17036 "head_commit_hash": commit_hash,
17037 "head_feed_hash": feed_hash,
17038 "entries": [{
17039 "seq": 1,
17040 "commit_hash": pointer.commit_hash,
17041 "feed_hash": pointer.feed_hash,
17042 "bytes_base64": STANDARD.encode(&raw),
17043 }],
17044 "next_after": 1,
17045 "complete": true,
17046 })
17047 .to_string();
17048 let identity = V2HeadIdentity {
17049 custody: "self".to_string(),
17050 fingerprint: remote.identity.fingerprint.clone(),
17051 public_key_spki: remote.identity.public_key_spki.clone(),
17052 previous: Vec::new(),
17053 rotations: Vec::new(),
17054 };
17055 let checkpoint = TrustState {
17056 v: 2,
17057 origin: "unused".to_string(),
17058 requested: TEST_BRAIN_ID.to_string(),
17059 brain: TEST_BRAIN_ID.to_string(),
17060 home: None,
17061 anchor: remote.key.multikey.clone(),
17062 current: remote.key.multikey,
17063 head_seq: legacy_item.entry.seq,
17064 feed_hash: Some(legacy_item.hash.clone()),
17065 rotations: Vec::new(),
17066 hub_signer: None,
17067 protocol_profile: None,
17068 };
17069 let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
17070 let state = tempfile::tempdir().unwrap();
17071 let cfg = test_hub_config(hub, state.path().to_path_buf());
17072 verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
17073 server.join().unwrap();
17074
17075 let mut wrong = checkpoint;
17076 wrong.feed_hash = Some("0".repeat(64));
17077 let (hub, server) = scripted_json_hub(vec![(
17078 200,
17079 json!({
17080 "v": 2,
17081 "head_seq": 1,
17082 "head_commit_hash": pointer.commit_hash,
17083 "head_feed_hash": pointer.feed_hash,
17084 "entries": [{
17085 "seq": 1,
17086 "commit_hash": pointer.commit_hash,
17087 "feed_hash": pointer.feed_hash,
17088 "bytes_base64": STANDARD.encode(&raw),
17089 }],
17090 "next_after": 1,
17091 "complete": true,
17092 })
17093 .to_string(),
17094 )]);
17095 let state = tempfile::tempdir().unwrap();
17096 let cfg = test_hub_config(hub, state.path().to_path_buf());
17097 assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
17098 server.join().unwrap();
17099 }
17100
17101 #[test]
17102 fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
17103 let rng = ring::rand::SystemRandom::new();
17104 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17105 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17106 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17107 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17108 let (old_spki, old_multikey) = public_identity_for(&old);
17109 let (new_spki, new_multikey) = public_identity_for(&new);
17110 let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
17111 v: 1,
17112 op: "rotate",
17113 brain: &old_multikey,
17114 public_key: &old_spki,
17115 new_brain: &new_multikey,
17116 new_public_key: &new_spki,
17117 prior_head_seq: 1,
17118 prior_feed_hash: Some(&"9".repeat(64)),
17119 ts: "2026-08-19T12:01:00.000Z".to_string(),
17120 })
17121 .unwrap();
17122 let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
17123 let rotation = format!(
17124 "{},\"sig\":\"{}\"}}",
17125 &rotation_unsigned[..rotation_unsigned.len() - 1],
17126 rotation_sig
17127 );
17128 let identity = V2HeadIdentity {
17129 custody: "self".to_string(),
17130 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17131 public_key_spki: new_spki.clone(),
17132 previous: vec![V2PreviousIdentity {
17133 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17134 public_key_spki: old_spki.clone(),
17135 }],
17136 rotations: vec![rotation],
17137 };
17138 let commit = |seq: u64,
17139 epoch: u64,
17140 multikey: &str,
17141 spki: &str,
17142 pair: &ring::signature::Ed25519KeyPair| {
17143 let value = json!({
17144 "actor_ref": "a".repeat(64),
17145 "asset_root": Value::Null,
17146 "brain": multikey,
17147 "changes_sha256": "b".repeat(64),
17148 "control_revision": "c".repeat(64),
17149 "materializer": "dbmd-projection-v1",
17150 "op": "changeset",
17151 "parent_asset_root": Value::Null,
17152 "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
17153 "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
17154 "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
17155 "public_key": spki,
17156 "seq": seq,
17157 "signer_epoch": epoch,
17158 "state_root": "1".repeat(64),
17159 "ts": "2026-08-19T12:00:00.000Z",
17160 "v": 2,
17161 "v1_bridge": Value::Null,
17162 });
17163 let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
17164 let mut object = value.as_object().unwrap().clone();
17165 object.insert(
17166 "sig".to_string(),
17167 Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17168 );
17169 crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
17170 };
17171
17172 assert!(verified_v2_commit_object(
17173 &commit(1, 1, &old_multikey, &old_spki, &old),
17174 &identity,
17175 )
17176 .is_ok());
17177 assert!(verified_v2_commit_object(
17178 &commit(2, 2, &new_multikey, &new_spki, &new),
17179 &identity,
17180 )
17181 .is_ok());
17182 assert!(verified_v2_commit_object(
17183 &commit(2, 1, &old_multikey, &old_spki, &old),
17184 &identity,
17185 )
17186 .is_err());
17187 assert!(verified_v2_commit_object(
17188 &commit(1, 2, &new_multikey, &new_spki, &new),
17189 &identity,
17190 )
17191 .is_err());
17192 }
17193
17194 #[test]
17195 fn a_self_custody_entry_verifies_like_any_hub_entry() {
17196 let rng = ring::rand::SystemRandom::new();
17197 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17198 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17199 let (spki, multikey) = public_identity_for(&pair);
17200 let key = AgentSigningKey {
17201 pkcs8: pkcs8.as_ref().to_vec(),
17202 multikey: multikey.clone(),
17203 public_key_spki: spki.clone(),
17204 };
17205 let files = vec![WireFeedFile {
17206 path: "DB.md".to_string(),
17207 sha256: "a".repeat(64),
17208 bytes: 3,
17209 }];
17210 let raw = self_custody_entry(
17211 &key,
17212 1,
17213 "2026-07-23T12:00:00.000Z".to_string(),
17214 &"c".repeat(64),
17215 &files,
17216 None,
17217 )
17218 .unwrap();
17219 let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
17223 let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
17224 let item = FeedItem { hash, entry };
17225 let identity = FeedIdentity {
17226 fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
17227 public_key_spki: spki,
17228 previous: Vec::new(),
17229 rotations: Vec::new(),
17230 };
17231 assert!(verify_feed_item(&item, &identity).is_ok());
17232 }
17233
17234 #[test]
17235 fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
17236 let rng = ring::rand::SystemRandom::new();
17237 let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17238 let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17239 let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17240 let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17241 let (old_spki, old_multikey) = public_identity_for(&old);
17242 let (new_spki, new_multikey) = public_identity_for(&new);
17243 let unsigned = serde_json::to_string(&UnsignedRotation {
17244 v: 1,
17245 op: "rotate",
17246 brain: &old_multikey,
17247 public_key: &old_spki,
17248 new_brain: &new_multikey,
17249 new_public_key: &new_spki,
17250 prior_head_seq: 1,
17251 prior_feed_hash: Some(&"a".repeat(64)),
17252 ts: "2026-07-30T12:00:00.000Z".to_string(),
17253 })
17254 .unwrap();
17255 let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
17256 let rotation = format!(
17257 "{},\"sig\":\"{}\"}}",
17258 &unsigned[..unsigned.len() - 1],
17259 signature
17260 );
17261 let identity = FeedIdentity {
17262 fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17263 public_key_spki: new_spki,
17264 previous: vec![PreviousIdentity {
17265 fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17266 public_key_spki: old_spki,
17267 }],
17268 rotations: vec![rotation],
17269 };
17270 let pin = TrustState {
17271 v: 2,
17272 origin: "https://hub.example".to_string(),
17273 requested: "brain".to_string(),
17274 brain: "brain".to_string(),
17275 home: None,
17276 anchor: old_multikey.clone(),
17277 current: old_multikey.clone(),
17278 head_seq: 1,
17279 feed_hash: Some("a".repeat(64)),
17280 rotations: Vec::new(),
17281 hub_signer: None,
17282 protocol_profile: None,
17283 };
17284 assert_eq!(
17285 verify_identity_chain(&identity, Some(&pin)).unwrap(),
17286 old_multikey
17287 );
17288 let mut accepted = pin.clone();
17289 accepted.current = new_multikey.clone();
17290 accepted.rotations = identity.rotations.clone();
17291 let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
17292 v: 1,
17293 op: "rotate",
17294 brain: &old_multikey,
17295 public_key: &identity.previous[0].public_key_spki,
17296 new_brain: &new_multikey,
17297 new_public_key: &identity.public_key_spki,
17298 prior_head_seq: 1,
17299 prior_feed_hash: Some(&"a".repeat(64)),
17300 ts: "2026-07-30T12:00:01.000Z".to_string(),
17301 })
17302 .unwrap();
17303 let alternate_signature =
17304 URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
17305 let mut rewritten = identity.clone();
17306 rewritten.rotations[0] = format!(
17307 "{},\"sig\":\"{}\"}}",
17308 &alternate_unsigned[..alternate_unsigned.len() - 1],
17309 alternate_signature
17310 );
17311 assert!(
17312 verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
17313 "an alternate valid statement must not rewrite accepted history"
17314 );
17315
17316 let mut stale_entry = FeedEntry {
17317 v: 1,
17318 seq: 2,
17319 ts: "2026-07-30T12:01:00.000Z".to_string(),
17320 brain: pin.current.clone(),
17321 public_key: identity.previous[0].public_key_spki.clone(),
17322 kind: "push".to_string(),
17323 op: "snapshot".to_string(),
17324 pack_sha256: "b".repeat(64),
17325 files: Vec::new(),
17326 removed: Vec::new(),
17327 prev_entry_hash: pin.feed_hash.clone(),
17328 sig: String::new(),
17329 };
17330 let stale_unsigned = UnsignedFeedEntry {
17331 v: stale_entry.v,
17332 seq: stale_entry.seq,
17333 ts: &stale_entry.ts,
17334 brain: &stale_entry.brain,
17335 public_key: &stale_entry.public_key,
17336 kind: &stale_entry.kind,
17337 op: &stale_entry.op,
17338 pack_sha256: &stale_entry.pack_sha256,
17339 files: &stale_entry.files,
17340 removed: &stale_entry.removed,
17341 prev_entry_hash: &stale_entry.prev_entry_hash,
17342 };
17343 stale_entry.sig = URL_SAFE_NO_PAD.encode(
17344 old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
17345 .as_ref(),
17346 );
17347 let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
17348 stale_exact.push(b'\n');
17349 let stale_item = FeedItem {
17350 hash: content_sha256(&stale_exact),
17351 entry: stale_entry,
17352 };
17353 assert!(
17354 reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
17355 .is_err(),
17356 "a key retired before the checkpoint must never append after it"
17357 );
17358 assert!(
17359 verify_feed_item(&stale_item, &identity).is_err(),
17360 "an old key must never append after its signed rotation boundary"
17361 );
17362
17363 let mut missing = identity.clone();
17364 missing.rotations.clear();
17365 assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
17366
17367 let mut tampered = identity;
17368 tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
17369 assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
17370 }
17371
17372 #[cfg(unix)]
17373 #[test]
17374 fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
17375 use std::os::unix::fs::symlink;
17376
17377 let dir = tempfile::tempdir().unwrap();
17378 let target = dir.path().join("valuable.txt");
17379 let planted = dir.path().join("agent.key");
17380 std::fs::write(&target, "do not overwrite").unwrap();
17381 symlink(&target, &planted).unwrap();
17382
17383 assert!(matches!(
17384 generate_agent_key(&planted),
17385 Err(LinkError::BadAgentKey { .. })
17386 ));
17387 assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
17388 }
17389
17390 #[cfg(unix)]
17391 #[test]
17392 fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
17393 use std::os::unix::fs::symlink;
17394
17395 let root = tempfile::tempdir().unwrap();
17396 let outside = tempfile::tempdir().unwrap();
17397 symlink(outside.path(), root.path().join("redirect")).unwrap();
17398
17399 assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
17400 assert!(!outside.path().join("agent.key").exists());
17401 }
17402
17403 #[test]
17406 fn address_bare_brain_with_and_without_sigil() {
17407 for raw in ["@acme-ops", "acme-ops"] {
17408 let a = Address::parse(raw).expect(raw);
17409 assert_eq!(a.brain, "acme-ops");
17410 assert_eq!(a.target, None);
17411 }
17412 }
17413
17414 #[test]
17415 fn address_ulid_target_parses_as_id() {
17416 let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
17417 assert_eq!(a.brain, "acme");
17418 assert_eq!(
17419 a.target,
17420 Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
17421 );
17422 }
17423
17424 #[test]
17425 fn address_md_path_target_parses_as_path() {
17426 let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
17427 assert_eq!(
17428 a.target,
17429 Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
17430 );
17431 }
17432
17433 #[test]
17434 fn address_rejects_malformed_forms() {
17435 for raw in [
17436 "",
17437 "@",
17438 "@/x",
17439 "@acme/",
17440 "@acme/../etc/passwd",
17441 "@acme/records/.hidden.md",
17442 "@ACME", "@acme/notes/x.txt", "@a b", ] {
17446 assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
17447 }
17448 }
17449
17450 #[test]
17453 fn safe_paths_accept_store_shapes_and_reject_escapes() {
17454 for ok in [
17455 "DB.md",
17456 "assets.jsonl",
17457 "records/clients/lumio.md",
17458 "sources/emails/2026/07/x.md",
17459 ] {
17460 assert!(safe_store_rel_path(ok), "should accept {ok:?}");
17461 }
17462 for bad in [
17463 "",
17464 "/etc/passwd",
17465 "../up.md",
17466 "records/../../up.md",
17467 "records//x.md",
17468 ".dbmd/config",
17469 "records/.hidden/x.md",
17470 "records/a b.md",
17471 "records\\win.md",
17472 ] {
17473 assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
17474 }
17475 }
17476
17477 #[cfg(unix)]
17478 #[test]
17479 fn opened_destination_capability_survives_an_ancestor_path_swap() {
17480 use std::os::unix::fs::symlink;
17481
17482 let work = tempfile::tempdir().unwrap();
17483 let outside = tempfile::tempdir().unwrap();
17484 let original = work.path().join("destination");
17485 let moved = work.path().join("destination-moved");
17486 let directory = open_or_create_dir_nofollow(&original).unwrap();
17487
17488 std::fs::rename(&original, &moved).unwrap();
17489 symlink(outside.path(), &original).unwrap();
17490 write_pull_entries_beneath_dir(
17491 &directory,
17492 &[("records/note.md".to_string(), b"held inode".to_vec())],
17493 )
17494 .unwrap();
17495
17496 assert_eq!(
17497 std::fs::read(moved.join("records/note.md")).unwrap(),
17498 b"held inode"
17499 );
17500 assert!(!outside.path().join("records/note.md").exists());
17501 }
17502
17503 #[test]
17507 fn hub_config_flag_beats_file_and_requires_some_source() {
17508 let dir = tempfile::tempdir().unwrap();
17509 std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
17510 std::fs::write(
17511 dir.path().join(CONFIG_REL_PATH),
17512 "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
17513 )
17514 .unwrap();
17515
17516 let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
17517 assert_eq!(from_flag.hub, "https://flag.example.com");
17518
17519 let from_file = hub_config(None, dir.path()).unwrap();
17520 assert_eq!(from_file.hub, "https://file.example.com");
17521
17522 let none = hub_config(None, tempfile::tempdir().unwrap().path());
17523 assert!(matches!(none, Err(LinkError::NoHub)));
17524 }
17525
17526 #[test]
17527 fn https_guard_allows_loopback_only_for_plain_http() {
17528 assert!(assert_safe_hub("https://hub.example.com").is_ok());
17529 assert!(assert_safe_hub("http://localhost:3000").is_ok());
17530 assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
17531 assert!(assert_safe_hub("http://[::1]:3000").is_ok());
17532 assert!(matches!(
17533 assert_safe_hub("http://hub.example.com"),
17534 Err(LinkError::UnsafeHub { .. })
17535 ));
17536 assert!(matches!(
17537 assert_safe_hub("hub.example.com"),
17538 Err(LinkError::UnsafeHub { .. })
17539 ));
17540 assert!(matches!(
17541 assert_safe_hub("http://localhost:80@127.0.0.1:1"),
17542 Err(LinkError::UnsafeHub { .. })
17543 ));
17544 assert!(matches!(
17545 assert_safe_hub("https://hub.example.com@attacker.example"),
17546 Err(LinkError::UnsafeHub { .. })
17547 ));
17548 assert!(matches!(
17549 assert_safe_hub("https://hub.example.com/base"),
17550 Err(LinkError::UnsafeHub { .. })
17551 ));
17552 }
17553
17554 #[test]
17555 fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
17556 for blocked in [
17557 "127.0.0.1",
17558 "10.0.0.1",
17559 "100.64.0.1",
17560 "169.254.169.254",
17561 "172.16.0.1",
17562 "192.168.0.1",
17563 "192.88.99.1",
17564 "198.18.0.1",
17565 "203.0.113.1",
17566 "::1",
17567 "fe80::1",
17568 "fd00::1",
17569 "2001:db8::1",
17570 "2001:1::1",
17571 "2002:7f00:1::",
17572 "3fff::1",
17573 ] {
17574 assert!(
17575 !is_public_registry_ip(blocked.parse().unwrap()),
17576 "must block {blocked}"
17577 );
17578 }
17579 assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
17580 assert!(is_public_registry_ip(
17581 "2606:4700:4700::1111".parse().unwrap()
17582 ));
17583 assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
17584 }
17585
17586 #[test]
17587 fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
17588 use ureq::Resolver as _;
17589
17590 let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
17591 let resolver = PinnedRegistryResolver {
17592 netloc: "home.example:443".to_string(),
17593 addresses: vec![pinned],
17594 };
17595 assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
17596 assert!(resolver.resolve("127.0.0.1:443").is_err());
17597 assert_eq!(
17598 resolver.resolve("home.example:443").unwrap(),
17599 vec![pinned],
17600 "subsequent connects reuse the validated answer instead of DNS"
17601 );
17602 }
17603
17604 #[test]
17605 fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
17606 let cfg = HubConfig {
17607 hub: "https://hub.example".to_string(),
17608 key: None,
17609 agent_key: None,
17610 brain_key: None,
17611 state_dir: tempfile::tempdir().unwrap().keep(),
17612 store_selected: false,
17613 };
17614 assert!(
17615 presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
17616 "a production hub must not turn its presigned URL into an SSRF primitive"
17617 );
17618
17619 let store_selected = HubConfig {
17620 hub: "https://127.0.0.1".to_string(),
17621 store_selected: true,
17622 ..cfg
17623 };
17624 assert!(
17625 hub_agent(&store_selected).is_err(),
17626 "bytes in a cloned store must not select a private-network hub"
17627 );
17628 }
17629
17630 #[test]
17631 fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
17632 assert_eq!(
17633 one_past_bounded_limit(MAX_PACK_BYTES),
17634 Some(MAX_PACK_BYTES + 1),
17635 "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
17636 );
17637 assert_eq!(
17638 presigned_download_read_limit(),
17639 MAX_PACK_BYTES + 1,
17640 "the presigned reader is capped by the client constant, not a hub response"
17641 );
17642 assert_eq!(
17643 one_past_bounded_limit(u64::MAX),
17644 None,
17645 "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
17646 );
17647 }
17648
17649 #[test]
17650 fn https_guard_matches_the_scheme_case_insensitively() {
17651 assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
17654 assert!(assert_safe_hub("Https://hub.example.com").is_ok());
17655 assert!(matches!(
17657 assert_safe_hub("HTTP://hub.example.com"),
17658 Err(LinkError::UnsafeHub { .. })
17659 ));
17660 }
17661
17662 #[test]
17663 fn clean_key_refuses_paste_artifacts_without_echoing() {
17664 assert_eq!(clean_key(" vc_account_abc ").unwrap(), "vc_account_abc");
17665 for bad in ["vc account", "vc\naccount", "ключ", ""] {
17666 let err = clean_key(bad).unwrap_err();
17667 assert!(matches!(err, LinkError::BadKey));
17668 assert!(
17669 !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
17670 "error must not echo the key"
17671 );
17672 }
17673 }
17674
17675 fn dead_hub() -> HubConfig {
17681 HubConfig {
17682 hub: "http://127.0.0.1:9".to_string(),
17683 key: Some("k".to_string()),
17684 agent_key: None,
17685 brain_key: None,
17686 state_dir: PathBuf::from("."),
17687 store_selected: false,
17688 }
17689 }
17690
17691 #[test]
17692 fn request_retries_a_connection_failure_before_sending() {
17693 use std::io::{Read as _, Write as _};
17694 use std::net::TcpListener;
17695 use std::thread;
17696 use std::time::Duration;
17697
17698 let probe = TcpListener::bind("127.0.0.1:0").unwrap();
17699 let address = probe.local_addr().unwrap();
17700 drop(probe);
17701 let server = thread::spawn(move || {
17702 thread::sleep(Duration::from_millis(40));
17703 let listener = TcpListener::bind(address).unwrap();
17704 let (mut stream, _) = listener.accept().unwrap();
17705 let mut request_bytes = [0_u8; 1024];
17706 let _ = stream.read(&mut request_bytes).unwrap();
17707 stream
17708 .write_all(
17709 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\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 response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
17723 assert_eq!(response.status, 200);
17724 assert_eq!(response.body, Some(json!({ "ok": true })));
17725 server.join().unwrap();
17726 }
17727
17728 #[test]
17729 fn a_commit_goes_back_for_a_receipt_it_lost() {
17730 use std::io::{Read as _, Write as _};
17731 use std::net::TcpListener;
17732 use std::thread;
17733
17734 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 (mut first, _) = listener.accept().unwrap();
17744 let mut bytes = [0_u8; 4096];
17745 let _ = first.read(&mut bytes).unwrap();
17746 first
17747 .write_all(
17748 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
17749 )
17750 .unwrap();
17751 drop(first);
17752 let (mut second, _) = listener.accept().unwrap();
17754 let _ = second.read(&mut bytes).unwrap();
17755 second
17756 .write_all(
17757 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\"}",
17758 )
17759 .unwrap();
17760 });
17761 let cfg = HubConfig {
17762 hub: format!("http://{address}"),
17763 key: Some("k".to_string()),
17764 agent_key: None,
17765 brain_key: None,
17766 state_dir: tempfile::tempdir().unwrap().keep(),
17767 store_selected: false,
17768 };
17769
17770 let response = request_patient(
17771 &cfg,
17772 "POST",
17773 "/api/hub/brains/b/v2/commits",
17774 Some(&json!({ "mutation_id": "dbmd-1" })),
17775 Auth::Required,
17776 )
17777 .expect("the receipt is collected on the second ask");
17778 assert_eq!(response.status, 200);
17779 assert_eq!(
17780 response
17781 .body
17782 .as_ref()
17783 .and_then(|value| value.get("outcome"))
17784 .and_then(Value::as_str),
17785 Some("converged"),
17786 "an already-applied mutation answers with its receipt"
17787 );
17788 server.join().unwrap();
17789 }
17790
17791 #[test]
17792 fn a_mutation_body_that_dies_mid_stream_is_a_transport_failure() {
17793 use std::io::{Read as _, Write as _};
17794 use std::net::TcpListener;
17795 use std::thread;
17796
17797 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17803 let address = listener.local_addr().unwrap();
17804 let server = thread::spawn(move || {
17805 let (mut stream, _) = listener.accept().unwrap();
17806 let mut request_bytes = [0_u8; 1024];
17807 let _ = stream.read(&mut request_bytes).unwrap();
17808 stream
17810 .write_all(
17811 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17812 )
17813 .unwrap();
17814 });
17815 let cfg = HubConfig {
17816 hub: format!("http://{address}"),
17817 key: None,
17818 agent_key: None,
17819 brain_key: None,
17820 state_dir: tempfile::tempdir().unwrap().keep(),
17821 store_selected: false,
17822 };
17823
17824 let error = request(&cfg, "POST", "/truncated", None, Auth::None)
17825 .expect_err("a truncated body must not read as success");
17826 match error {
17827 LinkError::Transport { hub, .. } => {
17828 assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
17829 }
17830 other => panic!("expected a transport failure, got {other:?}"),
17831 }
17832 server.join().unwrap();
17833 }
17834
17835 #[test]
17836 fn a_safe_get_retries_when_its_body_dies_mid_stream() {
17837 use std::io::{Read as _, Write as _};
17838 use std::net::{TcpListener, TcpStream};
17839 use std::thread;
17840
17841 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17842 let address = listener.local_addr().unwrap();
17843 let server = thread::spawn(move || {
17844 let read_request = |stream: &mut TcpStream| {
17845 let mut request = Vec::new();
17846 let mut bytes = [0_u8; 1024];
17847 loop {
17848 let read = stream.read(&mut bytes).unwrap();
17849 if read == 0 {
17850 break;
17851 }
17852 request.extend_from_slice(&bytes[..read]);
17853 let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17854 else {
17855 continue;
17856 };
17857 let headers = String::from_utf8_lossy(&request[..header_end]);
17858 let content_length = headers
17859 .lines()
17860 .find_map(|line| {
17861 let (name, value) = line.split_once(':')?;
17862 name.eq_ignore_ascii_case("content-length")
17863 .then(|| value.trim().parse::<usize>().ok())
17864 .flatten()
17865 })
17866 .unwrap_or(0);
17867 if request.len() >= header_end + 4 + content_length {
17868 break;
17869 }
17870 }
17871 };
17872 let (mut first, _) = listener.accept().unwrap();
17873 read_request(&mut first);
17874 first
17875 .write_all(
17876 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17877 )
17878 .unwrap();
17879 drop(first);
17880
17881 let (mut second, _) = listener.accept().unwrap();
17882 read_request(&mut second);
17883 second
17884 .write_all(
17885 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17886 )
17887 .unwrap();
17888 });
17889 let cfg = HubConfig {
17890 hub: format!("http://{address}"),
17891 key: None,
17892 agent_key: None,
17893 brain_key: None,
17894 state_dir: tempfile::tempdir().unwrap().keep(),
17895 store_selected: false,
17896 };
17897
17898 let response = request(&cfg, "GET", "/retry-body", None, Auth::None)
17899 .expect("a safe read retries the interrupted body");
17900 assert_eq!(response.status, 200);
17901 assert_eq!(response.body, Some(json!({ "ok": true })));
17902 server.join().unwrap();
17903 }
17904
17905 #[test]
17906 fn an_explicit_read_only_post_retries_when_its_body_dies_mid_stream() {
17907 use std::io::{Read as _, Write as _};
17908 use std::net::{TcpListener, TcpStream};
17909 use std::thread;
17910
17911 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17912 let address = listener.local_addr().unwrap();
17913 let server = thread::spawn(move || {
17914 let read_request = |stream: &mut TcpStream| {
17915 let mut request = Vec::new();
17916 let mut bytes = [0_u8; 1024];
17917 loop {
17918 let read = stream.read(&mut bytes).unwrap();
17919 if read == 0 {
17920 break;
17921 }
17922 request.extend_from_slice(&bytes[..read]);
17923 let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17924 else {
17925 continue;
17926 };
17927 let headers = String::from_utf8_lossy(&request[..header_end]);
17928 let content_length = headers
17929 .lines()
17930 .find_map(|line| {
17931 let (name, value) = line.split_once(':')?;
17932 name.eq_ignore_ascii_case("content-length")
17933 .then(|| value.trim().parse::<usize>().ok())
17934 .flatten()
17935 })
17936 .unwrap_or(0);
17937 if request.len() >= header_end + 4 + content_length {
17938 break;
17939 }
17940 }
17941 };
17942 let (mut first, _) = listener.accept().unwrap();
17943 read_request(&mut first);
17944 first
17945 .write_all(
17946 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17947 )
17948 .unwrap();
17949 drop(first);
17950
17951 let (mut second, _) = listener.accept().unwrap();
17952 read_request(&mut second);
17953 second
17954 .write_all(
17955 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17956 )
17957 .unwrap();
17958 });
17959 let cfg = HubConfig {
17960 hub: format!("http://{address}"),
17961 key: None,
17962 agent_key: None,
17963 brain_key: None,
17964 state_dir: tempfile::tempdir().unwrap().keep(),
17965 store_selected: false,
17966 };
17967
17968 let response = request_raw_retryable_read(
17969 &cfg,
17970 "POST",
17971 "/v2/stream",
17972 Some(&json!({ "files": ["proof"] })),
17973 Auth::None,
17974 1_024,
17975 )
17976 .expect("an explicitly safe POST retries the interrupted body");
17977 assert_eq!(response.status, 200);
17978 assert_eq!(
17979 serde_json::from_slice::<Value>(&response.body).unwrap(),
17980 json!({ "ok": true })
17981 );
17982 server.join().unwrap();
17983 }
17984
17985 #[test]
17986 fn object_store_transport_errors_never_render_presigned_urls() {
17987 use std::net::TcpListener;
17988
17989 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17990 let address = listener.local_addr().unwrap();
17991 drop(listener);
17992 let signature = "do-not-render-this-presigned-signature";
17993 let raw =
17994 format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
17995 let error = ureq::get(&raw)
17996 .timeout(std::time::Duration::from_millis(250))
17997 .call()
17998 .expect_err("the closed local port must fail");
17999 let ureq::Error::Transport(transport) = error else {
18000 panic!("expected a transport failure");
18001 };
18002
18003 let rendered = object_store_transport_error(transport).to_string();
18004 assert!(rendered.contains("the object store"));
18005 assert!(rendered.contains("network error"));
18006 assert!(!rendered.contains(&raw));
18007 assert!(!rendered.contains(signature));
18008 assert!(!rendered.contains("X-Amz-"));
18009 }
18010
18011 #[test]
18012 fn endpoint_cap_refuses_a_body_before_json_parsing() {
18013 let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
18014 let cfg = HubConfig {
18015 hub,
18016 key: None,
18017 agent_key: None,
18018 brain_key: None,
18019 state_dir: tempfile::tempdir().unwrap().keep(),
18020 store_selected: false,
18021 };
18022
18023 assert!(matches!(
18024 request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
18025 Err(LinkError::ResponseTooLarge { .. })
18026 ));
18027 server.join().unwrap();
18028 }
18029
18030 #[test]
18031 fn overall_deadline_stops_a_dribbled_response_body() {
18032 use std::io::{Read as _, Write as _};
18033 use std::net::TcpListener;
18034 use std::time::{Duration, Instant};
18035
18036 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18037 let url = format!("http://{}/dribble", listener.local_addr().unwrap());
18038 let server = std::thread::spawn(move || {
18039 let (mut stream, _) = listener.accept().unwrap();
18040 let mut request = [0_u8; 1024];
18041 let _ = stream.read(&mut request);
18042 stream
18043 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
18044 .unwrap();
18045 for byte in [b'x'; 32] {
18046 if stream.write_all(&[byte]).is_err() {
18047 break;
18048 }
18049 std::thread::sleep(Duration::from_millis(40));
18050 }
18051 });
18052 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
18053 let started = Instant::now();
18054 let response = http.get(&url).call().unwrap();
18055 let mut body = Vec::new();
18056 let error = response
18057 .into_reader()
18058 .read_to_end(&mut body)
18059 .expect_err("per-read progress must not reset the overall deadline");
18060 assert!(
18061 started.elapsed() < Duration::from_millis(700),
18062 "dribbled body exceeded the wall-clock budget: {error}"
18063 );
18064 server.join().unwrap();
18065 }
18066
18067 #[test]
18068 fn overall_deadline_stops_a_stalled_upload() {
18069 use std::net::TcpListener;
18070 use std::time::{Duration, Instant};
18071
18072 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18073 let url = format!("http://{}/upload", listener.local_addr().unwrap());
18074 let server = std::thread::spawn(move || {
18075 let (_stream, _) = listener.accept().unwrap();
18076 std::thread::sleep(Duration::from_millis(600));
18079 });
18080 let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
18081 let body = vec![0x5a; 32 * 1024 * 1024];
18082 let started = Instant::now();
18083 let error = http
18084 .put(&url)
18085 .send_bytes(&body)
18086 .expect_err("stalled request-body writes must time out");
18087 assert!(
18088 started.elapsed() < Duration::from_millis(700),
18089 "stalled upload exceeded the wall-clock budget: {error}"
18090 );
18091 server.join().unwrap();
18092 }
18093
18094 #[test]
18095 fn presigned_source_retries_share_one_upload_deadline() {
18096 use std::net::TcpListener;
18097 use std::time::{Duration, Instant};
18098
18099 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18100 let address = listener.local_addr().unwrap();
18101 let signature = "do-not-render-this-stalled-upload-signature";
18102 let url = format!(
18103 "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
18104 );
18105 let server = std::thread::spawn(move || {
18106 let (_stream, _) = listener.accept().unwrap();
18107 std::thread::sleep(Duration::from_millis(600));
18111 });
18112
18113 let directory = tempfile::tempdir().unwrap();
18114 std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
18115 std::fs::create_dir(directory.path().join("records")).unwrap();
18116 let relative = "records/stalled.bin";
18117 let bytes = vec![0x5a; 32 * 1024 * 1024];
18118 std::fs::write(directory.path().join(relative), &bytes).unwrap();
18119 let store = Store::open_strict(directory.path()).unwrap();
18120 let cfg = HubConfig {
18121 hub: format!("http://{address}"),
18122 key: None,
18123 agent_key: None,
18124 brain_key: None,
18125 state_dir: tempfile::tempdir().unwrap().keep(),
18126 store_selected: false,
18127 };
18128 let source = V2UploadSource {
18129 path: relative.to_string(),
18130 bytes: bytes.len() as u64,
18131 };
18132
18133 let started = Instant::now();
18134 let error = put_presigned_source_with_budget(
18135 &cfg,
18136 &url,
18137 &json!({ "content-length": source.bytes.to_string() }),
18138 &store,
18139 &source,
18140 None,
18141 Duration::from_millis(150),
18142 )
18143 .expect_err("a black-holed upload must leave at its shared deadline");
18144 assert!(
18145 started.elapsed() < Duration::from_millis(700),
18146 "presigned retries exceeded their shared budget: {error}"
18147 );
18148 let rendered = error.to_string();
18149 assert!(rendered.contains("the object store"));
18150 assert!(!rendered.contains(&url));
18151 assert!(!rendered.contains(signature));
18152 server.join().unwrap();
18153 }
18154
18155 #[test]
18156 fn verb_entry_gates_accept_the_hub_ref_shapes() {
18157 for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
18158 assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
18159 assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
18160 assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
18161 }
18162 }
18163
18164 #[test]
18165 fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
18166 let cfg = dead_hub();
18167 for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
18168 assert!(
18169 matches!(
18170 sync_pull(&cfg, bad, None),
18171 Err(LinkError::BadAddress { .. })
18172 ),
18173 "sync_pull must refuse {bad:?}"
18174 );
18175 assert!(
18176 matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
18177 "sync_push must refuse {bad:?}"
18178 );
18179 assert!(
18180 matches!(
18181 grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
18182 Err(LinkError::BadAddress { .. })
18183 ),
18184 "grant_issue must refuse {bad:?}"
18185 );
18186 assert!(
18187 matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
18188 "grant_list must refuse {bad:?}"
18189 );
18190 assert!(
18191 matches!(
18192 grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
18193 Err(LinkError::BadAddress { .. })
18194 ),
18195 "grant_revoke must refuse brain {bad:?}"
18196 );
18197 assert!(
18198 matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
18199 "head must refuse {bad:?}"
18200 );
18201 }
18202 }
18203
18204 #[test]
18205 fn grant_revoke_refuses_url_reshaping_grant_ids() {
18206 let cfg = dead_hub();
18207 for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
18208 assert!(
18209 matches!(
18210 grant_revoke(&cfg, "acme", bad),
18211 Err(LinkError::BadGrantId { .. })
18212 ),
18213 "grant_revoke must refuse grant id {bad:?}"
18214 );
18215 }
18216 }
18217
18218 #[test]
18219 fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
18220 let cfg = dead_hub();
18221 for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
18222 assert!(
18223 matches!(
18224 propose(&cfg, bad, "intake", "hi"),
18225 Err(LinkError::BadAddress { .. })
18226 ),
18227 "propose must refuse handle {bad:?}"
18228 );
18229 }
18230 let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
18231 assert!(matches!(
18232 propose(&cfg, "acme-site", "intake", &oversize),
18233 Err(LinkError::ProposeTooLarge { .. })
18234 ));
18235 assert!(matches!(
18238 propose(&cfg, "acme-site", "intake", "hi"),
18239 Err(LinkError::Transport { .. })
18240 ));
18241 }
18242
18243 #[test]
18244 fn resolve_refuses_a_hand_built_unsafe_address() {
18245 let cfg = dead_hub();
18246 for brain in ["../up", "a/b", "a?x", "a#f"] {
18247 let addr = Address {
18248 brain: brain.to_string(),
18249 target: None,
18250 };
18251 assert!(
18252 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18253 "resolve must refuse brain {brain:?}"
18254 );
18255 }
18256 for target in [
18257 AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
18258 AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), AddressTarget::Path("../up.md".to_string()),
18260 AddressTarget::Path("records/x.md#frag".to_string()),
18261 ] {
18262 let addr = Address {
18263 brain: "acme".to_string(),
18264 target: Some(target.clone()),
18265 };
18266 assert!(
18267 matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18268 "resolve must refuse target {target:?}"
18269 );
18270 }
18271 }
18272
18273 #[test]
18274 fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
18275 let mut local = std::collections::BTreeMap::new();
18276 local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
18277 local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
18278 let mut remote = std::collections::BTreeMap::new();
18279 remote.insert(
18280 "records/a.md".to_string(),
18281 V2BaselineFile {
18282 sha256: "c".repeat(64),
18283 bytes: 1,
18284 proof: None,
18285 },
18286 );
18287 remote.insert(
18288 "records/b.md".to_string(),
18289 V2BaselineFile {
18290 sha256: "b".repeat(64),
18291 bytes: 1,
18292 proof: None,
18293 },
18294 );
18295 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18296 }
18297
18298 #[test]
18299 fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
18300 let local = std::collections::BTreeMap::new();
18301 let mut remote = std::collections::BTreeMap::new();
18302 remote.insert(
18303 "private/local.md".to_string(),
18304 V2BaselineFile {
18305 sha256: "d".repeat(64),
18306 bytes: 1,
18307 proof: None,
18308 },
18309 );
18310 assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
18311 assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18312 }
18313
18314 fn scoped_test_head(revision: &str) -> V2VerifiedHead {
18315 V2VerifiedHead {
18316 requested: TEST_BRAIN_ID.to_string(),
18317 brain_id: TEST_BRAIN_ID.to_string(),
18318 view_kind: "scoped".to_string(),
18319 view_revision: revision.to_string(),
18320 control_revision: revision.to_string(),
18321 identity: V2HeadIdentity {
18322 custody: "hub".to_string(),
18323 fingerprint: "test".to_string(),
18324 public_key_spki: "test".to_string(),
18325 previous: Vec::new(),
18326 rotations: Vec::new(),
18327 },
18328 pointer: None,
18329 trust: TrustState {
18330 v: 2,
18331 origin: "https://hub.example".to_string(),
18332 requested: TEST_BRAIN_ID.to_string(),
18333 brain: TEST_BRAIN_ID.to_string(),
18334 home: None,
18335 anchor: "ed25519:test".to_string(),
18336 current: "ed25519:test".to_string(),
18337 head_seq: 0,
18338 feed_hash: None,
18339 rotations: Vec::new(),
18340 hub_signer: None,
18341 protocol_profile: Some("link-v2".to_string()),
18342 },
18343 alias: None,
18344 }
18345 }
18346
18347 #[test]
18348 fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
18349 let mut trust = scoped_test_head(&"a".repeat(64)).trust;
18350 assert!(accepted_as_v2(&trust));
18351
18352 trust.protocol_profile = None;
18353 trust.hub_signer = Some("ed25519:hub".to_string());
18354 assert!(accepted_as_v2(&trust));
18355
18356 trust.hub_signer = None;
18357 assert!(!accepted_as_v2(&trust));
18358 }
18359
18360 fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
18361 V2SyncBaseline {
18362 v: 2,
18363 origin: "https://hub.example".to_string(),
18364 brain: TEST_BRAIN_ID.to_string(),
18365 checkout_id: Some("c".repeat(64)),
18366 head_seq: Some(0),
18367 commit_hash: None,
18368 content_root: None,
18369 asset_root: None,
18370 assets: std::collections::BTreeMap::new(),
18371 view_kind: Some("scoped".to_string()),
18372 view_revision: Some(revision.to_string()),
18373 control_revision: Some(revision.to_string()),
18374 projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
18375 files: std::collections::BTreeMap::new(),
18376 local_policy_digest: None,
18377 local_eligibility: std::collections::BTreeMap::new(),
18378 remote_copy_remains: std::collections::BTreeMap::new(),
18379 }
18380 }
18381
18382 #[test]
18383 fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
18384 let cfg = test_hub_config(
18385 "https://hub.example".to_string(),
18386 tempfile::tempdir().unwrap().keep(),
18387 );
18388 let mut baseline = scoped_test_baseline(&"a".repeat(64));
18389 baseline.assets.insert(
18390 "assets/archive.bin".to_string(),
18391 V2BaselineAsset {
18392 blob_sha256: "b".repeat(64),
18393 bytes: MAX_STORE_BYTES + 1,
18394 media_type: "application/octet-stream".to_string(),
18395 wrappers: vec!["records/archive.md".to_string()],
18396 required: true,
18397 disposition: "hosted".to_string(),
18398 leaf_hash: "c".repeat(64),
18399 },
18400 );
18401
18402 let accepted = serde_json::to_vec(&baseline).unwrap();
18403 assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
18404
18405 baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
18406 let refused = serde_json::to_vec(&baseline).unwrap();
18407 assert!(matches!(
18408 parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
18409 Err(LinkError::InvalidFeed { .. })
18410 ));
18411 }
18412
18413 #[test]
18414 fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
18415 let directory = tempfile::tempdir().unwrap();
18416 std::fs::write(
18417 directory.path().join("DB.md"),
18418 scoped_projection_bytes(TEST_BRAIN_ID),
18419 )
18420 .unwrap();
18421 let store = Store::open_strict(directory.path()).unwrap();
18422 let head = scoped_test_head(&"a".repeat(64));
18423 let baseline = scoped_test_baseline(&"a".repeat(64));
18424 let mut view = v2_local_files(&store).unwrap();
18425 remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
18426 assert!(!view.riding.contains_key("DB.md"));
18427 assert!(!view.eligibility.contains_key("DB.md"));
18428 }
18429
18430 #[test]
18431 fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
18432 let directory = tempfile::tempdir().unwrap();
18433 std::fs::write(
18434 directory.path().join("DB.md"),
18435 scoped_projection_bytes(TEST_BRAIN_ID),
18436 )
18437 .unwrap();
18438 let store = Store::open_strict(directory.path()).unwrap();
18439 let head = scoped_test_head(&"a".repeat(64));
18440 let baseline = scoped_test_baseline(&"a".repeat(64));
18441
18442 let mut carried = v2_local_files(&store).unwrap();
18443 remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
18444 let handed_off =
18445 local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
18446 assert!(!handed_off.riding.contains_key("DB.md"));
18447
18448 let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
18449 assert!(!freshly_scanned.riding.contains_key("DB.md"));
18450
18451 std::fs::write(
18452 directory.path().join("DB.md"),
18453 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18454 )
18455 .unwrap();
18456 let tampered = Store::open_strict(directory.path()).unwrap();
18457 assert!(matches!(
18458 local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
18459 Err(LinkError::ScopedProjectionModified)
18460 ));
18461 }
18462
18463 #[test]
18464 fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
18465 let directory = tempfile::tempdir().unwrap();
18466 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18467 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18468 std::fs::write(
18469 directory.path().join("DB.md"),
18470 b"---\nname: Kept home test\n---\n",
18471 )
18472 .unwrap();
18473 std::fs::write(
18474 directory.path().join("records/notes/a.md"),
18475 b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
18476 )
18477 .unwrap();
18478 std::fs::write(
18479 directory.path().join("sources/private/secret.md"),
18480 b"---\ntype: note\n---\nlocal only\n",
18481 )
18482 .unwrap();
18483 std::fs::write(
18484 directory.path().join("sources/private/unlinked.md"),
18485 b"---\ntype: note\n---\nnot disclosed\n",
18486 )
18487 .unwrap();
18488 std::fs::write(
18489 directory.path().join(".sevralocal"),
18490 b"sources/private/**\n",
18491 )
18492 .unwrap();
18493
18494 let store = Store::open_strict(directory.path()).unwrap();
18495 let view = v2_local_files(&store).unwrap();
18496 assert!(!view.riding.contains_key("sources/private/secret.md"));
18497 assert_eq!(
18498 view.withheld_links,
18499 vec![V2WithheldLink {
18500 source: "records/notes/a.md".to_string(),
18501 target: "sources/private/secret.md".to_string(),
18502 }]
18503 );
18504 }
18505
18506 #[test]
18507 fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
18508 let directory = tempfile::tempdir().unwrap();
18513 std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18514 std::fs::write(
18515 directory.path().join("DB.md"),
18516 b"---\nname: Restored export\n---\n",
18517 )
18518 .unwrap();
18519 std::fs::write(
18520 directory.path().join("records/notes/a.md"),
18521 b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
18522 )
18523 .unwrap();
18524 std::fs::write(
18525 directory.path().join(".sevralocal"),
18526 b"sources/private/**\n",
18527 )
18528 .unwrap();
18529
18530 let store = Store::open_strict(directory.path()).unwrap();
18531 let view = v2_local_files(&store).unwrap();
18532 assert_eq!(
18533 view.withheld_links,
18534 vec![V2WithheldLink {
18535 source: "records/notes/a.md".to_string(),
18536 target: "sources/private/absent.md".to_string(),
18537 }]
18538 );
18539 std::fs::write(
18541 directory.path().join("records/notes/b.md"),
18542 b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
18543 )
18544 .unwrap();
18545 let store = Store::open_strict(directory.path()).unwrap();
18546 let view = v2_local_files(&store).unwrap();
18547 assert!(
18548 !view
18549 .withheld_links
18550 .iter()
18551 .any(|link| link.target == "records/notes/nowhere.md"),
18552 "an unclaimed dangling target must not be declared withheld"
18553 );
18554 }
18555
18556 #[test]
18557 fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
18558 let directory = tempfile::tempdir().unwrap();
18559 std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18560 std::fs::write(
18561 directory.path().join("DB.md"),
18562 b"---\nname: Withdrawal test\n---\n",
18563 )
18564 .unwrap();
18565 let source = b"---\ntype: note\n---\nlocal evidence\n";
18566 std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
18567 std::fs::write(
18568 directory.path().join(".sevralocal"),
18569 b"sources/private/**\n",
18570 )
18571 .unwrap();
18572 let store = Store::open_strict(directory.path()).unwrap();
18573 let view = v2_local_files(&store).unwrap();
18574 let mut remote = std::collections::BTreeMap::new();
18575 remote.insert(
18576 "sources/private/evidence.md".to_string(),
18577 V2BaselineFile {
18578 sha256: content_sha256(source),
18579 bytes: source.len() as u64,
18580 proof: None,
18581 },
18582 );
18583 assert_eq!(
18584 v2_content_withdrawal_operation(
18585 &store,
18586 &view,
18587 &remote,
18588 "sources/private/evidence.md",
18589 "approved retention change",
18590 )
18591 .unwrap(),
18592 json!({
18593 "op": "withdraw_from_hosting",
18594 "path": "sources/private/evidence.md",
18595 "expected": { "kind": "blob", "hash": content_sha256(source) },
18596 "reason": "approved retention change",
18597 })
18598 );
18599
18600 std::fs::write(
18601 directory.path().join("sources/private/evidence.md"),
18602 b"changed after review",
18603 )
18604 .unwrap();
18605 assert!(matches!(
18606 v2_content_withdrawal_operation(
18607 &store,
18608 &view,
18609 &remote,
18610 "sources/private/evidence.md",
18611 "approved retention change",
18612 ),
18613 Err(LinkError::InvalidPack { .. })
18614 ));
18615 }
18616
18617 #[test]
18618 fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
18619 let directory = tempfile::tempdir().unwrap();
18620 std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
18621 std::fs::write(
18622 directory.path().join("DB.md"),
18623 b"---\nname: Asset withdrawal test\n---\n",
18624 )
18625 .unwrap();
18626 let bytes = b"private binary";
18627 std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
18628 std::fs::write(
18629 directory.path().join(".sevralocal"),
18630 b"sources/files/private.pdf\n",
18631 )
18632 .unwrap();
18633 let store = Store::open_strict(directory.path()).unwrap();
18634 let view = v2_local_files(&store).unwrap();
18635 let local = crate::AssetRecord {
18636 path: "sources/files/private.pdf".to_string(),
18637 sha256: content_sha256(bytes),
18638 bytes: bytes.len() as u64,
18639 media_type: "application/pdf".to_string(),
18640 wrappers: vec!["sources/files/private.md".to_string()],
18641 required: true,
18642 };
18643 let current = V2BaselineAsset {
18644 blob_sha256: local.sha256.clone(),
18645 bytes: local.bytes,
18646 media_type: local.media_type.clone(),
18647 wrappers: local.wrappers.clone(),
18648 required: local.required,
18649 disposition: "hosted".to_string(),
18650 leaf_hash: "d".repeat(64),
18651 };
18652 assert_eq!(
18653 v2_asset_withdrawal_operation(
18654 &store,
18655 &view,
18656 &local.path,
18657 &local,
18658 ¤t,
18659 "approved retention change",
18660 )
18661 .unwrap(),
18662 json!({
18663 "op": "asset_withdraw",
18664 "path": local.path,
18665 "expected": { "kind": "asset", "hash": "d".repeat(64) },
18666 "reason": "approved retention change",
18667 })
18668 );
18669
18670 let mut mismatched = current.clone();
18671 mismatched.required = false;
18672 assert!(matches!(
18673 v2_asset_withdrawal_operation(
18674 &store,
18675 &view,
18676 &local.path,
18677 &local,
18678 &mismatched,
18679 "approved retention change",
18680 ),
18681 Err(LinkError::InvalidPack { .. })
18682 ));
18683 }
18684
18685 #[test]
18686 fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
18687 let first = v2_checkout_id(None).unwrap();
18688 assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
18689 assert_ne!(first, v2_checkout_id(None).unwrap());
18690 assert!(is_sha256(&first));
18691 }
18692
18693 #[test]
18694 fn scoped_projection_edit_and_scope_transition_fail_closed() {
18695 let directory = tempfile::tempdir().unwrap();
18696 std::fs::write(
18697 directory.path().join("DB.md"),
18698 b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
18699 )
18700 .unwrap();
18701 let store = Store::open_strict(directory.path()).unwrap();
18702 let head = scoped_test_head(&"a".repeat(64));
18703 let baseline = scoped_test_baseline(&"a".repeat(64));
18704 let mut view = v2_local_files(&store).unwrap();
18705 assert!(matches!(
18706 remove_scoped_projection(&head, Some(&baseline), &mut view),
18707 Err(LinkError::ScopedProjectionModified)
18708 ));
18709
18710 let changed = scoped_test_head(&"b".repeat(64));
18711 assert!(matches!(
18712 ensure_v2_view_compatible(&changed, Some(&baseline)),
18713 Err(LinkError::ScopedViewChanged)
18714 ));
18715
18716 let mut same_view_new_control = head.clone();
18717 same_view_new_control.control_revision = "c".repeat(64);
18718 assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
18719 assert!(!same_v2_head(&head, &same_view_new_control));
18720 }
18721
18722 #[test]
18723 fn verified_baseline_reuse_requires_exact_content_assets_view_and_authority() {
18724 let mut head = scoped_test_head(&"a".repeat(64));
18725 head.control_revision = "b".repeat(64);
18726 head.pointer = Some(V2PointerBody {
18727 v: 2,
18728 brain: TEST_BRAIN_ID.to_string(),
18729 seq: 7,
18730 commit_hash: "c".repeat(64),
18731 feed_hash: "d".repeat(64),
18732 content_root: Some("e".repeat(64)),
18733 asset_root: Some("f".repeat(64)),
18734 materializer: "dbmd-projection-v1".to_string(),
18735 signer_epoch: 1,
18736 control_revision: head.control_revision.clone(),
18737 backup_preparation: "0".repeat(64),
18738 prior_pointer_hash: Some("1".repeat(64)),
18739 signed_at: "2026-08-24T00:00:00.000Z".to_string(),
18740 });
18741 let mut baseline = scoped_test_baseline(&head.view_revision);
18742 baseline.head_seq = Some(7);
18743 baseline.commit_hash = Some("c".repeat(64));
18744 baseline.content_root = Some("e".repeat(64));
18745 baseline.asset_root = Some("f".repeat(64));
18746 baseline.control_revision = Some(head.control_revision.clone());
18747 assert!(v2_baseline_matches_head(&head, &baseline));
18748
18749 let mut changed = baseline.clone();
18750 changed.head_seq = Some(8);
18751 assert!(!v2_baseline_matches_head(&head, &changed));
18752 let mut changed = baseline.clone();
18753 changed.commit_hash = Some("2".repeat(64));
18754 assert!(!v2_baseline_matches_head(&head, &changed));
18755 let mut changed = baseline.clone();
18756 changed.content_root = Some("3".repeat(64));
18757 assert!(!v2_baseline_matches_head(&head, &changed));
18758 let mut changed = baseline.clone();
18759 changed.asset_root = Some("4".repeat(64));
18760 assert!(!v2_baseline_matches_head(&head, &changed));
18761 let mut changed = baseline.clone();
18762 changed.view_revision = Some("5".repeat(64));
18763 assert!(!v2_baseline_matches_head(&head, &changed));
18764 let mut changed = baseline.clone();
18765 changed.control_revision = Some("6".repeat(64));
18766 assert!(!v2_baseline_matches_head(&head, &changed));
18767
18768 let mut changed_head = head.clone();
18769 changed_head.view_kind = "full".to_string();
18770 assert!(!v2_baseline_matches_head(&changed_head, &baseline));
18771 }
18772
18773 #[test]
18774 fn legacy_baseline_without_authority_revision_refreshes_before_reuse() {
18775 let sandbox = tempfile::tempdir().unwrap();
18776 let cfg = test_hub_config(
18777 "https://hub.example".to_string(),
18778 sandbox.path().to_path_buf(),
18779 );
18780 let head = scoped_test_head(&"a".repeat(64));
18781 let baseline = scoped_test_baseline(&head.view_revision);
18782 let mut encoded = serde_json::to_value(&baseline).unwrap();
18783 encoded.as_object_mut().unwrap().remove("control_revision");
18784 let parsed =
18785 parse_v2_baseline(&cfg, TEST_BRAIN_ID, &serde_json::to_vec(&encoded).unwrap()).unwrap();
18786 assert!(parsed.control_revision.is_none());
18787 assert!(!v2_baseline_matches_head(&head, &parsed));
18788 }
18789
18790 #[test]
18791 fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
18792 let scoped = scoped_test_head(&"a".repeat(64));
18793 let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
18794 assert!(matches!(
18795 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
18796 Err(LinkError::ScopedProjectionModified)
18797 ));
18798
18799 let mut full = scoped.clone();
18800 full.view_kind = "full".to_string();
18801 let mut full_baseline = scoped_baseline.clone();
18802 full_baseline.view_kind = Some("full".to_string());
18803 full_baseline.projection_sha256 = None;
18804 assert!(matches!(
18805 ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
18806 Err(LinkError::InvalidPack { .. })
18807 ));
18808
18809 assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
18810 assert!(
18811 ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
18812 );
18813 }
18814
18815 #[test]
18816 fn scoped_view_metadata_is_explicitly_non_authoritative() {
18817 let head = scoped_test_head(&"a".repeat(64));
18818 let value: Value =
18819 serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
18820 assert_eq!(value["kind"], "link.md-scoped-view");
18821 assert_eq!(value["authoritative"], false);
18822 assert_eq!(value["visible_files"], 7);
18823 assert_eq!(value["brain"], TEST_BRAIN_ID);
18824 }
18825
18826 #[test]
18827 fn local_scoped_marker_requires_the_exact_generated_projection() {
18828 let directory = tempfile::tempdir().unwrap();
18829 std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
18830 std::fs::write(
18831 directory.path().join("DB.md"),
18832 scoped_projection_bytes(TEST_BRAIN_ID),
18833 )
18834 .unwrap();
18835 let head = scoped_test_head(&"a".repeat(64));
18836 std::fs::write(
18837 directory.path().join(".dbmd/view.json"),
18838 scoped_view_metadata(&head, 0).unwrap(),
18839 )
18840 .unwrap();
18841 let store = Store::open_strict(directory.path()).unwrap();
18842 assert!(has_verified_local_scoped_view(&store));
18843
18844 std::fs::write(
18845 directory.path().join("DB.md"),
18846 b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18847 )
18848 .unwrap();
18849 let altered = Store::open_strict(directory.path()).unwrap();
18850 assert!(!has_verified_local_scoped_view(&altered));
18851 }
18852
18853 fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
18854 use ring::signature::KeyPair as _;
18855
18856 let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
18857 let rng = ring::rand::SystemRandom::new();
18858 let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18859 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18860 let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
18861 let public_key = URL_SAFE_NO_PAD.encode(&public_der);
18862 let fingerprint = format!("{:x}", Sha256::digest(&public_der));
18863 let blob = b"new";
18864 let blob_hash = content_sha256(blob);
18865 let changes = json!({
18866 "mutation_id": "sync:proposal-fixture",
18867 "operations": [{
18868 "blob": blob_hash,
18869 "bytes": blob.len(),
18870 "expected": null,
18871 "op": "put",
18872 "path": "records/new.md",
18873 }],
18874 "reason": "fixture",
18875 "v": 2,
18876 });
18877 let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
18878 let changes_base64 = STANDARD.encode(&changes_bytes);
18879 let descriptor = json!({
18880 "base": null,
18881 "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
18882 "changes_base64": changes_base64,
18883 "rebase": "strict",
18884 "v": 2,
18885 });
18886 let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
18887 let payload_hash = "b".repeat(64);
18888 let submitted_at = "2026-08-19T12:00:00.000Z";
18889 let claim = json!({
18890 "actor_root": {
18891 "actor_class": "foreign_key",
18892 "credential": "ed25519:fixture",
18893 "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
18894 "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
18895 "principal": "key:fixture",
18896 "role": null,
18897 },
18898 "brain": TEST_BRAIN_ID,
18899 "clear_sha256": clear_hash,
18900 "control_revision": "c".repeat(64),
18901 "mutation_id": "sync:proposal-fixture",
18902 "payload_sha256": payload_hash,
18903 "proposal_id": proposal_id,
18904 "submitted_at": submitted_at,
18905 "v": 2,
18906 });
18907 let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
18908 let envelope = json!({
18909 "claim": claim,
18910 "fingerprint": fingerprint,
18911 "public_key": public_key,
18912 "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
18913 });
18914 let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18915 let submission_hash =
18916 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
18917 let mut head = scoped_test_head(&"c".repeat(64));
18918 head.view_kind = "full".to_string();
18919 head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
18920 let value = json!({
18921 "proposal": {
18922 "base": null,
18923 "blobs": [{
18924 "bytes": blob.len(),
18925 "endpoint": format!(
18926 "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
18927 ),
18928 "sha256": blob_hash,
18929 }],
18930 "changes_base64": changes_base64,
18931 "clear_sha256": clear_hash,
18932 "expires_at": "2026-08-26T12:00:00.000Z",
18933 "id": proposal_id,
18934 "payload_sha256": payload_hash,
18935 "proposer": { "class": "foreign_key" },
18936 "rebase": "strict",
18937 "state": "pending",
18938 "submission_claim_base64": STANDARD.encode(envelope_bytes),
18939 "submission_claim_sha256": submission_hash,
18940 "submitted_at": submitted_at,
18941 },
18942 "v": 2,
18943 });
18944 (head, proposal_id, value)
18945 }
18946
18947 #[test]
18948 fn v2_proposal_verifier_accepts_exact_signed_payload() {
18949 let (head, proposal_id, value) = signed_proposal_fixture();
18950 let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
18951 assert_eq!(verified.blobs.len(), 1);
18952 assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
18953 }
18954
18955 #[test]
18956 fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
18957 let (head, proposal_id, value) = signed_proposal_fixture();
18958
18959 let mut changed = value.clone();
18960 changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
18961 assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
18962
18963 let mut redirected = value.clone();
18964 redirected["proposal"]["blobs"][0]["endpoint"] =
18965 Value::String("https://attacker.example/blob".to_string());
18966 assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
18967
18968 let mut forged = value;
18969 let encoded = forged["proposal"]["submission_claim_base64"]
18970 .as_str()
18971 .unwrap();
18972 let mut envelope: Value =
18973 serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
18974 envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
18975 let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18976 forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
18977 forged["proposal"]["submission_claim_sha256"] = Value::String(
18978 crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
18979 );
18980 assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
18981 }
18982
18983 #[cfg(unix)]
18984 #[test]
18985 fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
18986 let sandbox = tempfile::tempdir().unwrap();
18987 let destination = sandbox.path().join("brain");
18988 let entries = vec![
18989 (
18990 "DB.md".to_string(),
18991 scoped_projection_bytes(TEST_BRAIN_ID),
18992 ),
18993 (
18994 "records/contacts/a.md".to_string(),
18995 b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
18996 .to_vec(),
18997 ),
18998 ];
18999 install_pulled_delta(&destination, &entries, &[], true).unwrap();
19000 assert!(destination.join("index.md").is_file());
19001 assert!(destination.join("records/index.md").is_file());
19002 assert!(destination.join("records/contacts/index.md").is_file());
19003 assert!(destination.join("records/contacts/index.jsonl").is_file());
19004 }
19005
19006 #[cfg(unix)]
19007 #[test]
19008 fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
19009 let sandbox = tempfile::tempdir().unwrap();
19010 let destination = sandbox.path().join("brain");
19011 let cache = sandbox.path().join("cache");
19012 std::fs::create_dir(&cache).unwrap();
19013 let db = scoped_projection_bytes(TEST_BRAIN_ID);
19014 let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
19015 let db_source = cache.join("db");
19016 let shared_source = cache.join("shared");
19017 crate::fsx::write_atomic(&db_source, &db).unwrap();
19018 crate::fsx::write_atomic(&shared_source, shared).unwrap();
19019 let mut entries = vec![V2StagedFile {
19020 path: "DB.md".to_string(),
19021 source: db_source,
19022 sha256: content_sha256(&db),
19023 bytes: db.len() as u64,
19024 }];
19025 for index in 0..512 {
19026 entries.push(V2StagedFile {
19027 path: format!("records/items/{index:05}.md"),
19028 source: shared_source.clone(),
19029 sha256: content_sha256(shared),
19030 bytes: shared.len() as u64,
19031 });
19032 }
19033 install_pulled_delta_sources(
19034 &destination,
19035 &entries,
19036 &[],
19037 false,
19038 None,
19039 &scoped_test_head(&"c".repeat(64)),
19040 )
19041 .unwrap();
19042 assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
19043 for index in 0..512 {
19044 assert_eq!(
19045 std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
19046 shared
19047 );
19048 }
19049 assert!(
19050 std::fs::read_dir(sandbox.path())
19051 .unwrap()
19052 .all(|entry| !entry
19053 .unwrap()
19054 .file_name()
19055 .to_string_lossy()
19056 .contains("pull-stage")),
19057 "the private stage must be atomically installed or removed"
19058 );
19059 }
19060
19061 #[cfg(unix)]
19062 #[test]
19063 fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
19064 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
19065
19066 let sandbox = tempfile::tempdir().unwrap();
19067 let root = sandbox.path().join("brain");
19068 std::fs::create_dir_all(root.join("records/items")).unwrap();
19069 let db = scoped_projection_bytes(TEST_BRAIN_ID);
19070 let old = b"---\ntype: note\n---\n\nold\n";
19071 let new = b"---\ntype: note\n---\n\nnew\n";
19072 let removed = b"---\ntype: note\n---\n\nremove me\n";
19073 std::fs::write(root.join("DB.md"), &db).unwrap();
19074 std::fs::write(root.join("records/items/change.md"), old).unwrap();
19075 std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
19076 for index in 0..512 {
19077 std::fs::write(
19078 root.join(format!("records/items/untouched-{index:04}.md")),
19079 old,
19080 )
19081 .unwrap();
19082 }
19083 let untouched = root.join("records/items/untouched-0256.md");
19084 let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
19085 let source = sandbox.path().join("changed-source");
19086 crate::fsx::write_atomic(&source, new).unwrap();
19087 let same_source = sandbox.path().join("unchanged-source");
19088 crate::fsx::write_atomic(&same_source, old).unwrap();
19089 let same_entry = V2StagedFile {
19090 path: "records/items/change.md".to_string(),
19091 source: same_source,
19092 sha256: content_sha256(old),
19093 bytes: old.len() as u64,
19094 };
19095 let entry = V2StagedFile {
19096 path: "records/items/change.md".to_string(),
19097 source,
19098 sha256: content_sha256(new),
19099 bytes: new.len() as u64,
19100 };
19101 let head = scoped_test_head(&"c".repeat(64));
19102
19103 install_established_v2_delta(
19107 Store::open_strict(&root).unwrap(),
19108 &[same_entry],
19109 &["records/items/already-absent.md".to_string()],
19110 true,
19111 None,
19112 &head,
19113 )
19114 .unwrap();
19115 assert_eq!(
19116 std::fs::metadata(&untouched).unwrap().ino(),
19117 untouched_inode
19118 );
19119 assert!(!root.join(V2_PULL_JOURNAL).exists());
19120
19121 install_established_v2_delta(
19122 Store::open_strict(&root).unwrap(),
19123 &[entry],
19124 &["records/items/delete.md".to_string()],
19125 false,
19126 None,
19127 &head,
19128 )
19129 .unwrap();
19130 assert_eq!(
19131 std::fs::read(root.join("records/items/change.md")).unwrap(),
19132 new
19133 );
19134 assert!(!root.join("records/items/delete.md").exists());
19135 assert_eq!(
19136 std::fs::metadata(&untouched).unwrap().ino(),
19137 untouched_inode
19138 );
19139 assert!(root.join(V2_PULL_JOURNAL).is_file());
19140 assert_eq!(
19141 std::fs::metadata(root.join(V2_PULL_JOURNAL))
19142 .unwrap()
19143 .permissions()
19144 .mode()
19145 & 0o777,
19146 0o600
19147 );
19148 let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
19149 .unwrap()
19150 .unwrap();
19151 assert_eq!(
19152 std::fs::metadata(root.join(&journal.backup_dir))
19153 .unwrap()
19154 .permissions()
19155 .mode()
19156 & 0o777,
19157 0o700
19158 );
19159 for entry in &journal.entries {
19160 if let Some(backup) = &entry.backup {
19161 assert_eq!(
19162 std::fs::metadata(root.join(&journal.backup_dir).join(backup))
19163 .unwrap()
19164 .permissions()
19165 .mode()
19166 & 0o777,
19167 0o600
19168 );
19169 }
19170 }
19171
19172 let cfg = test_hub_config(
19173 "https://example.test".to_string(),
19174 sandbox.path().join("state"),
19175 );
19176 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19177 assert_eq!(
19178 std::fs::read(root.join("records/items/change.md")).unwrap(),
19179 old
19180 );
19181 assert_eq!(
19182 std::fs::read(root.join("records/items/delete.md")).unwrap(),
19183 removed
19184 );
19185 assert_eq!(
19186 std::fs::metadata(&untouched).unwrap().ino(),
19187 untouched_inode
19188 );
19189 assert!(!root.join(V2_PULL_JOURNAL).exists());
19190 }
19191
19192 #[test]
19193 fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
19194 let body = b"bounded bytes";
19195 let path = "records/example.md".to_string();
19196 let file = V2BaselineFile {
19197 sha256: content_sha256(body),
19198 bytes: body.len() as u64,
19199 proof: None,
19200 };
19201 let header = serde_json::to_vec(&json!({
19202 "bytes": body.len(),
19203 "path": path,
19204 "sha256": file.sha256,
19205 "v": 2,
19206 }))
19207 .unwrap();
19208 let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
19209 stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
19210 stream.extend_from_slice(&header);
19211 stream.extend_from_slice(body);
19212 stream.extend_from_slice(&0_u32.to_be_bytes());
19213 let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
19214 assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
19215
19216 let mut tampered = stream.clone();
19217 let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
19218 tampered[body_offset] ^= 1;
19219 assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
19220
19221 let mut trailing = stream;
19222 trailing.push(0);
19223 assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
19224 }
19225
19226 #[test]
19227 fn first_checkout_resolution_does_not_recreate_the_same_conflict() {
19228 let path = "records/value.md".to_string();
19229 let mut local = std::collections::BTreeMap::new();
19230 local.insert(path.clone(), (content_sha256(b"local"), 5));
19231 let mut remote = std::collections::BTreeMap::new();
19232 remote.insert(
19233 path.clone(),
19234 V2BaselineFile {
19235 sha256: content_sha256(b"remote"),
19236 bytes: 6,
19237 proof: None,
19238 },
19239 );
19240
19241 assert_eq!(
19242 v2_initial_content_conflicts(&local, &remote, false),
19243 vec![path]
19244 );
19245 assert!(v2_initial_content_conflicts(&local, &remote, true).is_empty());
19246
19247 let mut resolution = std::collections::BTreeMap::new();
19248 resolution.insert(
19249 "records/value.md".to_string(),
19250 V2ResolutionOverride {
19251 expected_remote: Some(content_sha256(b"remote")),
19252 selected_local: Some(content_sha256(b"local")),
19253 },
19254 );
19255 assert!(v2_resolution_allows_path(
19256 Some(&resolution),
19257 "records/value.md",
19258 true
19259 ));
19260 assert!(v2_resolution_allows_path(
19261 Some(&resolution),
19262 "records/new-target.md",
19263 false
19264 ));
19265 assert!(!v2_resolution_allows_path(
19266 Some(&resolution),
19267 "records/unreviewed-remote.md",
19268 true
19269 ));
19270 }
19271
19272 #[test]
19273 fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
19274 let sandbox = tempfile::TempDir::new().unwrap();
19275 let root = sandbox.path().join("brain");
19276 std::fs::create_dir_all(&root).unwrap();
19277 std::fs::write(
19278 root.join("DB.md"),
19279 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19280 )
19281 .unwrap();
19282 let store = Store::open_strict(&root).unwrap();
19283 let incomplete = crate::ulid::mint();
19284 store
19285 .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
19286 .unwrap();
19287 let expired = crate::ulid::mint();
19288 store
19289 .create_dir_all(&v2_conflict_relative(&expired, "files"))
19290 .unwrap();
19291 let plan = V2ConflictPlan {
19292 v: 2,
19293 class: "content_resolution_required".to_string(),
19294 bundle: expired.clone(),
19295 brain: TEST_BRAIN_ID.to_string(),
19296 origin: "https://example.test".to_string(),
19297 created_unix: 0,
19298 expires_unix: 0,
19299 base_seq: None,
19300 base_commit: None,
19301 remote_seq: 0,
19302 remote_commit: None,
19303 remote_content_root: None,
19304 view_kind: "full".to_string(),
19305 view_revision: "a".repeat(64),
19306 files: vec![V2ConflictFile {
19307 path: "records/value.md".to_string(),
19308 base: V2ConflictCoordinate {
19309 sha256: None,
19310 bytes: None,
19311 file: None,
19312 },
19313 local: V2ConflictCoordinate {
19314 sha256: None,
19315 bytes: None,
19316 file: None,
19317 },
19318 remote: V2ConflictCoordinate {
19319 sha256: None,
19320 bytes: None,
19321 file: None,
19322 },
19323 }],
19324 };
19325 let mut bytes = serde_json::to_vec(&plan).unwrap();
19326 bytes.push(b'\n');
19327 store
19328 .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
19329 .unwrap();
19330
19331 let listed = sync_conflicts(&root, false, false).unwrap();
19332 assert_eq!(listed["bundles"], 2);
19333 assert_eq!(listed["pruned"], 0);
19334 let pruned = sync_conflicts(&root, true, false).unwrap();
19335 assert_eq!(pruned["bundles"], 0);
19336 assert_eq!(pruned["pruned"], 2);
19337 assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
19338 assert!(!root.join(".dbmd/conflicts").join(expired).exists());
19339 }
19340
19341 #[test]
19342 fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
19343 let sandbox = tempfile::TempDir::new().unwrap();
19344 let root = sandbox.path().join("brain");
19345 std::fs::create_dir_all(&root).unwrap();
19346 std::fs::write(
19347 root.join("DB.md"),
19348 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19349 )
19350 .unwrap();
19351 let store = Store::open_strict(&root).unwrap();
19352 let bundle = crate::ulid::mint();
19353 store
19354 .create_dir_all(&v2_conflict_relative(&bundle, "files"))
19355 .unwrap();
19356 store
19357 .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
19358 .unwrap();
19359
19360 assert!(sync_conflicts(&root, true, false).is_err());
19361 assert!(sync_conflicts(&root, false, true).is_err());
19362 let pruned = sync_conflicts(&root, true, true).unwrap();
19363 assert_eq!(pruned["pruned"], 1);
19364 assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
19365 }
19366
19367 #[test]
19368 fn ready_pull_journal_rolls_back_exact_preimages() {
19369 let sandbox = tempfile::TempDir::new().unwrap();
19370 let root = sandbox.path().join("brain");
19371 std::fs::create_dir_all(root.join("records")).unwrap();
19372 std::fs::write(
19373 root.join("DB.md"),
19374 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19375 )
19376 .unwrap();
19377 let path = "records/value.md";
19378 let old = b"---\ntype: note\n---\n\nold\n";
19379 let new = b"---\ntype: note\n---\n\nnew\n";
19380 std::fs::write(root.join(path), old).unwrap();
19381 let store = Store::open_strict(&root).unwrap();
19382 let bundle = crate::ulid::mint();
19383 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19384 store
19385 .create_private_dir_all(Path::new(&backup_dir))
19386 .unwrap();
19387 store
19388 .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
19389 .unwrap();
19390 let journal = V2PullJournal {
19391 v: 1,
19392 phase: V2PullPhase::Ready,
19393 brain: TEST_BRAIN_ID.to_string(),
19394 previous: V2PullCoordinate {
19395 head_seq: None,
19396 commit_hash: None,
19397 view_kind: None,
19398 view_revision: None,
19399 },
19400 next: V2PullCoordinate {
19401 head_seq: Some(2),
19402 commit_hash: Some("c".repeat(64)),
19403 view_kind: Some("full".to_string()),
19404 view_revision: Some("d".repeat(64)),
19405 },
19406 backup_dir: backup_dir.clone(),
19407 entries: vec![V2PullJournalEntry {
19408 path: path.to_string(),
19409 old: Some(V2PullFileCoordinate {
19410 sha256: content_sha256(old),
19411 bytes: old.len() as u64,
19412 }),
19413 new: Some(V2PullFileCoordinate {
19414 sha256: content_sha256(new),
19415 bytes: new.len() as u64,
19416 }),
19417 backup: Some("00000000".to_string()),
19418 }],
19419 };
19420 validate_v2_pull_journal(&journal).unwrap();
19421 store
19422 .write_private_atomic_new(
19423 Path::new(V2_PULL_JOURNAL),
19424 &v2_pull_journal_bytes(&journal).unwrap(),
19425 )
19426 .unwrap();
19427 store.write_atomic(Path::new(path), new).unwrap();
19428
19429 let cfg = test_hub_config(
19430 "https://example.test".to_string(),
19431 sandbox.path().join("state"),
19432 );
19433 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19434 assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
19435 assert!(!root.join(V2_PULL_JOURNAL).exists());
19436 assert!(!root.join(backup_dir).exists());
19437 }
19438
19439 #[test]
19440 fn preparing_pull_journal_discards_only_private_staging() {
19441 let sandbox = tempfile::TempDir::new().unwrap();
19442 let root = sandbox.path().join("brain");
19443 std::fs::create_dir_all(root.join(".dbmd")).unwrap();
19444 std::fs::write(
19445 root.join("DB.md"),
19446 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19447 )
19448 .unwrap();
19449 let store = Store::open_strict(&root).unwrap();
19450 let bundle = crate::ulid::mint();
19451 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19452 store
19453 .create_private_dir_all(Path::new(&backup_dir))
19454 .unwrap();
19455 let journal = V2PullJournal {
19456 v: 1,
19457 phase: V2PullPhase::Preparing,
19458 brain: TEST_BRAIN_ID.to_string(),
19459 previous: V2PullCoordinate {
19460 head_seq: None,
19461 commit_hash: None,
19462 view_kind: None,
19463 view_revision: None,
19464 },
19465 next: V2PullCoordinate {
19466 head_seq: Some(1),
19467 commit_hash: Some("a".repeat(64)),
19468 view_kind: Some("full".to_string()),
19469 view_revision: Some("b".repeat(64)),
19470 },
19471 backup_dir: backup_dir.clone(),
19472 entries: vec![V2PullJournalEntry {
19473 path: "records/new.md".to_string(),
19474 old: None,
19475 new: Some(V2PullFileCoordinate {
19476 sha256: "c".repeat(64),
19477 bytes: 1,
19478 }),
19479 backup: None,
19480 }],
19481 };
19482 store
19483 .write_private_atomic_new(
19484 Path::new(V2_PULL_JOURNAL),
19485 &v2_pull_journal_bytes(&journal).unwrap(),
19486 )
19487 .unwrap();
19488 let cfg = test_hub_config(
19489 "https://example.test".to_string(),
19490 sandbox.path().join("state"),
19491 );
19492
19493 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19494
19495 assert!(root.join("DB.md").is_file());
19496 assert!(!root.join(V2_PULL_JOURNAL).exists());
19497 assert!(!root.join(backup_dir).exists());
19498 }
19499
19500 #[test]
19501 fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
19502 let sandbox = tempfile::TempDir::new().unwrap();
19503 let root = sandbox.path().join("brain");
19504 std::fs::create_dir_all(root.join("records")).unwrap();
19505 std::fs::write(
19506 root.join("DB.md"),
19507 b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19508 )
19509 .unwrap();
19510 let new = b"---\ntype: note\n---\n\nnew\n";
19511 std::fs::write(root.join("records/value.md"), new).unwrap();
19512 let store = Store::open_strict(&root).unwrap();
19513 let bundle = crate::ulid::mint();
19514 let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19515 store
19516 .create_private_dir_all(Path::new(&backup_dir))
19517 .unwrap();
19518 let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
19519 store.create_private_dir_all(Path::new(&orphan)).unwrap();
19520 let next = V2PullCoordinate {
19521 head_seq: Some(2),
19522 commit_hash: Some("c".repeat(64)),
19523 view_kind: Some("full".to_string()),
19524 view_revision: Some("d".repeat(64)),
19525 };
19526 let journal = V2PullJournal {
19527 v: 1,
19528 phase: V2PullPhase::Ready,
19529 brain: TEST_BRAIN_ID.to_string(),
19530 previous: V2PullCoordinate {
19531 head_seq: Some(1),
19532 commit_hash: Some("a".repeat(64)),
19533 view_kind: Some("full".to_string()),
19534 view_revision: Some("b".repeat(64)),
19535 },
19536 next: next.clone(),
19537 backup_dir: backup_dir.clone(),
19538 entries: vec![V2PullJournalEntry {
19539 path: "records/value.md".to_string(),
19540 old: Some(V2PullFileCoordinate {
19541 sha256: "e".repeat(64),
19542 bytes: new.len() as u64,
19543 }),
19544 new: Some(V2PullFileCoordinate {
19545 sha256: content_sha256(new),
19546 bytes: new.len() as u64,
19547 }),
19548 backup: Some("00000000".to_string()),
19549 }],
19550 };
19551 store
19552 .write_private_atomic_new(
19553 Path::new(V2_PULL_JOURNAL),
19554 &v2_pull_journal_bytes(&journal).unwrap(),
19555 )
19556 .unwrap();
19557 let cfg = test_hub_config(
19558 "https://example.test".to_string(),
19559 sandbox.path().join("state"),
19560 );
19561 save_v2_baseline(
19562 &cfg,
19563 TEST_BRAIN_ID,
19564 &root,
19565 &V2SyncBaseline {
19566 v: 2,
19567 origin: "https://example.test".to_string(),
19568 brain: TEST_BRAIN_ID.to_string(),
19569 checkout_id: Some("c".repeat(64)),
19570 head_seq: next.head_seq,
19571 commit_hash: next.commit_hash.clone(),
19572 content_root: Some("f".repeat(64)),
19573 asset_root: None,
19574 assets: Default::default(),
19575 view_kind: next.view_kind.clone(),
19576 view_revision: next.view_revision.clone(),
19577 control_revision: Some("d".repeat(64)),
19578 projection_sha256: None,
19579 files: Default::default(),
19580 local_policy_digest: None,
19581 local_eligibility: Default::default(),
19582 remote_copy_remains: Default::default(),
19583 },
19584 )
19585 .unwrap();
19586
19587 recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19588
19589 assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
19590 assert!(!root.join(V2_PULL_JOURNAL).exists());
19591 assert!(!root.join(backup_dir).exists());
19592 assert!(!root.join(orphan).exists());
19593 }
19594}